From 3d578f1a415611db5f9a8df8ec8739506c7c6ffa Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 11 Nov 2025 05:23:58 +0800 Subject: [PATCH 1/7] fix(language-service): prevent auto insert snippet inside bracket in template --- .../lib/plugins/vue-template.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 0784333f74..ae1ffde759 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -314,6 +314,21 @@ export function create( } }, + async provideAutoInsertSnippet(document, selection, lastChange, token) { + if (document.languageId !== languageId) { + return; + } + const info = resolveEmbeddedCode(context, document.uri); + if (info?.code.id !== 'template') { + return; + } + + if (isInsideBracketExpression(document, selection)) { + return; + } + return baseServiceInstance.provideAutoInsertSnippet?.(document, selection, lastChange, token); + }, + provideHover(document, position, token) { if (document.languageId !== languageId) { return; @@ -748,3 +763,16 @@ function getPropName( } return { isEvent, propName: name }; } + +function isInsideBracketExpression(doc: TextDocument, selection: html.Position) { + const text = doc.getText({ + start: { line: 0, character: 0 }, + end: selection, + }); + const lastOpen = text.lastIndexOf('{{'); + if (lastOpen === -1) { + return false; + } + const lastClose = text.lastIndexOf('}}'); + return lastClose < lastOpen; +} From 741b8013b9870c5ca6c28de71deec6dbb9c7b212 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 11 Nov 2025 22:23:14 +0800 Subject: [PATCH 2/7] feat: handle open tag inside bracket --- .../lib/plugins/vue-template.ts | 78 +++++++++++++++++-- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index ae1ffde759..9abf471691 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -323,10 +323,11 @@ export function create( return; } - if (isInsideBracketExpression(document, selection)) { + const snippet = await baseServiceInstance.provideAutoInsertSnippet?.(document, selection, lastChange, token); + if (shouldSkipClosingTagFromInterpolation(document, selection, lastChange, snippet)) { return; } - return baseServiceInstance.provideAutoInsertSnippet?.(document, selection, lastChange, token); + return snippet; }, provideHover(document, position, token) { @@ -764,15 +765,78 @@ function getPropName( return { isEvent, propName: name }; } +function shouldSkipClosingTagFromInterpolation( + doc: TextDocument, + selection: html.Position, + lastChange: { text: string } | undefined, + snippet: string | null | undefined, +) { + if (!snippet || !lastChange || (lastChange.text !== '/' && lastChange.text !== '>')) { + return false; + } + const tagName = /^\$0<\/([^\s>\/]+)>$/.exec(snippet)?.[1] ?? /^([^\s>\/]+)>$/.exec(snippet)?.[1]; + if (!tagName) { + return false; + } + + // check if the open tag inside bracket + const textUpToSelection = doc.getText({ + start: { line: 0, character: 0 }, + end: selection, + }); + + const lowerText = textUpToSelection.toLowerCase(); + const targetTag = `<${tagName.toLowerCase()}`; + let searchIndex = lowerText.lastIndexOf(targetTag); + + while (searchIndex !== -1) { + const nextChar = lowerText.charAt(searchIndex + targetTag.length); + + // if the next character continues the tag name, skip this occurrence + const isNameContinuation = nextChar && /[0-9a-z:_-]/.test(nextChar); + if (isNameContinuation) { + searchIndex = lowerText.lastIndexOf(targetTag, searchIndex - 1); + continue; + } + + const tagPosition = doc.positionAt(searchIndex); + return isInsideBracketExpression(doc, tagPosition); + } + + return false; +} + function isInsideBracketExpression(doc: TextDocument, selection: html.Position) { const text = doc.getText({ start: { line: 0, character: 0 }, end: selection, }); - const lastOpen = text.lastIndexOf('{{'); - if (lastOpen === -1) { - return false; + const tokenMatcher = /|{{|}}/g; + let match: RegExpExecArray | null; + let inComment = false; + let lastOpen = -1; + let lastClose = -1; + + while ((match = tokenMatcher.exec(text)) !== null) { + switch (match[0]) { + case '': + inComment = false; + break; + case '{{': + if (!inComment) { + lastOpen = match.index; + } + break; + case '}}': + if (!inComment) { + lastClose = match.index; + } + break; + } } - const lastClose = text.lastIndexOf('}}'); - return lastClose < lastOpen; + + return lastOpen !== -1 && lastClose < lastOpen; } From 2045fa6bdfd1b5464fcdc524e4ad918488c44cca Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 11 Nov 2025 22:26:56 +0800 Subject: [PATCH 3/7] fix: improve closing tag handling --- packages/language-service/lib/plugins/vue-template.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 9abf471691..8928fb298d 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -788,6 +788,7 @@ function shouldSkipClosingTagFromInterpolation( const lowerText = textUpToSelection.toLowerCase(); const targetTag = `<${tagName.toLowerCase()}`; let searchIndex = lowerText.lastIndexOf(targetTag); + let foundInsideInterpolation = false; while (searchIndex !== -1) { const nextChar = lowerText.charAt(searchIndex + targetTag.length); @@ -800,10 +801,15 @@ function shouldSkipClosingTagFromInterpolation( } const tagPosition = doc.positionAt(searchIndex); - return isInsideBracketExpression(doc, tagPosition); + if (!isInsideBracketExpression(doc, tagPosition)) { + return false; + } + + foundInsideInterpolation = true; + searchIndex = lowerText.lastIndexOf(targetTag, searchIndex - 1); } - return false; + return foundInsideInterpolation; } function isInsideBracketExpression(doc: TextDocument, selection: html.Position) { From 45ff10565fcdf89489486022bb5ef6271f6dec76 Mon Sep 17 00:00:00 2001 From: SerKo Date: Tue, 11 Nov 2025 22:47:11 +0800 Subject: [PATCH 4/7] test: add interpolation test units --- .../tests/autoInsert/interpolation.spec.ts | 85 +++++++++++++++++++ .../tests/utils/autoInsert.ts | 54 ++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/language-service/tests/autoInsert/interpolation.spec.ts create mode 100644 packages/language-service/tests/utils/autoInsert.ts diff --git a/packages/language-service/tests/autoInsert/interpolation.spec.ts b/packages/language-service/tests/autoInsert/interpolation.spec.ts new file mode 100644 index 0000000000..baa9d9ff53 --- /dev/null +++ b/packages/language-service/tests/autoInsert/interpolation.spec.ts @@ -0,0 +1,85 @@ +import { createVueLanguagePlugin, getDefaultCompilerOptions } from '@vue/language-core'; +import * as ts from 'typescript'; +import { describe, expect, it } from 'vitest'; +import { URI } from 'vscode-uri'; +import { createVueLanguageServicePlugins } from '../..'; +import { createAutoInserter } from '../utils/autoInsert'; + +const vueCompilerOptions = getDefaultCompilerOptions(); +const vueLanguagePlugin = createVueLanguagePlugin( + ts, + {}, + vueCompilerOptions, + () => '', +); +const vueServicePLugins = createVueLanguageServicePlugins(ts); +const autoInserter = createAutoInserter([vueLanguagePlugin], vueServicePLugins); + +describe('auto insert inside interpolations', () => { + it('avoids completing HTML tags inside interpolation', async () => { + const snippet = await autoInserter.autoInsert( + ` + +`, + '>', + ); + + expect(snippet).toBeUndefined(); + }); + + it('still completes HTML tags in plain template regions', async () => { + const snippet = await autoInserter.autoInsert( + ` + +`, + '>', + ); + + expect(snippet).toBe('$0'); + }); + + it('completes HTML tags when bracket are inside HTML comments', async () => { + const snippet = await autoInserter.autoInsert( + ` + +`, + '>', + ); + + expect(snippet).toBe('$0'); + }); + + it('completes closing tags even if previous interpolation contains HTML strings', async () => { + const snippet = await autoInserter.autoInsert( + ` +