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 > root-patterned-template.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 +1702,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/embeddedGrammarFixtures/root-patterned-template.vue b/extensions/vscode/tests/embeddedGrammarFixtures/root-patterned-template.vue new file mode 100644 index 0000000000..0f28f43606 --- /dev/null +++ b/extensions/vscode/tests/embeddedGrammarFixtures/root-patterned-template.vue @@ -0,0 +1,7 @@ + + 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/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); // +`; + +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.', + ]); +}); + +test('definition and rename distinguish shadowed bindings', async () => { + const text = ``; + 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('in value') + 'in '.length, + text.indexOf('{{ value') + '{{ '.length, + ]); + 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([]); +}); + +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') }), + ]); +}); + +test.each(['', ' lang="html"'])('SFC root header supports navigation and completion (%s)', async lang => { + const text = source.replace('', + ); + const offset = text.indexOf('v-match="result"') + 'v-match="'.length; + const { body } = await request('quickinfo', text, offset); + expect(body.displayString).toContain('result:'); + const definition = await request('definition', text, offset); + const position = definition.document.positionAt(text.indexOf('result:')); + expect(definition.body).toEqual([ + expect.objectContaining({ start: { line: position.line + 1, offset: position.character + 1 } }), + ]); + const rename = await request('rename', text, offset, { findInStrings: false, findInComments: false }); + expect(rename.body.info.canRename).toBe(true); + expect(rename.body.locs.flatMap((file: { locs: unknown[] }) => file.locs)).toHaveLength(2); + const completions = await request('completionInfo', text, offset + 3); + expect(completions.body.entries.map((entry: { name: string }) => entry.name)).toContain('result'); + const binding = await request('quickinfo', text, text.indexOf('article.length')); + expect(binding.body.displayString).toBe('const article: string'); + expect((await request('semanticDiagnosticsSync', text)).body).toEqual([]); +}); + +test('header-only edits refresh coverage and diagnostic locations', async () => { + const text = ` +`; + expect((await request('semanticDiagnosticsSync', text)).body).toEqual([]); + for ( + const edited of [text.replace('v-match="a"', 'v-match="b"'), text.replace('v-match="a"', 'lang="html" v-match="b"')] + ) { + const { body, document } = await request('semanticDiagnosticsSync', edited); + const position = document.positionAt(edited.indexOf('v-match="b"') + 'v-match="'.length); + expect(body).toEqual([ + expect.objectContaining({ + text: expect.stringContaining('Non-exhaustive v-match'), + start: { line: position.line + 1, offset: position.character + 1 }, + }), + ]); + } + expect((await request('semanticDiagnosticsSync', text)).body).toEqual([]); +}); 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..0b8228571f --- /dev/null +++ b/packages/tsc/tests/patternedTemplates.spec.ts @@ -0,0 +1,444 @@ +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('', ''), +); + +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`, + ``, + ); +} + +for (const [index] of cases.entries()) { + for (const lang of ['', ' lang="html"']) { + sources.set( + `${workspace}/root-${index}${lang ? '-html' : ''}.vue`, + sources.get(`${workspace}/${index}.vue`)! + .replace(''), + ); + } +} +sources.set( + `${workspace}/root-generics.vue`, + ``, +); + +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', () => { + for (const [index, [name, , , exhaustive]] of cases.entries()) { + test.each(['', '-html'])(`SFC root ${name} (%s)`, suffix => { + const fileName = `${workspace}/root-${index}${suffix}.vue`; + const file = program.getSourceFile(fileName)!; + const diagnostics = [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]; + expect(diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, '\n'))) + .toEqual(exhaustive ? [] : [expect.stringContaining('Non-exhaustive v-match')]); + if (!exhaustive) { + expect(diagnostics[0]!.start).toBe(sources.get(fileName)!.indexOf('v-match="subject"') + 'v-match="'.length); + expect(diagnostics[0]!.length).toBe('subject'.length); + } + }); + } + 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); + 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', + 'scopes', + 'event-scopes', + 'root-generics', + ] + ) { + 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],