Skip to content

Commit 1d32381

Browse files
committed
refactor(language-service): move missing props hints to separate plugin
1 parent 4d93b40 commit 1d32381

File tree

3 files changed

+176
-146
lines changed

3 files changed

+176
-146
lines changed

packages/language-service/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { create as createVueDocumentDropPlugin } from './lib/plugins/vue-documen
2424
import { create as createVueDocumentLinksPlugin } from './lib/plugins/vue-document-links';
2525
import { create as createVueExtractFilePlugin } from './lib/plugins/vue-extract-file';
2626
import { create as createVueInlayHintsPlugin } from './lib/plugins/vue-inlayhints';
27+
import { create as createVueMissingPropsHintsPlugin } from './lib/plugins/vue-missing-props-hints';
2728
import { create as createVueSfcPlugin } from './lib/plugins/vue-sfc';
2829
import { create as createVueTemplatePlugin } from './lib/plugins/vue-template';
2930
import { create as createVueTwoslashQueriesPlugin } from './lib/plugins/vue-twoslash-queries';
@@ -193,6 +194,7 @@ function getCommonLanguageServicePlugins(
193194
createJsonPlugin(),
194195
createVueTemplatePlugin('html', ts, getTsPluginClient),
195196
createVueTemplatePlugin('pug', ts, getTsPluginClient),
197+
createVueMissingPropsHintsPlugin(getTsPluginClient),
196198
createVueSfcPlugin(),
197199
createVueTwoslashQueriesPlugin(getTsPluginClient),
198200
createVueDocumentLinksPlugin(),
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type { LanguageServiceContext } from '@volar/language-service';
2+
import { VueVirtualCode, hyphenateAttr, hyphenateTag } from '@vue/language-core';
3+
import * as html from 'vscode-html-languageservice';
4+
import type * as vscode from 'vscode-languageserver-protocol';
5+
import type { TextDocument } from 'vscode-languageserver-textdocument';
6+
import { URI } from 'vscode-uri';
7+
import { getNameCasing } from '../ideFeatures/nameCasing';
8+
import { AttrNameCasing, LanguageServicePlugin } from '../types';
9+
10+
export function create(
11+
getTsPluginClient?: (context: LanguageServiceContext) => import('@vue/typescript-plugin/lib/requests').Requests | undefined
12+
): LanguageServicePlugin {
13+
return {
14+
name: `vue-missing-props-hints`,
15+
capabilities: {
16+
inlayHintProvider: {},
17+
},
18+
create(context) {
19+
const tsPluginClient = getTsPluginClient?.(context);
20+
21+
return {
22+
23+
async provideInlayHints(document) {
24+
25+
if (!isSupportedDocument(document)) {
26+
return;
27+
}
28+
29+
if (!context.project.vue) {
30+
return;
31+
}
32+
const vueCompilerOptions = context.project.vue.compilerOptions;
33+
34+
const enabled = await context.env.getConfiguration?.<boolean>('vue.inlayHints.missingProps') ?? false;
35+
if (!enabled) {
36+
return;
37+
}
38+
39+
const uri = URI.parse(document.uri);
40+
const decoded = context.decodeEmbeddedDocumentUri(uri);
41+
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
42+
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
43+
if (!virtualCode) {
44+
return;
45+
}
46+
47+
const root = sourceScript?.generated?.root;
48+
if (!(root instanceof VueVirtualCode)) {
49+
return;
50+
}
51+
52+
const scanner = getScanner(context, document);
53+
if (!scanner) {
54+
return;
55+
}
56+
57+
const result: vscode.InlayHint[] = [];
58+
const casing = await getNameCasing(context, decoded[0]);
59+
const components = await tsPluginClient?.getComponentNames(root.fileName) ?? [];
60+
const componentProps: Record<string, string[]> = {};
61+
62+
let token: html.TokenType;
63+
let current: {
64+
unburnedRequiredProps: string[];
65+
labelOffset: number;
66+
insertOffset: number;
67+
} | undefined;
68+
69+
while ((token = scanner.scan()) !== html.TokenType.EOS) {
70+
if (token === html.TokenType.StartTag) {
71+
const tagName = scanner.getTokenText();
72+
const checkTag = tagName.includes('.')
73+
? tagName
74+
: components.find(component => component === tagName || hyphenateTag(component) === tagName);
75+
if (checkTag) {
76+
componentProps[checkTag] ??= (await tsPluginClient?.getComponentProps(root.fileName, checkTag) ?? [])
77+
.filter(prop => prop.required)
78+
.map(prop => prop.name);
79+
current = {
80+
unburnedRequiredProps: [...componentProps[checkTag]],
81+
labelOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
82+
insertOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
83+
};
84+
}
85+
}
86+
else if (token === html.TokenType.AttributeName) {
87+
if (current) {
88+
let attrText = scanner.getTokenText();
89+
90+
if (attrText === 'v-bind') {
91+
current.unburnedRequiredProps = [];
92+
}
93+
else {
94+
// remove modifiers
95+
if (attrText.includes('.')) {
96+
attrText = attrText.split('.')[0];
97+
}
98+
// normalize
99+
if (attrText.startsWith('v-bind:')) {
100+
attrText = attrText.slice('v-bind:'.length);
101+
}
102+
else if (attrText.startsWith(':')) {
103+
attrText = attrText.slice(':'.length);
104+
}
105+
else if (attrText.startsWith('v-model:')) {
106+
attrText = attrText.slice('v-model:'.length);
107+
}
108+
else if (attrText === 'v-model') {
109+
attrText = vueCompilerOptions.target >= 3 ? 'modelValue' : 'value'; // TODO: support for experimentalModelPropName?
110+
}
111+
else if (attrText.startsWith('v-on:')) {
112+
attrText = 'on-' + hyphenateAttr(attrText.slice('v-on:'.length));
113+
}
114+
else if (attrText.startsWith('@')) {
115+
attrText = 'on-' + hyphenateAttr(attrText.slice('@'.length));
116+
}
117+
118+
current.unburnedRequiredProps = current.unburnedRequiredProps.filter(propName => {
119+
return attrText !== propName
120+
&& attrText !== hyphenateAttr(propName);
121+
});
122+
}
123+
}
124+
}
125+
else if (token === html.TokenType.StartTagSelfClose || token === html.TokenType.StartTagClose) {
126+
if (current) {
127+
for (const requiredProp of current.unburnedRequiredProps) {
128+
result.push({
129+
label: `${requiredProp}!`,
130+
paddingLeft: true,
131+
position: document.positionAt(current.labelOffset),
132+
kind: 2 satisfies typeof vscode.InlayHintKind.Parameter,
133+
textEdits: [{
134+
range: {
135+
start: document.positionAt(current.insertOffset),
136+
end: document.positionAt(current.insertOffset),
137+
},
138+
newText: ` :${casing.attr === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp) : requiredProp}=`,
139+
}],
140+
});
141+
}
142+
current = undefined;
143+
}
144+
}
145+
if (token === html.TokenType.AttributeName || token === html.TokenType.AttributeValue) {
146+
if (current) {
147+
current.insertOffset = scanner.getTokenOffset() + scanner.getTokenLength();
148+
}
149+
}
150+
}
151+
152+
return result;
153+
},
154+
};
155+
},
156+
};
157+
158+
function getScanner(context: LanguageServiceContext, document: TextDocument): html.Scanner | undefined {
159+
if (document.languageId === 'html') {
160+
return context.inject('html/languageService').createScanner(document.getText());
161+
}
162+
else {
163+
const pugDocument = context.inject('pug/pugDocument', document);
164+
if (pugDocument) {
165+
return context.inject('pug/languageService').createScanner(pugDocument);
166+
}
167+
}
168+
}
169+
170+
function isSupportedDocument(document: TextDocument) {
171+
return document.languageId === 'jade' || document.languageId === 'html';
172+
}
173+
}

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

Lines changed: 1 addition & 146 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Disposable, LanguageServiceContext, LanguageServicePluginInstance } from '@volar/language-service';
1+
import type { Disposable, LanguageServiceContext } from '@volar/language-service';
22
import { VueVirtualCode, hyphenateAttr, hyphenateTag, tsCodegen } from '@vue/language-core';
33
import { camelize, capitalize } from '@vue/shared';
44
import { getComponentSpans } from '@vue/typescript-plugin/lib/common';
@@ -198,139 +198,6 @@ export function create(
198198
return htmlComplete;
199199
},
200200

