From 55d607b8139960e11695682490be2c6e1e9d3118 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 2 Dec 2025 04:41:36 +0800 Subject: [PATCH 1/2] feat(language-service): enhanced components auto import --- .../language-core/lib/codegen/codeFeatures.ts | 3 + .../lib/codegen/template/element.ts | 17 +- packages/language-core/lib/types.ts | 1 + packages/language-server/lib/server.ts | 6 + packages/language-service/index.ts | 4 +- .../lib/plugins/vue-template.ts | 167 +++++++++++-- packages/typescript-plugin/index.ts | 118 +++++++++- packages/typescript-plugin/lib/common.ts | 220 ++++++++++-------- .../typescript-plugin/lib/requests/index.ts | 11 + 9 files changed, 421 insertions(+), 126 deletions(-) diff --git a/packages/language-core/lib/codegen/codeFeatures.ts b/packages/language-core/lib/codegen/codeFeatures.ts index 1538f6cdde..62020041dc 100644 --- a/packages/language-core/lib/codegen/codeFeatures.ts +++ b/packages/language-core/lib/codegen/codeFeatures.ts @@ -7,6 +7,9 @@ const raw = { semantic: true, navigation: true, }, + htmlAutoImportOnly: { + htmlAutoImport: true, + }, verification: { verification: true, }, diff --git a/packages/language-core/lib/codegen/template/element.ts b/packages/language-core/lib/codegen/template/element.ts index f2f45a2945..b7329c4cb0 100644 --- a/packages/language-core/lib/codegen/template/element.ts +++ b/packages/language-core/lib/codegen/template/element.ts @@ -111,6 +111,11 @@ export function* generateComponent( yield `, `; } yield `]} */${endOfLine}`; + + // auto import support + yield `// @ts-ignore${newLine}`; // #2304 + yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly); + yield endOfLine; } else if (dynamicTagInfo) { yield `const ${componentOriginalVar} = (`; @@ -185,17 +190,7 @@ export function* generateComponent( // auto import support yield `// @ts-ignore${newLine}`; // #2304 - yield* generateCamelized( - capitalize(node.tag), - 'template', - tagOffsets[0], - { - completion: { - isAdditional: true, - onlyImport: true, - }, - }, - ); + yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly); yield endOfLine; } } diff --git a/packages/language-core/lib/types.ts b/packages/language-core/lib/types.ts index 95b6e6763c..fb15f3ef98 100644 --- a/packages/language-core/lib/types.ts +++ b/packages/language-core/lib/types.ts @@ -17,6 +17,7 @@ export type RawVueCompilerOptions = Partial initializing = undefined); + const transformedItems = new WeakSet(); let initializing: Promise | undefined; + let formattingOptions: html.FormattingOptions | undefined; + let lastCompletionDocument: TextDocument | undefined; return { ...baseServiceInstance, @@ -202,6 +216,16 @@ export function create( disposable?.dispose(); }, + provideDocumentFormattingEdits(_document, _range, options) { + formattingOptions = options; + return undefined; + }, + + provideOnTypeFormattingEdits(_document, _position, _key, options) { + formattingOptions = options; + return undefined; + }, + async provideCompletionItems(document, position, completionContext, token) { if (document.languageId !== languageId) { return; @@ -212,9 +236,10 @@ export function create( } const { - result: completionList, + result: htmlCompletion, target, info: { + tagNameCasing, components, propMap, }, @@ -230,24 +255,71 @@ export function create( ), ); - if (!completionList) { + if (!htmlCompletion) { return; } + const autoImportPlaceholderIndex = htmlCompletion.items.findIndex(item => + item.label === 'AutoImportsPlaceholder' + ); + if (autoImportPlaceholderIndex !== -1) { + const offset = document.offsetAt(position); + const map = context.language.maps.get(info.code, info.script); + let spliced = false; + for (const [sourceOffset] of map.toSourceLocation(offset)) { + const [formatOptions, preferences] = await Promise.all([ + getFormatCodeSettings(context, document, formattingOptions), + getUserPreferences(context, document), + ]); + const autoImport = await getAutoImportSuggestions( + info.root.fileName, + sourceOffset, + preferences, + formatOptions, + ); + if (!autoImport) { + continue; + } + const tsCompletion = convertCompletionInfo(ts, autoImport, document, position, entry => entry.data); + const placeholder = htmlCompletion.items[autoImportPlaceholderIndex]!; + for (const tsItem of tsCompletion.items) { + if (placeholder.textEdit) { + const newText = tsItem.textEdit?.newText ?? tsItem.label; + tsItem.textEdit = { + ...placeholder.textEdit, + newText: tagNameCasing === TagNameCasing.Kebab + ? hyphenateTag(newText) + : newText, + }; + } + else { + tsItem.textEdit = undefined; + } + } + htmlCompletion.items.splice(autoImportPlaceholderIndex, 1, ...tsCompletion.items); + spliced = true; + lastCompletionDocument = document; + break; + } + if (!spliced) { + htmlCompletion.items.splice(autoImportPlaceholderIndex, 1); + } + } + switch (target) { case 'tag': { - completionList.items.forEach(transformTag); + htmlCompletion.items.forEach(transformTag); break; } case 'attribute': { - addDirectiveModifiers(completionList, document); - completionList.items.forEach(transformAttribute); + addDirectiveModifiers(htmlCompletion, document); + htmlCompletion.items.forEach(transformAttribute); break; } } updateExtraCustomData([]); - return completionList; + return htmlCompletion; function transformTag(item: html.CompletionItem) { const tagName = capitalize(camelize(item.label)); @@ -359,6 +431,61 @@ export function create( } }, + async resolveCompletionItem(item) { + if (item.data?.__isAutoImport || item.data?.__isComponentAutoImport) { + const embeddedUri = URI.parse(lastCompletionDocument!.uri); + const decoded = context.decodeEmbeddedDocumentUri(embeddedUri); + if (!decoded) { + return item; + } + const sourceScript = context.language.scripts.get(decoded[0]); + if (!sourceScript) { + return item; + } + const [formatOptions, preferences] = await Promise.all([ + getFormatCodeSettings(context, lastCompletionDocument!, formattingOptions), + getUserPreferences(context, lastCompletionDocument!), + ]); + const details = await resolveAutoImportCompletionEntry(item.data, preferences, formatOptions); + if (details) { + const virtualCode = sourceScript.generated!.embeddedCodes.get(decoded[1])!; + const sourceDocument = context.documents.get( + sourceScript.id, + sourceScript.languageId, + sourceScript.snapshot, + ); + const embeddedDocument = context.documents.get(embeddedUri, virtualCode.languageId, virtualCode.snapshot); + const map = context.language.maps.get(virtualCode, sourceScript); + item = transformCompletionItem( + item, + embeddedRange => + getSourceRange( + [sourceDocument, embeddedDocument, map], + embeddedRange, + ), + embeddedDocument, + context, + ); + applyCompletionEntryDetails( + ts, + item, + details, + sourceDocument, + fileName => URI.file(fileName), + () => undefined, + ); + transformedItems.add(item); + } + } + return item; + }, + + transformCompletionItem(item) { + if (transformedItems.has(item)) { + return item; + } + }, + provideHover(document, position, token) { if (document.languageId !== languageId) { return; @@ -688,6 +815,19 @@ export function create( })); }, }, + { + getId: () => 'vue-auto-imports', + isApplicable: () => true, + provideTags() { + return [{ name: 'AutoImportsPlaceholder', attributes: [] }]; + }, + provideAttributes() { + return []; + }, + provideValues() { + return []; + }, + }, ]); return { @@ -697,6 +837,7 @@ export function create( version, target, info: { + tagNameCasing, components, propMap, }, diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 819f66be4e..d8429c9d23 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -1,7 +1,8 @@ +import { transformFileTextChanges } from '@volar/typescript/lib/node/transform.js'; import { createLanguageServicePlugin } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin'; import * as core from '@vue/language-core'; import type * as ts from 'typescript'; -import { createVueLanguageServiceProxy } from './lib/common'; +import { createVueLanguageServiceProxy, resolveCompletionEntryDetails, resolveCompletionResult } from './lib/common'; import type { Requests } from './lib/requests'; import { collectExtractProps } from './lib/requests/collectExtractProps'; import { getComponentDirectives } from './lib/requests/getComponentDirectives'; @@ -15,8 +16,12 @@ import { getImportPathForFile } from './lib/requests/getImportPathForFile'; import { isRefAtPosition } from './lib/requests/isRefAtPosition'; import { resolveModuleName } from './lib/requests/resolveModuleName'; +const projectToOriginalLanguageService = new WeakMap(); + export = createLanguageServicePlugin( (ts, info) => { + projectToOriginalLanguageService.set(info.project, info.languageService); + const vueOptions = getVueCompilerOptions(); const languagePlugin = core.createVueLanguagePlugin( ts, @@ -121,6 +126,115 @@ export = createLanguageServicePlugin( ), ); }); + session.addProtocolHandler('_vue:getAutoImportSuggestions', request => { + const [fileName, position, preferences, formatOptions]: Parameters = + request.arguments; + const { project, language, sourceScript, virtualCode } = getProjectAndVirtualCode(fileName); + const tsLanguageService = projectToOriginalLanguageService.get(project); + if (!tsLanguageService) { + return createResponse(undefined); + } + for (const code of core.forEachEmbeddedCode(virtualCode)) { + if (!code.id.startsWith('script_')) { + continue; + } + const map = language.maps.get(code, sourceScript); + for (const [tsPosition, mapping] of map.toGeneratedLocation(position)) { + if (!(mapping.data as core.VueCodeInformation).htmlAutoImport) { + continue; + } + const tsPosition2 = tsPosition + sourceScript.snapshot.getLength(); + const result = tsLanguageService.getCompletionsAtPosition( + fileName, + tsPosition2, + preferences, + formatOptions, + ); + if (result) { + resolveCompletionResult( + ts, + language, + fileName => fileName, + vueOptions, + fileName, + position, + result, + ); + result.entries = result.entries.filter((entry: any) => + entry.data?.__isComponentAutoImport || entry.data?.__isAutoImport + ); + for (const entry of result.entries) { + (entry.data as any).__getAutoImportSuggestions = { + fileName, + position: tsPosition + sourceScript.snapshot.getLength(), + entryName: (entry.data as any).__isComponentAutoImport?.oldName ?? entry.name, + source: entry.source, + }; + } + } + return createResponse(result); + } + const result = tsLanguageService.getCompletionsAtPosition(fileName, 0, preferences, formatOptions); + if (result) { + resolveCompletionResult( + ts, + language, + fileName => fileName, + vueOptions, + fileName, + position, + result, + ); + result.entries = result.entries.filter((entry: any) => + entry.data?.__isComponentAutoImport || entry.data?.__isAutoImport + ); + for (const entry of result.entries) { + (entry.data as any).__getAutoImportSuggestions = { + fileName, + position: 0, + entryName: (entry.data as any).__isComponentAutoImport?.oldName ?? entry.name, + source: entry.source, + }; + } + return createResponse(result); + } + } + return createResponse(undefined); + }); + session.addProtocolHandler('_vue:resolveAutoImportCompletionEntry', request => { + const [data, preferences, formatOptions]: Parameters = + request.arguments; + if (!(data as any).__getAutoImportSuggestions) { + return createResponse(undefined); + } + const { fileName, position, entryName, source } = (data as any).__getAutoImportSuggestions; + const { project, language } = getProject(fileName); + const tsLanguageService = projectToOriginalLanguageService.get(project); + if (!tsLanguageService) { + return createResponse(undefined); + } + const details = tsLanguageService.getCompletionEntryDetails( + fileName, + position, + entryName, + formatOptions, + source, + preferences, + data, + ); + if (details) { + for (const codeAction of details.codeActions ?? []) { + codeAction.changes = transformFileTextChanges( + language, + codeAction.changes, + false, + core.isCompletionEnabled, + ); + } + resolveCompletionEntryDetails(language, details, data); + } + return createResponse(details); + }); session.addProtocolHandler('_vue:isRefAtPosition', request => { const [fileName, position]: Parameters = request.arguments; const { project, language, sourceScript, virtualCode } = getProjectAndVirtualCode(fileName); @@ -215,7 +329,7 @@ export = createLanguageServicePlugin( } return { project, - language: (project as any).__vue__.language, + language: (project as any).__vue__.language as core.Language, }; } } diff --git a/packages/typescript-plugin/lib/common.ts b/packages/typescript-plugin/lib/common.ts index 90c641c8cb..af5d82fac4 100644 --- a/packages/typescript-plugin/lib/common.ts +++ b/packages/typescript-plugin/lib/common.ts @@ -53,129 +53,153 @@ function getCompletionsAtPosition( const fileName = filePath.replace(windowsPathReg, '/'); const result = getCompletionsAtPosition(fileName, position, options, formattingSettings); if (result) { - // filter __VLS_ - result.entries = result.entries.filter( - entry => - !entry.name.includes('__VLS_') - && !entry.labelDetails?.description?.includes('__VLS_'), + resolveCompletionResult( + ts, + language, + asScriptId, + vueOptions, + fileName, + position, + result, ); + } + return result; + }; +} - // filter global variables in template and styles - const sourceScript = language.scripts.get(asScriptId(fileName)); - const root = sourceScript?.generated?.root; - if (root instanceof VueVirtualCode) { - const blocks = [ - root.sfc.template, - ...root.sfc.styles, - ]; - const ranges = blocks.filter(Boolean).map(block => - [ - block!.startTagEnd, - block!.endTagStart, - ] as const - ); +function getCompletionEntryDetails( + language: Language, + getCompletionEntryDetails: ts.LanguageService['getCompletionEntryDetails'], +): ts.LanguageService['getCompletionEntryDetails'] { + return (...args) => { + const details = getCompletionEntryDetails(...args); + if (details) { + resolveCompletionEntryDetails(language, details, args[6]); + } + return details; + }; +} - if (ranges.some(([start, end]) => position >= start && position <= end)) { - const globalKinds = new Set(['var', 'function', 'module']); - const globalsOrKeywords = (ts as any).Completions.SortText.GlobalsOrKeywords; - const sortTexts = new Set([ - globalsOrKeywords, - 'z' + globalsOrKeywords, - globalsOrKeywords + '1', - ]); +export function resolveCompletionResult( + ts: typeof import('typescript'), + language: Language, + asScriptId: (fileName: string) => T, + vueOptions: VueCompilerOptions, + fileName: string, + position: number, + result: ts.CompletionInfo, +) { + // filter __VLS_ + result.entries = result.entries.filter( + entry => + !entry.name.includes('__VLS_') + && !entry.labelDetails?.description?.includes('__VLS_'), + ); - result.entries = result.entries.filter(entry => - !(entry.kind === 'const' && entry.name in vueOptions.macros) && ( - !globalKinds.has(entry.kind) - || !sortTexts.has(entry.sortText) - || isGloballyAllowed(entry.name) - ) - ); - } - } + // filter global variables in template and styles + const sourceScript = language.scripts.get(asScriptId(fileName)); + const root = sourceScript?.generated?.root; + if (root instanceof VueVirtualCode) { + const blocks = [ + root.sfc.template, + ...root.sfc.styles, + ]; + const ranges = blocks.filter(Boolean).map(block => + [ + block!.startTagEnd, + block!.endTagStart, + ] as const + ); - // modify label - for (const item of result.entries) { - if (item.source) { - const originalName = item.name; - for (const vueExt of vueOptions.extensions) { - const suffix = capitalize(vueExt.slice(1)); // .vue -> Vue - if (item.source.endsWith(vueExt) && item.name.endsWith(suffix)) { - item.name = capitalize(item.name.slice(0, -suffix.length)); - if (item.insertText) { - // #2286 - item.insertText = item.insertText.replace(`${suffix}$1`, '$1'); - } - if (item.data) { - // @ts-expect-error - item.data.__isComponentAutoImport = { - ext: vueExt, - suffix, - originalName, - newName: item.insertText, - }; - } - break; - } + if (ranges.some(([start, end]) => position >= start && position <= end)) { + const globalKinds = new Set(['var', 'function', 'module']); + const globalsOrKeywords = (ts as any).Completions.SortText.GlobalsOrKeywords; + const sortTexts = new Set([ + globalsOrKeywords, + 'z' + globalsOrKeywords, + globalsOrKeywords + '1', + ]); + + result.entries = result.entries.filter(entry => + !(entry.kind === 'const' && entry.name in vueOptions.macros) && ( + !globalKinds.has(entry.kind) + || !sortTexts.has(entry.sortText) + || isGloballyAllowed(entry.name) + ) + ); + } + } + + // modify label + for (const item of result.entries) { + if (item.source) { + const oldName = item.name; + for (const vueExt of vueOptions.extensions) { + const suffix = capitalize(vueExt.slice(1)); // .vue -> Vue + if (item.source.endsWith(vueExt) && item.name.endsWith(suffix)) { + item.name = capitalize(item.name.slice(0, -suffix.length)); + if (item.insertText) { + // #2286 + item.insertText = item.insertText.replace(`${suffix}$1`, '$1'); } if (item.data) { // @ts-expect-error - item.data.__isAutoImport = { - fileName, + item.data.__isComponentAutoImport = { + oldName, + newName: item.name, }; } + break; } } + if (item.data) { + // @ts-expect-error + item.data.__isAutoImport = { + fileName, + }; + } } - return result; - }; + } } -function getCompletionEntryDetails( - language: Language, - getCompletionEntryDetails: ts.LanguageService['getCompletionEntryDetails'], -): ts.LanguageService['getCompletionEntryDetails'] { - return (...args) => { - const details = getCompletionEntryDetails(...args); - // modify import statement - // @ts-expect-error - if (args[6]?.__isComponentAutoImport) { - // @ts-expect-error - const { originalName, newName } = args[6].__isComponentAutoImport; - for (const codeAction of details?.codeActions ?? []) { - for (const change of codeAction.changes) { - for (const textChange of change.textChanges) { - textChange.newText = textChange.newText.replace( - 'import ' + originalName + ' from ', - 'import ' + newName + ' from ', - ); - } +export function resolveCompletionEntryDetails( + language: Language, + details: ts.CompletionEntryDetails, + data: any, +) { + // modify import statement + if (data.__isComponentAutoImport) { + const { oldName, newName } = data.__isComponentAutoImport; + for (const codeAction of details?.codeActions ?? []) { + for (const change of codeAction.changes) { + for (const textChange of change.textChanges) { + textChange.newText = textChange.newText.replace( + 'import ' + oldName + ' from ', + 'import ' + newName + ' from ', + ); } } } - // @ts-expect-error - if (args[6]?.__isAutoImport) { - // @ts-expect-error - const { fileName } = args[6].__isAutoImport; - const sourceScript = language.scripts.get(fileName); - if (sourceScript?.generated?.root instanceof VueVirtualCode) { - const { vueSfc } = sourceScript.generated.root; - if (!vueSfc?.descriptor.script && !vueSfc?.descriptor.scriptSetup) { - for (const codeAction of details?.codeActions ?? []) { - for (const change of codeAction.changes) { - for (const textChange of change.textChanges) { - textChange.newText = `\n\n`; - break; - } + } + if (data.__isAutoImport) { + const { fileName } = data.__isAutoImport; + const sourceScript = language.scripts.get(fileName); + if (sourceScript?.generated?.root instanceof VueVirtualCode) { + const { vueSfc } = sourceScript.generated.root; + if (!vueSfc?.descriptor.script && !vueSfc?.descriptor.scriptSetup) { + for (const codeAction of details?.codeActions ?? []) { + for (const change of codeAction.changes) { + for (const textChange of change.textChanges) { + textChange.newText = `\n\n`; break; } break; } + break; } } } - return details; - }; + } } function getCodeFixesAtPosition( diff --git a/packages/typescript-plugin/lib/requests/index.ts b/packages/typescript-plugin/lib/requests/index.ts index 9c27fa06d0..008bc7687f 100644 --- a/packages/typescript-plugin/lib/requests/index.ts +++ b/packages/typescript-plugin/lib/requests/index.ts @@ -56,4 +56,15 @@ export interface Requests { fileName: string, position: ts.LineAndCharacter, ): Response; + getAutoImportSuggestions( + fileName: string, + position: number, + preferences: ts.UserPreferences, + formatOptions: ts.FormatCodeSettings, + ): Response; + resolveAutoImportCompletionEntry( + data: ts.CompletionEntryData, + preferences: ts.UserPreferences, + formatOptions: ts.FormatCodeSettings, + ): Response; } From 6bffedb70f8844865762c4d1b61de4445a848b4b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Tue, 2 Dec 2025 04:51:40 +0800 Subject: [PATCH 2/2] Update vue-template.ts --- packages/language-service/lib/plugins/vue-template.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 562ef4769d..87e5e727ea 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -216,14 +216,14 @@ export function create( disposable?.dispose(); }, - provideDocumentFormattingEdits(_document, _range, options) { + provideDocumentFormattingEdits(document, range, options, ...rest) { formattingOptions = options; - return undefined; + return baseServiceInstance.provideDocumentFormattingEdits?.(document, range, options, ...rest); }, - provideOnTypeFormattingEdits(_document, _position, _key, options) { + provideOnTypeFormattingEdits(document, position, key, options, ...rest) { formattingOptions = options; - return undefined; + return baseServiceInstance.provideOnTypeFormattingEdits?.(document, position, key, options, ...rest); }, async provideCompletionItems(document, position, completionContext, token) {