From b888b539323ad3eaee242bc6ea9038170382354f Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 03:20:26 +0800 Subject: [PATCH 1/8] feat: refine props completion logic to follow TS behavior --- packages/language-server/lib/server.ts | 3 - .../lib/plugins/vue-missing-props-hints.ts | 7 +- .../lib/plugins/vue-template.ts | 70 +++--- packages/typescript-plugin/index.ts | 22 +- .../lib/requests/getComponentEvents.ts | 48 ----- .../lib/requests/getComponentProps.ts | 199 ++++++++---------- .../typescript-plugin/lib/requests/index.ts | 6 +- .../lib/requests/isRefAtPosition.ts | 25 +-- .../typescript-plugin/lib/requests/utils.ts | 40 ++++ 9 files changed, 192 insertions(+), 228 deletions(-) delete mode 100644 packages/typescript-plugin/lib/requests/getComponentEvents.ts diff --git a/packages/language-server/lib/server.ts b/packages/language-server/lib/server.ts index 7edff3994a..a361c7bf6a 100644 --- a/packages/language-server/lib/server.ts +++ b/packages/language-server/lib/server.ts @@ -102,9 +102,6 @@ export function startServer(ts: typeof import('typescript')) { getComponentDirectives(...args) { return sendTsServerRequest('_vue:getComponentDirectives', args); }, - getComponentEvents(...args) { - return sendTsServerRequest('_vue:getComponentEvents', args); - }, getComponentNames(...args) { return sendTsServerRequest('_vue:getComponentNames', args); }, diff --git a/packages/language-service/lib/plugins/vue-missing-props-hints.ts b/packages/language-service/lib/plugins/vue-missing-props-hints.ts index 3cf95bbf07..ec8f2c8b87 100644 --- a/packages/language-service/lib/plugins/vue-missing-props-hints.ts +++ b/packages/language-service/lib/plugins/vue-missing-props-hints.ts @@ -33,6 +33,11 @@ export function create( return; } + const { template } = info.root.sfc; + if (!template) { + return; + } + const scanner = getScanner(context, document); if (!scanner) { return; @@ -77,7 +82,7 @@ export function create( } componentProps.set( checkTag, - (await getComponentProps(info.root.fileName, checkTag) ?? []) + (await getComponentProps(info.root.fileName, tagOffset + template.startTagEnd) ?? []) .filter(prop => prop.required) .map(prop => prop.name), ); diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 0784333f74..25003454ff 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -6,7 +6,8 @@ import type { LanguageServicePlugin, TextDocument, } from '@volar/language-service'; -import { hyphenateAttr, hyphenateTag, tsCodegen, type VueVirtualCode } from '@vue/language-core'; +import type * as CompilerDOM from '@vue/compiler-dom'; +import { getElementTagOffsets, hyphenateAttr, hyphenateTag, tsCodegen, type VueVirtualCode } from '@vue/language-core'; import { camelize, capitalize } from '@vue/shared'; import type { ComponentPropInfo } from '@vue/typescript-plugin/lib/requests/getComponentProps'; import { create as createHtmlService } from 'volar-service-html'; @@ -41,7 +42,6 @@ export function create( getComponentNames, getElementAttrs, getComponentProps, - getComponentEvents, getComponentDirectives, getComponentSlots, }: import('@vue/typescript-plugin/lib/requests').Requests, @@ -176,6 +176,7 @@ export function create( } = await runWithVueData( info.script.id, info.root, + document.offsetAt(position), () => baseServiceInstance.provideCompletionItems!( document, @@ -333,11 +334,16 @@ export function create( }, }; - async function runWithVueData(sourceDocumentUri: URI, root: VueVirtualCode, fn: () => T) { + async function runWithVueData( + sourceDocumentUri: URI, + root: VueVirtualCode, + position: number, + fn: () => T, + ) { // #4298: Precompute HTMLDocument before provideHtmlData to avoid parseHTMLDocument requesting component names from tsserver await fn(); - const { sync } = await provideHtmlData(sourceDocumentUri, root); + const { sync } = await provideHtmlData(sourceDocumentUri, root, position); let lastSync = await sync(); let result = await fn(); while (lastSync.version !== (lastSync = await sync()).version) { @@ -346,7 +352,7 @@ export function create( return { result, ...lastSync }; } - async function provideHtmlData(sourceDocumentUri: URI, root: VueVirtualCode) { + async function provideHtmlData(sourceDocumentUri: URI, root: VueVirtualCode, position: number) { await (initializing ??= initialize()); const casing = await checkCasing(context, sourceDocumentUri); @@ -372,7 +378,6 @@ export function create( const tagMap = new Map(); const propMap = new Map { let tagInfo = tagMap.get(tag); if (!tagInfo) { + if (!root.sfc.template?.ast) { + return []; + } + const node = getTouchingNode(root.sfc.template.ast, position); + if (!node) { + return []; + } + const offset = getElementTagOffsets(node, root.sfc.template)[0] + root.sfc.template.startTagEnd; + tagInfo = { attrs: [], propInfos: [], - events: [], directives: [], }; tagMap.set(tag, tagInfo); tasks.push((async () => { tagMap.set(tag, { attrs: await getElementAttrs(root.fileName, tag) ?? [], - propInfos: await getComponentProps(root.fileName, tag) ?? [], - events: await getComponentEvents(root.fileName, tag) ?? [], + propInfos: await getComponentProps(root.fileName, offset) ?? [], directives: await getComponentDirectives(root.fileName) ?? [], }); version++; })()); } - const { attrs, propInfos, events, directives } = tagInfo; + const { attrs, propInfos, directives } = tagInfo; for (let i = 0; i < propInfos.length; i++) { const prop = propInfos[i]!; @@ -502,7 +514,7 @@ export function create( ...attrs.map(attr => ({ name: attr })), ] ) { - const isGlobal = prop.isAttribute || !propNameSet.has(prop.name); + const isGlobal = !propNameSet.has(prop.name); const propName = casing.attr === AttrNameCasing.Camel ? prop.name : hyphenateAttr(prop.name); const isEvent = hyphenateAttr(propName).startsWith('on-'); @@ -541,7 +553,6 @@ export function create( ) { attributes.push({ name, - valueSet: prop.values?.some(value => typeof value === 'string') ? '__deferred__' : undefined, }); propMap.set(name, { name: propName, @@ -553,23 +564,6 @@ export function create( } } - for (const event of events) { - const eventName = casing.attr === AttrNameCasing.Camel ? event : hyphenateAttr(event); - - for ( - const name of [ - 'v-on:' + eventName, - '@' + eventName, - ] - ) { - attributes.push({ name }); - propMap.set(name, { - name: eventName, - kind: 'event', - }); - } - } - for (const directive of directives) { const name = hyphenateAttr(directive); attributes.push({ @@ -589,11 +583,6 @@ export function create( models.push(prop.name.slice('onUpdate:'.length)); } } - for (const event of events) { - if (event.startsWith('update:')) { - models.push(event.slice('update:'.length)); - } - } for (const model of models) { const name = casing.attr === AttrNameCasing.Camel ? model : hyphenateAttr(model); @@ -748,3 +737,16 @@ function getPropName( } return { isEvent, propName: name }; } + +function getTouchingNode( + node: CompilerDOM.ParentNode, + position: number, +): CompilerDOM.ElementNode | undefined { + for (const child of node.children) { + if (child.type === 1 satisfies CompilerDOM.NodeTypes.ELEMENT) { + if (position >= child.loc.start.offset && position <= child.loc.end.offset) { + return getTouchingNode(child, position) ?? child; + } + } + } +} diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 55724d9c78..60078a9d17 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -5,7 +5,6 @@ import { createVueLanguageServiceProxy } from './lib/common'; import type { Requests } from './lib/requests'; import { collectExtractProps } from './lib/requests/collectExtractProps'; import { getComponentDirectives } from './lib/requests/getComponentDirectives'; -import { getComponentEvents } from './lib/requests/getComponentEvents'; import { getComponentNames } from './lib/requests/getComponentNames'; import { getComponentProps } from './lib/requests/getComponentProps'; import { getComponentSlots } from './lib/requests/getComponentSlots'; @@ -140,20 +139,25 @@ export = createLanguageServicePlugin( const { project } = getProject(fileName); return createResponse(getComponentDirectives(ts, project.getLanguageService().getProgram()!, fileName)); }); - session.addProtocolHandler('_vue:getComponentEvents', request => { - const [fileName, tag]: Parameters = request.arguments; - const { project } = getProject(fileName); - return createResponse(getComponentEvents(ts, project.getLanguageService().getProgram()!, fileName, tag)); - }); session.addProtocolHandler('_vue:getComponentNames', request => { const [fileName]: Parameters = request.arguments; const { project } = getProject(fileName); return createResponse(getComponentNames(ts, project.getLanguageService().getProgram()!, fileName)); }); session.addProtocolHandler('_vue:getComponentProps', request => { - const [fileName, tag]: Parameters = request.arguments; - const { project } = getProject(fileName); - return createResponse(getComponentProps(ts, project.getLanguageService().getProgram()!, fileName, tag)); + const [fileName, position]: Parameters = request.arguments; + const { project, language, sourceScript, virtualCode } = getProjectAndVirtualCode(fileName); + return createResponse( + getComponentProps( + ts, + language, + project.getLanguageService(), + sourceScript, + virtualCode, + position, + sourceScript.generated ? sourceScript.snapshot.getLength() : 0, + ), + ); }); session.addProtocolHandler('_vue:getComponentSlots', request => { const [fileName]: Parameters = request.arguments; diff --git a/packages/typescript-plugin/lib/requests/getComponentEvents.ts b/packages/typescript-plugin/lib/requests/getComponentEvents.ts deleted file mode 100644 index ba3d0cbe01..0000000000 --- a/packages/typescript-plugin/lib/requests/getComponentEvents.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type * as ts from 'typescript'; -import { getComponentType, getVariableType } from './utils'; - -export function getComponentEvents( - ts: typeof import('typescript'), - program: ts.Program, - fileName: string, - tag: string, -): string[] { - const checker = program.getTypeChecker(); - const components = getVariableType(ts, program, fileName, '__VLS_components'); - if (!components) { - return []; - } - - const componentType = getComponentType(ts, program, fileName, components, tag); - if (!componentType) { - return []; - } - - const result = new Set(); - - // for (const sig of componentType.getCallSignatures()) { - // const emitParam = sig.parameters[1]; - // if (emitParam) { - // // TODO - // } - // } - - for (const sig of componentType.getConstructSignatures()) { - const instanceType = sig.getReturnType(); - const emitSymbol = instanceType.getProperty('$emit'); - if (emitSymbol) { - const emitType = checker.getTypeOfSymbolAtLocation(emitSymbol, components.node); - for (const call of emitType.getCallSignatures()) { - if (call.parameters.length) { - const eventNameParamSymbol = call.parameters[0]!; - const eventNameParamType = checker.getTypeOfSymbolAtLocation(eventNameParamSymbol, components.node); - if (eventNameParamType.isStringLiteral()) { - result.add(eventNameParamType.value); - } - } - } - } - } - - return [...result]; -} diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index 8356ba564b..2387fca764 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -1,145 +1,124 @@ +import type { Language, SourceScript, VueVirtualCode } from '@vue/language-core'; import type * as ts from 'typescript'; -import { getComponentType, getVariableType } from './utils'; +import { forEachTouchingNode } from './utils'; export interface ComponentPropInfo { name: string; required?: boolean; deprecated?: boolean; - isAttribute?: boolean; documentation?: string; - values?: string[]; } export function getComponentProps( ts: typeof import('typescript'), - program: ts.Program, - fileName: string, - tag: string, + language: Language, + languageService: ts.LanguageService, + sourceScript: SourceScript, + virtualCode: VueVirtualCode, + position: number, + leadingOffset: number = 0, ): ComponentPropInfo[] { - const components = getVariableType(ts, program, fileName, '__VLS_components'); - if (!components) { + const serviceScript = sourceScript.generated!.languagePlugin.typescript?.getServiceScript(virtualCode); + if (!serviceScript) { return []; } - const componentType = getComponentType(ts, program, fileName, components, tag); - if (!componentType) { + let mapped = false; + const map = language.maps.get(serviceScript.code, sourceScript); + for (const [position2, mapping] of map.toGeneratedLocation(position)) { + if (mapping.lengths.length === 2 && mapping.lengths.every(length => length === 0)) { + position = position2; + mapped = true; + break; + } + } + if (!mapped) { return []; } - const result = new Map(); - const checker = program.getTypeChecker(); - - for (const sig of componentType.getCallSignatures()) { - if (sig.parameters.length) { - const propParam = sig.parameters[0]!; - const propsType = checker.getTypeOfSymbolAtLocation(propParam, components.node); - const props = propsType.getProperties(); - for (const prop of props) { - handlePropSymbol(prop); - } - } + const program = languageService.getProgram()!; + const sourceFile = program.getSourceFile(virtualCode.fileName); + if (!sourceFile) { + return []; } - for (const sig of componentType.getConstructSignatures()) { - const instanceType = sig.getReturnType(); - const propsSymbol = instanceType.getProperty('$props'); - if (propsSymbol) { - const propsType = checker.getTypeOfSymbolAtLocation(propsSymbol, components.node); - const props = propsType.getProperties(); - for (const prop of props) { - handlePropSymbol(prop); - } + let node: ts.ObjectLiteralExpression | undefined; + for (const child of forEachTouchingNode(ts, sourceFile, position + leadingOffset)) { + if (ts.isObjectLiteralExpression(child)) { + node = child; } } + if (!node) { + return []; + } - return [...result.values()]; + const shadowOffset = 1145141919810; - function handlePropSymbol(prop: ts.Symbol) { - if (prop.flags & ts.SymbolFlags.Method) { // #2443 - return; + const original = map.toGeneratedLocation; + map.toGeneratedLocation = function*(sourceOffset, ...args) { + if (sourceOffset === shadowOffset) { + const generatedOffset = node.end - 1 - leadingOffset; + yield [generatedOffset, { + sourceOffsets: [sourceOffset], + generatedOffsets: [generatedOffset], + lengths: [0], + data: { + completion: true, + }, + }]; } - const name = prop.name; - const required = !(prop.flags & ts.SymbolFlags.Optional) || undefined; - const { - documentation, - deprecated, - } = generateDocumentation(prop.getDocumentationComment(checker), prop.getJsDocTags()); - const values: any[] = []; - const type = checker.getTypeOfSymbol(prop); - const subTypes: ts.Type[] | undefined = (type as any).types; - - if (subTypes) { - for (const subType of subTypes) { - const value = (subType as any).value; - if (value) { - values.push(value); - } - } + else { + yield* original(sourceOffset, ...args); } + }; - let isAttribute: boolean | undefined; - for (const { parent } of checker.getRootSymbols(prop).flatMap(root => root.declarations ?? [])) { - if (!ts.isInterfaceDeclaration(parent)) { - continue; - } - const { text } = parent.name; - if ( - text.endsWith('HTMLAttributes') - || text === 'AriaAttributes' - || text === 'SVGAttributes' - ) { - isAttribute = true; - break; - } - } + const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, shadowOffset, undefined); + const properties = completions?.entries.filter(entry => entry.kind === 'property') ?? []; + const result: ComponentPropInfo[] = []; - result.set(name, { - name, - required, - deprecated, - isAttribute, - documentation, - values, + for (const entry of properties) { + const details = languageService.getCompletionEntryDetails( + virtualCode.fileName, + shadowOffset, + entry.name, + undefined, + entry.source, + undefined, + entry.data, + ); + const modifiers = entry.kindModifiers?.split(',') ?? []; + result.push({ + name: entry.name, + required: !modifiers.includes('optional'), + deprecated: modifiers.includes('deprecated'), + documentation: details ? [...generateDocumentation(ts, details)].join('') : '', }); } -} + map.toGeneratedLocation = original; -function generateDocumentation(parts: ts.SymbolDisplayPart[], jsDocTags: ts.JSDocTagInfo[]) { - const parsedComment = _symbolDisplayPartsToMarkdown(parts); - const parsedJsDoc = _jsDocTagInfoToMarkdown(jsDocTags); - const documentation = [parsedComment, parsedJsDoc].filter(str => !!str).join('\n\n'); - const deprecated = jsDocTags.some(tag => tag.name === 'deprecated'); - return { - documentation, - deprecated, - }; + return result; } -function _symbolDisplayPartsToMarkdown(parts: ts.SymbolDisplayPart[]) { - return parts.map(part => { - switch (part.kind) { - case 'keyword': - return `\`${part.text}\``; - case 'functionName': - return `**${part.text}**`; - default: - return part.text; - } - }).join(''); -} - -function _jsDocTagInfoToMarkdown(jsDocTags: ts.JSDocTagInfo[]) { - return jsDocTags.map(tag => { - const tagName = `*@${tag.name}*`; - const tagText = tag.text?.map(t => { - if (t.kind === 'parameterName') { - return `\`${t.text}\``; - } - else { - return t.text; +function* generateDocumentation( + ts: typeof import('typescript'), + details: ts.CompletionEntryDetails, +): Generator { + if (details.displayParts.length) { + yield `\`\`\`\n`; + yield ts.displayPartsToString(details.displayParts); + yield `\n\`\`\`\n\n`; + } + if (details.documentation) { + yield ts.displayPartsToString(details.documentation); + yield `\n\n`; + } + if (details.tags?.length) { + for (const tag of details.tags) { + yield `*@${tag.name}*`; + if (tag.text?.length) { + yield ` — ${ts.displayPartsToString(tag.text)}`; } - }).join('') || ''; - - return `${tagName} ${tagText}`; - }).join('\n\n'); + yield `\n\n`; + } + } } diff --git a/packages/typescript-plugin/lib/requests/index.ts b/packages/typescript-plugin/lib/requests/index.ts index 4cf06bf368..ef0ad409a4 100644 --- a/packages/typescript-plugin/lib/requests/index.ts +++ b/packages/typescript-plugin/lib/requests/index.ts @@ -19,16 +19,12 @@ export interface Requests { getComponentDirectives( fileName: string, ): Response>; - getComponentEvents( - fileName: string, - tag: string, - ): Response>; getComponentNames( fileName: string, ): Response>; getComponentProps( fileName: string, - tag: string, + position: number, ): Response>; getComponentSlots( fileName: string, diff --git a/packages/typescript-plugin/lib/requests/isRefAtPosition.ts b/packages/typescript-plugin/lib/requests/isRefAtPosition.ts index 7ed2ba4d25..dcb3839ebe 100644 --- a/packages/typescript-plugin/lib/requests/isRefAtPosition.ts +++ b/packages/typescript-plugin/lib/requests/isRefAtPosition.ts @@ -2,6 +2,7 @@ import { isCompletionEnabled, type Language, type SourceScript, type VueVirtualCode } from '@vue/language-core'; import type * as ts from 'typescript'; +import { forEachTouchingNode } from './utils'; export function isRefAtPosition( ts: typeof import('typescript'), @@ -39,7 +40,12 @@ export function isRefAtPosition( return false; } - const node = findPositionIdentifier(sourceFile, sourceFile, position + leadingOffset); + let node: ts.Identifier | undefined; + for (const child of forEachTouchingNode(ts, sourceFile, position + leadingOffset)) { + if (ts.isIdentifier(child)) { + node = child; + } + } if (!node) { return false; } @@ -56,21 +62,4 @@ export function isRefAtPosition( && decl.name.expression.text === 'RefSymbol' ) ); - - function findPositionIdentifier(sourceFile: ts.SourceFile, node: ts.Node, offset: number) { - let result: ts.Node | undefined; - - node.forEachChild(child => { - if (!result) { - if (child.end === offset && ts.isIdentifier(child)) { - result = child; - } - else if (child.end >= offset && child.getStart(sourceFile) < offset) { - result = findPositionIdentifier(sourceFile, child, offset); - } - } - }); - - return result; - } } diff --git a/packages/typescript-plugin/lib/requests/utils.ts b/packages/typescript-plugin/lib/requests/utils.ts index 2717c2b04f..2906a4c563 100644 --- a/packages/typescript-plugin/lib/requests/utils.ts +++ b/packages/typescript-plugin/lib/requests/utils.ts @@ -81,3 +81,43 @@ function searchVariableDeclarationNode( } } } + +export function* forEachTouchingNode( + ts: typeof import('typescript'), + sourceFile: ts.SourceFile, + position: number, +) { + yield* binaryVisit(ts, sourceFile, sourceFile, position); +} + +function* binaryVisit( + ts: typeof import('typescript'), + sourceFile: ts.SourceFile, + node: ts.Node, + position: number, +): Generator { + const nodes: ts.Node[] = []; + ts.forEachChild(node, child => { + nodes.push(child); + }); + + let left = 0; + let right = nodes.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const node = nodes[mid]!; + + if (position > node.getEnd()) { + left = mid + 1; + } + else if (position < node.getStart(sourceFile)) { + right = mid - 1; + } + else { + yield node; + yield* binaryVisit(ts, sourceFile, node, position); + return; + } + } +} From 1dde672404c527913d6b5cb9f055cedfbb2d8624 Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 03:32:36 +0800 Subject: [PATCH 2/8] fix: strip quotes --- .../typescript-plugin/lib/requests/getComponentProps.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index 2387fca764..50120f744d 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -88,7 +88,7 @@ export function getComponentProps( ); const modifiers = entry.kindModifiers?.split(',') ?? []; result.push({ - name: entry.name, + name: stripQuotes(entry.name), required: !modifiers.includes('optional'), deprecated: modifiers.includes('deprecated'), documentation: details ? [...generateDocumentation(ts, details)].join('') : '', @@ -99,6 +99,13 @@ export function getComponentProps( return result; } +function stripQuotes(str: string) { + if (str.startsWith('"') && str.endsWith('"')) { + return str.slice(1, -1); + } + return str; +} + function* generateDocumentation( ts: typeof import('typescript'), details: ts.CompletionEntryDetails, From 57c6edfb4549de9011158b23abf0204127e5238f Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 03:36:27 +0800 Subject: [PATCH 3/8] refactor: hoist shadow mapping --- .../lib/requests/getComponentProps.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index 50120f744d..b1ab225887 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -53,19 +53,19 @@ export function getComponentProps( } const shadowOffset = 1145141919810; + const shadowMapping = { + sourceOffsets: [shadowOffset], + generatedOffsets: [node.end - 1 - leadingOffset], + lengths: [0], + data: { + completion: true, + }, + }; const original = map.toGeneratedLocation; map.toGeneratedLocation = function*(sourceOffset, ...args) { if (sourceOffset === shadowOffset) { - const generatedOffset = node.end - 1 - leadingOffset; - yield [generatedOffset, { - sourceOffsets: [sourceOffset], - generatedOffsets: [generatedOffset], - lengths: [0], - data: { - completion: true, - }, - }]; + yield [shadowMapping.generatedOffsets[0]!, shadowMapping]; } else { yield* original(sourceOffset, ...args); From 274e88d28069b5985acd0e5c34bae73fb34c86b7 Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 04:31:37 +0800 Subject: [PATCH 4/8] fix: trigger completion inside prop name --- .../lib/plugins/vue-missing-props-hints.ts | 7 +- .../lib/plugins/vue-template.ts | 27 +--- .../lib/requests/getComponentProps.ts | 127 +++++++++++------- packages/typescript-plugin/package.json | 3 +- pnpm-lock.yaml | 3 + 5 files changed, 90 insertions(+), 77 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-missing-props-hints.ts b/packages/language-service/lib/plugins/vue-missing-props-hints.ts index ec8f2c8b87..f0d1064230 100644 --- a/packages/language-service/lib/plugins/vue-missing-props-hints.ts +++ b/packages/language-service/lib/plugins/vue-missing-props-hints.ts @@ -33,11 +33,6 @@ export function create( return; } - const { template } = info.root.sfc; - if (!template) { - return; - } - const scanner = getScanner(context, document); if (!scanner) { return; @@ -82,7 +77,7 @@ export function create( } componentProps.set( checkTag, - (await getComponentProps(info.root.fileName, tagOffset + template.startTagEnd) ?? []) + (await getComponentProps(info.root.fileName, tagOffset) ?? []) .filter(prop => prop.required) .map(prop => prop.name), ); diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 25003454ff..ad5df110ff 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -6,8 +6,7 @@ import type { LanguageServicePlugin, TextDocument, } from '@volar/language-service'; -import type * as CompilerDOM from '@vue/compiler-dom'; -import { getElementTagOffsets, hyphenateAttr, hyphenateTag, tsCodegen, type VueVirtualCode } from '@vue/language-core'; +import { hyphenateAttr, hyphenateTag, tsCodegen, type VueVirtualCode } from '@vue/language-core'; import { camelize, capitalize } from '@vue/shared'; import type { ComponentPropInfo } from '@vue/typescript-plugin/lib/requests/getComponentProps'; import { create as createHtmlService } from 'volar-service-html'; @@ -466,15 +465,6 @@ export function create( provideAttributes: tag => { let tagInfo = tagMap.get(tag); if (!tagInfo) { - if (!root.sfc.template?.ast) { - return []; - } - const node = getTouchingNode(root.sfc.template.ast, position); - if (!node) { - return []; - } - const offset = getElementTagOffsets(node, root.sfc.template)[0] + root.sfc.template.startTagEnd; - tagInfo = { attrs: [], propInfos: [], @@ -484,7 +474,7 @@ export function create( tasks.push((async () => { tagMap.set(tag, { attrs: await getElementAttrs(root.fileName, tag) ?? [], - propInfos: await getComponentProps(root.fileName, offset) ?? [], + propInfos: await getComponentProps(root.fileName, position) ?? [], directives: await getComponentDirectives(root.fileName) ?? [], }); version++; @@ -737,16 +727,3 @@ function getPropName( } return { isEvent, propName: name }; } - -function getTouchingNode( - node: CompilerDOM.ParentNode, - position: number, -): CompilerDOM.ElementNode | undefined { - for (const child of node.children) { - if (child.type === 1 satisfies CompilerDOM.NodeTypes.ELEMENT) { - if (position >= child.loc.start.offset && position <= child.loc.end.offset) { - return getTouchingNode(child, position) ?? child; - } - } - } -} diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index b1ab225887..7036077056 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -1,4 +1,5 @@ -import type { Language, SourceScript, VueVirtualCode } from '@vue/language-core'; +import type * as CompilerDOM from '@vue/compiler-dom'; +import { getElementTagOffsets, type Language, type SourceScript, type VueVirtualCode } from '@vue/language-core'; import type * as ts from 'typescript'; import { forEachTouchingNode } from './utils'; @@ -23,39 +24,62 @@ export function getComponentProps( return []; } + const { template } = virtualCode.sfc; + if (!template?.ast) { + return []; + } + let mapped = false; const map = language.maps.get(serviceScript.code, sourceScript); - for (const [position2, mapping] of map.toGeneratedLocation(position)) { - if (mapping.lengths.length === 2 && mapping.lengths.every(length => length === 0)) { - position = position2; + + for (const [offset, mapping] of map.toGeneratedLocation(position + template.startTagEnd)) { + if (mapping.data.semantic && mapping.data.verification && mapping.data.navigation) { + position = offset; mapped = true; break; } } if (!mapped) { - return []; - } + const templateNode = getTouchingTemplateNode(template.ast, position); + if (!templateNode) { + return []; + } - const program = languageService.getProgram()!; - const sourceFile = program.getSourceFile(virtualCode.fileName); - if (!sourceFile) { - return []; - } + position = getElementTagOffsets(templateNode, template)[0]; + for (const [offset, mapping] of map.toGeneratedLocation(position + template.startTagEnd)) { + if (mapping.lengths.length === 2 && mapping.lengths.every(length => length === 0)) { + position = offset; + mapped = true; + break; + } + } + if (!mapped) { + return []; + } - let node: ts.ObjectLiteralExpression | undefined; - for (const child of forEachTouchingNode(ts, sourceFile, position + leadingOffset)) { - if (ts.isObjectLiteralExpression(child)) { - node = child; + const program = languageService.getProgram()!; + const sourceFile = program.getSourceFile(virtualCode.fileName); + if (!sourceFile) { + return []; } - } - if (!node) { - return []; + + let node: ts.ObjectLiteralExpression | undefined; + for (const child of forEachTouchingNode(ts, sourceFile, position + leadingOffset)) { + if (ts.isObjectLiteralExpression(child)) { + node = child; + } + } + if (!node) { + return []; + } + + position = node.end - 1 - leadingOffset; } const shadowOffset = 1145141919810; const shadowMapping = { sourceOffsets: [shadowOffset], - generatedOffsets: [node.end - 1 - leadingOffset], + generatedOffsets: [position], lengths: [0], data: { completion: true, @@ -67,36 +91,49 @@ export function getComponentProps( if (sourceOffset === shadowOffset) { yield [shadowMapping.generatedOffsets[0]!, shadowMapping]; } - else { - yield* original(sourceOffset, ...args); - } + yield* original.call(map, sourceOffset, ...args); }; - const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, shadowOffset, undefined); - const properties = completions?.entries.filter(entry => entry.kind === 'property') ?? []; - const result: ComponentPropInfo[] = []; - - for (const entry of properties) { - const details = languageService.getCompletionEntryDetails( - virtualCode.fileName, - shadowOffset, - entry.name, - undefined, - entry.source, - undefined, - entry.data, - ); - const modifiers = entry.kindModifiers?.split(',') ?? []; - result.push({ - name: stripQuotes(entry.name), - required: !modifiers.includes('optional'), - deprecated: modifiers.includes('deprecated'), - documentation: details ? [...generateDocumentation(ts, details)].join('') : '', - }); + try { + const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, shadowOffset, undefined); + + return completions?.entries + .filter(entry => entry.kind === 'property') + .map(entry => { + const modifiers = entry.kindModifiers?.split(',') ?? []; + const details = languageService.getCompletionEntryDetails( + virtualCode.fileName, + shadowOffset, + entry.name, + undefined, + entry.source, + undefined, + entry.data, + ); + return { + name: stripQuotes(entry.name), + required: !modifiers.includes('optional'), + deprecated: modifiers.includes('deprecated'), + documentation: details ? [...generateDocumentation(ts, details)].join('') : '', + }; + }) ?? []; } - map.toGeneratedLocation = original; + finally { + map.toGeneratedLocation = original; + } +} - return result; +function getTouchingTemplateNode( + node: CompilerDOM.ParentNode, + position: number, +): CompilerDOM.ElementNode | undefined { + for (const child of node.children) { + if (child.type === 1 satisfies CompilerDOM.NodeTypes.ELEMENT) { + if (position >= child.loc.start.offset && position <= child.loc.end.offset) { + return getTouchingTemplateNode(child, position) ?? child; + } + } + } } function stripQuotes(str: string) { diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json index eb359e8f7d..75a041a8b0 100644 --- a/packages/typescript-plugin/package.json +++ b/packages/typescript-plugin/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@types/node": "^22.10.4", - "@types/path-browserify": "^1.0.1" + "@types/path-browserify": "^1.0.1", + "@vue/compiler-dom": "^3.5.13" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 492722b2cf..940e20d5f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: '@types/path-browserify': specifier: ^1.0.1 version: 1.0.3 + '@vue/compiler-dom': + specifier: ^3.5.13 + version: 3.5.13 test-workspace: devDependencies: From 4e0b695ca7c8d7055d33f18b3b6c40b6be8f375b Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 04:43:12 +0800 Subject: [PATCH 5/8] test: update snapshot --- packages/language-server/tests/completions.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/language-server/tests/completions.spec.ts b/packages/language-server/tests/completions.spec.ts index 542428c8d6..555f248f05 100644 --- a/packages/language-server/tests/completions.spec.ts +++ b/packages/language-server/tests/completions.spec.ts @@ -498,7 +498,13 @@ test('#4796', async () => { { "documentation": { "kind": "markdown", - "value": "The message to display", + "value": "\`\`\` + (property) msg?: string + \`\`\` + + The message to display + + ", }, "insertTextFormat": 1, "kind": 5, From 81fb691845908e79831718582ff6d39624591af6 Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 15:17:50 +0800 Subject: [PATCH 6/8] refactor: simplify --- .../lib/requests/getComponentProps.ts | 74 +++++++++---------- .../typescript-plugin/lib/requests/utils.ts | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index 7036077056..80aeb6ca2f 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -1,5 +1,5 @@ import type * as CompilerDOM from '@vue/compiler-dom'; -import { getElementTagOffsets, type Language, type SourceScript, type VueVirtualCode } from '@vue/language-core'; +import type { Language, SourceScript, VueVirtualCode } from '@vue/language-core'; import type * as ts from 'typescript'; import { forEachTouchingNode } from './utils'; @@ -19,6 +19,12 @@ export function getComponentProps( position: number, leadingOffset: number = 0, ): ComponentPropInfo[] { + const program = languageService.getProgram()!; + const sourceFile = program.getSourceFile(virtualCode.fileName); + if (!sourceFile) { + return []; + } + const serviceScript = sourceScript.generated!.languagePlugin.typescript?.getServiceScript(virtualCode); if (!serviceScript) { return []; @@ -32,11 +38,16 @@ export function getComponentProps( let mapped = false; const map = language.maps.get(serviceScript.code, sourceScript); - for (const [offset, mapping] of map.toGeneratedLocation(position + template.startTagEnd)) { - if (mapping.data.semantic && mapping.data.verification && mapping.data.navigation) { - position = offset; - mapped = true; - break; + outer: for (const [offset] of map.toGeneratedLocation(position + template.startTagEnd)) { + for (const node of forEachTouchingNode(ts, sourceFile, offset + leadingOffset)) { + if (ts.isObjectLiteralExpression(node)) { + position = offset; + if (sourceFile.text[position - 1 + leadingOffset] === "'") { + position--; + } + mapped = true; + break outer; + } } } if (!mapped) { @@ -45,40 +56,29 @@ export function getComponentProps( return []; } - position = getElementTagOffsets(templateNode, template)[0]; - for (const [offset, mapping] of map.toGeneratedLocation(position + template.startTagEnd)) { - if (mapping.lengths.length === 2 && mapping.lengths.every(length => length === 0)) { - position = offset; - mapped = true; - break; + position = templateNode.loc.start.offset; + outer: for (const [offset] of map.toGeneratedLocation(position + template.startTagEnd)) { + for (const node of forEachTouchingNode(ts, sourceFile, offset + leadingOffset)) { + if ( + ts.isVariableDeclaration(node) + && node.initializer + && ts.isCallExpression(node.initializer) + && node.initializer.arguments.length + ) { + position = node.initializer.arguments[0]!.end - 1 - leadingOffset; + mapped = true; + break outer; + } } } if (!mapped) { return []; } - - const program = languageService.getProgram()!; - const sourceFile = program.getSourceFile(virtualCode.fileName); - if (!sourceFile) { - return []; - } - - let node: ts.ObjectLiteralExpression | undefined; - for (const child of forEachTouchingNode(ts, sourceFile, position + leadingOffset)) { - if (ts.isObjectLiteralExpression(child)) { - node = child; - } - } - if (!node) { - return []; - } - - position = node.end - 1 - leadingOffset; } - const shadowOffset = 1145141919810; - const shadowMapping = { - sourceOffsets: [shadowOffset], + const offset = 1145141919810; + const mapping = { + sourceOffsets: [offset], generatedOffsets: [position], lengths: [0], data: { @@ -88,14 +88,14 @@ export function getComponentProps( const original = map.toGeneratedLocation; map.toGeneratedLocation = function*(sourceOffset, ...args) { - if (sourceOffset === shadowOffset) { - yield [shadowMapping.generatedOffsets[0]!, shadowMapping]; + if (sourceOffset === offset) { + yield [mapping.generatedOffsets[0]!, mapping]; } yield* original.call(map, sourceOffset, ...args); }; try { - const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, shadowOffset, undefined); + const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, offset, undefined); return completions?.entries .filter(entry => entry.kind === 'property') @@ -103,7 +103,7 @@ export function getComponentProps( const modifiers = entry.kindModifiers?.split(',') ?? []; const details = languageService.getCompletionEntryDetails( virtualCode.fileName, - shadowOffset, + offset, entry.name, undefined, entry.source, diff --git a/packages/typescript-plugin/lib/requests/utils.ts b/packages/typescript-plugin/lib/requests/utils.ts index 2906a4c563..5862a7e8bd 100644 --- a/packages/typescript-plugin/lib/requests/utils.ts +++ b/packages/typescript-plugin/lib/requests/utils.ts @@ -108,7 +108,7 @@ function* binaryVisit( const mid = Math.floor((left + right) / 2); const node = nodes[mid]!; - if (position > node.getEnd()) { + if (position > node.end) { left = mid + 1; } else if (position < node.getStart(sourceFile)) { From 51ab5d323e100edd0697462df549f8f9b3622e1d Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 15:24:26 +0800 Subject: [PATCH 7/8] fix: filter with navigation feature --- .../lib/requests/getComponentProps.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts index 80aeb6ca2f..3770eb08ed 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -1,5 +1,5 @@ import type * as CompilerDOM from '@vue/compiler-dom'; -import type { Language, SourceScript, VueVirtualCode } from '@vue/language-core'; +import { isDefinitionEnabled, type Language, type SourceScript, type VueVirtualCode } from '@vue/language-core'; import type * as ts from 'typescript'; import { forEachTouchingNode } from './utils'; @@ -38,15 +38,17 @@ export function getComponentProps( let mapped = false; const map = language.maps.get(serviceScript.code, sourceScript); - outer: for (const [offset] of map.toGeneratedLocation(position + template.startTagEnd)) { - for (const node of forEachTouchingNode(ts, sourceFile, offset + leadingOffset)) { - if (ts.isObjectLiteralExpression(node)) { - position = offset; - if (sourceFile.text[position - 1 + leadingOffset] === "'") { - position--; + outer: for (const [offset, mapping] of map.toGeneratedLocation(position + template.startTagEnd)) { + if (isDefinitionEnabled(mapping.data)) { + for (const node of forEachTouchingNode(ts, sourceFile, offset + leadingOffset)) { + if (ts.isObjectLiteralExpression(node)) { + position = offset; + if (sourceFile.text[position - 1 + leadingOffset] === "'") { + position--; + } + mapped = true; + break outer; } - mapped = true; - break outer; } } } From 3d2a3c5e45a02ca97ebc6e7ec4cce507caef7171 Mon Sep 17 00:00:00 2001 From: KazariEX Date: Sun, 12 Oct 2025 15:32:01 +0800 Subject: [PATCH 8/8] refactor: remove `getComponentType` --- .../typescript-plugin/lib/requests/utils.ts | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/packages/typescript-plugin/lib/requests/utils.ts b/packages/typescript-plugin/lib/requests/utils.ts index 5862a7e8bd..c09ee7e60d 100644 --- a/packages/typescript-plugin/lib/requests/utils.ts +++ b/packages/typescript-plugin/lib/requests/utils.ts @@ -2,40 +2,6 @@ import { camelize, capitalize } from '@vue/shared'; import * as path from 'path-browserify'; import type * as ts from 'typescript'; -export function getComponentType( - ts: typeof import('typescript'), - program: ts.Program, - fileName: string, - components: NonNullable>, - tag: string, -) { - const checker = program.getTypeChecker(); - const name = tag.split('.') as [string, ...string[]]; - - let componentSymbol = components.type.getProperty(name[0]) - ?? components.type.getProperty(camelize(name[0])) - ?? components.type.getProperty(capitalize(camelize(name[0]))); - let componentType: ts.Type | undefined; - - if (!componentSymbol) { - const name = getSelfComponentName(fileName); - if (name === capitalize(camelize(tag))) { - componentType = getVariableType(ts, program, fileName, '__VLS_export')?.type; - } - } - else { - componentType = checker.getTypeOfSymbolAtLocation(componentSymbol, components.node); - for (let i = 1; i < name.length; i++) { - componentSymbol = componentType.getProperty(name[i]!); - if (componentSymbol) { - componentType = checker.getTypeOfSymbolAtLocation(componentSymbol, components.node); - } - } - } - - return componentType; -} - export function getSelfComponentName(fileName: string) { const baseName = path.basename(fileName); return capitalize(camelize(baseName.slice(0, baseName.lastIndexOf('.'))));