diff --git a/packages/language-server/lib/server.ts b/packages/language-server/lib/server.ts index 01fc0ec93b..b1f2353a2c 100644 --- a/packages/language-server/lib/server.ts +++ b/packages/language-server/lib/server.ts @@ -103,14 +103,11 @@ 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); }, - getComponentProps(...args) { - return sendTsServerRequest('_vue:getComponentProps', args); + getComponentMeta(...args) { + return sendTsServerRequest('_vue:getComponentMeta', args); }, getComponentSlots(...args) { return sendTsServerRequest('_vue:getComponentSlots', args); diff --git a/packages/language-server/tests/completions.spec.ts b/packages/language-server/tests/completions.spec.ts index cd5c5d22e6..0f891606e3 100644 --- a/packages/language-server/tests/completions.spec.ts +++ b/packages/language-server/tests/completions.spec.ts @@ -51,13 +51,8 @@ test('#4670', async () => { ).filter(label => label.includes('click')), ).toMatchInlineSnapshot(` [ - "onclick", - "ondblclick", - "v-on:auxclick", "@auxclick", - "v-on:click", "@click", - "v-on:dblclick", "@dblclick", ] `); @@ -71,130 +66,187 @@ test('HTML tags and built-in components', async () => { ).toMatchInlineSnapshot(` [ "!DOCTYPE", - "html", - "head", - "title", - "base", - "link", - "meta", - "style", - "body", + "Transition", + "TransitionGroup", + "KeepAlive", + "Teleport", + "Suspense", + "component", + "slot", + "template", + "BaseTransition", + "Fixture", + "a", + "abbr", + "address", + "area", "article", - "section", - "nav", "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figcaption", + "figure", + "footer", + "form", "h1", "h2", "h3", "h4", "h5", "h6", + "head", "header", - "footer", - "address", - "p", + "hgroup", "hr", - "pre", - "blockquote", - "ol", - "ul", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", "li", - "dl", - "dt", - "dd", - "figure", - "figcaption", + "link", "main", - "div", - "a", - "em", - "strong", - "small", - "s", - "cite", + "map", + "mark", + "menu", + "meta", + "meter", + "nav", + "noindex", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "picture", + "pre", + "progress", "q", - "dfn", - "abbr", - "ruby", - "rb", - "rt", "rp", - "time", - "code", - "var", + "rt", + "ruby", + "s", "samp", - "kbd", + "script", + "section", + "select", + "small", + "source", + "span", + "strong", + "style", "sub", + "summary", "sup", - "i", - "b", - "u", - "mark", - "bdi", - "bdo", - "span", - "br", - "wbr", - "ins", - "del", - "picture", - "img", - "iframe", - "embed", - "object", - "param", - "video", - "audio", - "source", - "track", - "map", - "area", "table", - "caption", - "colgroup", - "col", "tbody", - "thead", - "tfoot", - "tr", "td", - "th", - "form", - "label", - "input", - "button", - "select", - "datalist", - "optgroup", - "option", "textarea", - "output", - "progress", - "meter", - "fieldset", - "legend", - "details", - "summary", - "dialog", - "script", - "noscript", - "canvas", - "data", - "hgroup", - "menu", - "search", - "fencedframe", - "selectedcontent", - "Transition", - "TransitionGroup", - "KeepAlive", - "Teleport", - "Suspense", - "component", - "slot", - "template", - "BaseTransition", - "Fixture", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", + "webview", + "svg", + "animate", + "animateMotion", + "animateTransform", + "circle", + "clipPath", + "defs", + "desc", + "ellipse", + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence", + "filter", + "foreignObject", + "g", + "image", + "line", + "linearGradient", + "marker", + "mask", + "metadata", + "mpath", + "path", + "pattern", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "switch", + "symbol", + "text", + "textPath", + "tspan", + "use", + "view", ] `); }); @@ -217,130 +269,187 @@ test('Auto import', async () => { ).toMatchInlineSnapshot(` [ "!DOCTYPE", - "html", - "head", - "title", - "base", - "link", - "meta", - "style", - "body", + "Transition", + "TransitionGroup", + "KeepAlive", + "Teleport", + "Suspense", + "component", + "slot", + "template", + "BaseTransition", + "Fixture", + "a", + "abbr", + "address", + "area", "article", - "section", - "nav", "aside", + "audio", + "b", + "base", + "bdi", + "bdo", + "blockquote", + "body", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "embed", + "fieldset", + "figcaption", + "figure", + "footer", + "form", "h1", "h2", "h3", "h4", "h5", "h6", + "head", "header", - "footer", - "address", - "p", + "hgroup", "hr", - "pre", - "blockquote", - "ol", - "ul", + "html", + "i", + "iframe", + "img", + "input", + "ins", + "kbd", + "keygen", + "label", + "legend", "li", - "dl", - "dt", - "dd", - "figure", - "figcaption", + "link", "main", - "div", - "a", - "em", - "strong", - "small", - "s", - "cite", + "map", + "mark", + "menu", + "meta", + "meter", + "nav", + "noindex", + "noscript", + "object", + "ol", + "optgroup", + "option", + "output", + "p", + "param", + "picture", + "pre", + "progress", "q", - "dfn", - "abbr", - "ruby", - "rb", - "rt", "rp", - "time", - "code", - "var", + "rt", + "ruby", + "s", "samp", - "kbd", + "script", + "section", + "select", + "small", + "source", + "span", + "strong", + "style", "sub", + "summary", "sup", - "i", - "b", - "u", - "mark", - "bdi", - "bdo", - "span", - "br", - "wbr", - "ins", - "del", - "picture", - "img", - "iframe", - "embed", - "object", - "param", - "video", - "audio", - "source", - "track", - "map", - "area", "table", - "caption", - "colgroup", - "col", "tbody", - "thead", - "tfoot", - "tr", "td", - "th", - "form", - "label", - "input", - "button", - "select", - "datalist", - "optgroup", - "option", "textarea", - "output", - "progress", - "meter", - "fieldset", - "legend", - "details", - "summary", - "dialog", - "script", - "noscript", - "canvas", - "data", - "hgroup", - "menu", - "search", - "fencedframe", - "selectedcontent", - "Transition", - "TransitionGroup", - "KeepAlive", - "Teleport", - "Suspense", - "component", - "slot", - "template", - "BaseTransition", - "Fixture", + "tfoot", + "th", + "thead", + "time", + "title", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", + "webview", + "svg", + "animate", + "animateMotion", + "animateTransform", + "circle", + "clipPath", + "defs", + "desc", + "ellipse", + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feConvolveMatrix", + "feDiffuseLighting", + "feDisplacementMap", + "feDistantLight", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feImage", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", + "fePointLight", + "feSpecularLighting", + "feSpotLight", + "feTile", + "feTurbulence", + "filter", + "foreignObject", + "g", + "image", + "line", + "linearGradient", + "marker", + "mask", + "metadata", + "mpath", + "path", + "pattern", + "polygon", + "polyline", + "radialGradient", + "rect", + "stop", + "switch", + "symbol", + "text", + "textPath", + "tspan", + "use", + "view", "BaseTransition", "BaseTransitionPropsValidators", "callWithAsyncErrorHandling", @@ -801,13 +910,12 @@ test('#4796', async () => { ).toMatchInlineSnapshot(` { "documentation": { - "kind": "markdown", + "kind": "plaintext", "value": "The message to display", }, "insertTextFormat": 1, "kind": 5, "label": ":msg", - "sortText": ":msg", "textEdit": { "newText": ":msg="$1"", "range": { diff --git a/packages/language-server/tests/inlayHints.spec.ts b/packages/language-server/tests/inlayHints.spec.ts index 187f2be2f7..6748e38e3e 100644 --- a/packages/language-server/tests/inlayHints.spec.ts +++ b/packages/language-server/tests/inlayHints.spec.ts @@ -61,7 +61,7 @@ test('Missing props', async () => { " `); diff --git a/packages/language-service/lib/data.ts b/packages/language-service/lib/data.ts index 64a86ce7ed..ab6821d133 100644 --- a/packages/language-service/lib/data.ts +++ b/packages/language-service/lib/data.ts @@ -40,29 +40,6 @@ export function loadTemplateData(lang: string) { resolveReferences(data); - for (const attr of [...data.globalAttributes ?? []]) { - if (!attr.name.startsWith('v-')) { - data.globalAttributes?.push( - { ...attr, name: `:${attr.name}` }, - { ...attr, name: `v-bind:${attr.name}` }, - ); - } - } - - const vOn = data.globalAttributes?.find(d => d.name === 'v-on'); - const vSlot = data.globalAttributes?.find(d => d.name === 'v-slot'); - const vBind = data.globalAttributes?.find(d => d.name === 'v-bind'); - - if (vOn) { - data.globalAttributes?.push({ ...vOn, name: '@' }); - } - if (vSlot) { - data.globalAttributes?.push({ ...vSlot, name: '#' }); - } - if (vBind) { - data.globalAttributes?.push({ ...vBind, name: ':' }); - } - return data; } 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 5e3bcfc6e9..6b3f406429 100644 --- a/packages/language-service/lib/plugins/vue-missing-props-hints.ts +++ b/packages/language-service/lib/plugins/vue-missing-props-hints.ts @@ -7,11 +7,12 @@ import type { } from '@volar/language-service'; import { hyphenateAttr, hyphenateTag } from '@vue/language-core'; import * as html from 'vscode-html-languageservice'; +import type { PropertyMeta } from '../../../component-meta'; import { AttrNameCasing, getAttrNameCasing } from '../nameCasing'; import { resolveEmbeddedCode } from '../utils'; export function create( - { getComponentNames, getElementNames, getComponentProps }: import('@vue/typescript-plugin/lib/requests').Requests, + { getComponentNames, getElementNames, getComponentMeta }: import('@vue/typescript-plugin/lib/requests').Requests, ): LanguageServicePlugin { return { name: 'vue-missing-props-hints', @@ -41,7 +42,7 @@ export function create( const result: InlayHint[] = []; const attrNameCasing = await getAttrNameCasing(context, info.script.id); const components = await getComponentNames(info.root.fileName) ?? []; - const componentProps = new Map(); + const componentProps = new Map(); intrinsicElementNames ??= new Set( await getElementNames(info.root.fileName) ?? [], @@ -49,7 +50,7 @@ export function create( let token: html.TokenType; let current: { - unburnedRequiredProps: string[]; + unburnedRequiredProps: PropertyMeta[]; labelOffset: number; } | undefined; @@ -77,9 +78,8 @@ export function create( } componentProps.set( checkTag, - (await getComponentProps(info.root.fileName, checkTag) ?? []) - .filter(prop => prop.required) - .map(prop => prop.name), + ((await getComponentMeta(info.root.fileName, checkTag))?.props ?? []) + .filter(prop => prop.required), ); } @@ -120,9 +120,9 @@ export function create( attrText = 'on-' + hyphenateAttr(attrText.slice('@'.length)); } - current.unburnedRequiredProps = current.unburnedRequiredProps.filter(propName => { - return attrText !== propName - && attrText !== hyphenateAttr(propName); + current.unburnedRequiredProps = current.unburnedRequiredProps.filter(prop => { + return attrText !== prop.name + && attrText !== hyphenateAttr(prop.name); }); } } @@ -131,7 +131,7 @@ export function create( if (current) { for (const requiredProp of current.unburnedRequiredProps) { result.push({ - label: `${requiredProp}!`, + label: requiredProp.name, paddingLeft: true, position: document.positionAt(current.labelOffset), kind: 2 satisfies typeof InlayHintKind.Parameter, @@ -141,7 +141,7 @@ export function create( end: document.positionAt(current.labelOffset), }, newText: ` :${ - attrNameCasing === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp) : requiredProp + attrNameCasing === AttrNameCasing.Kebab ? hyphenateAttr(requiredProp.name) : requiredProp.name }=`, }], }); diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index d23a29d5fd..a6fd79833a 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -10,6 +10,7 @@ import { } from '@volar/language-service'; import { getSourceRange } from '@volar/language-service/lib/utils/featureWorkers'; import { + forEachElementNode, forEachInterpolationNode, hyphenateAttr, hyphenateTag, @@ -17,7 +18,6 @@ import { 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, resolveReference } from 'volar-service-html'; import { create as createPugService } from 'volar-service-pug'; import { @@ -25,56 +25,19 @@ import { convertCompletionInfo, } from 'volar-service-typescript/lib/utils/lspConverters.js'; import * as html from 'vscode-html-languageservice'; -import { URI, Utils } from 'vscode-uri'; +import { URI } from 'vscode-uri'; +import type { ComponentMeta, PropertyMeta } from '../../../component-meta'; import { loadModelModifiersData, loadTemplateData } from '../data'; import { format } from '../htmlFormatter'; import { AttrNameCasing, getAttrNameCasing, getTagNameCasing, TagNameCasing } from '../nameCasing'; import { resolveEmbeddedCode } from '../utils'; -const specialTags = new Set([ - 'slot', - 'component', - 'template', -]); - -const specialProps = new Set([ - 'class', - 'data-allow-mismatch', - 'is', - 'key', - 'ref', - 'style', -]); - -const builtInComponents = new Set([ - 'Transition', - 'TransitionGroup', - 'KeepAlive', - 'Suspense', - 'Teleport', -]); - -// Sort text priority tokens -const SORT_TOKEN = { - COMPONENT_PROP: '\u0000', - COLON_PREFIX: '\u0001', - AT_PREFIX: '\u0002', - V_BIND_PREFIX: '\u0003', - V_MODEL_PREFIX: '\u0004', - V_ON_PREFIX: '\u0005', - VUE_EVENT: '\u0004', - SPECIAL_PROP: '\u0001', - CONTROL_FLOW: '\u0003', - DIRECTIVE: '\u0002', - HTML_ATTR: '\u0001', -} as const; +const EVENT_PROP_REGEX = /^on[A-Z]/; // String constants const AUTO_IMPORT_PLACEHOLDER = 'AutoImportsPlaceholder'; -const EVENT_PROP_PREFIX = 'on-'; const UPDATE_EVENT_PREFIX = 'update:'; const UPDATE_PROP_PREFIX = 'onUpdate:'; -const MODEL_VALUE_PROP = 'modelValue'; // Directive prefixes const DIRECTIVE_V_ON = 'v-on:'; @@ -84,24 +47,12 @@ const V_ON_SHORTHAND = '@'; const V_BIND_SHORTHAND = ':'; const DIRECTIVE_V_FOR_NAME = 'v-for'; -// Control flow directives -const CONTROL_FLOW_DIRECTIVES = ['v-if', 'v-else-if', 'v-else', 'v-for'] as const; - // Templates const V_FOR_SNIPPET = '="${1:value} in ${2:source}"'; -type CompletionTarget = 'tag' | 'attribute' | 'value'; - interface TagInfo { - attrs: string[]; - propInfos: ComponentPropInfo[]; - events: string[]; -} - -interface AttributeMetadata { - name: string; - kind: 'prop' | 'event'; - info?: ComponentPropInfo; + attrs: { name: string; type: string }[]; + meta: ComponentMeta | undefined | null; } let builtInData: html.HTMLDataV1 | undefined; @@ -110,20 +61,9 @@ let modelData: html.HTMLDataV1 | undefined; export function create( ts: typeof import('typescript'), languageId: 'html' | 'jade', - { - getComponentNames, - getComponentProps, - getComponentEvents, - getComponentDirectives, - getComponentSlots, - getElementAttrs, - resolveModuleName, - getAutoImportSuggestions, - resolveAutoImportCompletionEntry, - }: import('@vue/typescript-plugin/lib/requests').Requests, + tsserver: import('@vue/typescript-plugin/lib/requests').Requests, ): LanguageServicePlugin { - let customData: html.IHTMLDataProvider[] = []; - let extraCustomData: html.IHTMLDataProvider[] = []; + let htmlData: html.IHTMLDataProvider[] = []; let modulePathCache: | Map | string | null | undefined> | undefined; @@ -153,7 +93,7 @@ export function create( const map = modulePathCache; if (!map.has(ref)) { const fileName = baseUri.fsPath.replace(/\\/g, '/'); - const promise = resolveModuleName(fileName, ref); + const promise = tsserver.resolveModuleName(fileName, ref); map.set(ref, promise); if (promise instanceof Promise) { promise.then(res => map.set(ref, res)); @@ -175,10 +115,7 @@ export function create( useDefaultDataProvider: false, getDocumentContext, getCustomData() { - return [ - ...customData, - ...extraCustomData, - ]; + return htmlData; }, onDidChangeCustomData, }) @@ -187,14 +124,10 @@ export function create( useDefaultDataProvider: false, getDocumentContext, getCustomData() { - return [ - ...customData, - ...extraCustomData, - ]; + return htmlData; }, onDidChangeCustomData, }); - const htmlDataProvider = html.getDefaultHTMLDataProvider(); return { name: `vue-template (${languageId})`, @@ -283,11 +216,13 @@ export function create( // https://vuejs.org/api/built-in-directives.html#v-bind const vBindModifiers = extractDirectiveModifiers(builtInData.globalAttributes?.find(x => x.name === 'v-bind')); const vModelModifiers = extractModelModifiers(modelData.globalAttributes); - - const disposable = context.env.onDidChangeConfiguration?.(() => initializing = undefined); const transformedItems = new WeakSet(); + const defaultHtmlTags = new Map(); + + for (const tag of html.getDefaultHTMLDataProvider().provideTags()) { + defaultHtmlTags.set(tag.name, tag); + } - let initializing: Promise | undefined; let lastCompletionDocument: TextDocument | undefined; return { @@ -295,7 +230,6 @@ export function create( dispose() { baseServiceInstance.dispose?.(); - disposable?.dispose(); }, async provideCompletionItems(document, position, completionContext, token) { @@ -307,17 +241,26 @@ export function create( return; } + const prevText = document.getText({ start: { line: 0, character: 0 }, end: position }); + const hint: 'v' | ':' | '@' | undefined = prevText.match(/\bv[\S]*$/) + ? 'v' + : prevText.match(/[:][\S]*$/) + ? ':' + : prevText.match(/[@][\S]*$/) + ? '@' + : undefined; + const { result: htmlCompletion, - target, info: { tagNameCasing, components, - propMap, }, - } = await runWithVueData( + } = await runWithVueDataProvider( info.script.id, info.root, + hint, + 'completion', () => baseServiceInstance.provideCompletionItems!( document, @@ -326,126 +269,107 @@ export function create( token, ), ); + const componentSet = new Set(components); if (!htmlCompletion) { return; } - - await replaceAutoImportPlaceholder(htmlCompletion, info); - - switch (target) { - case 'tag': { - htmlCompletion.items.forEach(transformTag); - break; - } - case 'attribute': { - addDirectiveModifiers(htmlCompletion, document); - htmlCompletion.items.forEach(transformAttribute); - break; - } + if (!prevText.match(/\b[\S]+$/)) { + htmlCompletion.isIncomplete = true; } + await resolveAutoImportPlaceholder(htmlCompletion, info); + resolveComponentItemKinds(htmlCompletion); + return htmlCompletion; - async function replaceAutoImportPlaceholder( + async function resolveAutoImportPlaceholder( htmlCompletion: CompletionList, info: NonNullable>, ) { const autoImportPlaceholderIndex = htmlCompletion.items.findIndex(item => item.label === AUTO_IMPORT_PLACEHOLDER ); - if (autoImportPlaceholderIndex !== -1) { - const offset = document.offsetAt(position); - const map = context.language.maps.get(info.code, info.script); - let spliced = false; - for (const [sourceOffset] of map.toSourceLocation(offset)) { - const autoImport = await getAutoImportSuggestions( - info.root.fileName, - sourceOffset, - ); - if (!autoImport) { - continue; + if (autoImportPlaceholderIndex === -1) { + return; + } + const offset = document.offsetAt(position); + const map = context.language.maps.get(info.code, info.script); + let spliced = false; + for (const [sourceOffset] of map.toSourceLocation(offset)) { + const autoImport = await tsserver.getAutoImportSuggestions( + info.root.fileName, + sourceOffset, + ); + if (!autoImport) { + continue; + } + const tsCompletion = convertCompletionInfo(ts, autoImport, document, position, entry => entry.data); + const placeholder = htmlCompletion.items[autoImportPlaceholderIndex]!; + for (const tsItem of tsCompletion.items) { + if (placeholder.textEdit) { + const newText = tsItem.textEdit?.newText ?? tsItem.label; + tsItem.textEdit = { + ...placeholder.textEdit, + newText: tagNameCasing === TagNameCasing.Kebab + ? hyphenateTag(newText) + : newText, + }; } - const tsCompletion = convertCompletionInfo(ts, autoImport, document, position, entry => entry.data); - const placeholder = htmlCompletion.items[autoImportPlaceholderIndex]!; - for (const tsItem of tsCompletion.items) { - if (placeholder.textEdit) { - const newText = tsItem.textEdit?.newText ?? tsItem.label; - tsItem.textEdit = { - ...placeholder.textEdit, - newText: tagNameCasing === TagNameCasing.Kebab - ? hyphenateTag(newText) - : newText, - }; - } - else { - tsItem.textEdit = undefined; - } + else { + tsItem.textEdit = undefined; } - htmlCompletion.items.splice(autoImportPlaceholderIndex, 1, ...tsCompletion.items); - spliced = true; - lastCompletionDocument = document; - break; - } - if (!spliced) { - htmlCompletion.items.splice(autoImportPlaceholderIndex, 1); } + htmlCompletion.items.splice(autoImportPlaceholderIndex, 1, ...tsCompletion.items); + spliced = true; + lastCompletionDocument = document; + break; } - } - - function transformTag(item: html.CompletionItem) { - const tagName = capitalize(camelize(item.label)); - if (components?.includes(tagName)) { - item.kind = 6 satisfies typeof CompletionItemKind.Variable; - item.sortText = SORT_TOKEN.COMPONENT_PROP + (item.sortText ?? item.label); + if (!spliced) { + htmlCompletion.items.splice(autoImportPlaceholderIndex, 1); } } - function transformAttribute(item: html.CompletionItem) { - let prop = propMap.get(item.label); + function resolveComponentItemKinds(htmlCompletion: CompletionList) { + for (const item of htmlCompletion.items) { + switch (item.kind) { + case 10 satisfies typeof CompletionItemKind.Property: + if ( + componentSet.has(item.label) + || componentSet.has(capitalize(camelize(item.label))) + ) { + item.kind = 6 satisfies typeof CompletionItemKind.Variable; + } + break; + case 12 satisfies typeof CompletionItemKind.Value: + addDirectiveModifiers(htmlCompletion, item, document); + + if ( + typeof item.documentation === 'object' && item.documentation.value.includes('*@deprecated*') + ) { + item.tags = [1 satisfies typeof CompletionItemTag.Deprecated]; + } - if (prop) { - if (prop.info?.documentation) { - item.documentation = { - kind: 'markdown', - value: prop.info.documentation, - }; - } - if (prop.info?.deprecated) { - item.tags = [1 satisfies typeof CompletionItemTag.Deprecated]; - } - } - else { - const name = stripDirectivePrefix(item.label); - if (specialProps.has(name)) { - prop = { - name, - kind: 'prop', - }; - } - } + if (item.label.startsWith(DIRECTIVE_V_ON) || item.label.startsWith(V_ON_SHORTHAND)) { + item.kind = 23 satisfies typeof CompletionItemKind.Event; + } + else if ( + item.label.startsWith(DIRECTIVE_V_BIND) + || item.label.startsWith(V_BIND_SHORTHAND) + || item.label.startsWith(DIRECTIVE_V_MODEL) + ) { + item.kind = 5 satisfies typeof CompletionItemKind.Field; + } + else if (item.label.startsWith('v-')) { + item.kind = 14 satisfies typeof CompletionItemKind.Keyword; + } - // Set item kind - if (prop) { - if (prop.kind === 'prop' && !prop.info?.isAttribute) { - item.kind = 5 satisfies typeof CompletionItemKind.Field; - } - else if (prop.kind === 'event' || hyphenateAttr(prop.name).startsWith(EVENT_PROP_PREFIX)) { - item.kind = 23 satisfies typeof CompletionItemKind.Event; + if (item.label === DIRECTIVE_V_FOR_NAME) { + item.textEdit!.newText = item.label + V_FOR_SNIPPET; + } + break; } } - else if (CONTROL_FLOW_DIRECTIVES.includes(item.label as any)) { - item.kind = 14 satisfies typeof CompletionItemKind.Keyword; - } - else if (item.label.startsWith('v-')) { - item.kind = 3 satisfies typeof CompletionItemKind.Function; - } - - item.sortText = buildAttributeSortText(item.label, prop); - - if (item.label === DIRECTIVE_V_FOR_NAME) { - item.textEdit!.newText = item.label + V_FOR_SNIPPET; - } } }, @@ -462,7 +386,7 @@ export function create( if (!sourceScript) { return item; } - const details = await resolveAutoImportCompletionEntry(data); + const details = await tsserver.resolveAutoImportCompletionEntry(data); if (details) { const virtualCode = sourceScript.generated!.embeddedCodes.get(decoded[1])!; const sourceDocument = context.documents.get( @@ -510,14 +434,142 @@ export function create( if (info?.code.id !== 'template') { return; } - const { + let { result: htmlHover, - } = await runWithVueData( + } = await runWithVueDataProvider( info.script.id, info.root, + undefined, + 'hover', () => baseServiceInstance.provideHover!(document, position, token), ); + const templateAst = info.root.sfc.template?.ast; + + if (!templateAst || (htmlHover && hasContents(htmlHover.contents))) { + return htmlHover; + } + + for (const element of forEachElementNode(templateAst)) { + const tagStart = element.loc.start.offset + element.loc.source.indexOf(element.tag); + const tagEnd = tagStart + element.tag.length; + const offset = document.offsetAt(position); + + if (offset >= tagStart && offset <= tagEnd) { + const meta = await tsserver.getComponentMeta(info.root.fileName, element.tag); + const props = meta?.props.filter(p => !p.global); + const modelProps = new Set(); + let tableContents = ''; + + for (const event of meta?.events ?? []) { + if (event.name.startsWith(UPDATE_EVENT_PREFIX)) { + const modelName = event.name.slice(UPDATE_EVENT_PREFIX.length); + const modelProp = props?.find(p => p.name === modelName); + if (modelProp) { + modelProps.add(modelProp); + } + } + } + for (const prop of props ?? []) { + if (prop.name.startsWith(UPDATE_PROP_PREFIX)) { + const modelName = prop.name.slice(UPDATE_PROP_PREFIX.length); + const modelProp = props?.find(p => p.name === modelName); + if (modelProp) { + modelProps.add(modelProp); + } + } + } + + if (props?.length) { + tableContents += `PropDescriptionDefault\n`; + for (const p of props) { + tableContents += ` + ${printName(p, modelProps.has(p))} + ${printDescription(p)} + ${p.default ? `${p.default}` : ''} + \n`; + } + } + + if (meta?.events?.length) { + tableContents += `EventDescription\n`; + for (const e of meta.events) { + tableContents += ` + ${printName(e)} + ${printDescription(e)} + \n`; + } + } + + if (meta?.slots?.length) { + tableContents += `SlotDescription\n`; + for (const s of meta.slots) { + tableContents += ` + ${printName(s)} + ${printDescription(s)} + \n`; + } + } + + if (meta?.exposed.length) { + tableContents += `ExposedDescription\n`; + for (const e of meta.exposed) { + tableContents += ` + ${printName(e)} + ${printDescription(e)} + \n`; + } + } + + htmlHover ??= { + range: { + start: document.positionAt(tagStart), + end: document.positionAt(tagEnd), + }, + contents: '', + }; + htmlHover.contents = { + kind: 'markdown', + value: tableContents + ? `\n${tableContents}\n
` + : `No type information available.`, + }; + } + } + return htmlHover; + + function printName(meta: { name: string; tags: { name: string }[]; required?: boolean }, model?: boolean) { + let name = meta.name; + if (meta.tags.some(tag => tag.name === 'deprecated')) { + name = `${name}`; + } + if (meta.required) { + name += ' required'; + } + if (model) { + name += ' model'; + } + return name; + } + + function printDescription(meta: { description?: string; type: string }) { + let desc = `${meta.type}`; + if (meta.description) { + desc = `${meta.description}
${desc}`; + desc = `

${desc}

`; + } + return desc; + } + + function hasContents(contents: html.MarkupContent | html.MarkedString | html.MarkedString[]) { + if (typeof contents === 'string') { + return !!contents; + } + if (Array.isArray(contents)) { + return contents.some(hasContents); + } + return !!contents.value; + } }, async provideDocumentLinks(document, token) { @@ -540,11 +592,17 @@ export function create( }, }; - async function runWithVueData(sourceDocumentUri: URI, root: VueVirtualCode, fn: () => T) { + async function runWithVueDataProvider( + sourceDocumentUri: URI, + root: VueVirtualCode, + hint: 'v' | ':' | '@' | undefined, + mode: 'completion' | 'hover', + 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, hint, mode); let lastSync = await sync(); let result = await fn(); while (lastSync.version !== (lastSync = await sync()).version) { @@ -553,78 +611,48 @@ export function create( return { result, ...lastSync }; } - async function provideHtmlData(sourceDocumentUri: URI, root: VueVirtualCode) { - await (initializing ??= initialize()); - + async function provideHtmlData( + sourceDocumentUri: URI, + root: VueVirtualCode, + hint: 'v' | ':' | '@' | undefined, + mode: 'completion' | 'hover', + ) { const [tagNameCasing, attrNameCasing] = await Promise.all([ getTagNameCasing(context, sourceDocumentUri), getAttrNameCasing(context, sourceDocumentUri), ]); - for (const tag of builtInData!.tags ?? []) { - if (specialTags.has(tag.name)) { - continue; - } - if (tagNameCasing === TagNameCasing.Kebab) { - tag.name = hyphenateTag(tag.name); - } - else { - tag.name = camelize(capitalize(tag.name)); - } - } - let version = 0; - let target: CompletionTarget; let components: string[] | undefined; + let elements: string[] | undefined; let directives: string[] | undefined; let values: string[] | undefined; const tasks: Promise[] = []; const tagDataMap = new Map(); - const propMap = new Map(); updateExtraCustomData([ - { - getId: () => htmlDataProvider.getId(), - isApplicable: () => true, - provideTags() { - target = 'tag'; - return htmlDataProvider.provideTags() - .filter(tag => !specialTags.has(tag.name)); - }, - provideAttributes(tag) { - target = 'attribute'; - const attrs = htmlDataProvider.provideAttributes(tag); - if (tag === 'slot') { - const nameAttr = attrs.find(attr => attr.name === 'name'); - if (nameAttr) { - nameAttr.valueSet = 'slot'; - } - } - return attrs; - }, - provideValues(tag, attr) { - target = 'value'; - return htmlDataProvider.provideValues(tag, attr); - }, - }, - html.newHTMLDataProvider('vue-template-built-in', builtInData!), { getId: () => 'vue-template', isApplicable: () => true, provideTags: () => { - const components = getComponents(); + const { components, elements } = getComponentsAndElements(); const codegen = tsCodegen.get(root.sfc); const names = new Set(); const tags: html.ITagData[] = []; + for (const tag of builtInData?.tags ?? []) { + tags.push({ + ...tag, + name: tagNameCasing === TagNameCasing.Kebab ? hyphenateTag(tag.name) : tag.name, + }); + } + for (const tag of components) { - if (tagNameCasing === TagNameCasing.Kebab) { - names.add(hyphenateTag(tag)); - } - else { - names.add(tag); - } + names.add(tagNameCasing === TagNameCasing.Kebab ? hyphenateTag(tag) : tag); + } + for (const tag of elements) { + names.add(tag); } if (codegen) { for ( @@ -633,109 +661,131 @@ export function create( ...codegen.getSetupExposed(), ] ) { - if (tagNameCasing === TagNameCasing.Kebab) { - names.add(hyphenateTag(name)); - } - else { - names.add(name); - } + names.add(tagNameCasing === TagNameCasing.Kebab ? hyphenateTag(name) : name); } } + + const added = new Set(tags.map(t => t.name)); for (const name of names) { - tags.push({ name, attributes: [] }); + if (!added.has(name)) { + const defaultTag = defaultHtmlTags.get(name); + tags.push({ + ...defaultTag, + name, + attributes: [], + }); + } } return tags; }, provideAttributes: tag => { const directives = getDirectives(); - const { attrs, propInfos, events } = getTagData(tag); + const { attrs, meta } = getTagData(tag); const attributes: html.IAttributeData[] = []; - const models: string[] = []; + + for (const attr of builtInData?.globalAttributes ?? []) { + if (attr.name === 'is' && tag.toLowerCase() !== 'component') { + continue; + } + if (attr.name === 'ref' || attr.name.startsWith('v-')) { + attributes.push(attr); + } + else { + attributes.push({ + ...attr, + name: ':' + attr.name, + }); + } + } for ( - const prop of [ - ...propInfos, - ...attrs.map(attr => ({ name: attr, isAttribute: true })), - ] + const [propName, propMeta] of [ + ...meta?.props.map(prop => [prop.name, prop] as const) ?? [], + ...attrs.map(attr => [attr.name, undefined]), + ] as [string, PropertyMeta | undefined][] ) { - const propName = attrNameCasing === AttrNameCasing.Camel ? prop.name : hyphenateAttr(prop.name); - const isEvent = hyphenateAttr(propName).startsWith(EVENT_PROP_PREFIX); - if (isEvent) { - const eventName = attrNameCasing === AttrNameCasing.Camel - ? propName['on'.length]!.toLowerCase() + propName.slice('onX'.length) - : propName.slice(EVENT_PROP_PREFIX.length); - - for (const name of [DIRECTIVE_V_ON + eventName, V_ON_SHORTHAND + eventName]) { - attributes.push({ name }); - propMap.set(name, { - name: propName, - kind: 'event', - info: prop, + if (propName.match(EVENT_PROP_REGEX)) { + let labelName = propName.slice(2); + labelName = labelName.charAt(0).toLowerCase() + labelName.slice(1); + if (attrNameCasing === AttrNameCasing.Kebab) { + labelName = hyphenateAttr(labelName); + } + + if (!hint || hint === '@' || hint === 'v') { + const prefix = !hint || hint === '@' ? V_ON_SHORTHAND : DIRECTIVE_V_ON; + attributes.push({ + name: prefix + labelName, + description: propMeta && createDescription(propMeta), }); } } else { - const propInfo = propInfos.find(prop => { + const labelName = attrNameCasing === AttrNameCasing.Camel ? propName : hyphenateAttr(propName); + const propMeta2 = meta?.props.find(prop => { const name = attrNameCasing === AttrNameCasing.Camel ? prop.name : hyphenateAttr(prop.name); - return name === propName; + return name === labelName; }); - - for (const name of [propName, V_BIND_SHORTHAND + propName, DIRECTIVE_V_BIND + propName]) { + if (!hint || hint === ':' || hint === 'v') { + const prefix = !hint || hint === ':' ? V_BIND_SHORTHAND : DIRECTIVE_V_BIND; attributes.push({ - name, - valueSet: prop.values?.some(value => typeof value === 'string') ? '__deferred__' : undefined, + name: prefix + labelName, + description: propMeta2 && createDescription(propMeta2), }); - propMap.set(name, { - name: propName, - kind: 'prop', - info: propInfo, + } + if (!hint || hint === 'v') { + attributes.push({ + name: labelName, + description: propMeta2 && createDescription(propMeta2), }); } } } - for (const event of events) { - const eventName = attrNameCasing === AttrNameCasing.Camel ? event : hyphenateAttr(event); - for (const name of [DIRECTIVE_V_ON + eventName, V_ON_SHORTHAND + eventName]) { - attributes.push({ name }); - propMap.set(name, { - name: eventName, - kind: 'event', + for (const event of meta?.events ?? []) { + const eventName = attrNameCasing === AttrNameCasing.Camel ? event.name : hyphenateAttr(event.name); + + if (!hint || hint === '@' || hint === 'v') { + const prefix = !hint || hint === '@' ? V_ON_SHORTHAND : DIRECTIVE_V_ON; + attributes.push({ + name: prefix + eventName, + description: event && createDescription(event), }); } } + for (const directive of directives) { attributes.push({ name: hyphenateAttr(directive), }); } + for ( - const prop of [ - ...propInfos, - ...attrs.map(attr => ({ name: attr })), - ] + const [propName, propMeta] of [ + ...meta?.props.map(prop => [prop.name, prop] as const) ?? [], + ...attrs.map(attr => [attr.name, undefined]), + ] as [string, PropertyMeta | undefined][] ) { - if (prop.name.startsWith(UPDATE_PROP_PREFIX)) { - models.push(prop.name.slice(UPDATE_PROP_PREFIX.length)); - } - } - for (const event of events) { - if (event.startsWith(UPDATE_EVENT_PREFIX)) { - models.push(event.slice(UPDATE_EVENT_PREFIX.length)); + if (propName.startsWith(UPDATE_PROP_PREFIX)) { + const model = propName.slice(UPDATE_PROP_PREFIX.length); + const label = DIRECTIVE_V_MODEL + + (attrNameCasing === AttrNameCasing.Camel ? model : hyphenateAttr(model)); + attributes.push({ + name: label, + description: propMeta && createDescription(propMeta), + }); } } - for (const model of models) { - const name = attrNameCasing === AttrNameCasing.Camel ? model : hyphenateAttr(model); - attributes.push({ name: DIRECTIVE_V_MODEL + name }); - propMap.set(DIRECTIVE_V_MODEL + name, { - name, - kind: 'prop', - }); - if (model === MODEL_VALUE_PROP) { - propMap.set('v-model', { - name, - kind: 'prop', - }); + if (!hint || hint === 'v') { + for (const event of meta?.events ?? []) { + if (event.name.startsWith(UPDATE_EVENT_PREFIX)) { + const model = event.name.slice(UPDATE_EVENT_PREFIX.length); + const label = DIRECTIVE_V_MODEL + + (attrNameCasing === AttrNameCasing.Camel ? model : hyphenateAttr(model)); + attributes.push({ + name: label, + description: createDescription(event), + }); + } } } @@ -748,15 +798,9 @@ export function create( { getId: () => 'vue-auto-imports', isApplicable: () => true, - provideTags() { - return [{ name: AUTO_IMPORT_PLACEHOLDER, attributes: [] }]; - }, - provideAttributes() { - return []; - }, - provideValues() { - return []; - }, + provideTags: () => [{ name: AUTO_IMPORT_PLACEHOLDER, attributes: [] }], + provideAttributes: () => [], + provideValues: () => [], }, ]); @@ -765,22 +809,38 @@ export function create( await Promise.all(tasks); return { version, - target, info: { tagNameCasing, components, - propMap, }, }; }, }; + function createDescription(meta: Pick) { + if (mode === 'hover') { + // dedupe from TS hover + return; + } + let description = meta?.description ?? ''; + for (const tag of meta.tags) { + description += `\n\n*@${tag.name}* ${tag.text ?? ''}`; + } + if (!description) { + return; + } + return { + kind: 'markdown' as const, + value: description, + }; + } + function getAttrValues(tag: string, attr: string) { if (!values) { values = []; tasks.push((async () => { if (tag === 'slot' && attr === 'name') { - values = await getComponentSlots(root.fileName) ?? []; + values = await tsserver.getComponentSlots(root.fileName) ?? []; } version++; })()); @@ -791,13 +851,12 @@ export function create( function getTagData(tag: string) { let data = tagDataMap.get(tag); if (!data) { - data = { attrs: [], propInfos: [], events: [] }; + data = { attrs: [], meta: undefined }; tagDataMap.set(tag, data); tasks.push((async () => { tagDataMap.set(tag, { - attrs: await getElementAttrs(root.fileName, tag) ?? [], - propInfos: await getComponentProps(root.fileName, tag) ?? [], - events: await getComponentEvents(root.fileName, tag) ?? [], + attrs: await tsserver.getElementAttrs(root.fileName, tag) ?? [], + meta: await tsserver.getComponentMeta(root.fileName, tag), }); version++; })()); @@ -809,28 +868,40 @@ export function create( if (!directives) { directives = []; tasks.push((async () => { - directives = await getComponentDirectives(root.fileName) ?? []; + directives = await tsserver.getComponentDirectives(root.fileName) ?? []; version++; })()); } return directives; } - function getComponents() { - if (!components) { + function getComponentsAndElements() { + if (!components || !elements) { components = []; + elements = []; tasks.push((async () => { - components = await getComponentNames(root.fileName) ?? []; - components = components.filter(name => !builtInComponents.has(name)); + const res = await Promise.all([ + tsserver.getComponentNames(root.fileName), + tsserver.getElementNames(root.fileName), + ]); + components = res[0] ?? []; + elements = res[1] ?? []; version++; })()); } - return components; + return { + components, + elements, + }; } } - function addDirectiveModifiers(completionList: CompletionList, document: TextDocument) { - const replacement = getReplacement(completionList, document); + function addDirectiveModifiers( + list: CompletionList, + item: html.CompletionItem, + document: TextDocument, + ) { + const replacement = getReplacement(item, document); if (!replacement?.text.includes('.')) { return; } @@ -872,52 +943,25 @@ export function create( kind: 20 satisfies typeof CompletionItemKind.EnumMember, }; - completionList.items.push(newItem); + list.items.push(newItem); } } - - async function initialize() { - customData = await getHtmlCustomData(); - } - - async function getHtmlCustomData() { - const customData: string[] = await context.env.getConfiguration?.('html.customData') ?? []; - const newData: html.IHTMLDataProvider[] = []; - for (const customDataPath of customData) { - for (const workspaceFolder of context.env.workspaceFolders) { - const uri = Utils.resolvePath(workspaceFolder, customDataPath); - const json = await context.env.fs?.readFile(uri); - if (json) { - try { - const data = JSON.parse(json); - newData.push(html.newHTMLDataProvider(customDataPath, data)); - } - catch (error) { - console.error(error); - } - } - } - } - return newData; - } }, }; - function updateExtraCustomData(extraData: html.IHTMLDataProvider[]) { - extraCustomData = extraData; + function updateExtraCustomData(newData: html.IHTMLDataProvider[]) { + htmlData = newData; onDidChangeCustomDataListeners.forEach(l => l()); } } -function getReplacement(list: html.CompletionList, doc: TextDocument) { - for (const item of list.items) { - if (item.textEdit && 'range' in item.textEdit) { - return { - item: item, - textEdit: item.textEdit, - text: doc.getText(item.textEdit.range), - }; - } +function getReplacement(item: html.CompletionItem, doc: TextDocument) { + if (item.textEdit && 'range' in item.textEdit) { + return { + item: item, + textEdit: item.textEdit, + text: doc.getText(item.textEdit.range), + }; } } @@ -954,66 +998,3 @@ function extractModelModifiers(attributes: html.IAttributeData[] | undefined): R } return modifiers; } - -function buildAttributeSortText( - label: string, - prop: AttributeMetadata | undefined, -): string { - const tokens: string[] = []; - - if (prop) { - if (!prop.info?.isAttribute) { - tokens.push(SORT_TOKEN.COMPONENT_PROP); - - if (label.startsWith(V_BIND_SHORTHAND)) { - tokens.push(SORT_TOKEN.COLON_PREFIX); - } - else if (label.startsWith(V_ON_SHORTHAND)) { - tokens.push(SORT_TOKEN.AT_PREFIX); - } - else if (label.startsWith(DIRECTIVE_V_BIND)) { - tokens.push(SORT_TOKEN.V_BIND_PREFIX); - } - else if (label.startsWith(DIRECTIVE_V_MODEL)) { - tokens.push(SORT_TOKEN.V_MODEL_PREFIX); - } - else if (label.startsWith(DIRECTIVE_V_ON)) { - tokens.push(SORT_TOKEN.V_ON_PREFIX); - } - else { - tokens.push(SORT_TOKEN.COMPONENT_PROP); - } - - if (specialProps.has(prop.name)) { - tokens.push(SORT_TOKEN.SPECIAL_PROP); - } - else { - tokens.push(SORT_TOKEN.COMPONENT_PROP); - } - } - - if (prop.name.startsWith('onVue:')) { - tokens.unshift(SORT_TOKEN.VUE_EVENT); - } - } - else if (CONTROL_FLOW_DIRECTIVES.includes(label as any)) { - tokens.push(SORT_TOKEN.CONTROL_FLOW); - } - else if (label.startsWith('v-')) { - tokens.push(SORT_TOKEN.DIRECTIVE); - } - else { - tokens.push(SORT_TOKEN.HTML_ATTR); - } - - return tokens.join('') + label; -} - -function stripDirectivePrefix(name: string): string { - for (const prefix of [DIRECTIVE_V_BIND, V_BIND_SHORTHAND]) { - if (name.startsWith(prefix) && name !== prefix) { - return name.slice(prefix.length); - } - } - return name; -} diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index f7a85f907f..b217d1e3b9 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -12,9 +12,8 @@ import { 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 { getComponentMeta } from './lib/requests/getComponentMeta'; import { getComponentNames } from './lib/requests/getComponentNames'; -import { getComponentProps } from './lib/requests/getComponentProps'; import { getComponentSlots } from './lib/requests/getComponentSlots'; import { getElementAttrs } from './lib/requests/getElementAttrs'; import { getElementNames } from './lib/requests/getElementNames'; @@ -273,20 +272,17 @@ 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, virtualCode } = getProjectAndVirtualCode(fileName); - return createResponse(getComponentEvents(ts, project.getLanguageService().getProgram()!, virtualCode, tag)); - }); session.addProtocolHandler('_vue:getComponentNames', request => { const [fileName]: Parameters = request.arguments; const { project, virtualCode } = getProjectAndVirtualCode(fileName); return createResponse(getComponentNames(ts, project.getLanguageService().getProgram()!, virtualCode)); }); - session.addProtocolHandler('_vue:getComponentProps', request => { - const [fileName, tag]: Parameters = request.arguments; - const { project, virtualCode } = getProjectAndVirtualCode(fileName); - return createResponse(getComponentProps(ts, project.getLanguageService().getProgram()!, virtualCode, tag)); + session.addProtocolHandler('_vue:getComponentMeta', request => { + const [fileName, tag]: Parameters = request.arguments; + const { project, virtualCode, language } = getProjectAndVirtualCode(fileName); + const program = project.getLanguageService().getProgram()!; + const sourceFile = program.getSourceFile(virtualCode.fileName)!; + return createResponse(getComponentMeta(ts, program, language, sourceFile, virtualCode, tag)); }); session.addProtocolHandler('_vue:getComponentSlots', request => { const [fileName]: Parameters = request.arguments; diff --git a/packages/typescript-plugin/lib/requests/getComponentDirectives.ts b/packages/typescript-plugin/lib/requests/getComponentDirectives.ts index 2bfe8bf31e..3d1f2d1a2e 100644 --- a/packages/typescript-plugin/lib/requests/getComponentDirectives.ts +++ b/packages/typescript-plugin/lib/requests/getComponentDirectives.ts @@ -2,15 +2,6 @@ import { names } from '@vue/language-core'; import type * as ts from 'typescript'; import { getVariableType } from './utils'; -const builtInDirectives = new Set([ - 'vBind', - 'vIf', - 'vOn', - 'vOnce', - 'vShow', - 'vSlot', -]); - export function getComponentDirectives( ts: typeof import('typescript'), program: ts.Program, @@ -29,6 +20,5 @@ export function getComponentDirectives( return directives.type.getProperties() .map(({ name }) => name) - .filter(name => name.startsWith('v') && name.length >= 2 && name[1] === name[1]!.toUpperCase()) - .filter(name => !builtInDirectives.has(name)); + .filter(name => name.match(/^v[A-Z]/)); } diff --git a/packages/typescript-plugin/lib/requests/getComponentEvents.ts b/packages/typescript-plugin/lib/requests/getComponentEvents.ts deleted file mode 100644 index dc2ead74bd..0000000000 --- a/packages/typescript-plugin/lib/requests/getComponentEvents.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { VueVirtualCode } from '@vue/language-core'; -import type * as ts from 'typescript'; -import { getComponentType } from './utils'; - -export function getComponentEvents( - ts: typeof import('typescript'), - program: ts.Program, - virtualCode: VueVirtualCode, - tag: string, -): string[] { - const sourceFile = program.getSourceFile(virtualCode.fileName); - if (!sourceFile) { - return []; - } - - const checker = program.getTypeChecker(); - const componentType = getComponentType(ts, checker, sourceFile, virtualCode, 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.type.getConstructSignatures()) { - const instanceType = sig.getReturnType(); - const emitSymbol = instanceType.getProperty('$emit'); - if (emitSymbol) { - const emitType = checker.getTypeOfSymbolAtLocation(emitSymbol, componentType.node); - for (const call of emitType.getCallSignatures()) { - if (call.parameters.length) { - const eventNameParamSymbol = call.parameters[0]!; - const eventNameParamType = checker.getTypeOfSymbolAtLocation(eventNameParamSymbol, componentType.node); - if (eventNameParamType.isStringLiteral()) { - result.add(eventNameParamType.value); - } - } - } - } - } - - return [...result]; -} diff --git a/packages/typescript-plugin/lib/requests/getComponentMeta.ts b/packages/typescript-plugin/lib/requests/getComponentMeta.ts new file mode 100644 index 0000000000..d6a2969cc6 --- /dev/null +++ b/packages/typescript-plugin/lib/requests/getComponentMeta.ts @@ -0,0 +1,29 @@ +import type { Language, VueVirtualCode } from '@vue/language-core'; +import type * as ts from 'typescript'; +import type { ComponentMeta } from 'vue-component-meta'; +import { getComponentMeta as _get } from 'vue-component-meta/lib/componentMeta'; +import { getComponentType } from './utils'; + +export function getComponentMeta( + ts: typeof import('typescript'), + program: ts.Program, + language: Language, + sourceFile: ts.SourceFile, + virtualCode: VueVirtualCode, + tag: string, +): ComponentMeta | undefined { + const checker = program.getTypeChecker(); + const componentType = getComponentType(ts, checker, sourceFile, virtualCode, tag); + if (!componentType) { + return; + } + return _get( + ts, + checker, + ts.createPrinter(), + language, + componentType.node, + componentType.type, + false, + ); +} diff --git a/packages/typescript-plugin/lib/requests/getComponentProps.ts b/packages/typescript-plugin/lib/requests/getComponentProps.ts deleted file mode 100644 index ddc956b34f..0000000000 --- a/packages/typescript-plugin/lib/requests/getComponentProps.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { VueVirtualCode } from '@vue/language-core'; -import { capitalize } from '@vue/shared'; -import type * as ts from 'typescript'; -import { getComponentType } 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, - virtualCode: VueVirtualCode, - tag: string, -): ComponentPropInfo[] { - const sourceFile = program.getSourceFile(virtualCode.fileName); - if (!sourceFile) { - return []; - } - - const checker = program.getTypeChecker(); - const componentType = getComponentType(ts, checker, sourceFile, virtualCode, tag); - if (!componentType) { - return []; - } - - const map = new Map(); - const result: ComponentPropInfo[] = []; - - for (const sig of componentType.type.getCallSignatures()) { - if (sig.parameters.length) { - const propParam = sig.parameters[0]!; - const propsType = checker.getTypeOfSymbolAtLocation(propParam, componentType.node); - const props = propsType.getProperties(); - for (const prop of props) { - handlePropSymbol(prop); - } - } - } - - for (const sig of componentType.type.getConstructSignatures()) { - const instanceType = sig.getReturnType(); - const propsSymbol = instanceType.getProperty('$props'); - if (propsSymbol) { - const propsType = checker.getTypeOfSymbolAtLocation(propsSymbol, componentType.node); - const props = propsType.getProperties(); - for (const prop of props) { - handlePropSymbol(prop); - } - } - } - - for (const prop of map.values()) { - if (prop.name.startsWith('ref_')) { - continue; - } - if (prop.name.startsWith('onVnode')) { - const vnodeEvent = prop.name.slice('onVnode'.length); - prop.name = 'onVue:' + capitalize(vnodeEvent); - } - result.push(prop); - } - - return result; - - function handlePropSymbol(prop: ts.Symbol) { - if (prop.flags & ts.SymbolFlags.Method) { // #2443 - 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); - } - } - } - - 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; - } - } - - map.set(name, { - name, - required, - deprecated, - isAttribute, - documentation, - values, - }); - } -} - -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 _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; - } - }).join('') || ''; - - return `${tagName} ${tagText}`; - }).join('\n\n'); -} diff --git a/packages/typescript-plugin/lib/requests/getElementAttrs.ts b/packages/typescript-plugin/lib/requests/getElementAttrs.ts index 53db2d9053..8e5619a5e7 100644 --- a/packages/typescript-plugin/lib/requests/getElementAttrs.ts +++ b/packages/typescript-plugin/lib/requests/getElementAttrs.ts @@ -1,5 +1,6 @@ import { names } from '@vue/language-core'; import type * as ts from 'typescript'; +import { getComponentMeta as _get } from 'vue-component-meta/lib/componentMeta'; import { getVariableType } from './utils'; export function getElementAttrs( @@ -7,7 +8,7 @@ export function getElementAttrs( program: ts.Program, fileName: string, tag: string, -): string[] { +) { const sourceFile = program.getSourceFile(fileName); if (!sourceFile) { return []; @@ -24,5 +25,12 @@ export function getElementAttrs( return []; } - return checker.getTypeOfSymbol(elementType).getProperties().map(c => c.name); + return checker.getTypeOfSymbol(elementType).getProperties().map(c => ({ + name: c.name, + type: checker.typeToString( + checker.getTypeOfSymbolAtLocation(c, sourceFile), + elements.node, + ts.TypeFormatFlags.NoTruncation, + ), + })); } diff --git a/packages/typescript-plugin/lib/requests/index.ts b/packages/typescript-plugin/lib/requests/index.ts index d92944b53e..41d9b3d524 100644 --- a/packages/typescript-plugin/lib/requests/index.ts +++ b/packages/typescript-plugin/lib/requests/index.ts @@ -20,17 +20,13 @@ export interface Requests { getComponentDirectives( fileName: string, ): Response>; - getComponentEvents( - fileName: string, - tag: string, - ): Response>; getComponentNames( fileName: string, ): Response>; - getComponentProps( + getComponentMeta( fileName: string, tag: string, - ): Response>; + ): Response>; getComponentSlots( fileName: string, ): Response>; diff --git a/packages/typescript-plugin/lib/requests/utils.ts b/packages/typescript-plugin/lib/requests/utils.ts index d401efd428..98c41a250f 100644 --- a/packages/typescript-plugin/lib/requests/utils.ts +++ b/packages/typescript-plugin/lib/requests/utils.ts @@ -38,27 +38,26 @@ export function getComponentType( let componentSymbol = components.type.getProperty(nameParts[0]) ?? components.type.getProperty(camelize(nameParts[0])) ?? components.type.getProperty(capitalize(camelize(nameParts[0]))); - let componentType: ts.Type | undefined; - if (!componentSymbol) { - const name = getSelfComponentName(fileName); - if (name === capitalize(camelize(tag))) { - componentType = getVariableType(ts, checker, sourceFile, names._export)?.type; - } - } - else { - componentType = checker.getTypeOfSymbolAtLocation(componentSymbol, components.node); + if (componentSymbol) { + let componentType = checker.getTypeOfSymbolAtLocation(componentSymbol, components.node); for (let i = 1; i < nameParts.length; i++) { componentSymbol = componentType.getProperty(nameParts[i]!); if (componentSymbol) { componentType = checker.getTypeOfSymbolAtLocation(componentSymbol, components.node); } } + if (componentType) { + return { + node: components.node, + type: componentType, + }; + } } - if (componentType) { - return { - node: components.node, - type: componentType, - }; + else { + const name = getSelfComponentName(fileName); + if (name === capitalize(camelize(tag))) { + return getVariableType(ts, checker, sourceFile, names._export); + } } } @@ -93,7 +92,7 @@ function searchVariableDeclarationNode( sourceFile: ts.SourceFile, name: string, ) { - let result: ts.Node | undefined; + let result: ts.VariableDeclaration | undefined; walk(sourceFile); return result; diff --git a/packages/typescript-plugin/package.json b/packages/typescript-plugin/package.json index 4e57f9071c..8c16b9e157 100644 --- a/packages/typescript-plugin/package.json +++ b/packages/typescript-plugin/package.json @@ -16,7 +16,8 @@ "@volar/typescript": "2.4.27", "@vue/language-core": "workspace:*", "@vue/shared": "^3.5.0", - "path-browserify": "^1.0.1" + "path-browserify": "^1.0.1", + "vue-component-meta": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.4", diff --git a/packages/typescript-plugin/tsconfig.json b/packages/typescript-plugin/tsconfig.json index dcf4eb3dfd..6a43e0ee48 100644 --- a/packages/typescript-plugin/tsconfig.json +++ b/packages/typescript-plugin/tsconfig.json @@ -3,6 +3,7 @@ "include": ["*", "lib/**/*"], "exclude": ["tests"], "references": [ + { "path": "../component-meta/tsconfig.json" }, { "path": "../language-core/tsconfig.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 89768bcba6..05f3f50ad6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -280,6 +280,9 @@ importers: path-browserify: specifier: ^1.0.1 version: 1.0.1 + vue-component-meta: + specifier: workspace:* + version: link:../component-meta devDependencies: '@types/node': specifier: ^22.10.4