Skip to content

Commit 15cc24e

Browse files
feat(language-service): enhanced component auto import (#5790)
1 parent 07db66a commit 15cc24e

File tree

9 files changed

+421
-126
lines changed

9 files changed

+421
-126
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const raw = {
77
semantic: true,
88
navigation: true,
99
},
10+
htmlAutoImportOnly: {
11+
htmlAutoImport: true,
12+
},
1013
verification: {
1114
verification: true,
1215
},

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,11 @@ export function* generateComponent(
111111
yield `, `;
112112
}
113113
yield `]} */${endOfLine}`;
114+
115+
// auto import support
116+
yield `// @ts-ignore${newLine}`; // #2304
117+
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly);
118+
yield endOfLine;
114119
}
115120
else if (dynamicTagInfo) {
116121
yield `const ${componentOriginalVar} = (`;
@@ -185,17 +190,7 @@ export function* generateComponent(
185190

186191
// auto import support
187192
yield `// @ts-ignore${newLine}`; // #2304
188-
yield* generateCamelized(
189-
capitalize(node.tag),
190-
'template',
191-
tagOffsets[0],
192-
{
193-
completion: {
194-
isAdditional: true,
195-
onlyImport: true,
196-
},
197-
},
198-
);
193+
yield* generateCamelized(capitalize(node.tag), 'template', tagOffsets[0], codeFeatures.htmlAutoImportOnly);
199194
yield endOfLine;
200195
}
201196
}

packages/language-core/lib/types.ts

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

1919
export interface VueCodeInformation extends CodeInformation {
20+
htmlAutoImport?: boolean;
2021
__combineToken?: symbol;
2122
__linkedToken?: symbol;
2223
}

packages/language-server/lib/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,12 @@ export function startServer(ts: typeof import('typescript')) {
123123
getImportPathForFile(...args) {
124124
return sendTsServerRequest('_vue:getImportPathForFile', args);
125125
},
126+
getAutoImportSuggestions(...args) {
127+
return sendTsServerRequest('_vue:getAutoImportSuggestions', args);
128+
},
129+
resolveAutoImportCompletionEntry(...args) {
130+
return sendTsServerRequest('_vue:resolveAutoImportCompletionEntry', args);
131+
},
126132
isRefAtPosition(...args) {
127133
return sendTsServerRequest('_vue:isRefAtPosition', args);
128134
},

packages/language-service/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export function createVueLanguageServicePlugins(
7474
createVueDocumentHighlightsPlugin(client),
7575
createVueExtractFilePlugin(ts, client),
7676
createVueMissingPropsHintsPlugin(client),
77-
createVueTemplatePlugin('html', client),
78-
createVueTemplatePlugin('jade', client),
77+
createVueTemplatePlugin(ts, 'html', client),
78+
createVueTemplatePlugin(ts, 'jade', client),
7979
createVueTwoslashQueriesPlugin(client),
8080
];
8181
}

packages/language-service/lib/plugins/vue-template.ts

