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-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, 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..f0d1064230 100644 --- a/packages/language-service/lib/plugins/vue-missing-props-hints.ts +++ b/packages/language-service/lib/plugins/vue-missing-props-hints.ts @@ -77,7 +77,7 @@ export function create( } componentProps.set( checkTag, - (await getComponentProps(info.root.fileName, checkTag) ?? []) + (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 0784333f74..ad5df110ff 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -41,7 +41,6 @@ export function create( getComponentNames, getElementAttrs, getComponentProps, - getComponentEvents, getComponentDirectives, getComponentSlots, }: import('@vue/typescript-plugin/lib/requests').Requests, @@ -176,6 +175,7 @@ export function create( } = await runWithVueData( info.script.id, info.root, + document.offsetAt(position), () => baseServiceInstance.provideCompletionItems!( document, @@ -333,11 +333,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 +351,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 +377,6 @@ export function create( const tagMap = new Map(); const propMap = new Map { 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, position) ?? [], 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 +504,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 +543,6 @@ export function create( ) { attributes.push({ name, - valueSet: prop.values?.some(value => typeof value === 'string') ? '__deferred__' : undefined, }); propMap.set(name, { name: propName, @@ -553,23 +554,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 +573,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); 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..3770eb08ed 100644 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ b/packages/typescript-plugin/lib/requests/getComponentProps.ts @@ -1,145 +1,170 @@ +import type * as CompilerDOM from '@vue/compiler-dom'; +import { isDefinitionEnabled, type Language, type SourceScript, type 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 program = languageService.getProgram()!; + const sourceFile = program.getSourceFile(virtualCode.fileName); + if (!sourceFile) { return []; } - const componentType = getComponentType(ts, program, fileName, components, tag); - if (!componentType) { + const serviceScript = sourceScript.generated!.languagePlugin.typescript?.getServiceScript(virtualCode); + if (!serviceScript) { 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 { template } = virtualCode.sfc; + if (!template?.ast) { + 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 mapped = false; + const map = language.maps.get(serviceScript.code, sourceScript); + + 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; + } } } } - - return [...result.values()]; - - function handlePropSymbol(prop: ts.Symbol) { - if (prop.flags & ts.SymbolFlags.Method) { // #2443 - return; + if (!mapped) { + const templateNode = getTouchingTemplateNode(template.ast, position); + if (!templateNode) { + return []; } - 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); + 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 []; + } + } - 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 offset = 1145141919810; + const mapping = { + sourceOffsets: [offset], + generatedOffsets: [position], + lengths: [0], + data: { + completion: true, + }, + }; + + const original = map.toGeneratedLocation; + map.toGeneratedLocation = function*(sourceOffset, ...args) { + if (sourceOffset === offset) { + yield [mapping.generatedOffsets[0]!, mapping]; } + yield* original.call(map, sourceOffset, ...args); + }; - result.set(name, { - name, - required, - deprecated, - isAttribute, - documentation, - values, - }); + try { + const completions = languageService.getCompletionsAtPosition(virtualCode.fileName, offset, undefined); + + return completions?.entries + .filter(entry => entry.kind === 'property') + .map(entry => { + const modifiers = entry.kindModifiers?.split(',') ?? []; + const details = languageService.getCompletionEntryDetails( + virtualCode.fileName, + offset, + 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('') : '', + }; + }) ?? []; + } + finally { + 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, - }; +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 _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 stripQuotes(str: string) { + if (str.startsWith('"') && str.endsWith('"')) { + return str.slice(1, -1); + } + return str; } -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..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('.')))); @@ -81,3 +47,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.end) { + left = mid + 1; + } + else if (position < node.getStart(sourceFile)) { + right = mid - 1; + } + else { + yield node; + yield* binaryVisit(ts, sourceFile, node, position); + return; + } + } +} 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: