Skip to content

Commit b8503cd

Browse files
feat(typescript-plugin): add support for template "Add Import" quick fix (#5799)
Co-authored-by: Johnson Chu <[email protected]>
1 parent a1b8b71 commit b8503cd

File tree

9 files changed

+225
-19
lines changed

9 files changed

+225
-19
lines changed

packages/language-core/lib/codegen/codeFeatures.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,15 @@ const raw = {
77
semantic: true,
88
navigation: true,
99
},
10-
htmlAutoImportOnly: {
11-
htmlAutoImport: true,
10+
importCompletionOnly: {
11+
__importCompletion: true,
1212
},
1313
verification: {
1414
verification: true,
1515
},
1616
completion: {
1717
completion: true,
1818
},
19-
additionalCompletion: {
20-
completion: { isAdditional: true },
21-
},
2219
withoutCompletion: {
2320
verification: true,
2421
semantic: true,
@@ -30,9 +27,9 @@ const raw = {
3027
navigationWithoutRename: {
3128
navigation: { shouldRename: () => false },
3229
},
33-
navigationAndAdditionalCompletion: {
30+
navigationAndCompletion: {
3431
navigation: true,
35-
completion: { isAdditional: true },
32+
completion: true,
3633
},
3734
navigationAndVerification: {
3835
navigation: true,

packages/language-core/lib/codegen/template/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ export function createTemplateCodegenContext(
314314
source,
315315
offset,
316316
{
317-
...codeFeatures.additionalCompletion,
317+
...codeFeatures.importCompletionOnly,
318318
...codeFeatures.semanticWithoutHighlight,
319319
},
320320
];
@@ -324,7 +324,7 @@ export function createTemplateCodegenContext(
324324
varName,
325325
source,
326326
offset,
327-
codeFeatures.additionalCompletion,
327+
codeFeatures.importCompletionOnly,
328328
];
329329
}
330330
yield `,`;

packages/language-core/lib/codegen/template/element.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function* generateComponent(
114114

115115
// auto import support
116116
yield `// @ts-ignore${newLine}`; // #2304
117-
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly);
117+
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.importCompletionOnly);
118118
yield endOfLine;
119119
}
120120
else if (dynamicTagInfo) {
@@ -190,7 +190,7 @@ export function* generateComponent(
190190

191191
// auto import support
192192
yield `// @ts-ignore${newLine}`; // #2304
193-
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly);
193+
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.importCompletionOnly);
194194
yield endOfLine;
195195
}
196196
}

packages/language-core/lib/codegen/template/styleScopedClasses.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function* generateStyleScopedClassReference(
1717
): Generator<Code> {
1818
if (!className) {
1919
yield `/** @type {__VLS_StyleScopedClasses['`;
20-
yield ['', 'template', offset, codeFeatures.additionalCompletion];
20+
yield ['', 'template', offset, codeFeatures.completion];
2121
yield `']} */${endOfLine}`;
2222
return;
2323
}
@@ -39,7 +39,7 @@ export function* generateStyleScopedClassReference(
3939
className,
4040
block.name,
4141
offset,
42-
codeFeatures.navigationAndAdditionalCompletion,
42+
codeFeatures.navigationAndCompletion,
4343
classNameEscapeRegex,
4444
);
4545
yield `'`;

packages/language-core/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export type RawVueCompilerOptions = Partial<Omit<VueCompilerOptions, 'target' |
1717
};
1818

1919
export interface VueCodeInformation extends CodeInformation {
20-
htmlAutoImport?: boolean;
20+
__importCompletion?: boolean;
2121
__combineToken?: symbol;
2222
__linkedToken?: symbol;
2323
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import type { TextDocument } from '@volar/language-server';
2+
import { afterEach, expect, test } from 'vitest';
3+
import { URI } from 'vscode-uri';
4+
import { getLanguageServer, testWorkspacePath } from './server.js';
5+
6+
const openedDocuments: TextDocument[] = [];
7+
8+
afterEach(async () => {
9+
const server = await getLanguageServer();
10+
for (const document of openedDocuments) {
11+
await server.close(document.uri);
12+
}
13+
openedDocuments.length = 0;
14+
});
15+
16+
test('"Add import" quick fix for undefined variable in template', async () => {
17+
await prepareDocument('tsconfigProject/fixture.ts', 'typescript', `export function foo() {}`);
18+
19+
const codeFixes = await requestCodeFixes(
20+
'tsconfigProject/fixture.vue',
21+
'vue',
22+
`
23+
<template>
24+
<button @click="|foo"></button>
25+
</template>
26+
27+
<script setup lang="ts">
28+
</script>
29+
`,
30+
'foo',
31+
);
32+
33+
expect(
34+
codeFixes.some(
35+
codeFix =>
36+
codeFix.fixName === 'import'
37+
&& codeFix.description.includes('./fixture'),
38+
),
39+
).toBe(true);
40+
});
41+
42+
async function requestCodeFixes(
43+
fileName: string,
44+
languageId: string,
45+
content: string,
46+
identifier: string,
47+
) {
48+
const offset = content.indexOf('|');
49+
expect(offset).toBeGreaterThanOrEqual(0);
50+
51+
content = content.slice(0, offset) + content.slice(offset + 1);
52+
53+
const server = await getLanguageServer();
54+
const document = await prepareDocument(fileName, languageId, content);
55+
const start = document.positionAt(offset);
56+
const end = document.positionAt(offset + identifier.length);
57+
58+
const diagnostics = await server.tsserver.message({
59+
seq: server.nextSeq(),
60+
command: 'semanticDiagnosticsSync',
61+
arguments: {
62+
file: URI.parse(document.uri).fsPath,
63+
startLine: start.line + 1,
64+
startOffset: start.character + 1,
65+
endLine: end.line + 1,
66+
endOffset: end.character + 1,
67+
},
68+
});
69+
expect(diagnostics.success).toBe(true);
70+
71+
const errorCodes = (diagnostics.body as any[])
72+
.map((diagnostic: any) => diagnostic.code)
73+
.filter((code): code is number => typeof code === 'number');
74+
75+
expect(errorCodes.length).toBeGreaterThan(0);
76+
77+
const res = await server.tsserver.message({
78+
seq: server.nextSeq(),
79+
command: 'getCodeFixes',
80+
arguments: {
81+
file: URI.parse(document.uri).fsPath,
82+
startLine: start.line + 1,
83+
startOffset: start.character + 1,
84+
endLine: end.line + 1,
85+
endOffset: end.character + 1,
86+
errorCodes,
87+
},
88+
});
89+
90+
expect(res.success).toBe(true);
91+
return res.body as any[];
92+
}
93+
94+
async function prepareDocument(fileName: string, languageId: string, content: string) {
95+
const server = await getLanguageServer();
96+
const uri = URI.file(`${testWorkspacePath}/${fileName}`);
97+
const document = await server.open(uri.toString(), languageId, content);
98+
if (openedDocuments.every(d => d.uri !== document.uri)) {
99+
openedDocuments.push(document);
100+
}
101+
return document;
102+
}

packages/typescript-plugin/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { transformFileTextChanges } from '@volar/typescript/lib/node/transform.j
22
import { createLanguageServicePlugin } from '@volar/typescript/lib/quickstart/createLanguageServicePlugin';
33
import * as core from '@vue/language-core';
44
import type * as ts from 'typescript';
5-
import { createVueLanguageServiceProxy, resolveCompletionEntryDetails, resolveCompletionResult } from './lib/common';
5+
import {
6+
postprocessLanguageService,
7+
preprocessLanguageService,
8+
resolveCompletionEntryDetails,
9+
resolveCompletionResult,
10+
} from './lib/common';
611
import type { Requests } from './lib/requests';
712
import { collectExtractProps } from './lib/requests/collectExtractProps';
813
import { getComponentDirectives } from './lib/requests/getComponentDirectives';
@@ -32,10 +37,14 @@ export = createLanguageServicePlugin(
3237
vueOptions.globalTypesPath = core.createGlobalTypesWriter(vueOptions, ts.sys.writeFile);
3338
addVueCommands();
3439

40+
let _language: core.Language<string> | undefined;
41+
preprocessLanguageService(info.languageService, () => _language);
42+
3543
return {
3644
languagePlugins: [languagePlugin],
3745
setup: language => {
38-
info.languageService = createVueLanguageServiceProxy(
46+
_language = language;
47+
info.languageService = postprocessLanguageService(
3948
ts,
4049
language,
4150
info.languageService,
@@ -140,7 +149,7 @@ export = createLanguageServicePlugin(
140149
}
141150
const map = language.maps.get(code, sourceScript);
142151
for (const [tsPosition, mapping] of map.toGeneratedLocation(position)) {
143-
if (!(mapping.data as core.VueCodeInformation).htmlAutoImport) {
152+
if (!(mapping.data as core.VueCodeInformation).__importCompletion) {
144153
continue;
145154
}
146155
const tsPosition2 = tsPosition + sourceScript.snapshot.getLength();

packages/typescript-plugin/lib/common.ts

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,108 @@
1-
import { type Language, type VueCompilerOptions, VueVirtualCode } from '@vue/language-core';
1+
import {
2+
toGeneratedOffset,
3+
toGeneratedRange,
4+
toSourceOffsets,
5+
toSourceRanges,
6+
} from '@volar/typescript/lib/node/transform';
7+
import { getServiceScript } from '@volar/typescript/lib/node/utils';
8+
import { type Language, type VueCodeInformation, type VueCompilerOptions, VueVirtualCode } from '@vue/language-core';
29
import { capitalize, isGloballyAllowed } from '@vue/shared';
310
import type * as ts from 'typescript';
411

512
const windowsPathReg = /\\/g;
613

7-
export function createVueLanguageServiceProxy<T>(
14+
export function preprocessLanguageService(
15+
languageService: ts.LanguageService,
16+
getLanguage: () => Language<any> | undefined,
17+
) {
18+
const { getCompletionsAtPosition, getCodeFixesAtPosition } = languageService;
19+
20+
languageService.getCompletionsAtPosition = (fileName, position, preferences, formatOptions) => {
21+
let result = getCompletionsAtPosition(fileName, position, preferences, formatOptions);
22+
const language = getLanguage();
23+
if (language) {
24+
const [serviceScript, targetScript, sourceScript] = getServiceScript(language, fileName);
25+
if (serviceScript && sourceScript?.generated?.root instanceof VueVirtualCode) {
26+
for (
27+
const sourceOffset of toSourceOffsets(
28+
sourceScript,
29+
language,
30+
serviceScript,
31+
position,
32+
() => true,
33+
)
34+
) {
35+
const generatedOffset2 = toGeneratedOffset(
36+
language,
37+
serviceScript,
38+
sourceScript,
39+
sourceOffset[1],
40+
(data: VueCodeInformation) => !!data.__importCompletion,
41+
);
42+
if (generatedOffset2 !== undefined) {
43+
const completion2 = getCompletionsAtPosition(targetScript.id, generatedOffset2, preferences, formatOptions);
44+
if (completion2) {
45+
const existingNames = new Set(result?.entries.map(entry => entry.name));
46+
for (const entry of completion2.entries) {
47+
if (!existingNames.has(entry.name)) {
48+
result?.entries.push(entry);
49+
}
50+
}
51+
}
52+
}
53+
}
54+
}
55+
}
56+
return result;
57+
};
58+
languageService.getCodeFixesAtPosition = (fileName, start, end, errorCodes, formatOptions, preferences) => {
59+
let fixes = getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences);
60+
const language = getLanguage();
61+
if (
62+
language
63+
&& errorCodes.includes(2339) // Property 'xxx' does not exist on type 'yyy'.ts(2339)
64+
) {
65+
const [serviceScript, targetScript, sourceScript] = getServiceScript(language, fileName);
66+
if (serviceScript && sourceScript?.generated?.root instanceof VueVirtualCode) {
67+
for (
68+
const sourceRange of toSourceRanges(
69+
sourceScript,
70+
language,
71+
serviceScript,
72+
start,
73+
end,
74+
true,
75+
() => true,
76+
)
77+
) {
78+
const generateRange2 = toGeneratedRange(
79+
language,
80+
serviceScript,
81+
sourceScript,
82+
sourceRange[1],
83+
sourceRange[2],
84+
(data: VueCodeInformation) => !!data.__importCompletion,
85+
);
86+
if (generateRange2 !== undefined) {
87+
let importFixes = getCodeFixesAtPosition(
88+
targetScript.id,
89+
generateRange2[0],
90+
generateRange2[1],
91+
[2304], // Cannot find name 'xxx'.ts(2304)
92+
formatOptions,
93+
preferences,
94+
);
95+
importFixes = importFixes.filter(fix => fix.fixName === 'import');
96+
fixes = fixes.concat(importFixes);
97+
}
98+
}
99+
}
100+
}
101+
return fixes;
102+
};
103+
}
104+
105+
export function postprocessLanguageService<T>(
8106
ts: typeof import('typescript'),
9107
language: Language<T>,
10108
languageService: ts.LanguageService,

test-workspace/tsconfigProject/fixture.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)