From 266b1650f83d0ae382532a31fdcde6ee1a5bbd20 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Thu, 6 Mar 2025 23:08:59 +0800 Subject: [PATCH] refactor(language-service): move missing props hints to separate plugin --- packages/language-service/index.ts | 2 + .../lib/plugins/vue-missing-props-hints.ts | 173 ++++++++++++++++++ .../lib/plugins/vue-template.ts | 148 +-------------- 3 files changed, 176 insertions(+), 147 deletions(-) create mode 100644 packages/language-service/lib/plugins/vue-missing-props-hints.ts diff --git a/packages/language-service/index.ts b/packages/language-service/index.ts index 764711814f..9a14bb46ed 100644 --- a/packages/language-service/index.ts +++ b/packages/language-service/index.ts @@ -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'; @@ -193,6 +194,7 @@ function getCommonLanguageServicePlugins( createJsonPlugin(), createVueTemplatePlugin('html', ts, getTsPluginClient), createVueTemplatePlugin('pug', ts, getTsPluginClient), + createVueMissingPropsHintsPlugin(getTsPluginClient), createVueSfcPlugin(), createVueTwoslashQueriesPlugin(getTsPluginClient), createVueDocumentLinksPlugin(), diff --git a/packages/language-service/lib/plugins/vue-missing-props-hints.ts b/packages/language-service/lib/plugins/vue-missing-props-hints.ts new file mode 100644 index 0000000000..80f6e91493 --- /dev/null +++ b/packages/language-service/lib/plugins/vue-missing-props-hints.ts @@ -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?.('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 = {}; + + 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'; + } +} diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 149887d5f5..d728efb96e 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -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'; @@ -86,7 +86,6 @@ export function create( '@', // vue event shorthand ], }, - inlayHintProvider: {}, hoverProvider: true, diagnosticProvider: { interFileDependencies: false, @@ -198,139 +197,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?.('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 = {}; - 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)) { @@ -993,18 +859,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());