Lines changed: 154 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import type {
2-
CompletionItemKind,
3-
CompletionItemTag,
4-
CompletionList,
5-
Disposable,
6-
LanguageServicePlugin,
7-
TextDocument,
1+
import {
2+
type CompletionItemKind,
3+
type CompletionItemTag,
4+
type CompletionList,
5+
type Disposable,
6+
type LanguageServicePlugin,
7+
type TextDocument,
8+
transformCompletionItem,
89
} from '@volar/language-service';
10+
import { getSourceRange } from '@volar/language-service/lib/utils/featureWorkers';
911
import {
1012
forEachInterpolationNode,
1113
hyphenateAttr,
@@ -17,6 +19,12 @@ import { camelize, capitalize } from '@vue/shared';
1719
import type { ComponentPropInfo } from '@vue/typescript-plugin/lib/requests/getComponentProps';
1820
import { create as createHtmlService, resolveReference } from 'volar-service-html';
1921
import { create as createPugService } from 'volar-service-pug';
22+
import { getFormatCodeSettings } from 'volar-service-typescript/lib/configs/getFormatCodeSettings.js';
23+
import { getUserPreferences } from 'volar-service-typescript/lib/configs/getUserPreferences.js';
24+
import {
25+
applyCompletionEntryDetails,
26+
convertCompletionInfo,
27+
} from 'volar-service-typescript/lib/utils/lspConverters.js';
2028
import * as html from 'vscode-html-languageservice';
2129
import { URI, Utils } from 'vscode-uri';
2230
import { loadModelModifiersData, loadTemplateData } from '../data';
@@ -51,6 +59,7 @@ let builtInData: html.HTMLDataV1 | undefined;
5159
let modelData: html.HTMLDataV1 | undefined;
5260

5361
export function create(
62+
ts: typeof import('typescript'),
5463
languageId: 'html' | 'jade',
5564
{
5665
getComponentNames,
@@ -60,6 +69,8 @@ export function create(
6069
getComponentSlots,
6170
getElementAttrs,
6271
resolveModuleName,
72+
getAutoImportSuggestions,
73+
resolveAutoImportCompletionEntry,
6374
}: import('@vue/typescript-plugin/lib/requests').Requests,
6475
): LanguageServicePlugin {
6576
let customData: html.IHTMLDataProvider[] = [];
@@ -264,8 +275,11 @@ export function create(
264275
}
265276

266277
const disposable = context.env.onDidChangeConfiguration?.(() => initializing = undefined);
278+
const transformedItems = new WeakSet<html.CompletionItem>();
267279

268280
let initializing: Promise<void> | undefined;
281+
let formattingOptions: html.FormattingOptions | undefined;
282+
let lastCompletionDocument: TextDocument | undefined;
269283

270284
return {
271285
...baseServiceInstance,
@@ -275,6 +289,16 @@ export function create(
275289
disposable?.dispose();
276290
},
277291

292+
provideDocumentFormattingEdits(document, range, options, ...rest) {
293+
formattingOptions = options;
294+
return baseServiceInstance.provideDocumentFormattingEdits?.(document, range, options, ...rest);
295+
},
296+
297+
provideOnTypeFormattingEdits(document, position, key, options, ...rest) {
298+
formattingOptions = options;
299+
return baseServiceInstance.provideOnTypeFormattingEdits?.(document, position, key, options, ...rest);
300+
},
301+
278302
async provideCompletionItems(document, position, completionContext, token) {
279303
if (document.languageId !== languageId) {
280304
return;
@@ -285,9 +309,10 @@ export function create(
285309
}
286310

287311
const {
288-
result: completionList,
312+
result: htmlCompletion,
289313
target,
290314
info: {
315+
tagNameCasing,
291316
components,
292317
propMap,
293318
},
@@ -303,24 +328,71 @@ export function create(
303328
),
304329
);
305330

306-
if (!completionList) {
331+
if (!htmlCompletion) {
307332
return;
308333
}
309334

335+
const autoImportPlaceholderIndex = htmlCompletion.items.findIndex(item =>
336+
item.label === 'AutoImportsPlaceholder'
337+
);
338+
if (autoImportPlaceholderIndex !== -1) {
339+
const offset = document.offsetAt(position);
340+
const map = context.language.maps.get(info.code, info.script);
341+
let spliced = false;
342+
for (const [sourceOffset] of map.toSourceLocation(offset)) {
343+
const [formatOptions, preferences] = await Promise.all([
344+
getFormatCodeSettings(context, document, formattingOptions),
345+
getUserPreferences(context, document),
346+
]);
347+
const autoImport = await getAutoImportSuggestions(
348+
info.root.fileName,
349+
sourceOffset,
350+
preferences,
351+
formatOptions,
352+
);
353+
if (!autoImport) {
354+
continue;
355+
}
356+
const tsCompletion = convertCompletionInfo(ts, autoImport, document, position, entry => entry.data);
357+
const placeholder = htmlCompletion.items[autoImportPlaceholderIndex]!;
358+
for (const tsItem of tsCompletion.items) {
359+
if (placeholder.textEdit) {
360+
const newText = tsItem.textEdit?.newText ?? tsItem.label;
361+
tsItem.textEdit = {
362+
...placeholder.textEdit,
363+
newText: tagNameCasing === TagNameCasing.Kebab
364+
? hyphenateTag(newText)
365+
: newText,
366+
};
367+
}
368+
else {
369+
tsItem.textEdit = undefined;
370+
}
371+
}
372+
htmlCompletion.items.splice(autoImportPlaceholderIndex, 1, ...tsCompletion.items);
373+
spliced = true;
374+
lastCompletionDocument = document;
375+
break;
376+
}
377+
if (!spliced) {
378+
htmlCompletion.items.splice(autoImportPlaceholderIndex, 1);
379+
}
380+
}
381+
310382
switch (target) {
311383
case 'tag': {
312-
completionList.items.forEach(transformTag);
384+
htmlCompletion.items.forEach(transformTag);
313385
break;
314386
}
315387
case 'attribute': {
316-
addDirectiveModifiers(completionList, document);
317-
completionList.items.forEach(transformAttribute);
388+
addDirectiveModifiers(htmlCompletion, document);
389+
htmlCompletion.items.forEach(transformAttribute);
318390
break;
319391
}
320392
}
321393

322394
updateExtraCustomData([]);
323-
return completionList;
395+
return htmlCompletion;
324396

325397
function transformTag(item: html.CompletionItem) {
326398
const tagName = capitalize(camelize(item.label));
@@ -432,6 +504,61 @@ export function create(
432504
}
433505
},
434506

507+
async resolveCompletionItem(item) {
508+
if (item.data?.__isAutoImport || item.data?.__isComponentAutoImport) {
509+
const embeddedUri = URI.parse(lastCompletionDocument!.uri);
510+
const decoded = context.decodeEmbeddedDocumentUri(embeddedUri);
511+
if (!decoded) {
512+
return item;
513+
}
514+
const sourceScript = context.language.scripts.get(decoded[0]);
515+
if (!sourceScript) {
516+
return item;
517+
}
518+
const [formatOptions, preferences] = await Promise.all([
519+
getFormatCodeSettings(context, lastCompletionDocument!, formattingOptions),
520+
getUserPreferences(context, lastCompletionDocument!),
521+
]);
522+
const details = await resolveAutoImportCompletionEntry(item.data, preferences, formatOptions);
523+
if (details) {
524+
const virtualCode = sourceScript.generated!.embeddedCodes.get(decoded[1])!;
525+
const sourceDocument = context.documents.get(
526+
sourceScript.id,
527+
sourceScript.languageId,
528+
sourceScript.snapshot,
529+
);
530+
const embeddedDocument = context.documents.get(embeddedUri, virtualCode.languageId, virtualCode.snapshot);
531+
const map = context.language.maps.get(virtualCode, sourceScript);
532+
item = transformCompletionItem(
533+
item,
534+
embeddedRange =>
535+
getSourceRange(
536+
[sourceDocument, embeddedDocument, map],
537+
embeddedRange,
538+
),
539+
embeddedDocument,
540+
context,
541+
);
542+
applyCompletionEntryDetails(
543+
ts,
544+
item,
545+
details,
546+
sourceDocument,
547+
fileName => URI.file(fileName),
548+
() => undefined,
549+
);
550+
transformedItems.add(item);
551+
}
552+
}
553+
return item;
554+
},
555+
556+
transformCompletionItem(item) {
557+
if (transformedItems.has(item)) {
558+
return item;
559+
}
560+
},
561+
435562
provideHover(document, position, token) {
436563
if (document.languageId !== languageId) {
437564
return;
@@ -761,6 +888,19 @@ export function create(
761888
}));
762889
},
763890
},
891+
{
892+
getId: () => 'vue-auto-imports',
893+
isApplicable: () => true,
894+
provideTags() {
895+
return [{ name: 'AutoImportsPlaceholder', attributes: [] }];
896+
},
897+
provideAttributes() {
898+
return [];
899+
},
900+
provideValues() {
901+
return [];
902+
},
903+
},
764904
]);
765905

766906
return {
@@ -770,6 +910,7 @@ export function create(
770910
version,
771911
target,
772912
info: {
913+
tagNameCasing,
773914
components,
774915
propMap,
775916
},

0 commit comments

Comments
 (0)