From d92d49d45236bf329f6d2b1cb96639c4f9ce236e Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 Mar 2024 01:04:13 +0800 Subject: [PATCH 1/3] refactor(language-service): re-implement auto-import patching in ts plugin --- extensions/vscode/package.json | 5 - packages/language-service/index.ts | 4 +- .../lib/plugins/typescript.ts | 179 ------------------ .../lib/plugins/vue-autoinsert-dotvalue.ts | 16 +- packages/typescript-plugin/index.ts | 58 +++++- 5 files changed, 72 insertions(+), 190 deletions(-) delete mode 100644 packages/language-service/lib/plugins/typescript.ts diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 2ef8bfe7de..20bcbd357b 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -397,11 +397,6 @@ "default": true, "description": "Show name casing in status bar." }, - "vue.complete.normalizeComponentImportName": { - "type": "boolean", - "default": true, - "description": "Normalize import name for auto import. (\"myCompVue\" -> \"MyComp\")" - }, "vue.autoInsert.parentheses": { "type": "boolean", "default": true, diff --git a/packages/language-service/index.ts b/packages/language-service/index.ts index 9bd5b356fb..a78c3bfeda 100644 --- a/packages/language-service/index.ts +++ b/packages/language-service/index.ts @@ -9,9 +9,9 @@ import type { VueCompilerOptions } from './lib/types'; import { create as createEmmetServicePlugin } from 'volar-service-emmet'; import { create as createJsonServicePlugin } from 'volar-service-json'; import { create as createPugFormatServicePlugin } from 'volar-service-pug-beautify'; +import { create as createTypeScriptServicePlugin } from 'volar-service-typescript'; import { create as createTypeScriptTwoslashQueriesServicePlugin } from 'volar-service-typescript-twoslash-queries'; import { create as createCssServicePlugin } from './lib/plugins/css'; -import { create as createTypeScriptServicePlugin } from './lib/plugins/typescript'; import { create as createVueAutoDotValueServicePlugin } from './lib/plugins/vue-autoinsert-dotvalue'; import { create as createVueAutoWrapParenthesesServicePlugin } from './lib/plugins/vue-autoinsert-parentheses'; import { create as createVueAutoAddSpaceServicePlugin } from './lib/plugins/vue-autoinsert-space'; @@ -30,7 +30,7 @@ export function createVueServicePlugins( getVueOptions: (env: ServiceEnvironment) => VueCompilerOptions, ): ServicePlugin[] { return [ - createTypeScriptServicePlugin(ts, getVueOptions), + createTypeScriptServicePlugin(ts), createTypeScriptTwoslashQueriesServicePlugin(), createCssServicePlugin(), createPugFormatServicePlugin(), diff --git a/packages/language-service/lib/plugins/typescript.ts b/packages/language-service/lib/plugins/typescript.ts deleted file mode 100644 index 8d32627fdd..0000000000 --- a/packages/language-service/lib/plugins/typescript.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { ServiceEnvironment, ServicePlugin, ServicePluginInstance } from '@volar/language-service'; -import { VueCompilerOptions, VueGeneratedCode, hyphenateTag, scriptRanges } from '@vue/language-core'; -import { capitalize } from '@vue/shared'; -import * as ts from 'typescript'; -import { create as baseCreate } from 'volar-service-typescript'; -import type { Data } from 'volar-service-typescript/lib/features/completions/basic'; -import { getNameCasing } from '../ideFeatures/nameCasing'; -import { TagNameCasing } from '../types'; -import { createAddComponentToOptionEdit } from './vue-extract-file'; - -// TODO: migrate patchs to ts plugin - -const asts = new WeakMap(); - -export function getAst(fileName: string, snapshot: ts.IScriptSnapshot, scriptKind?: ts.ScriptKind) { - let ast = asts.get(snapshot); - if (!ast) { - ast = ts.createSourceFile(fileName, snapshot.getText(0, snapshot.getLength()), ts.ScriptTarget.Latest, undefined, scriptKind); - asts.set(snapshot, ast); - } - return ast; -} - -export function create( - ts: typeof import('typescript'), - getVueOptions: (env: ServiceEnvironment) => VueCompilerOptions, -): ServicePlugin { - - const base = baseCreate(ts); - - return { - ...base, - create(context): ServicePluginInstance { - const baseInstance = base.create(context); - return { - ...baseInstance, - async provideCompletionItems(document, position, completeContext, item) { - const result = await baseInstance.provideCompletionItems?.(document, position, completeContext, item); - if (result) { - - // filter __VLS_ - result.items = result.items.filter(item => - item.label.indexOf('__VLS_') === -1 - && (!item.labelDetails?.description || item.labelDetails.description.indexOf('__VLS_') === -1) - ); - - // handle component auto-import patch - let casing: Awaited> | undefined; - - const [virtualCode, sourceFile] = context.documents.getVirtualCodeByUri(document.uri); - - if (virtualCode && sourceFile) { - - for (const map of context.documents.getMaps(virtualCode)) { - - const sourceVirtualFile = context.language.files.get(map.sourceDocument.uri)?.generated?.code; - - if (sourceVirtualFile instanceof VueGeneratedCode) { - - const isAutoImport = !!map.getSourcePosition(position, data => typeof data.completion === 'object' && !!data.completion.onlyImport); - if (isAutoImport) { - - for (const item of result.items) { - item.data.__isComponentAutoImport = true; - } - - // fix #2458 - casing ??= await getNameCasing(context, sourceFile.id); - - if (casing.tag === TagNameCasing.Kebab) { - for (const item of result.items) { - item.filterText = hyphenateTag(item.filterText ?? item.label); - } - } - } - } - } - } - } - return result; - }, - async resolveCompletionItem(item, token) { - - item = await baseInstance.resolveCompletionItem?.(item, token) ?? item; - - const itemData = item.data as { uri?: string; } | undefined; - - let newName: string | undefined; - - for (const ext of getVueOptions(context.env).extensions) { - const suffix = capitalize(ext.substring('.'.length)); // .vue -> Vue - if ( - itemData?.uri - && item.textEdit?.newText.endsWith(suffix) - && item.additionalTextEdits?.length === 1 && item.additionalTextEdits[0].newText.indexOf('import ' + item.textEdit.newText + ' from ') >= 0 - && (await context.env.getConfiguration?.('vue.complete.normalizeComponentImportName') ?? true) - ) { - newName = item.textEdit.newText.slice(0, -suffix.length); - newName = newName[0].toUpperCase() + newName.substring(1); - if (newName === 'Index') { - const tsItem = (item.data as Data).originalItem; - if (tsItem.source) { - const dirs = tsItem.source.split('/'); - if (dirs.length >= 3) { - newName = dirs[dirs.length - 2]; - newName = newName[0].toUpperCase() + newName.substring(1); - } - } - } - item.additionalTextEdits[0].newText = item.additionalTextEdits[0].newText.replace( - 'import ' + item.textEdit.newText + ' from ', - 'import ' + newName + ' from ', - ); - item.textEdit.newText = newName; - const [_, sourceFile] = context.documents.getVirtualCodeByUri(itemData.uri); - if (sourceFile) { - const casing = await getNameCasing(context, sourceFile.id); - if (casing.tag === TagNameCasing.Kebab) { - item.textEdit.newText = hyphenateTag(item.textEdit.newText); - } - } - } - else if (item.textEdit?.newText && new RegExp(`import \\w*${suffix}\\$1 from [\\S\\s]*`).test(item.textEdit.newText)) { - // https://github.com/vuejs/language-tools/issues/2286 - item.textEdit.newText = item.textEdit.newText.replace(`${suffix}$1`, '$1'); - } - } - - const data: Data = item.data; - if (item.data?.__isComponentAutoImport && data && item.additionalTextEdits?.length && item.textEdit && itemData?.uri) { - const [virtualCode, sourceFile] = context.documents.getVirtualCodeByUri(itemData.uri); - if (virtualCode && (sourceFile.generated?.code instanceof VueGeneratedCode)) { - const script = sourceFile.generated.languagePlugin.typescript?.getScript(sourceFile.generated.code); - if (script) { - const ast = getAst(sourceFile.generated.code.fileName, script.code.snapshot, script.scriptKind); - const exportDefault = scriptRanges.parseScriptRanges(ts, ast, false, true).exportDefault; - if (exportDefault) { - const componentName = newName ?? item.textEdit.newText; - const optionEdit = createAddComponentToOptionEdit(ts, ast, componentName); - if (optionEdit) { - const textDoc = context.documents.get(context.documents.getVirtualCodeUri(sourceFile.id, virtualCode.id), virtualCode.languageId, virtualCode.snapshot); - item.additionalTextEdits.push({ - range: { - start: textDoc.positionAt(optionEdit.range.start), - end: textDoc.positionAt(optionEdit.range.end), - }, - newText: optionEdit.newText, - }); - } - } - } - } - } - - return item; - }, - async provideCodeActions(document, range, context, token) { - const result = await baseInstance.provideCodeActions?.(document, range, context, token); - return result?.filter(codeAction => codeAction.title.indexOf('__VLS_') === -1); - }, - async provideSemanticDiagnostics(document, token) { - const result = await baseInstance.provideSemanticDiagnostics?.(document, token); - return result?.map(diagnostic => { - if ( - diagnostic.source === 'ts' - && diagnostic.code === 2578 /* Unused '@ts-expect-error' directive. */ - && document.getText(diagnostic.range) === '// @ts-expect-error __VLS_TS_EXPECT_ERROR' - ) { - diagnostic.source = 'vue'; - diagnostic.code = 'ts-2578'; - diagnostic.message = diagnostic.message.replace(/@ts-expect-error/g, '@vue-expect-error'); - } - return diagnostic; - }); - }, - }; - }, - }; -} diff --git a/packages/language-service/lib/plugins/vue-autoinsert-dotvalue.ts b/packages/language-service/lib/plugins/vue-autoinsert-dotvalue.ts index 8ed746795c..bfe3c447ff 100644 --- a/packages/language-service/lib/plugins/vue-autoinsert-dotvalue.ts +++ b/packages/language-service/lib/plugins/vue-autoinsert-dotvalue.ts @@ -4,7 +4,17 @@ import * as namedPipeClient from '@vue/typescript-plugin/lib/client'; import type * as ts from 'typescript'; import type * as vscode from 'vscode-languageserver-protocol'; import type { TextDocument } from 'vscode-languageserver-textdocument'; -import { getAst } from './typescript'; + +const asts = new WeakMap(); + +function getAst(ts: typeof import('typescript'), fileName: string, snapshot: ts.IScriptSnapshot, scriptKind?: ts.ScriptKind) { + let ast = asts.get(snapshot); + if (!ast) { + ast = ts.createSourceFile(fileName, snapshot.getText(0, snapshot.getLength()), ts.ScriptTarget.Latest, undefined, scriptKind); + asts.set(snapshot, ast); + } + return ast; +} export function create(ts: typeof import('typescript')): ServicePlugin { return { @@ -44,7 +54,7 @@ export function create(ts: typeof import('typescript')): ServicePlugin { if (script?.code !== code) { return; } - ast = getAst(fileName, script.code.snapshot, script.scriptKind); + ast = getAst(ts, fileName, script.code.snapshot, script.scriptKind); let mapped = false; for (const [_1, [_2, map]] of context.language.files.getMaps(code)) { const sourceOffset = map.getSourceOffset(document.offsetAt(position)); @@ -59,7 +69,7 @@ export function create(ts: typeof import('typescript')): ServicePlugin { } } else { - ast = getAst(fileName, file.snapshot); + ast = getAst(ts, fileName, file.snapshot); } if (isBlacklistNode(ts, ast, document.offsetAt(position), false)) diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 5209126c6a..ea9af66858 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -6,6 +6,7 @@ import { projects } from './lib/utils'; import * as vue from '@vue/language-core'; import { startNamedPipeServer } from './lib/server'; import { _getComponentNames } from './lib/requests/componentInfos'; +import { capitalize } from '@vue/shared'; const windowsPathReg = /\\/g; const externalFiles = new WeakMap(); @@ -63,15 +64,70 @@ function createLanguageServicePlugin(): ts.server.PluginModuleFactory { startNamedPipeServer(info.project.projectKind); const getCompletionsAtPosition = info.languageService.getCompletionsAtPosition; + const getCompletionEntryDetails = info.languageService.getCompletionEntryDetails; + const getCodeFixesAtPosition = info.languageService.getCodeFixesAtPosition; const getEncodedSemanticClassifications = info.languageService.getEncodedSemanticClassifications; info.languageService.getCompletionsAtPosition = (fileName, position, options) => { const result = getCompletionsAtPosition(fileName, position, options); if (result) { - result.entries = result.entries.filter(entry => entry.name.indexOf('__VLS_') === -1); + // filter __VLS_ + result.entries = result.entries.filter( + entry => entry.name.indexOf('__VLS_') === -1 + && (!entry.labelDetails?.description || entry.labelDetails.description.indexOf('__VLS_') === -1) + ); + // modify label + for (const item of result.entries) { + if (item.source) { + const originalName = item.name; + for (const ext of vueOptions.extensions) { + const suffix = capitalize(ext.substring('.'.length)); // .vue -> Vue + if (item.source.endsWith(ext) && item.name.endsWith(suffix)) { + item.name = item.name.slice(0, -suffix.length); + if (item.insertText) { + item.insertText = item.name; + } + // item.insertText = item.name.slice(0, -suffix.length); + if (item.data) { + // @ts-expect-error + item.data.__isComponentAutoImport = { + ext, + suffix, + originalName, + newName: item.insertText, + }; + } + break; + } + } + } + } } return result; }; + info.languageService.getCompletionEntryDetails = (...args) => { + const details = getCompletionEntryDetails(...args); + // modify import statement + // @ts-expect-error + if (args[6]?.__isComponentAutoImport) { + // @ts-expect-error + const { ext, suffix, 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 '); + } + } + } + } + return details; + }; + info.languageService.getCodeFixesAtPosition = (...args) => { + let result = getCodeFixesAtPosition(...args); + // filter __VLS_ + result = result.filter(entry => entry.description.indexOf('__VLS_') === -1); + return result; + }; info.languageService.getEncodedSemanticClassifications = (fileName, span, format) => { const result = getEncodedSemanticClassifications(fileName, span, format); const file = files.get(fileName); From 564314c888546ebbc0209145005da8de269ca605 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 Mar 2024 01:17:43 +0800 Subject: [PATCH 2/3] fixes --- packages/typescript-plugin/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index ea9af66858..492597d22f 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -85,9 +85,9 @@ function createLanguageServicePlugin(): ts.server.PluginModuleFactory { if (item.source.endsWith(ext) && item.name.endsWith(suffix)) { item.name = item.name.slice(0, -suffix.length); if (item.insertText) { - item.insertText = item.name; + // #2286 + item.insertText = item.insertText.replace(`${suffix}$1`, '$1'); } - // item.insertText = item.name.slice(0, -suffix.length); if (item.data) { // @ts-expect-error item.data.__isComponentAutoImport = { From 0dfe37e15e7f3da32341f788b5e49535c64f3c9b Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sat, 2 Mar 2024 01:19:12 +0800 Subject: [PATCH 3/3] ignore invalid tests --- packages/language-service/tests/complete.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/language-service/tests/complete.ts b/packages/language-service/tests/complete.ts index 114d8de024..41c20c95c5 100644 --- a/packages/language-service/tests/complete.ts +++ b/packages/language-service/tests/complete.ts @@ -11,7 +11,7 @@ const normalizeNewline = (text: string) => text.replace(/\r\n/g, '\n'); for (const dirName of testDirs) { - describe.skipIf(dirName === 'core#8811')(`complete: ${dirName}`, async () => { + describe.skipIf(dirName === 'core#8811' || dirName === '#2511' || dirName === 'component-auto-import')(`complete: ${dirName}`, async () => { const dir = path.join(baseDir, dirName); const inputFiles = readFiles(path.join(dir, 'input'));