Skip to content

Commit 7c2618e

Browse files
feat(language-service): document links for template refs (#5385)
Co-authored-by: KazariEX <[email protected]>
1 parent 67532b1 commit 7c2618e

File tree

2 files changed

+209
-36
lines changed

2 files changed

+209
-36
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { TextDocument } from '@volar/language-server';
2+
import { afterEach, expect, test } from 'vitest';
3+
import { URI } from 'vscode-uri';
4+
import { getLanguageServer, testWorkspacePath } from './server.js';
5+
6+
test('Document links', async () => {
7+
expect(
8+
await requestDocumentLinks('fixture.vue', 'vue', `
9+
<script setup>
10+
import { useTemplateRef } from 'vue';
11+
const ref1 = useTemplateRef("single-ref") // Expect 1 document link to template
12+
const ref2 = useTemplateRef("multi-ref") // Expect 2 document links to template
13+
const ref3 = useTemplateRef("for-ref") // Expect 1 document link to template
14+
const ref4 = useTemplateRef("broken-ref") // Expect 0 document links to template
15+
</script>
16+
17+
<template>
18+
<div class="myclass">Expect one document link to style</div>
19+
<div ref="single-ref"></div>
20+
<div ref="multi-ref"></div>
21+
<span ref="multi-ref"></span>
22+
<div v-for="x of [1, 2, 3]" ref="for-ref">{{ x }}</div>
23+
</template>
24+
25+
<style scoped>
26+
.myclass {
27+
color: red;
28+
}
29+
</style>
30+
`)
31+
).toMatchInlineSnapshot(`
32+
[
33+
{
34+
"range": {
35+
"end": {
36+
"character": 23,
37+
"line": 10,
38+
},
39+
"start": {
40+
"character": 16,
41+
"line": 10,
42+
},
43+
},
44+
"target": "file://\${testWorkspacePath}/fixture.vue#L19%2C4-L19%2C12",
45+
},
46+
{
47+
"range": {
48+
"end": {
49+
"character": 42,
50+
"line": 3,
51+
},
52+
"start": {
53+
"character": 32,
54+
"line": 3,
55+
},
56+
},
57+
"target": "file://\${testWorkspacePath}/fixture.vue#L12%2C15-L12%2C25",
58+
},
59+
{
60+
"range": {
61+
"end": {
62+
"character": 41,
63+
"line": 4,
64+
},
65+
"start": {
66+
"character": 32,
67+
"line": 4,
68+
},
69+
},
70+
"target": "file://\${testWorkspacePath}/fixture.vue#L13%2C15-L13%2C24",
71+
},
72+
{
73+
"range": {
74+
"end": {
75+
"character": 41,
76+
"line": 4,
77+
},
78+
"start": {
79+
"character": 32,
80+
"line": 4,
81+
},
82+
},
83+
"target": "file://\${testWorkspacePath}/fixture.vue#L14%2C16-L14%2C25",
84+
},
85+
{
86+
"range": {
87+
"end": {
88+
"character": 39,
89+
"line": 5,
90+
},
91+
"start": {
92+
"character": 32,
93+
"line": 5,
94+
},
95+
},
96+
"target": "file://\${testWorkspacePath}/fixture.vue#L15%2C38-L15%2C45",
97+
},
98+
]
99+
`);
100+
});
101+
102+
const openedDocuments: TextDocument[] = [];
103+
104+
afterEach(async () => {
105+
const server = await getLanguageServer();
106+
for (const document of openedDocuments) {
107+
await server.close(document.uri);
108+
}
109+
openedDocuments.length = 0;
110+
});
111+
112+
async function requestDocumentLinks(fileName: string, languageId: string, content: string) {
113+
const server = await getLanguageServer();
114+
let document = await prepareDocument(fileName, languageId, content);
115+
116+
const documentLinks = await server.vueserver.sendDocumentLinkRequest(document.uri);
117+
expect(documentLinks).toBeDefined();
118+
expect(documentLinks!.length).greaterThan(0);
119+
120+
for (const documentLink of documentLinks!) {
121+
documentLink.target = 'file://${testWorkspacePath}' + documentLink.target!.slice(URI.file(testWorkspacePath).toString().length)
122+
}
123+
124+
return documentLinks!;
125+
}
126+
127+
async function prepareDocument(fileName: string, languageId: string, content: string) {
128+
const server = await getLanguageServer();
129+
const uri = URI.file(`${testWorkspacePath}/${fileName}`);
130+
const document = await server.open(uri.toString(), languageId, content);
131+
if (openedDocuments.every(d => d.uri !== document.uri)) {
132+
openedDocuments.push(document);
133+
}
134+
return document;
135+
}

packages/language-service/lib/plugins/vue-document-links.ts

Lines changed: 74 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function create(): LanguageServicePlugin {
1717
const decoded = context.decodeEmbeddedDocumentUri(uri);
1818
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
1919
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
20-
if (!sourceScript?.generated || virtualCode?.id !== 'template') {
20+
if (!sourceScript?.generated || (virtualCode?.id !== 'template' && virtualCode?.id !== "scriptsetup_raw")) {
2121
return;
2222
}
2323

@@ -26,52 +26,90 @@ export function create(): LanguageServicePlugin {
2626
return;
2727
}
2828

29-
const result: vscode.DocumentLink[] = [];
30-
3129
const { sfc } = root;
3230
const codegen = tsCodegen.get(sfc);
33-
const scopedClasses = codegen?.getGeneratedTemplate()?.scopedClasses ?? [];
34-
const styleClasses = new Map<string, {
35-
index: number;
36-
style: Sfc['styles'][number];
37-
classOffset: number;
38-
}[]>();
39-
const option = root.vueCompilerOptions.experimentalResolveStyleCssClasses;
31+
const result: vscode.DocumentLink[] = [];
4032

41-
for (let i = 0; i < sfc.styles.length; i++) {
42-
const style = sfc.styles[i];
43-
if (option === 'always' || (option === 'scoped' && style.scoped)) {
44-
for (const className of style.classNames) {
45-
if (!styleClasses.has(className.text.slice(1))) {
46-
styleClasses.set(className.text.slice(1), []);
33+
if (virtualCode.id === 'template') {
34+
const scopedClasses = codegen?.getGeneratedTemplate()?.scopedClasses ?? [];
35+
const styleClasses = new Map<string, {
36+
index: number;
37+
style: Sfc['styles'][number];
38+
classOffset: number;
39+
}[]>();
40+
const option = root.vueCompilerOptions.experimentalResolveStyleCssClasses;
41+
42+
for (let i = 0; i < sfc.styles.length; i++) {
43+
const style = sfc.styles[i];
44+
if (option === 'always' || (option === 'scoped' && style.scoped)) {
45+
for (const className of style.classNames) {
46+
if (!styleClasses.has(className.text.slice(1))) {
47+
styleClasses.set(className.text.slice(1), []);
48+
}
49+
styleClasses.get(className.text.slice(1))!.push({
50+
index: i,
51+
style,
52+
classOffset: className.offset,
53+
});
4754
}
48-
styleClasses.get(className.text.slice(1))!.push({
49-
index: i,
50-
style,
51-
classOffset: className.offset,
52-
});
5355
}
5456
}
55-
}
5657

57-
for (const { className, offset } of scopedClasses) {
58-
const styles = styleClasses.get(className);
59-
if (styles) {
60-
for (const style of styles) {
61-
const styleDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index);
62-
const styleVirtualCode = sourceScript.generated.embeddedCodes.get('style_' + style.index);
63-
if (!styleVirtualCode) {
64-
continue;
58+
for (const { className, offset } of scopedClasses) {
59+
const styles = styleClasses.get(className);
60+
if (styles) {
61+
for (const style of styles) {
62+
const styleDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index);
63+
const styleVirtualCode = sourceScript.generated.embeddedCodes.get('style_' + style.index);
64+
if (!styleVirtualCode) {
65+
continue;
66+
}
67+
const styleDocument = context.documents.get(styleDocumentUri, styleVirtualCode.languageId, styleVirtualCode.snapshot);
68+
const start = styleDocument.positionAt(style.classOffset);
69+
const end = styleDocument.positionAt(style.classOffset + className.length + 1);
70+
result.push({
71+
range: {
72+
start: document.positionAt(offset),
73+
end: document.positionAt(offset + className.length),
74+
},
75+
target: context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index) + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
76+
});
6577
}
66-
const styleDocument = context.documents.get(styleDocumentUri, styleVirtualCode.languageId, styleVirtualCode.snapshot);
67-
const start = styleDocument.positionAt(style.classOffset);
68-
const end = styleDocument.positionAt(style.classOffset + className.length + 1);
78+
}
79+
}
80+
}
81+
else if (virtualCode.id === 'scriptsetup_raw') {
82+
if (!sfc.scriptSetup) {
83+
return;
84+
}
85+
86+
const templateVirtualCode = sourceScript.generated.embeddedCodes.get('template');
87+
if (!templateVirtualCode) {
88+
return;
89+
}
90+
const templateDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'template');
91+
const templateDocument = context.documents.get(templateDocumentUri, templateVirtualCode.languageId, templateVirtualCode.snapshot);
92+
93+
const templateRefs = codegen?.getGeneratedTemplate()?.templateRefs;
94+
const useTemplateRefs = codegen?.getScriptSetupRanges()?.useTemplateRef ?? [];
95+
96+
for (const { arg } of useTemplateRefs) {
97+
if (!arg) {
98+
continue;
99+
}
100+
101+
const name = sfc.scriptSetup.content.slice(arg.start + 1, arg.end - 1);
102+
103+
for (const { offset } of templateRefs?.get(name) ?? []) {
104+
const start = templateDocument.positionAt(offset);
105+
const end = templateDocument.positionAt(offset + name.length);
106+
69107
result.push({
70108
range: {
71-
start: document.positionAt(offset),
72-
end: document.positionAt(offset + className.length),
109+
start: document.positionAt(arg.start + 1),
110+
end: document.positionAt(arg.end - 1),
73111
},
74-
target: context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index) + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
112+
target: templateDocumentUri + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
75113
});
76114
}
77115
}

0 commit comments

Comments
 (0)