diff --git a/packages/language-service/lib/plugins/vue-template.ts b/packages/language-service/lib/plugins/vue-template.ts index 0784333f74..4f9e19066a 100644 --- a/packages/language-service/lib/plugins/vue-template.ts +++ b/packages/language-service/lib/plugins/vue-template.ts @@ -314,6 +314,22 @@ 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; + } + + const snippet = await baseServiceInstance.provideAutoInsertSnippet?.(document, selection, lastChange, token); + if (shouldSkipClosingTagFromInterpolation(document, selection, lastChange, snippet)) { + return; + } + return snippet; + }, + provideHover(document, position, token) { if (document.languageId !== languageId) { return; @@ -748,3 +764,85 @@ 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); + let foundInsideInterpolation = false; + + 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); + if (!isInsideBracketExpression(doc, tagPosition)) { + return false; + } + + foundInsideInterpolation = true; + searchIndex = lowerText.lastIndexOf(targetTag, searchIndex - 1); + } + + return foundInsideInterpolation; +} + +function isInsideBracketExpression(doc: TextDocument, selection: html.Position) { + const text = doc.getText({ + start: { line: 0, character: 0 }, + end: selection, + }); + 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; + } + } + + return lastOpen !== -1 && lastClose < lastOpen; +} diff --git a/packages/language-service/tests/autoInsert/4403.spec.ts b/packages/language-service/tests/autoInsert/4403.spec.ts new file mode 100644 index 0000000000..17be3f8d17 --- /dev/null +++ b/packages/language-service/tests/autoInsert/4403.spec.ts @@ -0,0 +1,65 @@ +import { defineAutoInsertTest } from '../utils/autoInsert'; + +const issue = '#' + __filename.split('.')[0]; + +defineAutoInsertTest({ + title: `${issue} auto insert inside interpolations`, + languageId: 'vue', + input: ` +