201-
async provideInlayHints(document) {
202-
203-
if (!isSupportedDocument(document)) {
204-
return;
205-
}
206-
207-
if (!context.project.vue) {
208-
return;
209-
}
210-
const vueCompilerOptions = context.project.vue.compilerOptions;
211-
212-
const enabled = await context.env.getConfiguration?.<boolean>('vue.inlayHints.missingProps') ?? false;
213-
if (!enabled) {
214-
return;
215-
}
216-
217-
const uri = URI.parse(document.uri);
218-
const decoded = context.decodeEmbeddedDocumentUri(uri);
219-
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
220-
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
221-
if (!virtualCode) {
222-
return;
223-
}
224-
225-
const root = sourceScript?.generated?.root;
226-
if (!(root instanceof VueVirtualCode)) {
227-
return;
228-
}
229-
230-
const scanner = getScanner(baseServiceInstance, document);
231-
if (!scanner) {
232-
return;
233-
}
234-
235-
const result: vscode.InlayHint[] = [];
236-
237-
// visualize missing required props
238-
const casing = await getNameCasing(context, decoded[0]);
239-
const components = await tsPluginClient?.getComponentNames(root.fileName) ?? [];
240-
const componentProps: Record<string, string[]> = {};
241-
let token: html.TokenType;
242-
let current: {
243-
unburnedRequiredProps: string[];
244-
labelOffset: number;
245-
insertOffset: number;
246-
} | undefined;
247-
248-
while ((token = scanner.scan()) !== html.TokenType.EOS) {
249-
if (token === html.TokenType.StartTag) {
250-
const tagName = scanner.getTokenText();
251-
const checkTag = tagName.includes('.')
252-
? tagName
253-
: components.find(component => component === tagName || hyphenateTag(component) === tagName);
254-
if (checkTag) {
255-
componentProps[checkTag] ??= (await tsPluginClient?.getComponentProps(root.fileName, checkTag) ?? [])
256-
.filter(prop => prop.required)
257-
.map(prop => prop.name);
258-
current = {
259-
unburnedRequiredProps: [...componentProps[checkTag]],
260-
labelOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
261-
insertOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
262-
};
263-
}
264-
}
265-
else if (token === html.TokenType.AttributeName) {
266-
if (current) {
267-
let attrText = scanner.getTokenText();
268-
269-
if (attrText === 'v-bind') {
270-
current.unburnedRequiredProps = [];
271-
}
272-
else {
273-
// remove modifiers
274-
if (attrText.includes('.')) {
275-
attrText = attrText.split('.')[0];
276-
}
277-
// normalize
278-
if (attrText.startsWith('v-bind:')) {
279-
attrText = attrText.slice('v-bind:'.length);
280-
}
281-
else if (attrText.startsWith(':')) {
282-
attrText = attrText.slice(':'.length);
283-
}
284-
else if (attrText.startsWith('v-model:')) {
285-
attrText = attrText.slice('v-model:'.length);
286-
}
287-
else if (attrText === 'v-model') {
288-
attrText = vueCompilerOptions.target >= 3 ? 'modelValue' : 'value'; // TODO: support for experimentalModelPropName?
289-
}
290-
else if (attrText.startsWith('v-on:')) {
291-
attrText = 'on-' + hyphenateAttr(attrText.slice('v-on:'.length));
292-
}
293-
else if (attrText.startsWith('@')) {
294-
attrText = 'on-' + hyphenateAttr(attrText.slice('@'.length));
295-
}
296-
297-
current.unburnedRequiredProps = current.unburnedRequiredProps.filter(propName => {
298-
return attrText !== propName
299-
&& attrText !== hyphenateAttr(propName);
300-
});
301-
}
302-
}
303-
}
304-
else if (token === html.TokenType.StartTagSelfClose || token === html.TokenType.StartTagClose) {
305-
if (current) {
306-
for (const requiredProp of current.unburnedRequiredProps) {
307-
result.push({
308-
label: `${requiredProp}!`,
309-
paddingLeft: true,
310-
position: document.positionAt(current.labelOffset),
311-
kind: 2 satisfies typeof vscode.InlayHintKind.Parameter,
312-
textEdits: [{
313-
range: {
314-
start: document.positionAt(current.insertOffset),
315-
end: document.positionAt(current.insertOffset),
316-
},
317-
newText: ` :${casing.attr === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp) : requiredProp}=`,
318-
}],
319-
});
320-
}
321-
current = undefined;
322-
}
323-
}
324-
if (token === html.TokenType.AttributeName || token === html.TokenType.AttributeValue) {
325-
if (current) {
326-
current.insertOffset = scanner.getTokenOffset() + scanner.getTokenLength();
327-
}
328-
}
329-
}
330-
331-
return result;
332-
},
333-
334201
provideHover(document, position, token) {
335202

336203
if (!isSupportedDocument(document)) {
@@ -993,18 +860,6 @@ export function create(
993860
},
994861
};
995862

996-
function getScanner(service: LanguageServicePluginInstance, document: TextDocument) {
997-
if (mode === 'html') {
998-
return service.provide['html/languageService']().createScanner(document.getText());
999-
}
1000-
else {
1001-
const pugDocument = service.provide['pug/pugDocument'](document);
1002-
if (pugDocument) {
1003-
return service.provide['pug/languageService']().createScanner(pugDocument);
1004-
}
1005-
}
1006-
}
1007-
1008863
function updateExtraCustomData(extraData: html.IHTMLDataProvider[]) {
1009864
extraCustomData = extraData;
1010865
onDidChangeCustomDataListeners.forEach(l => l());

0 commit comments

Comments
 (0)