Skip to content

Commit

Permalink
refactor(language-service): move missing props hints to separate plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
johnsoncodehk committed Mar 6, 2025
1 parent 4d93b40 commit 1d32381
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 146 deletions.
2 changes: 2 additions & 0 deletions packages/language-service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { create as createVueDocumentDropPlugin } from './lib/plugins/vue-documen
import { create as createVueDocumentLinksPlugin } from './lib/plugins/vue-document-links';
import { create as createVueExtractFilePlugin } from './lib/plugins/vue-extract-file';
import { create as createVueInlayHintsPlugin } from './lib/plugins/vue-inlayhints';
import { create as createVueMissingPropsHintsPlugin } from './lib/plugins/vue-missing-props-hints';
import { create as createVueSfcPlugin } from './lib/plugins/vue-sfc';
import { create as createVueTemplatePlugin } from './lib/plugins/vue-template';
import { create as createVueTwoslashQueriesPlugin } from './lib/plugins/vue-twoslash-queries';
Expand Down Expand Up @@ -193,6 +194,7 @@ function getCommonLanguageServicePlugins(
createJsonPlugin(),
createVueTemplatePlugin('html', ts, getTsPluginClient),
createVueTemplatePlugin('pug', ts, getTsPluginClient),
createVueMissingPropsHintsPlugin(getTsPluginClient),
createVueSfcPlugin(),
createVueTwoslashQueriesPlugin(getTsPluginClient),
createVueDocumentLinksPlugin(),
Expand Down
173 changes: 173 additions & 0 deletions packages/language-service/lib/plugins/vue-missing-props-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { LanguageServiceContext } from '@volar/language-service';
import { VueVirtualCode, hyphenateAttr, hyphenateTag } from '@vue/language-core';
import * as html from 'vscode-html-languageservice';
import type * as vscode from 'vscode-languageserver-protocol';
import type { TextDocument } from 'vscode-languageserver-textdocument';
import { URI } from 'vscode-uri';
import { getNameCasing } from '../ideFeatures/nameCasing';
import { AttrNameCasing, LanguageServicePlugin } from '../types';

export function create(
getTsPluginClient?: (context: LanguageServiceContext) => import('@vue/typescript-plugin/lib/requests').Requests | undefined
): LanguageServicePlugin {
return {
name: `vue-missing-props-hints`,
capabilities: {
inlayHintProvider: {},
},
create(context) {
const tsPluginClient = getTsPluginClient?.(context);

return {

async provideInlayHints(document) {

if (!isSupportedDocument(document)) {
return;
}

if (!context.project.vue) {
return;
}
const vueCompilerOptions = context.project.vue.compilerOptions;

const enabled = await context.env.getConfiguration?.<boolean>('vue.inlayHints.missingProps') ?? false;
if (!enabled) {
return;
}

const uri = URI.parse(document.uri);
const decoded = context.decodeEmbeddedDocumentUri(uri);
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
if (!virtualCode) {
return;
}

const root = sourceScript?.generated?.root;
if (!(root instanceof VueVirtualCode)) {
return;
}

const scanner = getScanner(context, document);
if (!scanner) {
return;
}

const result: vscode.InlayHint[] = [];
const casing = await getNameCasing(context, decoded[0]);
const components = await tsPluginClient?.getComponentNames(root.fileName) ?? [];
const componentProps: Record<string, string[]> = {};

let token: html.TokenType;
let current: {
unburnedRequiredProps: string[];
labelOffset: number;
insertOffset: number;
} | undefined;

while ((token = scanner.scan()) !== html.TokenType.EOS) {
if (token === html.TokenType.StartTag) {
const tagName = scanner.getTokenText();
const checkTag = tagName.includes('.')
? tagName
: components.find(component => component === tagName || hyphenateTag(component) === tagName);
if (checkTag) {
componentProps[checkTag] ??= (await tsPluginClient?.getComponentProps(root.fileName, checkTag) ?? [])
.filter(prop => prop.required)
.map(prop => prop.name);
current = {
unburnedRequiredProps: [...componentProps[checkTag]],
labelOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
insertOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
};
}
}
else if (token === html.TokenType.AttributeName) {
if (current) {
let attrText = scanner.getTokenText();

if (attrText === 'v-bind') {
current.unburnedRequiredProps = [];
}
else {
// remove modifiers
if (attrText.includes('.')) {
attrText = attrText.split('.')[0];
}
// normalize
if (attrText.startsWith('v-bind:')) {
attrText = attrText.slice('v-bind:'.length);
}
else if (attrText.startsWith(':')) {
attrText = attrText.slice(':'.length);
}
else if (attrText.startsWith('v-model:')) {
attrText = attrText.slice('v-model:'.length);
}
else if (attrText === 'v-model') {
attrText = vueCompilerOptions.target >= 3 ? 'modelValue' : 'value'; // TODO: support for experimentalModelPropName?
}
else if (attrText.startsWith('v-on:')) {
attrText = 'on-' + hyphenateAttr(attrText.slice('v-on:'.length));
}
else if (attrText.startsWith('@')) {
attrText = 'on-' + hyphenateAttr(attrText.slice('@'.length));
}

current.unburnedRequiredProps = current.unburnedRequiredProps.filter(propName => {
return attrText !== propName
&& attrText !== hyphenateAttr(propName);
});
}
}
}
else if (token === html.TokenType.StartTagSelfClose || token === html.TokenType.StartTagClose) {
if (current) {
for (const requiredProp of current.unburnedRequiredProps) {
result.push({
label: `${requiredProp}!`,
paddingLeft: true,
position: document.positionAt(current.labelOffset),
kind: 2 satisfies typeof vscode.InlayHintKind.Parameter,
textEdits: [{
range: {
start: document.positionAt(current.insertOffset),
end: document.positionAt(current.insertOffset),
},
newText: ` :${casing.attr === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp) : requiredProp}=`,
}],
});
}
current = undefined;
}
}
if (token === html.TokenType.AttributeName || token === html.TokenType.AttributeValue) {
if (current) {
current.insertOffset = scanner.getTokenOffset() + scanner.getTokenLength();
}
}
}

return result;
},
};
},
};

function getScanner(context: LanguageServiceContext, document: TextDocument): html.Scanner | undefined {
if (document.languageId === 'html') {
return context.inject('html/languageService').createScanner(document.getText());
}
else {
const pugDocument = context.inject('pug/pugDocument', document);
if (pugDocument) {
return context.inject('pug/languageService').createScanner(pugDocument);
}
}
}

function isSupportedDocument(document: TextDocument) {
return document.languageId === 'jade' || document.languageId === 'html';
}
}
147 changes: 1 addition & 146 deletions packages/language-service/lib/plugins/vue-template.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Disposable, LanguageServiceContext, LanguageServicePluginInstance } from '@volar/language-service';
import type { Disposable, LanguageServiceContext } from '@volar/language-service';
import { VueVirtualCode, hyphenateAttr, hyphenateTag, tsCodegen } from '@vue/language-core';
import { camelize, capitalize } from '@vue/shared';
import { getComponentSpans } from '@vue/typescript-plugin/lib/common';
Expand Down Expand Up @@ -198,139 +198,6 @@ export function create(
return htmlComplete;
},

async provideInlayHints(document) {

if (!isSupportedDocument(document)) {
return;
}

if (!context.project.vue) {
return;
}
const vueCompilerOptions = context.project.vue.compilerOptions;

const enabled = await context.env.getConfiguration?.<boolean>('vue.inlayHints.missingProps') ?? false;
if (!enabled) {
return;
}

const uri = URI.parse(document.uri);
const decoded = context.decodeEmbeddedDocumentUri(uri);
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
if (!virtualCode) {
return;
}

const root = sourceScript?.generated?.root;
if (!(root instanceof VueVirtualCode)) {
return;
}

const scanner = getScanner(baseServiceInstance, document);
if (!scanner) {
return;
}

const result: vscode.InlayHint[] = [];

// visualize missing required props
const casing = await getNameCasing(context, decoded[0]);
const components = await tsPluginClient?.getComponentNames(root.fileName) ?? [];
const componentProps: Record<string, string[]> = {};
let token: html.TokenType;
let current: {
unburnedRequiredProps: string[];
labelOffset: number;
insertOffset: number;
} | undefined;

while ((token = scanner.scan()) !== html.TokenType.EOS) {
if (token === html.TokenType.StartTag) {
const tagName = scanner.getTokenText();
const checkTag = tagName.includes('.')
? tagName
: components.find(component => component === tagName || hyphenateTag(component) === tagName);
if (checkTag) {
componentProps[checkTag] ??= (await tsPluginClient?.getComponentProps(root.fileName, checkTag) ?? [])
.filter(prop => prop.required)
.map(prop => prop.name);
current = {
unburnedRequiredProps: [...componentProps[checkTag]],
labelOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
insertOffset: scanner.getTokenOffset() + scanner.getTokenLength(),
};
}
}
else if (token === html.TokenType.AttributeName) {
if (current) {
let attrText = scanner.getTokenText();

if (attrText === 'v-bind') {
current.unburnedRequiredProps = [];
}
else {
// remove modifiers
if (attrText.includes('.')) {
attrText = attrText.split('.')[0];
}
// normalize
if (attrText.startsWith('v-bind:')) {
attrText = attrText.slice('v-bind:'.length);
}
else if (attrText.startsWith(':')) {
attrText = attrText.slice(':'.length);
}
else if (attrText.startsWith('v-model:')) {
attrText = attrText.slice('v-model:'.length);
}
else if (attrText === 'v-model') {
attrText = vueCompilerOptions.target >= 3 ? 'modelValue' : 'value'; // TODO: support for experimentalModelPropName?
}
else if (attrText.startsWith('v-on:')) {
attrText = 'on-' + hyphenateAttr(attrText.slice('v-on:'.length));
}
else if (attrText.startsWith('@')) {
attrText = 'on-' + hyphenateAttr(attrText.slice('@'.length));
}

current.unburnedRequiredProps = current.unburnedRequiredProps.filter(propName => {
return attrText !== propName
&& attrText !== hyphenateAttr(propName);
});
}
}
}
else if (token === html.TokenType.StartTagSelfClose || token === html.TokenType.StartTagClose) {
if (current) {
for (const requiredProp of current.unburnedRequiredProps) {
result.push({
label: `${requiredProp}!`,
paddingLeft: true,
position: document.positionAt(current.labelOffset),
kind: 2 satisfies typeof vscode.InlayHintKind.Parameter,
textEdits: [{
range: {
start: document.positionAt(current.insertOffset),
end: document.positionAt(current.insertOffset),
},
newText: ` :${casing.attr === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp) : requiredProp}=`,
}],
});
}
current = undefined;
}
}
if (token === html.TokenType.AttributeName || token === html.TokenType.AttributeValue) {
if (current) {
current.insertOffset = scanner.getTokenOffset() + scanner.getTokenLength();
}
}
}

return result;
},

provideHover(document, position, token) {

if (!isSupportedDocument(document)) {
Expand Down Expand Up @@ -993,18 +860,6 @@ export function create(
},
};

function getScanner(service: LanguageServicePluginInstance, document: TextDocument) {
if (mode === 'html') {
return service.provide['html/languageService']().createScanner(document.getText());
}
else {
const pugDocument = service.provide['pug/pugDocument'](document);
if (pugDocument) {
return service.provide['pug/languageService']().createScanner(pugDocument);
}
}
}

function updateExtraCustomData(extraData: html.IHTMLDataProvider[]) {
extraCustomData = extraData;
onDidChangeCustomDataListeners.forEach(l => l());
Expand Down

0 comments on commit 1d32381

Please sign in to comment.