Skip to content

Commit 4a89b6e

Browse files
authored
fix(language-service): prevent auto-insertion of html snippets in template interpolation (#5744)
1 parent 50e7f20 commit 4a89b6e

File tree

3 files changed

+254
-0
lines changed

3 files changed

+254
-0
lines changed

packages/language-service/lib/plugins/vue-template.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,22 @@ export function create(
314314
}
315315
},
316316

317+
async provideAutoInsertSnippet(document, selection, lastChange, token) {
318+
if (document.languageId !== languageId) {
319+
return;
320+
}
321+
const info = resolveEmbeddedCode(context, document.uri);
322+
if (info?.code.id !== 'template') {
323+
return;
324+
}
325+
326+
const snippet = await baseServiceInstance.provideAutoInsertSnippet?.(document, selection, lastChange, token);
327+
if (shouldSkipClosingTagFromInterpolation(document, selection, lastChange, snippet)) {
328+
return;
329+
}
330+
return snippet;
331+
},
332+
317333
provideHover(document, position, token) {
318334
if (document.languageId !== languageId) {
319335
return;
@@ -748,3 +764,85 @@ function getPropName(
748764
}
749765
return { isEvent, propName: name };
750766
}
767+
768+
function shouldSkipClosingTagFromInterpolation(
769+
doc: TextDocument,
770+
selection: html.Position,
771+
lastChange: { text: string } | undefined,
772+
snippet: string | null | undefined,
773+
) {
774+
if (!snippet || !lastChange || (lastChange.text !== '/' && lastChange.text !== '>')) {
775+
return false;
776+
}
777+
const tagName = /^\$0<\/([^\s>/]+)>$/.exec(snippet)?.[1] ?? /^([^\s>/]+)>$/.exec(snippet)?.[1];
778+
if (!tagName) {
779+
return false;
780+
}
781+
782+
// check if the open tag inside bracket
783+
const textUpToSelection = doc.getText({
784+
start: { line: 0, character: 0 },
785+
end: selection,
786+
});
787+
788+
const lowerText = textUpToSelection.toLowerCase();
789+
const targetTag = `<${tagName.toLowerCase()}`;
790+
let searchIndex = lowerText.lastIndexOf(targetTag);
791+
let foundInsideInterpolation = false;
792+
793+
while (searchIndex !== -1) {
794+
const nextChar = lowerText.charAt(searchIndex + targetTag.length);
795+
796+
// if the next character continues the tag name, skip this occurrence
797+
const isNameContinuation = nextChar && /[0-9a-z:_-]/.test(nextChar);
798+
if (isNameContinuation) {
799+
searchIndex = lowerText.lastIndexOf(targetTag, searchIndex - 1);
800+
continue;
801+
}
802+
803+
const tagPosition = doc.positionAt(searchIndex);
804+
if (!isInsideBracketExpression(doc, tagPosition)) {
805+
return false;
806+
}
807+
808+
foundInsideInterpolation = true;
809+
searchIndex = lowerText.lastIndexOf(targetTag, searchIndex - 1);
810+
}
811+
812+
return foundInsideInterpolation;
813+
}
814+
815+
function isInsideBracketExpression(doc: TextDocument, selection: html.Position) {
816+
const text = doc.getText({
817+
start: { line: 0, character: 0 },
818+
end: selection,
819+
});
820+
const tokenMatcher = /<!--|-->|{{|}}/g;
821+
let match: RegExpExecArray | null;
822+
let inComment = false;
823+
let lastOpen = -1;
824+
let lastClose = -1;
825+
826+
while ((match = tokenMatcher.exec(text)) !== null) {
827+
switch (match[0]) {
828+
case '<!--':
829+
inComment = true;
830+
break;
831+
case '-->':
832+
inComment = false;
833+
break;
834+
case '{{':
835+
if (!inComment) {
836+
lastOpen = match.index;
837+
}
838+
break;
839+
case '}}':
840+
if (!inComment) {
841+
lastClose = match.index;
842+
}
843+
break;
844+
}
845+
}
846+
847+
return lastOpen !== -1 && lastClose < lastOpen;
848+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { defineAutoInsertTest } from '../utils/autoInsert';
2+
3+
const issue = '#' + __filename.split('.')[0];
4+
5+
defineAutoInsertTest({
6+
title: `${issue} auto insert inside interpolations`,
7+
languageId: 'vue',
8+
input: `
9+
<template>
10+
{{ "<div|" }}
11+
</template>
12+
`,
13+
insertedText: '>',
14+
output: undefined,
15+
});
16+
17+
defineAutoInsertTest({
18+
title: `${issue} still completes HTML tags in plain template regions`,
19+
languageId: 'vue',
20+
input: `
21+
<template>
22+
<div|
23+
</template>
24+
`,
25+
insertedText: '>',
26+
output: '$0</div>',
27+
});
28+
29+
defineAutoInsertTest({
30+
title: `${issue} completes HTML tags when bracket are inside HTML comments`,
31+
languageId: 'vue',
32+
input: `
33+
<template>
34+
<!-- {{ -->
35+
<div|
36+
<!-- }}-->
37+
</template>
38+
`,
39+
insertedText: '>',
40+
output: '$0</div>',
41+
});
42+
43+
defineAutoInsertTest({
44+
title: `${issue} completes closing tags even if previous interpolation contains HTML strings`,
45+
languageId: 'vue',
46+
input: `
47+
<template>
48+
<div>{{ "<div></div>" }}<|
49+
</template>
50+
`,
51+
insertedText: '/',
52+
output: 'div>',
53+
});
54+
55+
defineAutoInsertTest({
56+
title: `${issue} avoids closing tags spawned from string literals when typing \`</\``,
57+
languageId: 'vue',
58+
input: `
59+
<template>
60+
{{ "<div>" }}<|
61+
</template>
62+
`,
63+
insertedText: '/',
64+
output: undefined,
65+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createServiceEnvironment } from '@volar/kit/lib/createServiceEnvironment';
2+
import {
3+
createLanguage,
4+
createLanguageService,
5+
createUriMap,
6+
type LanguagePlugin,
7+
type LanguageServicePlugin,
8+
} from '@volar/language-service';
9+
import { createVueLanguagePlugin, getDefaultCompilerOptions } from '@vue/language-core';
10+
import * as ts from 'typescript';
11+
import { describe, expect, it } from 'vitest';
12+
import { URI } from 'vscode-uri';
13+
import { createVueLanguageServicePlugins } from '../..';
14+
15+
// TODO: migrate to @volar/kit
16+
export function createAutoInserter(
17+
languages: LanguagePlugin<URI>[],
18+
services: LanguageServicePlugin[],
19+
) {
20+
let settings = {};
21+
22+
const fakeUri = URI.parse('file:///dummy.txt');
23+
const env = createServiceEnvironment(() => settings);
24+
const language = createLanguage(languages, createUriMap(false), () => {});
25+
const languageService = createLanguageService(language, services, env, {});
26+
27+
return {
28+
env,
29+
autoInsert,
30+
get settings() {
31+
return settings;
32+
},
33+
set settings(v) {
34+
settings = v;
35+
},
36+
};
37+
38+
async function autoInsert(textWithCursor: string, insertedText: string, languageId: string, cursor = '|') {
39+
const cursorIndex = textWithCursor.indexOf(cursor);
40+
if (cursorIndex === -1) {
41+
throw new Error('Cursor marker not found in input text.');
42+
}
43+
const content = textWithCursor.slice(0, cursorIndex) + insertedText
44+
+ textWithCursor.slice(cursorIndex + cursor.length);
45+
const snapshot = ts.ScriptSnapshot.fromString(content);
46+
language.scripts.set(fakeUri, snapshot, languageId);
47+
const document = languageService.context.documents.get(fakeUri, languageId, snapshot);
48+
return await languageService.getAutoInsertSnippet(
49+
fakeUri,
50+
document.positionAt(cursorIndex + insertedText.length),
51+
{
52+
rangeOffset: cursorIndex,
53+
rangeLength: 0,
54+
text: insertedText,
55+
},
56+
);
57+
}
58+
}
59+
60+
// util
61+
62+
const vueCompilerOptions = getDefaultCompilerOptions();
63+
const vueLanguagePlugin = createVueLanguagePlugin<URI>(
64+
ts,
65+
{},
66+
vueCompilerOptions,
67+
() => '',
68+
);
69+
const vueServicePLugins = createVueLanguageServicePlugins(ts);
70+
const autoInserter = createAutoInserter([vueLanguagePlugin], vueServicePLugins);
71+
72+
export function defineAutoInsertTest(options: {
73+
title: string;
74+
input: string;
75+
insertedText: string;
76+
output: string | undefined;
77+
languageId: string;
78+
cursor?: string;
79+
}) {
80+
describe(`auto insert: ${options.title}`, () => {
81+
it(`auto insert`, async () => {
82+
const snippet = await autoInserter.autoInsert(
83+
options.input,
84+
options.insertedText,
85+
options.languageId,
86+
options.cursor,
87+
);
88+
expect(snippet).toBe(options.output);
89+
});
90+
});
91+
}

0 commit comments

Comments
 (0)