From ff0a1c44ced257bf9a4a66d5c4866f47f7fadf57 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 14:41:32 +0900 Subject: [PATCH 1/5] feat: add RFC 823 patterned template reference tooling --- .../vscode/syntaxes/vue.tmLanguage.json | 118 +++++- .../tests/__snapshots__/grammar.spec.ts.snap | 320 +++++++++++++++ .../patterned-templates.vue | 14 + .../vscode/tests/embeddedGrammars/_lock.json | 10 +- extensions/vscode/tests/grammar.spec.ts | 4 + packages/language-core/index.ts | 1 + .../lib/codegen/template/index.ts | 10 + .../lib/codegen/template/matchTypes.ts | 94 +++++ .../lib/codegen/template/templateChild.ts | 8 +- .../lib/codegen/template/vMatch.ts | 209 ++++++++++ .../lib/plugins/vue-template-html.ts | 3 + .../language-core/lib/template/compile.ts | 2 + .../lib/template/patterns/parser.ts | 369 ++++++++++++++++++ .../lib/template/patterns/prepare.ts | 125 ++++++ .../lib/template/patterns/warnings.ts | 26 ++ .../language-core/lib/virtualCode/rawIr.ts | 2 +- packages/language-server/lib/server.ts | 3 + .../language-server/tests/completions.spec.ts | 5 + .../tests/patternedTemplates.spec.ts | 92 +++++ packages/language-service/index.ts | 2 + packages/language-service/lib/data.ts | 25 +- .../lib/plugins/vue-match-warnings.ts | 28 ++ packages/tsc/index.ts | 5 +- packages/tsc/lib/matchWarnings.ts | 69 ++++ packages/tsc/tests/patternedTemplates.spec.ts | 271 +++++++++++++ .../tsc/tests/patternedTemplatesCli.spec.ts | 60 +++ packages/typescript-plugin/index.ts | 6 + .../lib/requests/getMatchWarnings.ts | 1 + .../typescript-plugin/lib/requests/index.ts | 1 + 29 files changed, 1872 insertions(+), 11 deletions(-) create mode 100644 extensions/vscode/tests/embeddedGrammarFixtures/patterned-templates.vue create mode 100644 packages/language-core/lib/codegen/template/matchTypes.ts create mode 100644 packages/language-core/lib/codegen/template/vMatch.ts create mode 100644 packages/language-core/lib/template/patterns/parser.ts create mode 100644 packages/language-core/lib/template/patterns/prepare.ts create mode 100644 packages/language-core/lib/template/patterns/warnings.ts create mode 100644 packages/language-server/tests/patternedTemplates.spec.ts create mode 100644 packages/language-service/lib/plugins/vue-match-warnings.ts create mode 100644 packages/tsc/lib/matchWarnings.ts create mode 100644 packages/tsc/tests/patternedTemplates.spec.ts create mode 100644 packages/tsc/tests/patternedTemplatesCli.spec.ts create mode 100644 packages/typescript-plugin/lib/requests/getMatchWarnings.ts diff --git a/extensions/vscode/syntaxes/vue.tmLanguage.json b/extensions/vscode/syntaxes/vue.tmLanguage.json index 2f1845879a..ccd7809fbf 100644 --- a/extensions/vscode/syntaxes/vue.tmLanguage.json +++ b/extensions/vscode/syntaxes/vue.tmLanguage.json @@ -1239,6 +1239,9 @@ }, "vue-directives": { "patterns": [ + { + "include": "#vue-directives-match-arm" + }, { "include": "#vue-directives-control" }, @@ -1307,7 +1310,7 @@ ] }, "vue-directives-control": { - "begin": "(?:(v-for)|(v-if|v-else-if|v-else))(?=[=/>)\\s])", + "begin": "(?:(v-for)|(v-if|v-else-if|v-else|v-match))(?=[=/>)\\s])", "beginCaptures": { "1": { "name": "keyword.control.loop.vue" @@ -1585,6 +1588,119 @@ "include": "source.json#value" } ] + }, + "vue-directives-match-arm": { + "begin": "(v-when)(?=[=/>)\\s])", + "beginCaptures": { + "1": { + "name": "keyword.control.conditional.vue" + } + }, + "end": "(?=\\s*[^=\\s])", + "name": "meta.attribute.directive.control.vue", + "patterns": [ + { + "begin": "(=)\\s*('|\"|`)", + "beginCaptures": { + "1": { + "name": "punctuation.separator.key-value.html.vue" + }, + "2": { + "name": "punctuation.definition.string.begin.html.vue" + } + }, + "end": "(\\2)", + "endCaptures": { + "1": { + "name": "punctuation.definition.string.end.html.vue" + } + }, + "contentName": "meta.pattern.vue", + "patterns": [ + { + "include": "#vue-match-pattern" + } + ] + } + ] + }, + "vue-match-pattern": { + "patterns": [ + { + "begin": "\\b(if)\\s*(\\()", + "beginCaptures": { + "1": { + "name": "keyword.control.conditional.vue" + }, + "2": { + "name": "punctuation.definition.parameters.begin.ts" + } + }, + "end": "\\)(?=\\s*['\"`])", + "endCaptures": { + "0": { + "name": "punctuation.definition.parameters.end.ts" + } + }, + "contentName": "source.ts.embedded.html.vue", + "patterns": [ + { + "include": "source.ts#expression" + } + ] + }, + { + "match": "'(?:\\\\.|[^'\\\\])*'|\"(?:\\\\.|[^\"\\\\])*\"", + "name": "string.quoted.vue" + }, + { + "match": "\\b(const|as)\\b\\s*([\\w$]+)?", + "captures": { + "1": { + "name": "keyword.declaration.pattern.vue" + }, + "2": { + "name": "variable.other.constant.ts" + } + } + }, + { + "match": "\\b(let|var)\\b", + "name": "invalid.illegal.pattern.vue" + }, + { + "match": "(? multi-line-setup.vue 1`] = ` #^ text.html.vue" `; +exports[`embedded grammar > patterned-templates.vue 1`] = ` +"> +#^^ text.html.vue punctuation.definition.tag.begin.html.vue +# ^^^^^^ text.html.vue entity.name.tag.script.html.vue +# ^ text.html.vue punctuation.definition.tag.end.html.vue +> +#^^ text.html.vue punctuation.definition.tag.begin.html.vue +# ^^^^^^^^ text.html.vue entity.name.tag.template.html.vue +# ^ text.html.vue punctuation.definition.tag.end.html.vue +> +#^ text.html.vue" +`; + exports[`embedded grammar > single-line-setup.vue 1`] = ` "> #^ text.html.vue punctuation.definition.tag.begin.html.vue @@ -1290,6 +1558,58 @@ exports[`grammar > namespaced.vue 1`] = ` #^ text.html.vue" `; +exports[`grammar > patterned-templates.vue 1`] = ` +"> +#^^ text.html.vue punctuation.definition.tag.begin.html.vue +# ^^^^^^ text.html.vue entity.name.tag.script.html.vue +# ^ text.html.vue punctuation.definition.tag.end.html.vue +> +#^^ text.html.vue punctuation.definition.tag.begin.html.vue +# ^^^^^^^^ text.html.vue entity.name.tag.template.html.vue +# ^ text.html.vue punctuation.definition.tag.end.html.vue +> +#^ text.html.vue" +`; + exports[`grammar > script-tag-in-script.vue 1`] = ` ">' #^ text.html.vue punctuation.definition.tag.begin.html.vue diff --git a/extensions/vscode/tests/embeddedGrammarFixtures/patterned-templates.vue b/extensions/vscode/tests/embeddedGrammarFixtures/patterned-templates.vue new file mode 100644 index 0000000000..f30e0366cc --- /dev/null +++ b/extensions/vscode/tests/embeddedGrammarFixtures/patterned-templates.vue @@ -0,0 +1,14 @@ + + diff --git a/extensions/vscode/tests/embeddedGrammars/_lock.json b/extensions/vscode/tests/embeddedGrammars/_lock.json index 0ff3712a74..93b1eeea2c 100644 --- a/extensions/vscode/tests/embeddedGrammars/_lock.json +++ b/extensions/vscode/tests/embeddedGrammars/_lock.json @@ -1,27 +1,27 @@ [ { "file": "css.tmLanguage.json", - "url": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/css/syntaxes/css.tmLanguage.json", + "url": "https://raw.githubusercontent.com/microsoft/vscode/283e479dda1c4a4a0fed0fcf110bf48a8ee43aae/extensions/css/syntaxes/css.tmLanguage.json", "checksum": "sha256-9678e7410bd051eafc1ad51d3c8562885a17e7d61602d76cc5030d06520724cd" }, { "file": "html.tmLanguage.json", - "url": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/html/syntaxes/html.tmLanguage.json", + "url": "https://raw.githubusercontent.com/microsoft/vscode/283e479dda1c4a4a0fed0fcf110bf48a8ee43aae/extensions/html/syntaxes/html.tmLanguage.json", "checksum": "sha256-80dedf4fb27e88889ac8fb72763954a6d2660502c686f4415208d8c8d00352cd" }, { "file": "javascript.tmLanguage.json", - "url": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/javascript/syntaxes/JavaScript.tmLanguage.json", + "url": "https://raw.githubusercontent.com/microsoft/vscode/283e479dda1c4a4a0fed0fcf110bf48a8ee43aae/extensions/javascript/syntaxes/JavaScript.tmLanguage.json", "checksum": "sha256-db6f17f15bc4f5e860a3b8fa6055a69720a53df845c8d5121cdc4f128c16291f" }, { "file": "scss.tmLanguage.json", - "url": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/scss/syntaxes/scss.tmLanguage.json", + "url": "https://raw.githubusercontent.com/microsoft/vscode/283e479dda1c4a4a0fed0fcf110bf48a8ee43aae/extensions/scss/syntaxes/scss.tmLanguage.json", "checksum": "sha256-8f2824a80a7c6fd558fc538ec52d0a7a42a4d7ecb7ddf20d79f0d1f00fa6602b" }, { "file": "typescript.tmLanguage.json", - "url": "https://raw.githubusercontent.com/microsoft/vscode/main/extensions/typescript-basics/syntaxes/TypeScript.tmLanguage.json", + "url": "https://raw.githubusercontent.com/microsoft/vscode/283e479dda1c4a4a0fed0fcf110bf48a8ee43aae/extensions/typescript-basics/syntaxes/TypeScript.tmLanguage.json", "checksum": "sha256-4e92e0d7de560217d6c8d3236d85e6e17a5d77825b15729a230c761743122661" } ] diff --git a/extensions/vscode/tests/grammar.spec.ts b/extensions/vscode/tests/grammar.spec.ts index 70608334ba..d840aa5be2 100644 --- a/extensions/vscode/tests/grammar.spec.ts +++ b/extensions/vscode/tests/grammar.spec.ts @@ -40,6 +40,10 @@ describe('embedded grammar', async () => { it(fixture, async () => { const result = await snapshot(`tests/embeddedGrammarFixtures/${fixture}`); + if (fixture === 'patterned-templates.vue') { + expect(result).toContain('constant.language.wildcard.vue'); + expect(result).toContain('keyword.declaration.pattern.vue'); + } expect(result).toMatchSnapshot(); }); } diff --git a/packages/language-core/index.ts b/packages/language-core/index.ts index 96be55b1ea..7006eda595 100644 --- a/packages/language-core/index.ts +++ b/packages/language-core/index.ts @@ -5,6 +5,7 @@ export * from './lib/parsers/scriptRanges'; export * from './lib/parsers/scriptSetupRanges'; export * from './lib/plugins'; export * from './lib/template/compile'; +export * from './lib/template/patterns/warnings'; export * from './lib/types'; export * from './lib/utils/collectBindings'; export * from './lib/utils/forEachTemplateNode'; diff --git a/packages/language-core/lib/codegen/template/index.ts b/packages/language-core/lib/codegen/template/index.ts index 0ab43844fd..8e756d6a8d 100644 --- a/packages/language-core/lib/codegen/template/index.ts +++ b/packages/language-core/lib/codegen/template/index.ts @@ -67,6 +67,16 @@ function* generateWorker( if (vueCompilerOptions.inferTemplateDollarEl) { ctx.dollarVars.add('$el'); } + for (const error of template.errors) { + if (error.code !== 'V_MATCH_SYNTAX' || !error.loc) { + continue; + } + yield 'const '; + const token = yield* startBoundary('template', error.loc.start.offset, codeFeatures.verification); + yield ctx.getInternalVariable(); + yield endBoundary(token, error.loc.end.offset); + yield `: { ${JSON.stringify(error.message)}: never } = {};\n`; + } if (template.ast) { yield* generateTemplateChild(options, ctx, template.ast); } diff --git a/packages/language-core/lib/codegen/template/matchTypes.ts b/packages/language-core/lib/codegen/template/matchTypes.ts new file mode 100644 index 0000000000..adf64f9944 --- /dev/null +++ b/packages/language-core/lib/codegen/template/matchTypes.ts @@ -0,0 +1,94 @@ +/** + * RFC 823 symbolic space subtraction. Object products are split one property + * at a time; arrays are split into empty/nonempty tails. No Cartesian product + * is materialized up front. Unknown spaces remain uncovered conservatively. + * Emitted only for templates containing v-match. + */ +export const matchTypes = ` +type __VLS_PMAny = 0 extends (1 & T) ? true : false; +type __VLS_PMUnknown = [keyof T] extends [never] ? {} extends T ? true : false : false; +type __VLS_PMUnion = T extends C ? ([C] extends [T] ? false : true) : never; +type __VLS_PMSingle = __VLS_PMAny extends true ? false + : true extends __VLS_PMUnion ? false + : T extends string | number | bigint | boolean | null | undefined + ? string extends T ? false : number extends T ? false : bigint extends T ? false : boolean extends T ? false : true + : false; +type __VLS_PMPretty = { [K in keyof T]: T[K] }; +type __VLS_PMReplace = [V] extends [never] ? never + : {} extends Pick ? __VLS_PMPretty & { [Q in K]-?: V }> + : [T[K]] extends [V] ? [V] extends [T[K]] ? T : __VLS_PMPretty & { [Q in K]-?: V }> + : __VLS_PMPretty & { [Q in K]-?: V }>; +type __VLS_PMDesc

= P extends ['any'] ? unknown + : P extends ['literal' | 'value', infer V] ? V + : P extends ['or', infer A extends unknown[]] ? __VLS_PMDesc + : P extends ['object', infer A extends [PropertyKey, unknown][]] ? { [Q in A[number] as Q[0]]: __VLS_PMDesc } + : P extends ['array', infer A extends unknown[], infer R] ? [...{ [I in keyof A]: __VLS_PMDesc }, ...(R extends true ? unknown[] : [])] + : never; +type __VLS_PMMatch = P extends ['any'] ? T + : __VLS_PMAny extends true ? __VLS_PMDesc

+ : unknown extends T ? __VLS_PMDesc

+ : __VLS_PMUnknown extends true ? T & __VLS_PMDesc

+ : T extends unknown ? P extends ['literal' | 'value', infer V] ? T & V + : P extends ['or', infer A extends unknown[]] ? __VLS_PMMatch + : P extends ['object', infer A extends [PropertyKey, unknown][]] ? T extends null | undefined ? never : __VLS_PMObjectMatch + : P extends ['array', infer A extends unknown[], infer R] ? T extends readonly unknown[] ? T extends unknown[] ? __VLS_PMArrayMatch : Readonly<__VLS_PMArrayMatch> : never + : never : never; +type __VLS_PMRequire = {} extends Pick ? __VLS_PMPretty & Record> : T; +type __VLS_PMObjectMatch = A extends [infer H extends [PropertyKey, unknown], ...infer R] + ? H[0] extends keyof T ? H[1] extends ['any'] ? __VLS_PMObjectMatch<__VLS_PMRequire, R> : __VLS_PMObjectMatch<__VLS_PMReplace>, R> : __VLS_PMObjectMatch }, R> + : T; +type __VLS_PMArrayMatch = A extends [infer H, ...infer Tail] + ? T extends readonly [] ? never + : T extends readonly [infer V, ...infer Rest] ? __VLS_PMPair<__VLS_PMMatch, __VLS_PMArrayMatch> + : T extends readonly [(infer V)?, ...infer Rest] ? __VLS_PMPair<__VLS_PMMatch, __VLS_PMArrayMatch> + : never + : R extends true ? T : [] extends T ? [] : never; +type __VLS_PMPair = [H] extends [never] ? never : T extends readonly unknown[] ? [H, ...T] : never; +type __VLS_PMSubtract = [T] extends [never] ? never : P extends ['any'] ? never + : __VLS_PMAny extends true ? T : unknown extends T ? T : __VLS_PMUnknown extends true ? T + : string extends T ? T : number extends T ? T : bigint extends T ? T + : P extends ['or', infer A extends unknown[]] ? __VLS_PMSubtractMany + : T extends unknown ? P extends ['literal', infer V] ? Exclude + : P extends ['value', infer V] ? __VLS_PMSingle extends true ? Exclude : T + : P extends ['object', infer A extends [PropertyKey, unknown][]] ? T extends null | undefined ? T : __VLS_PMObjectSubtract + : P extends ['array', infer A extends unknown[], infer R] ? T extends readonly unknown[] ? __VLS_PMArraySubtract : T + : T : never; +type __VLS_PMSubtractMany = A extends [infer H, ...infer R] ? __VLS_PMSubtractMany<__VLS_PMSubtract, R> : T; +type __VLS_PMObjectSubtract = [T] extends [never] ? never + : A extends [infer H extends [PropertyKey, unknown], ...infer R] + ? H[0] extends keyof T + ? H[1] extends ['any'] ? ({} extends Pick ? Omit & { [K in H[0]]?: never } : never) | __VLS_PMObjectSubtract<__VLS_PMRequire, R> + : ({} extends Pick ? Omit & { [K in H[0]]?: never } : never) + | __VLS_PMReplace> + | __VLS_PMObjectSubtract<__VLS_PMReplace>, R> + : T + : never; +type __VLS_PMArraySubtract = A extends [infer H, ...infer Tail] + ? T extends readonly [] ? T + : T extends readonly [infer V, ...infer Rest] + ? __VLS_PMPair<__VLS_PMSubtract, Rest> | __VLS_PMPair<__VLS_PMMatch, __VLS_PMArraySubtract> + : T extends readonly [(infer V)?, ...infer Rest] + ? [] | __VLS_PMPair<__VLS_PMSubtract, Rest> | __VLS_PMPair<__VLS_PMMatch, __VLS_PMArraySubtract> + : T + : R extends true ? never : T extends readonly [] ? never : T extends readonly [unknown, ...unknown[]] ? T + : T extends readonly [(infer V)?, ...infer Rest] ? [T[0], ...Rest] : never; +type __VLS_PMAtom = T extends string ? string extends T ? '_' : T extends \`\${string}\${"'" | '"' | "\\\\" | "\\n" | "\\r"}\${string}\` ? '_' : \`'\${T}'\` + : T extends bigint ? bigint extends T ? '_' : \`\${T}n\` + : T extends number ? number extends T ? '_' : \`\${T}\` + : T extends boolean | null | undefined ? \`\${T}\` + : T extends readonly unknown[] ? \`[\${__VLS_PMTuple}]\` : '_'; +type __VLS_PMTuple = T extends readonly [] ? '' + : T extends readonly [infer H, ...infer R] ? \`\${__VLS_PMAtom}\${R extends [] ? '' : \`, \${__VLS_PMTuple}\`}\` : '...'; +type __VLS_PMHint = __VLS_PMAny extends true ? 'Add an unguarded v-when="_" to prove coverage.' + : T extends string ? string extends T ? 'An open string requires an unguarded catch-all.' + : T extends \`\${string}\${"'" | '"' | "\\\\" | "\\n" | "\\r"}\${string}\` ? 'Add an unguarded v-when="_".' + : \`Missing v-when="'\${T}'".\` + : T extends bigint ? bigint extends T ? 'An open bigint requires an unguarded catch-all.' : \`Missing v-when="\${T}n".\` + : T extends number ? number extends T ? 'An open number requires an unguarded catch-all.' : \`Missing v-when="\${T}".\` + : T extends boolean | null | undefined ? \`Missing v-when="\${T}".\` + : T extends readonly unknown[] ? \`Missing v-when="[\${__VLS_PMTuple}]".\` + : 'Coverage cannot be proven. Add missing structural arms or an unguarded v-when="_".'; +type __VLS_PMAssert = [T] extends [never] ? true : { + 'Non-exhaustive v-match': T +} & { [K in __VLS_PMHint]: never }; +`; diff --git a/packages/language-core/lib/codegen/template/templateChild.ts b/packages/language-core/lib/codegen/template/templateChild.ts index b9a420c348..57da2c422d 100644 --- a/packages/language-core/lib/codegen/template/templateChild.ts +++ b/packages/language-core/lib/codegen/template/templateChild.ts @@ -1,4 +1,5 @@ import * as CompilerDOM from '@vue/compiler-dom'; +import { getTemplateMatch } from '../../template/patterns/prepare'; import type { Code } from '../../types'; import { hyphenateTag } from '../../utils/shared'; import { codeFeatures } from '../codeFeatures'; @@ -10,6 +11,7 @@ import { generateInterpolation } from './interpolation'; import { generateSlotOutlet } from './slotOutlet'; import { generateVFor } from './vFor'; import { generateVIf } from './vIf'; +import { generateVMatch } from './vMatch'; import { generateVSlot } from './vSlot'; export function* generateTemplateChild( @@ -32,7 +34,11 @@ export function* generateTemplateChild( } } else if (node.type === CompilerDOM.NodeTypes.ELEMENT) { - if (node.tagType === CompilerDOM.ElementTypes.SLOT) { + const match = getTemplateMatch(node); + if (match) { + yield* generateVMatch(options, ctx, match); + } + else if (node.tagType === CompilerDOM.ElementTypes.SLOT) { yield* generateSlotOutlet(options, ctx, node); } else { diff --git a/packages/language-core/lib/codegen/template/vMatch.ts b/packages/language-core/lib/codegen/template/vMatch.ts new file mode 100644 index 0000000000..24fd708ee3 --- /dev/null +++ b/packages/language-core/lib/codegen/template/vMatch.ts @@ -0,0 +1,209 @@ +import { toString } from 'muggle-string'; +import type { MatchPattern, PatternBinding } from '../../template/patterns/parser'; +import type { TemplateMatch, TemplateMatchArm } from '../../template/patterns/prepare'; +import type { Code } from '../../types'; +import { codeFeatures } from '../codeFeatures'; +import { endBoundary, startBoundary } from '../utils/boundary'; +import type { TemplateCodegenContext } from './context'; +import type { TemplateCodegenOptions } from './index'; +import { generateInterpolation } from './interpolation'; +import { matchTypes } from './matchTypes'; +import { generateTemplateChild } from './templateChild'; + +export function* generateVMatch( + options: TemplateCodegenOptions, + ctx: TemplateCodegenContext, + match: TemplateMatch, +): Generator { + if (!ctx.generatedTypes.has('__VLS_PMMatch')) { + ctx.generatedTypes.add('__VLS_PMMatch'); + yield matchTypes; + } + const valueDeclarations: [string, Code[]][] = []; + const { subject, arms } = match; + const subjectCodes = [ + ...generateInterpolation( + options, + ctx, + options.template, + codeFeatures.all, + subject.content, + subject.loc.start.offset, + ), + ]; + const subjectText = toString(subjectCodes); + const local = ctx.getInternalVariable(); + const endMatchScope = ctx.startScope(); + yield `{\nconst ${local} = (`; + yield* subjectCodes; + yield `);\n`; + let remaining = `typeof ${local}`; + for (const arm of arms) { + const patternType = ctx.getInternalVariable(); + const descriptors = [...generateDescriptor(arm.pattern, arm)]; + for (const [name, codes] of valueDeclarations.splice(0)) { + yield `const ${name} = (`; + yield* codes; + yield ');\n'; + } + yield `type ${patternType} = `; + yield* descriptors; + yield `;\n`; + const narrowed = ctx.getInternalVariable(); + yield `type ${narrowed} = __VLS_PMMatch<${remaining}, ${patternType}>;\n`; + // Marker is inspected by the language service for warning-only unreachable arms. + yield `type __VLS_match_arm_${arm.offset}_${arm.directive.exp!.loc.end.offset} = ${narrowed};\n`; + const endArmScope = ctx.startScope(); + const localValue = ctx.getInternalVariable(); + const condition = `((value: typeof ${local}): value is typeof ${local} & ${narrowed} => true)(${local})`; + // Retain the source expression as a second narrowing target so result.data + // and enclosing v-for/slot aliases receive the same refinement as bindings. + const sourceCondition = + `((value: typeof ${local}): value is typeof ${local} & ${narrowed} => true)(${subjectText})`; + yield `if (${condition} && ${sourceCondition}) {\nconst ${localValue} = ${local} as ${narrowed};\n`; + const conditionLength = ctx.blockConditions.length; + ctx.blockConditions.push(condition, sourceCondition); + for (const binding of arm.bindings) { + ctx.declare(binding.name); + } + yield* generateBindings(arm.pattern, localValue, arm); + if (arm.guard) { + const codes = [ + ...generateInterpolation( + options, + ctx, + options.template, + codeFeatures.all, + arm.guard.text, + arm.offset + arm.guard.start, + '(', + ')', + ), + ]; + yield 'if '; + yield* codes; + yield ' {\n'; + ctx.blockConditions.push(toString(codes)); + } + yield* generateTemplateChild(options, ctx, arm.node, true, true); + if (arm.guard) { + yield '}\n'; + } + yield* endArmScope(); + yield '}\n'; + ctx.blockConditions.length = conditionLength; + if (!arm.guard) { + const next = ctx.getInternalVariable(); + yield `type ${next} = __VLS_PMSubtract<${remaining}, ${patternType}>;\n`; + remaining = next; + } + } + const check = ctx.getInternalVariable(); + yield 'const '; + const token = yield* startBoundary('template', subject.loc.start.offset, codeFeatures.verification); + yield check; + yield endBoundary(token, subject.loc.end.offset); + yield `: __VLS_PMAssert<${remaining}> = true`; + yield ';\n'; + yield* endMatchScope(); + yield '}\n'; + + function* generateDescriptor(pattern: MatchPattern, arm: TemplateMatchArm): Generator { + switch (pattern.kind) { + case 'wildcard': + case 'binding': + yield "['any']"; + break; + case 'as': + yield* generateDescriptor(pattern.pattern, arm); + break; + case 'literal': + yield `['literal', ${pattern.text.replace(/^\+/, '')}]`; + break; + case 'value': { + // A value may have a union type. Coverage accepts it only when that + // type is a statically known singleton; narrowing uses its full type. + const value = ctx.getInternalVariable(); + // Descriptors are generated before their type declaration is emitted. + // Store the expression in a typeof-compatible const outside that type. + const codes = [ + ...generateInterpolation( + options, + ctx, + options.template, + codeFeatures.all, + pattern.text, + arm.offset + pattern.start, + ), + ]; + valueDeclarations.push([value, codes]); + yield `['value', typeof ${value}]`; + break; + } + case 'or': + yield "['or', ["; + for (const p of pattern.patterns) { + yield* generateDescriptor(p, arm); + yield ','; + } + yield ']]'; + break; + case 'object': + yield "['object', ["; + for (const property of pattern.properties) { + yield `[${JSON.stringify(property.key)}, `; + yield* generateDescriptor(property.pattern, arm); + yield '],'; + } + yield ']]'; + break; + case 'array': + yield "['array', ["; + for (const p of pattern.elements) { + yield* generateDescriptor(p, arm); + yield ','; + } + yield `], ${!!pattern.rest}]`; + } + } + function* declare(binding: PatternBinding, value: string, arm: TemplateMatchArm): Generator { + yield 'const '; + yield [binding.name, 'template', arm.offset + binding.start, codeFeatures.all]; + yield ` = ${value};\n`; + } + function* generateBindings(pattern: MatchPattern, value: string, arm: TemplateMatchArm): Generator { + switch (pattern.kind) { + case 'binding': + yield* declare(pattern, value, arm); + break; + case 'as': + yield* generateBindings(pattern.pattern, value, arm); + yield* declare(pattern.binding, value, arm); + break; + case 'object': + for (const property of pattern.properties) { + yield* generateBindings(property.pattern, `${value}[${JSON.stringify(property.key)}]`, arm); + } + if (pattern.rest?.binding) { + yield 'const { '; + for (const property of pattern.properties) { + yield `[${JSON.stringify(property.key)}]: ${ctx.getInternalVariable()}, `; + } + yield '...'; + yield [pattern.rest.binding.name, 'template', arm.offset + pattern.rest.binding.start, codeFeatures.all]; + yield ` } = ${value};\n`; + } + break; + case 'array': + for (let i = 0; i < pattern.elements.length; i++) { + yield* generateBindings(pattern.elements[i]!, `${value}[${i}]`, arm); + } + if (pattern.rest?.binding) { + yield `const [${','.repeat(pattern.elements.length)}...`; + yield [pattern.rest.binding.name, 'template', arm.offset + pattern.rest.binding.start, codeFeatures.all]; + yield `] = ${value};\n`; + } + break; + } + } +} diff --git a/packages/language-core/lib/plugins/vue-template-html.ts b/packages/language-core/lib/plugins/vue-template-html.ts index 470b2e551f..305919a00e 100644 --- a/packages/language-core/lib/plugins/vue-template-html.ts +++ b/packages/language-core/lib/plugins/vue-template-html.ts @@ -38,6 +38,9 @@ const plugin: VueLanguagePlugin = () => { }, updateSFCTemplate(oldAst, change) { + if (/v-(match|when)\b/.test(oldAst.source)) { + return; + } const newSource = oldAst.source.slice(0, change.start) + change.newText + oldAst.source.slice(change.end); diff --git a/packages/language-core/lib/template/compile.ts b/packages/language-core/lib/template/compile.ts index 2f1c09fde3..5fa162566e 100644 --- a/packages/language-core/lib/template/compile.ts +++ b/packages/language-core/lib/template/compile.ts @@ -1,4 +1,5 @@ import { type CompilerOptions, getBaseTransformPreset, parse, type RootNode, transform } from '@vue/compiler-dom'; +import { prepareTemplateMatches } from './patterns/prepare'; import { transformElement } from './transforms/transformElement'; import { transformText } from './transforms/transformText'; import { transformFor } from './transforms/vFor'; @@ -6,6 +7,7 @@ import { transformIf } from './transforms/vIf'; export function compileTemplate(source: string, options: CompilerOptions) { const ast = parse(source, options); + prepareTemplateMatches(ast, options); transformTemplate(ast, options); return ast; } diff --git a/packages/language-core/lib/template/patterns/parser.ts b/packages/language-core/lib/template/patterns/parser.ts new file mode 100644 index 0000000000..db9f36d27a --- /dev/null +++ b/packages/language-core/lib/template/patterns/parser.ts @@ -0,0 +1,369 @@ +// Mirrored from vuejs/core packages/compiler-core/src/patterns.ts for RFC 823. +// This Draft can be tested against released compilers; use the shared export +// when the compiler reference implementation becomes an available dependency. +/** Pattern grammar for the RFC 823 reference implementation. No JS evaluation. */ +export interface PatternRange { + start: number; + end: number; +} + +export type MatchPattern = + & PatternRange + & ( + | { kind: 'wildcard' } + | { kind: 'literal' | 'value'; text: string } + | { kind: 'binding'; name: string } + | { kind: 'as'; pattern: MatchPattern; binding: PatternBinding } + | { kind: 'or'; patterns: MatchPattern[] } + | { kind: 'object'; properties: PatternProperty[]; rest?: PatternRest } + | { kind: 'array'; elements: MatchPattern[]; rest?: PatternRest } + ); + +export interface PatternBinding extends PatternRange { + name: string; +} +export interface PatternProperty extends PatternRange { + key: string; + keyText: string; + pattern: MatchPattern; +} +export interface PatternRest extends PatternRange { + binding?: PatternBinding; +} +export interface MatchArm { + pattern: MatchPattern; + bindings: PatternBinding[]; + guard?: PatternRange & { text: string }; +} + +export class PatternSyntaxError extends SyntaxError { + constructor(message: string, public offset: number) { + super(message); + } +} + +const identifier = /^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*/u; +const reserved = new Set( + ('await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof interface implements let new null package private protected public return static super switch this throw true try typeof var void while with yield eval arguments') + .split(' '), +); +const numeric = + /^(?:0[xX][\da-fA-F]+n?|0[bB][01]+n?|0[oO][0-7]+n?|(?:0|[1-9]\d*)n|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/; + +export function parseMatchPattern(source: string): MatchArm { + let pos = 0; + const bindings: PatternBinding[] = []; + const names = new Set(); + const fail = (message: string): never => { + throw new PatternSyntaxError(message, pos); + }; + const space = () => { + while (/\s/.test(source[pos] || '') && pos < source.length) { + pos++; + } + }; + const eat = (text: string) => { + space(); + if (!source.startsWith(text, pos)) { + return false; + } + pos += text.length; + return true; + }; + const word = (text: string) => { + space(); + if (!source.startsWith(text, pos) || /^[$_\u200c\u200d\p{ID_Continue}]/u.test(source.slice(pos + text.length))) { + return false; + } + pos += text.length; + return true; + }; + const expect = (text: string) => { + if (!eat(text)) { + fail(`Expected ${text} in pattern.`); + } + }; + const name = () => { + space(); + const start = pos; + const value = identifier.exec(source.slice(pos))?.[0]; + if (!value) { + return fail('Expected an identifier.'); + } + pos += value.length; + return { name: value, start, end: pos }; + }; + const bind = (): PatternBinding => { + const result = name(); + if (reserved.has(result.name)) { + fail(`Invalid binding name ${result.name}.`); + } + if (names.has(result.name)) { + fail(`Duplicate pattern binding ${result.name}.`); + } + names.add(result.name); + bindings.push(result); + return result; + }; + const string = () => { + const start = pos; + const quote = source[pos++]; + let value = ''; + while (pos < source.length) { + let char = source[pos++]; + if (char === quote) { + return { value, text: source.slice(start, pos) }; + } + if (char === '\n' || char === '\r') { + fail('Unterminated string in pattern.'); + } + if (char === '\\') { + char = source[pos++]; + const escapes: Record = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', '0': '\0' }; + if (char === 'x' || char === 'u') { + let hex: string; + if (char === 'u' && source[pos] === '{') { + const end = source.indexOf('}', ++pos); + if (end < 0) { + fail('Invalid Unicode escape.'); + } + hex = source.slice(pos, end); + pos = end + 1; + } + else { + const size = char === 'x' ? 2 : 4; + hex = source.slice(pos, pos + size); + if (hex.length !== size) { + fail('Invalid string escape.'); + } + pos += size; + } + if (!/^[\da-f]+$/i.test(hex) || parseInt(hex, 16) > 0x10ffff) { + fail('Invalid string escape.'); + } + value += String.fromCodePoint(parseInt(hex, 16)); + } + else if (/[1-9]/.test(char || '') || (char === '0' && /\d/.test(source[pos] || ''))) { + fail('Legacy octal escapes are not supported.'); + } + else if (char === '\n' || char === '\r' || !char) { + fail('Invalid string escape.'); + } + else { + value += escapes[char] ?? char; + } + } + else { + value += char; + } + } + return fail('Unterminated string in pattern.'); + }; + const rest = (close: string): PatternRest => { + const start = pos - 3; + const binding = word('const') ? bind() : undefined; + space(); + if (source[pos] !== close) { + fail('Rest must be last, without a trailing comma; use ... or ...const name.'); + } + return { start, end: pos, binding }; + }; + const parse = (): MatchPattern => { + let pattern = atom(); + const alternatives = [pattern]; + while (eat('|')) { + alternatives.push(atom()); + } + if (alternatives.length > 1) { + if (alternatives.some(p => getPatternBindings(p).length)) { + fail('Bindings inside or-patterns are not supported.'); + } + pattern = { kind: 'or', patterns: alternatives, start: pattern.start, end: pos }; + } + if (word('as')) { + const binding = bind(); + pattern = { kind: 'as', pattern, binding, start: pattern.start, end: pos }; + } + return pattern; + }; + const atom = (): MatchPattern => { + space(); + const start = pos; + if (eat('(')) { + const pattern = parse(); + expect(')'); + return { ...pattern, start, end: pos }; + } + if (eat('{')) { + const properties: PatternProperty[] = []; + const keys = new Set(); + let remainder: PatternRest | undefined; + while (!eat('}')) { + if (eat('...')) { + remainder = rest('}'); + expect('}'); + break; + } + space(); + const keyStart = pos; + let key: string; + let keyText: string; + let pattern: MatchPattern; + if (word('const')) { + const binding = bind(); + key = binding.name; + keyText = JSON.stringify(key); + pattern = { kind: 'binding', ...binding }; + } + else { + if (source[pos] === "'" || source[pos] === '"') { + const token = string(); + key = token.value; + keyText = token.text; + } + else { + const number = numeric.exec(source.slice(pos))?.[0]; + if (number) { + pos += number.length; + if (number.endsWith('n')) { + fail('Bigint object keys are not supported.'); + } + key = String(Number(number)); + keyText = JSON.stringify(key); + } + else { + key = name().name; + keyText = JSON.stringify(key); + } + } + expect(':'); + pattern = parse(); + } + if (keys.has(key)) { + fail(`Duplicate pattern property ${key}.`); + } + keys.add(key); + properties.push({ key, keyText, pattern, start: keyStart, end: pos }); + if (eat('}')) { + break; + } + expect(','); + } + return { kind: 'object', properties, rest: remainder, start, end: pos }; + } + if (eat('[')) { + const elements: MatchPattern[] = []; + let remainder: PatternRest | undefined; + while (!eat(']')) { + if (eat('...')) { + remainder = rest(']'); + expect(']'); + break; + } + elements.push(parse()); + if (eat(']')) { + break; + } + expect(','); + } + return { kind: 'array', elements, rest: remainder, start, end: pos }; + } + if (word('const')) { + return { kind: 'binding', ...bind() }; + } + if (word('let') || word('var')) { + fail('Only const pattern bindings are supported.'); + } + if (source[pos] === "'" || source[pos] === '"') { + const token = string(); + return { kind: 'literal', text: token.text, start, end: pos }; + } + const sign = source[pos] === '-' || source[pos] === '+' ? source[pos++] : ''; + const number = numeric.exec(source.slice(pos))?.[0]; + if (number) { + pos += number.length; + if (sign === '+' && number.endsWith('n')) { + fail('Unary plus cannot be used with bigint.'); + } + return { kind: 'literal', text: sign + number, start, end: pos }; + } + if (sign) { + fail('Expected a numeric literal after sign.'); + } + const value = name(); + if (value.name === '_') { + return { kind: 'wildcard', start, end: pos }; + } + if (['true', 'false', 'null'].includes(value.name)) { + return { kind: 'literal', text: value.name, start, end: pos }; + } + if (reserved.has(value.name)) { + fail('Expected a literal, value or structural pattern.'); + } + while (true) { + if (eat('.')) { + name(); + } + else if (eat('[')) { + space(); + if (source[pos] === "'" || source[pos] === '"') { + string(); + } + else { + const number = numeric.exec(source.slice(pos))?.[0]; + if (number) { + pos += number.length; + } + else { + name(); + } + } + expect(']'); + } + else { + break; + } + } + return { kind: 'value', text: source.slice(start, pos).trim(), start, end: pos }; + }; + const pattern = parse(); + let guard: MatchArm['guard']; + if (word('if')) { + expect('('); + const start = pos; + const end = source.trimEnd().length - 1; + if (source[end] !== ')' || !source.slice(start, end).trim()) { + fail('Expected if (guard).'); + } + guard = { text: source.slice(start, end), start, end }; + pos = end + 1; + } + space(); + if (pos !== source.length) { + fail('Unexpected token in pattern.'); + } + return { pattern, bindings, guard }; +} + +export function getPatternBindings(pattern: MatchPattern): PatternBinding[] { + switch (pattern.kind) { + case 'binding': + return [pattern]; + case 'as': + return [...getPatternBindings(pattern.pattern), pattern.binding]; + case 'or': + return pattern.patterns.flatMap(getPatternBindings); + case 'array': + return [ + ...pattern.elements.flatMap(getPatternBindings), + ...(pattern.rest?.binding ? [pattern.rest.binding] : []), + ]; + case 'object': + return [ + ...pattern.properties.flatMap(p => getPatternBindings(p.pattern)), + ...(pattern.rest?.binding ? [pattern.rest.binding] : []), + ]; + default: + return []; + } +} diff --git a/packages/language-core/lib/template/patterns/prepare.ts b/packages/language-core/lib/template/patterns/prepare.ts new file mode 100644 index 0000000000..85af3c9024 --- /dev/null +++ b/packages/language-core/lib/template/patterns/prepare.ts @@ -0,0 +1,125 @@ +import * as CompilerDOM from '@vue/compiler-dom'; +import { type MatchArm, parseMatchPattern, PatternSyntaxError } from './parser'; + +export interface TemplateMatchArm extends MatchArm { + node: CompilerDOM.ElementNode; + directive: CompilerDOM.DirectiveNode; + offset: number; +} +export interface TemplateMatch { + subject: CompilerDOM.SimpleExpressionNode; + arms: TemplateMatchArm[]; +} +const matches = new WeakMap(); +export const getTemplateMatch = (node: CompilerDOM.ElementNode) => matches.get(node); + +export function prepareTemplateMatches(ast: CompilerDOM.RootNode, options: CompilerDOM.CompilerOptions) { + function report(message: string, loc: CompilerDOM.SourceLocation, warning = false) { + const error = Object.assign(new SyntaxError(message), { code: 'V_MATCH_SYNTAX', loc }); + if (warning) { + options.onWarn?.(error); + } + else if (options.onError) { + options.onError(error); + } + else { + throw error; + } + } + function visit(node: CompilerDOM.RootNode | CompilerDOM.TemplateChildNode) { + if (node.type !== CompilerDOM.NodeTypes.ROOT && node.type !== CompilerDOM.NodeTypes.ELEMENT) { + return; + } + if (node.type === CompilerDOM.NodeTypes.ELEMENT) { + const orphan = CompilerDOM.findDir(node, 'when', true); + if (orphan) { + report('v-when must be a direct child of v-match.', orphan.loc); + node.props = node.props.filter(p => p !== orphan); + } + const match = CompilerDOM.findDir(node, 'match', true); + if (match) { + node.props = node.props.filter(p => p !== match); + if ( + !match.exp || match.exp.type !== CompilerDOM.NodeTypes.SIMPLE_EXPRESSION || !match.exp.content.trim() + || match.arg || match.modifiers.length + ) { + report('v-match requires a subject expression and accepts no arguments or modifiers.', match.loc); + return; + } + const arms: TemplateMatchArm[] = []; + let wildcard = false; + for (const child of node.children) { + if ( + child.type === CompilerDOM.NodeTypes.COMMENT + || (child.type === CompilerDOM.NodeTypes.TEXT && !child.content.trim()) + ) { + continue; + } + const when = child.type === CompilerDOM.NodeTypes.ELEMENT && CompilerDOM.findDir(child, 'when', true); + if (!when || child.type !== CompilerDOM.NodeTypes.ELEMENT) { + report('Every direct child of v-match must declare v-when.', child.loc, true); + continue; + } + child.props = child.props.filter(p => p !== when); + if ( + !when.exp || when.exp.type !== CompilerDOM.NodeTypes.SIMPLE_EXPRESSION || when.arg || when.modifiers.length + ) { + report('v-when requires a pattern and accepts no arguments or modifiers.', when.loc); + continue; + } + if (CompilerDOM.findDir(child, /^(if|else-if|else|for|match)$/, true)) { + report('v-when cannot share an element with another structural directive.', when.loc); + continue; + } + try { + const arm = parseMatchPattern(when.exp.content); + if (wildcard) { + report('An unguarded wildcard arm must be last and unique.', when.loc); + } + if (arm.pattern.kind === 'wildcard' && !arm.guard) { + wildcard = true; + } + if (child.tag === 'template') { + child.tagType = CompilerDOM.ElementTypes.TEMPLATE; + } + arms.push({ ...arm, node: child, directive: when, offset: when.exp.loc.start.offset }); + } + catch (error) { + if (!(error instanceof PatternSyntaxError)) { + throw error; + } + report(error.message, when.exp.loc); + } + } + if (!arms.length) { + report('v-match has no v-when arms.', match.loc, true); + } + const block: TemplateMatch = { subject: match.exp, arms }; + const children = arms.map(arm => arm.node); + if (node.tag === 'template' && node.props.length === 0) { + node.tagType = CompilerDOM.ElementTypes.TEMPLATE; + node.children = children; + matches.set(node, block); + } + else { + const inner: CompilerDOM.ElementNode = { + type: CompilerDOM.NodeTypes.ELEMENT, + tag: 'template', + tagType: CompilerDOM.ElementTypes.TEMPLATE, + ns: node.ns, + props: [], + children, + loc: node.loc, + codegenNode: undefined, + }; + matches.set(inner, block); + node.children = [inner]; + } + } + } + for (const child of node.children) { + visit(child); + } + } + visit(ast); +} diff --git a/packages/language-core/lib/template/patterns/warnings.ts b/packages/language-core/lib/template/patterns/warnings.ts new file mode 100644 index 0000000000..ec51a6425b --- /dev/null +++ b/packages/language-core/lib/template/patterns/warnings.ts @@ -0,0 +1,26 @@ +import type * as ts from 'typescript'; + +/** Query the same symbolic remaining-space types used by vue-tsc. */ +export function getMatchWarnings(ts: typeof import('typescript'), program: ts.Program, fileName: string) { + const file = program.getSourceFile(fileName); + const warnings: { start: number; end: number; message: string }[] = []; + if (!file) { + return warnings; + } + const checker = program.getTypeChecker(); + function visit(node: ts.Node) { + if (ts.isTypeAliasDeclaration(node)) { + const match = /^__VLS_match_arm_(\d+)_(\d+)$/.exec(ts.idText(node.name)); + if (match && checker.getTypeAtLocation(node).flags & ts.TypeFlags.Never) { + warnings.push({ + start: +match[1]!, + end: +match[2]!, + message: 'Unreachable v-when: this pattern cannot match any remaining value.', + }); + } + } + ts.forEachChild(node, visit); + } + visit(file); + return warnings; +} diff --git a/packages/language-core/lib/virtualCode/rawIr.ts b/packages/language-core/lib/virtualCode/rawIr.ts index 3d564a6fea..2685809a51 100644 --- a/packages/language-core/lib/virtualCode/rawIr.ts +++ b/packages/language-core/lib/virtualCode/rawIr.ts @@ -99,7 +99,7 @@ export function parseRawIR(source: string, options: CompilerDOM.CompilerOptions) item.loc.start.offset -= block.innerStart; item.loc.end.offset -= block.innerStart; block.initialValue[key].push( - ...list.splice(list.indexOf(item), 1)!, + ...list.splice(list.indexOf(item), 1), ); } } diff --git a/packages/language-server/lib/server.ts b/packages/language-server/lib/server.ts index 3342667efb..34fbbf709f 100644 --- a/packages/language-server/lib/server.ts +++ b/packages/language-server/lib/server.ts @@ -106,6 +106,9 @@ export function startServer(ts: typeof import('typescript')) { getComponentMeta(...args) { return sendTsServerRequest('_vue:getComponentMeta', args); }, + getMatchWarnings(...args) { + return sendTsServerRequest('_vue:getMatchWarnings', 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 14d62cd751..de7d39a77d 100644 --- a/packages/language-server/tests/completions.spec.ts +++ b/packages/language-server/tests/completions.spec.ts @@ -1180,3 +1180,8 @@ async function prepareDocument(fileName: string, languageId: string, content: st } return document; } + +test('RFC 823 long-form directive names are completed', async () => { + const result = await requestCompletionListToVueServer('fixture.vue', 'vue', ''); + expect(result.items.map(item => item.label)).toEqual(expect.arrayContaining(['v-match', 'v-when'])); +}); diff --git a/packages/language-server/tests/patternedTemplates.spec.ts b/packages/language-server/tests/patternedTemplates.spec.ts new file mode 100644 index 0000000000..31afc6841a --- /dev/null +++ b/packages/language-server/tests/patternedTemplates.spec.ts @@ -0,0 +1,92 @@ +import { afterEach, expect, test } from 'vitest'; +import { URI } from 'vscode-uri'; +import { getLanguageServer, testWorkspacePath } from './server'; + +const uri = URI.file(`${testWorkspacePath}/tsconfigProject/patterned-templates.vue`).toString(); +afterEach(async () => { + await (await getLanguageServer()).close(uri); +}); + +const source = ` +`; + +async function request(command: string, text: string, offset?: number, extra = {}) { + const server = await getLanguageServer(); + const document = await server.open(uri, 'vue', text); + const position = offset === undefined ? undefined : document.positionAt(offset); + const result = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command, + arguments: { + file: URI.parse(uri).fsPath, + ...(position ? { line: position.line + 1, offset: position.character + 1 } : {}), + ...extra, + }, + }); + expect(result.success).toBe(true); + return { body: result.body, document }; +} + +test('pattern bindings have precise hover and guard / element completion', async () => { + const offset = source.indexOf('article.length'); + const { body } = await request('quickinfo', source, offset); + expect(body.displayString).toBe('const article: string'); + const completions = await request('completionInfo', source, offset + 'article.'.length); + expect(completions.body.entries.map((entry: { name: string }) => entry.name)).toContain('toUpperCase'); + expect(completions.body.entries.map((entry: { name: string }) => entry.name)).not.toContain('toFixed'); +}); + +test('definition and rename connect declaration, guard, props, events and children', async () => { + const { body, document } = await request('definition', source, source.lastIndexOf('article')); + const declaration = document.positionAt(source.indexOf('const article') + 6); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ start: { line: declaration.line + 1, offset: declaration.character + 1 } }), + ]), + ); + const rename = await request('rename', source, source.lastIndexOf('article'), { + findInStrings: false, + findInComments: false, + }); + expect(rename.body.info.canRename).toBe(true); + expect(rename.body.locs.flatMap((loc: { locs: unknown[] }) => loc.locs)).toHaveLength(5); +}); + +test('editor diagnostics enforce exhaustive coverage by default and refresh after edits', async () => { + const missing = source.replace(`

{{ error.message }}

`, ''); + const diagnostics = await request('semanticDiagnosticsSync', missing); + expect(diagnostics.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 2322, text: expect.stringContaining('Non-exhaustive v-match') }), + ]), + ); + await (await getLanguageServer()).close(uri); + const complete = await request('semanticDiagnosticsSync', source); + expect(complete.body).toEqual([]); +}); + +test('type-aware unreachable arms warn without becoming vue-tsc errors', async () => { + const text = ` +`; + const server = await getLanguageServer(); + await server.open(uri, 'vue', text); + const result = await server.tsserver.message({ + seq: server.nextSeq(), + type: 'request', + command: '_vue:getMatchWarnings', + arguments: [URI.parse(uri).fsPath], + }); + expect(result.success).toBe(true); + expect(result.body).toHaveLength(2); + expect(result.body.map((warning: { message: string }) => warning.message)).toEqual([ + 'Unreachable v-when: this pattern cannot match any remaining value.', + 'Unreachable v-when: this pattern cannot match any remaining value.', + ]); +}); diff --git a/packages/language-service/index.ts b/packages/language-service/index.ts index 2d0e6deb46..2fd14311b6 100644 --- a/packages/language-service/index.ts +++ b/packages/language-service/index.ts @@ -22,6 +22,7 @@ import { create as createVueDocumentHighlightsPlugin } from './lib/plugins/vue-d import { create as createVueExtractFilePlugin } from './lib/plugins/vue-extract-file'; import { create as createVueFormatPerBlockPlugin } from './lib/plugins/vue-format-per-block'; import { create as createVueInlayHintsPlugin } from './lib/plugins/vue-inlayhints'; +import { create as createVueMatchWarningsPlugin } from './lib/plugins/vue-match-warnings'; import { create as createVueMissingPropsHintsPlugin } from './lib/plugins/vue-missing-props-hints'; import { create as createVueScopedClassLinksPlugin } from './lib/plugins/vue-scoped-class-links'; import { create as createVueSfcPlugin } from './lib/plugins/vue-sfc'; @@ -75,5 +76,6 @@ export function createVueLanguageServicePlugins( createVueTemplatePlugin(ts, 'html', client), createVueTemplatePlugin(ts, 'jade', client), createVueTwoslashQueriesPlugin(client), + createVueMatchWarningsPlugin(client), ]; } diff --git a/packages/language-service/lib/data.ts b/packages/language-service/lib/data.ts index ab6821d133..ca9a6220f2 100644 --- a/packages/language-service/lib/data.ts +++ b/packages/language-service/lib/data.ts @@ -40,7 +40,30 @@ export function loadTemplateData(lang: string) { resolveReferences(data); - return data; + return { + ...data, + globalAttributes: [ + ...data.globalAttributes ?? [], + { + name: 'v-match', + description: + 'RFC #823 reference implementation: evaluate one subject and render the first matching v-when arm. Template type checking requires exhaustive coverage.', + references: [{ + name: 'RFC #823 (Draft reference implementation)', + url: 'https://github.com/vuejs/rfcs/pull/823', + }], + }, + { + name: 'v-when', + description: + 'RFC #823 reference implementation: a pattern with optional const/rest/as bindings and an if (guard). Must be a direct v-match child. No arguments, modifiers, or shorthand.', + references: [{ + name: 'RFC #823 (Draft reference implementation)', + url: 'https://github.com/vuejs/rfcs/pull/823', + }], + }, + ], + }; } export function loadLanguageBlocks(lang: string): html.HTMLDataV1 { diff --git a/packages/language-service/lib/plugins/vue-match-warnings.ts b/packages/language-service/lib/plugins/vue-match-warnings.ts new file mode 100644 index 0000000000..cef028e83f --- /dev/null +++ b/packages/language-service/lib/plugins/vue-match-warnings.ts @@ -0,0 +1,28 @@ +import type { LanguageServicePlugin } from '@volar/language-service'; +import type { Requests } from '@vue/typescript-plugin/lib/requests'; +import { resolveEmbeddedCode } from '../utils'; + +export function create({ getMatchWarnings }: Requests): LanguageServicePlugin { + return { + name: 'vue-match-warnings', + capabilities: { diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false } }, + create(context) { + return { + async provideDiagnostics(document) { + const info = resolveEmbeddedCode(context, document.uri); + if (info?.code.id !== 'template' || !document.getText().includes('v-match')) { + return; + } + const warnings = await getMatchWarnings(info.root.fileName); + return warnings?.map(warning => ({ + range: { start: document.positionAt(warning.start), end: document.positionAt(warning.end) }, + severity: 2, + code: 'V_MATCH_UNREACHABLE', + source: 'vue', + message: warning.message, + })); + }, + }; + }, + }; +} diff --git a/packages/tsc/index.ts b/packages/tsc/index.ts index 703a120d49..fc3ee3f084 100644 --- a/packages/tsc/index.ts +++ b/packages/tsc/index.ts @@ -1,5 +1,6 @@ import { runTsc } from '@volar/typescript/lib/quickstart/runTsc'; import * as core from '@vue/language-core'; +import { withMatchWarnings } from './lib/matchWarnings'; const windowsPathReg = /\\/g; @@ -38,11 +39,11 @@ export function run(tscPath = require.resolve('typescript/lib/tsc')) { ); try { - return main(); + return withMatchWarnings(main); } catch (err) { if (err === extensionsChangedException) { - return main(); + return withMatchWarnings(main); } else { throw err; diff --git a/packages/tsc/lib/matchWarnings.ts b/packages/tsc/lib/matchWarnings.ts new file mode 100644 index 0000000000..99d8447732 --- /dev/null +++ b/packages/tsc/lib/matchWarnings.ts @@ -0,0 +1,69 @@ +import * as core from '@vue/language-core'; +import type * as ts from 'typescript'; + +/** runTsc currently has no after-program hook. Restore its adapter on exit. */ +export function withMatchWarnings(run: () => T): T { + const proxy = require( + '@volar/typescript/lib/node/proxyCreateProgram', + ) as typeof import('@volar/typescript/lib/node/proxyCreateProgram'); + const originalProxy = proxy.proxyCreateProgram; + proxy.proxyCreateProgram = (ts, createProgram, plugins) => + originalProxy( + ts, + new Proxy(createProgram, { + apply(target, thisArg, args) { + const options = args[0] as ts.CreateProgramOptions; + const program = Reflect.apply(target, thisArg, args) as ts.Program & { + getBindAndCheckDiagnostics: ts.Program['getSemanticDiagnostics']; + }; + const warned = new WeakSet(); + function reportWarnings(file?: ts.SourceFile) { + for (const sourceFile of file ? [file] : program.getSourceFiles()) { + if (warned.has(sourceFile)) { + continue; + } + warned.add(sourceFile); + const source = options.host?.readFile(sourceFile.fileName); + if (!source?.includes('v-match')) { + continue; + } + const warnings = core.getMatchWarnings(ts, program, sourceFile.fileName); + if (!warnings.length) { + continue; + } + const template = core.parseRawIR(source, {}).rawIr.templates[0]; + if (!template) { + continue; + } + const originalFile = ts.createSourceFile(sourceFile.fileName, source, sourceFile.languageVersion); + for (const warning of warnings) { + const position = ts.getLineAndCharacterOfPosition(originalFile, template.innerStart + warning.start); + ts.sys.write( + `${sourceFile.fileName}(${position.line + 1},${ + position.character + 1 + }): warning V_MATCH_UNREACHABLE: ${warning.message}${ts.sys.newLine}`, + ); + } + } + } + // Reuse the current checker; do not create a second TypeScript Program. + // Warnings are separate from TS errors so --noEmit and --build stay green. + for (const name of ['getSemanticDiagnostics', 'getBindAndCheckDiagnostics'] as const) { + const original = program[name].bind(program); + program[name] = (file, cancellationToken) => { + reportWarnings(file); + return original(file, cancellationToken); + }; + } + return program; + }, + }), + plugins, + ); + try { + return run(); + } + finally { + proxy.proxyCreateProgram = originalProxy; + } +} diff --git a/packages/tsc/tests/patternedTemplates.spec.ts b/packages/tsc/tests/patternedTemplates.spec.ts new file mode 100644 index 0000000000..7d6c0f64fc --- /dev/null +++ b/packages/tsc/tests/patternedTemplates.spec.ts @@ -0,0 +1,271 @@ +import { proxyCreateProgram } from '@volar/typescript'; +import * as core from '@vue/language-core'; +import * as path from 'node:path'; +import * as ts from 'typescript'; +import { describe, expect, test } from 'vitest'; + +const cases: [string, string, string[], boolean][] = [ + ['literal union', "'a' | 'b'", ["'a'", "'b'"], true], + ['missing literal', "'a' | 'b' | 'c'", ["'a'", "'b'"], false], + ['boolean', 'boolean', ['true', 'false'], true], + ['missing false', 'boolean', ['true'], false], + ['nullable', "'a' | null | undefined", ["'a'", 'null', 'undefined'], true], + ['undefined is distinct', "'a' | null | undefined", ["'a'", 'null'], false], + ['open string', 'string', ["'a'", "'b'"], false], + ['open number', 'number', ['1', '2'], false], + ['open string needs catch-all', 'string', ['{}'], false], + ['open number needs catch-all', 'number', ['{}'], false], + ['any requires fallback', 'any', ['{}', '[...]', 'true', 'false', 'null', 'undefined'], false], + ['unknown requires fallback', 'unknown', ['{}', '[...]', 'null', 'undefined'], false], + ['unknown fallback', 'unknown', ['_'], true], + ['any binding fallback', 'any', ['const value'], true], + ['never', 'never', [], true], + ['or union', "'a' | 'b' | 'c'", ["('a' | 'b') as ab", "'c'"], true], + ['guard does not cover', "'a' | 'b'", ["'a' if (true)", "'b'"], false], + ['complementary guards', 'boolean', ['_ if (subject)', '_ if (!subject)'], false], + ['guarded wildcard', 'unknown', ['_ if (true)'], false], + ['unguarded after guard', 'boolean', ['true if (true)', 'true', 'false'], true], + ['tags', "{ kind: 'ok'; data: string } | { kind: 'error'; error: Error }", [ + "{ kind: 'ok', const data }", + "{ kind: 'error', const error }", + ], true], + ['partial tag', "{ kind: 'ok'; data: string | null } | { kind: 'error' }", [ + "{ kind: 'ok', data: null }", + "{ kind: 'error' }", + ], false], + ['nested object product', "{ x: 'a' | 'b'; nested: { y: true | false } }", [ + "{ x: 'a', nested: { y: true } }", + "{ x: 'a', nested: { y: false } }", + "{ x: 'b' }", + ], true], + ['missing object product', "{ x: 'a' | 'b'; nested: { y: true | false } }", [ + "{ x: 'a', nested: { y: true } }", + "{ x: 'b' }", + ], false], + ['optional absent', "{ x?: 'a' }", ["{ x: 'a' }", '{ x: undefined }'], false], + ['optional present wildcard', "{ x?: 'a' }", ['{ x: _ }'], false], + ['optional empty object', "{ x?: 'a' }", ['{}'], true], + ['open objects', '{ x: 1; y: 2 }', ['{ x: 1 }'], true], + ['object rest does not constrain', '{ x: 1; y: number }', ['{ x: 1, ...const rest }'], true], + ['tuple product', "['left' | 'right', 'top' | 'bottom']", [ + "['left', 'top']", + "['left', 'bottom']", + "['right', 'top']", + "['right', 'bottom']", + ], true], + ['missing tuple product', "['left' | 'right', 'top' | 'bottom']", [ + "['left', 'top']", + "['left', 'bottom']", + "['right', 'top']", + ], false], + ['tuple with rest', '[string, number, boolean]', ['[const first, ...const tail]'], true], + ['array partition', 'string[]', ['[]', '[const first, ...const tail]'], true], + ['array missing long', 'string[]', ['[]', '[const first]'], false], + ['all arrays', 'readonly string[]', ['[...const items]'], true], + ['nullable array', 'string[] | null', ['[...]'], false], + ['array boolean head', 'boolean[]', ['[]', '[true, ...]', '[false, ...]'], true], + ['array missing head', 'boolean[]', ['[]', '[true, ...]'], false], + ['tuple optional', '[string?]', ['[]', '[const value]'], true], + ['tuple optional missing', '[string?]', ['[]'], false], +]; + +const workspace = path.resolve(__dirname, '../../../test-workspace/patterned-templates').replace(/\\/g, '/'); +const sources = new Map(); +for (const [index, [, type, arms]] of cases.entries()) { + sources.set( + `${workspace}/${index}.vue`, + `\n`, + ); +} +sources.set( + `${workspace}/narrowing.vue`, + ` +`, +); +sources.set( + `${workspace}/values.vue`, + ` +`, +); + +sources.set( + `${workspace}/rest.vue`, + ``, +); + +sources.set( + `${workspace}/imported.ts`, + `export type Result = { kind: 'ok'; data: T } | { kind: 'error'; error: Error };`, +); +sources.set( + `${workspace}/generics.vue`, + ``, +); + +sources.set( + `${workspace}/nested.vue`, + ``, +); + +sources.set( + `${workspace}/unknown-narrowing.vue`, + ``, +); +sources.set( + `${workspace}/invalid.vue`, + ``, +); + +sources.set( + `${workspace}/javascript.vue`, + ``, +); + +sources.set( + `${workspace}/javascript-missing.vue`, + sources.get(`${workspace}/javascript.vue`)!.replace('', ''), +); + +const options: ts.CompilerOptions = { + allowJs: true, + checkJs: true, + strict: true, + noEmit: true, + skipLibCheck: true, + allowNonTsExtensions: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + jsx: ts.JsxEmit.Preserve, + types: [], +}; +const host = ts.createCompilerHost(options); +const directoryExists = host.directoryExists?.bind(host); +host.directoryExists = dir => dir.replace(/\\/g, '/') === workspace || !!directoryExists?.(dir); +const readFile = host.readFile.bind(host); +const fileExists = host.fileExists.bind(host); +host.readFile = file => sources.get(file.replace(/\\/g, '/')) ?? readFile(file); +host.fileExists = file => sources.has(file.replace(/\\/g, '/')) || fileExists(file); +const createProgram = proxyCreateProgram(ts, ts.createProgram, (ts, options) => { + const vueOptions = core.createParsedCommandLineByJson(ts, ts.sys, workspace, {}).vueOptions; + return [core.createVueLanguagePlugin(ts, options.options, vueOptions, id => id)]; +}); +const program = createProgram({ rootNames: [...sources.keys()], options, host }); + +describe('RFC 823 required coverage', () => { + test('inferred JavaScript template types require coverage', () => { + const file = program.getSourceFile(`${workspace}/javascript-missing.vue`)!; + const diagnostics = program.getSemanticDiagnostics(file); + expect(diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))).toEqual([ + expect.stringContaining('Non-exhaustive v-match'), + ]); + }); + + test('invalid pattern is a vue-tsc error', () => { + const file = program.getSourceFile(`${workspace}/invalid.vue`)!; + const diagnostics = program.getSemanticDiagnostics(file); + expect(diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))).toEqual([ + expect.stringContaining('Only const pattern bindings'), + ]); + }); + + for (const [index, [name, , , exhaustive]] of cases.entries()) { + test(name, () => { + const fileName = `${workspace}/${index}.vue`; + const file = program.getSourceFile(fileName)!; + const diagnostics = [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]; + const messages = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n')); + expect(messages, name).toHaveLength(exhaustive ? 0 : 1); + if (!exhaustive) { + expect(messages[0]).toContain('Non-exhaustive v-match'); + if (name === 'missing tuple product') { + expect(messages[0]?.replaceAll('\\"', '"')).toContain(`v-when="['right', 'bottom']"`); + } + if (name === 'missing literal') { + expect(messages[0]?.replaceAll('\\"', '"')).toContain('v-when="\'c\'"'); + } + expect(sources.get(fileName)!.slice(diagnostics[0]!.start, diagnostics[0]!.start! + diagnostics[0]!.length!)) + .toBe('subject'); + } + }); + } + for (const name of ['narrowing', 'values', 'rest', 'generics', 'nested', 'unknown-narrowing', 'javascript']) { + test(name, () => { + const file = program.getSourceFile(`${workspace}/${name}.vue`)!; + expect( + [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)].map(d => + ts.flattenDiagnosticMessageText(d.messageText, '\n') + ), + ).toEqual([]); + }); + } +}); diff --git a/packages/tsc/tests/patternedTemplatesCli.spec.ts b/packages/tsc/tests/patternedTemplatesCli.spec.ts new file mode 100644 index 0000000000..50b1eb8611 --- /dev/null +++ b/packages/tsc/tests/patternedTemplatesCli.spec.ts @@ -0,0 +1,60 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { expect, test } from 'vitest'; + +function check(arms: string) { + const directory = fs.mkdtempSync(path.resolve(__dirname, '../../../test-workspace/.patterned-cli-')); + try { + fs.writeFileSync( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: true, + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'Bundler', + types: [], + }, + include: ['*.vue'], + }), + ); + fs.writeFileSync( + path.join(directory, 'App.vue'), + ``, + ); + return spawnSync(process.execPath, [ + path.resolve(__dirname, '../bin/vue-tsc.js'), + '--noEmit', + '--pretty', + 'false', + '-p', + directory, + ], { encoding: 'utf8' }); + } + finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +test('vue-tsc --noEmit fails on missing coverage with a source diagnostic', () => { + const result = check(``); + expect(result.status).toBe(2); + expect(result.stdout).toContain('App.vue('); + expect(result.stdout).toContain('Non-exhaustive v-match'); + expect(result.stdout.replaceAll('\\"', '"')).toContain(`v-when="'b'"`); +}); + +test('vue-tsc reports unreachable arms as warnings without failing', () => { + const result = check(``); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout.match(/warning V_MATCH_UNREACHABLE/g)).toHaveLength(1); +}); + +test('vue-tsc fails on invalid pattern syntax even with a catch-all', () => { + const result = check(``); + expect(result.status).toBe(2); + expect(result.stdout).toContain('Only const pattern bindings'); +}); diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index a400ef1a04..3d8567a823 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -19,6 +19,7 @@ import { getComponentSlots } from './lib/requests/getComponentSlots'; import { getElementAttrs } from './lib/requests/getElementAttrs'; import { getElementNames } from './lib/requests/getElementNames'; import { getImportPathForFile } from './lib/requests/getImportPathForFile'; +import { getMatchWarnings } from './lib/requests/getMatchWarnings'; import { isRefAtPosition } from './lib/requests/isRefAtPosition'; import { resolveModuleName } from './lib/requests/resolveModuleName'; @@ -272,6 +273,11 @@ export = createLanguageServicePlugin( ), ); }); + session.addProtocolHandler('_vue:getMatchWarnings', request => { + const [fileName]: Parameters = request.arguments; + const { program, virtualCode } = getProjectAndVirtualCode(fileName); + return createResponse(getMatchWarnings(ts, program, virtualCode.fileName)); + }); session.addProtocolHandler('_vue:getComponentNames', request => { const [fileName]: Parameters = request.arguments; const { program, virtualCode } = getProjectAndVirtualCode(fileName); diff --git a/packages/typescript-plugin/lib/requests/getMatchWarnings.ts b/packages/typescript-plugin/lib/requests/getMatchWarnings.ts new file mode 100644 index 0000000000..3b016a5cac --- /dev/null +++ b/packages/typescript-plugin/lib/requests/getMatchWarnings.ts @@ -0,0 +1 @@ +export { getMatchWarnings } from '@vue/language-core'; diff --git a/packages/typescript-plugin/lib/requests/index.ts b/packages/typescript-plugin/lib/requests/index.ts index c6d61f3b51..f3def8b41c 100644 --- a/packages/typescript-plugin/lib/requests/index.ts +++ b/packages/typescript-plugin/lib/requests/index.ts @@ -4,6 +4,7 @@ import type { VueCompletionData } from '../common'; type Response = T | null | undefined | Promise; export interface Requests { + getMatchWarnings(fileName: string): Response>; collectExtractProps( fileName: string, templateCodeRange: [number, number], From 8ad8836f953de297bfeb68a32479a9998d1feafb Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 14:57:51 +0900 Subject: [PATCH 2/5] refactor(language-core): use checked pattern type declarations --- packages/language-core/lib/codegen/names.ts | 4 + .../language-core/lib/codegen/script/index.ts | 6 +- .../lib/codegen/template/matchTypes.ts | 94 ---------- .../lib/codegen/template/vMatch.ts | 13 +- .../tests/fixtures/pattern-matching.ts | 25 +++ .../tests/patternMatchingTypes.spec.ts | 42 +++++ .../language-core/types/pattern-matching.d.ts | 162 ++++++++++++++++++ 7 files changed, 243 insertions(+), 103 deletions(-) delete mode 100644 packages/language-core/lib/codegen/template/matchTypes.ts create mode 100644 packages/language-core/tests/fixtures/pattern-matching.ts create mode 100644 packages/language-core/tests/patternMatchingTypes.spec.ts create mode 100644 packages/language-core/types/pattern-matching.d.ts diff --git a/packages/language-core/lib/codegen/names.ts b/packages/language-core/lib/codegen/names.ts index 0c67d10d7e..4c6878df64 100644 --- a/packages/language-core/lib/codegen/names.ts +++ b/packages/language-core/lib/codegen/names.ts @@ -20,6 +20,10 @@ const raw = { slots: '', src: '', + MatchPattern: '', + SubtractPattern: '', + CheckMatchExhaustive: '', + Emit: '', EmitProps: '', GlobalComponents: '', diff --git a/packages/language-core/lib/codegen/script/index.ts b/packages/language-core/lib/codegen/script/index.ts index 215f6d46cd..16b45dbea9 100644 --- a/packages/language-core/lib/codegen/script/index.ts +++ b/packages/language-core/lib/codegen/script/index.ts @@ -38,7 +38,7 @@ function* generateWorker( ): Generator { const { script, scriptRanges, scriptSetup, scriptSetupRanges, vueCompilerOptions, fileName } = options; - yield* generateGlobalTypesReference(vueCompilerOptions, fileName); + yield* generateGlobalTypesReference(vueCompilerOptions, fileName, options.templateAndStyleTypes); // `; + const armDeclaration = text.indexOf('{ const value') + '{ const '.length; + const armReference = text.indexOf('value.toUpperCase'); + const { body, document } = await request('definition', text, armReference); + const position = document.positionAt(armDeclaration); + expect(body).toEqual([ + expect.objectContaining({ start: { line: position.line + 1, offset: position.character + 1 } }), + ]); + const rename = await request('rename', text, armReference, { findInStrings: false, findInComments: false }); + const offsets = rename.body.locs.flatMap((file: { locs: { start: { line: number; offset: number } }[] }) => + file.locs.map(loc => document.offsetAt({ line: loc.start.line - 1, character: loc.start.offset - 1 })) + ); + expect(offsets.sort((a: number, b: number) => a - b)).toEqual([ + armDeclaration, + text.indexOf('value.length'), + text.indexOf(':title="value"') + ':title="'.length, + armReference, + text.indexOf('{{ value') + '{{ '.length, + ]); + const inner = await request('quickinfo', text, text.indexOf('value.toFixed')); + expect(inner.body.displayString).toBe('const value: 1 | 2'); + const diagnostics = await request('semanticDiagnosticsSync', text); + expect(diagnostics.body).toEqual([]); +}); + +test('editor rejects repeated names within one pattern', async () => { + const text = ` +`; + const { body } = await request('semanticDiagnosticsSync', text); + expect(body).toEqual([ + expect.objectContaining({ text: expect.stringContaining('Duplicate pattern binding value') }), + ]); +}); diff --git a/packages/tsc/tests/patternedTemplates.spec.ts b/packages/tsc/tests/patternedTemplates.spec.ts index 7d6c0f64fc..0617abdac9 100644 --- a/packages/tsc/tests/patternedTemplates.spec.ts +++ b/packages/tsc/tests/patternedTemplates.spec.ts @@ -195,6 +195,107 @@ sources.set( sources.get(`${workspace}/javascript.vue`)!.replace('', ''), ); +sources.set( + `${workspace}/ScopeSlot.vue`, + ``, +); +sources.set( + `${workspace}/scopes.vue`, + ``, +); +sources.set( + `${workspace}/event-scopes.vue`, + ``, +); + +const duplicatePatterns = [ + '{ a: const value, b: const value }', + '{ const value, nested: { const value } }', + '{ const value, ...const value }', + '[const value, ...const value]', + '{ const value } as value', + 'const value as value', +]; +for (const [index, pattern] of duplicatePatterns.entries()) { + sources.set( + `${workspace}/duplicate-${index}.vue`, + ``, + ); +} + const options: ts.CompilerOptions = { allowJs: true, checkJs: true, @@ -222,6 +323,13 @@ const createProgram = proxyCreateProgram(ts, ts.createProgram, (ts, options) => const program = createProgram({ rootNames: [...sources.keys()], options, host }); describe('RFC 823 required coverage', () => { + for (const [index, pattern] of duplicatePatterns.entries()) { + test(`rejects duplicate declarations in ${pattern}`, () => { + const file = program.getSourceFile(`${workspace}/duplicate-${index}.vue`)!; + expect(program.getSemanticDiagnostics(file).map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))) + .toEqual([expect.stringContaining('Duplicate pattern binding value')]); + }); + } test('inferred JavaScript template types require coverage', () => { const file = program.getSourceFile(`${workspace}/javascript-missing.vue`)!; const diagnostics = program.getSemanticDiagnostics(file); @@ -258,7 +366,19 @@ describe('RFC 823 required coverage', () => { } }); } - for (const name of ['narrowing', 'values', 'rest', 'generics', 'nested', 'unknown-narrowing', 'javascript']) { + for ( + const name of [ + 'narrowing', + 'values', + 'rest', + 'generics', + 'nested', + 'unknown-narrowing', + 'javascript', + 'scopes', + 'event-scopes', + ] + ) { test(name, () => { const file = program.getSourceFile(`${workspace}/${name}.vue`)!; expect( From 1e89b84018bba8b9609315fbc49cce89ecdca9d0 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 16:27:35 +0900 Subject: [PATCH 4/5] fix(language-core): resolve loop sources before shadowing bindings --- .../lib/codegen/template/vFor.ts | 52 +++++++++++++------ .../tests/patternedTemplates.spec.ts | 7 +-- packages/tsc/tests/patternedTemplates.spec.ts | 7 +++ 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/language-core/lib/codegen/template/vFor.ts b/packages/language-core/lib/codegen/template/vFor.ts index 5ef642b268..5a7420a828 100644 --- a/packages/language-core/lib/codegen/template/vFor.ts +++ b/packages/language-core/lib/codegen/template/vFor.ts @@ -1,4 +1,5 @@ import * as CompilerDOM from '@vue/compiler-dom'; +import { toString } from 'muggle-string'; import type { Code } from '../../types'; import { collectBindingNames } from '../../utils/collectBindings'; import { codeFeatures } from '../codeFeatures'; @@ -6,7 +7,7 @@ import { names } from '../names'; import { getTypeScriptAST, newLine } from '../utils'; import type { TemplateCodegenContext } from './context'; import type { TemplateCodegenOptions } from './index'; -import { generateInterpolation } from './interpolation'; +import { generateInterpolation, getConditionBindings } from './interpolation'; import { generateTemplateChild } from './templateChild'; export function* generateVFor( @@ -17,11 +18,39 @@ export function* generateVFor( const { source } = node.parseResult; const { leftExpressionRange, leftExpressionText } = parseVForNode(node); const endScope = ctx.startScope(); + const bindingNames: string[] = []; + if (leftExpressionText) { + const collectAst = getTypeScriptAST(options.typescript, options.template, `const [${leftExpressionText}]`); + bindingNames.push(...collectBindingNames(options.typescript, collectAst, collectAst)); + } + const sourceCodes: Code[] = source.type === CompilerDOM.NodeTypes.SIMPLE_EXPRESSION + ? [ + `${names.vFor}(`, + ...generateInterpolation( + options, + ctx, + options.template, + codeFeatures.all, + source.content, + source.loc.start.offset, + `(`, + `)`, + ), + `!)`, // #3102 + ] + : [`{} as any`]; + // Evaluate the iterable before a loop binding can shadow its source. + const sourceBindings = getConditionBindings(options.typescript, ctx, options.template, toString(sourceCodes)); + const sourceVar = bindingNames.some(name => sourceBindings.has(name)) ? ctx.getInternalVariable() : undefined; + if (sourceVar) { + yield `const ${sourceVar} = `; + yield* sourceCodes; + yield `;${newLine}`; + } yield `for (const [`; if (leftExpressionRange && leftExpressionText) { - const collectAst = getTypeScriptAST(options.typescript, options.template, `const [${leftExpressionText}]`); - ctx.declare(...collectBindingNames(options.typescript, collectAst, collectAst)); + ctx.declare(...bindingNames); yield [ leftExpressionText, 'template', @@ -30,22 +59,11 @@ export function* generateVFor( ]; } yield `] of `; - if (source.type === CompilerDOM.NodeTypes.SIMPLE_EXPRESSION) { - yield `${names.vFor}(`; - yield* generateInterpolation( - options, - ctx, - options.template, - codeFeatures.all, - source.content, - source.loc.start.offset, - `(`, - `)`, - ); - yield `!)`; // #3102 + if (sourceVar) { + yield sourceVar; } else { - yield `{} as any`; + yield* sourceCodes; } yield `) {${newLine}`; diff --git a/packages/language-server/tests/patternedTemplates.spec.ts b/packages/language-server/tests/patternedTemplates.spec.ts index d18842d937..0c71c93a80 100644 --- a/packages/language-server/tests/patternedTemplates.spec.ts +++ b/packages/language-server/tests/patternedTemplates.spec.ts @@ -98,7 +98,7 @@ defineProps<{ rows: { value: string }[] }>(); `; @@ -118,10 +118,11 @@ defineProps<{ rows: { value: string }[] }>(); text.indexOf('value.length'), text.indexOf(':title="value"') + ':title="'.length, armReference, + text.indexOf('in value') + 'in '.length, text.indexOf('{{ value') + '{{ '.length, ]); - const inner = await request('quickinfo', text, text.indexOf('value.toFixed')); - expect(inner.body.displayString).toBe('const value: 1 | 2'); + const inner = await request('quickinfo', text, text.indexOf('value.toLowerCase')); + expect(inner.body.displayString).toBe('const value: string'); const diagnostics = await request('semanticDiagnosticsSync', text); expect(diagnostics.body).toEqual([]); }); diff --git a/packages/tsc/tests/patternedTemplates.spec.ts b/packages/tsc/tests/patternedTemplates.spec.ts index 0617abdac9..bf76a0d373 100644 --- a/packages/tsc/tests/patternedTemplates.spec.ts +++ b/packages/tsc/tests/patternedTemplates.spec.ts @@ -224,6 +224,7 @@ defineProps<{ count: number; rows: { value: string; count: boolean }[] }>(); {{ exactType(row, {} as { value: string; count: boolean }) }} {{ exactType(value, {} as 'setup') }} {{ exactType(count, {} as number) }} +{{ exactType(Math, {} as number) }} +