Skip to content

feat(language-service): document links for template refs #5385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions packages/language-server/tests/documentLinks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { TextDocument } from '@volar/language-server';
import { afterEach, expect, test } from 'vitest';
import { URI } from 'vscode-uri';
import { getLanguageServer, testWorkspacePath } from './server.js';

test('Document links', async () => {
expect(
await requestDocumentLinks('fixture.vue', 'vue', `
<script setup>
import { useTemplateRef } from 'vue';
const ref1 = useTemplateRef("single-ref") // Expect 1 document link to template
const ref2 = useTemplateRef("multi-ref") // Expect 2 document links to template
const ref3 = useTemplateRef("for-ref") // Expect 1 document link to template
const ref4 = useTemplateRef("broken-ref") // Expect 0 document links to template
</script>

<template>
<div class="myclass">Expect one document link to style</div>
<div ref="single-ref"></div>
<div ref="multi-ref"></div>
<span ref="multi-ref"></span>
<div v-for="x of [1, 2, 3]" ref="for-ref">{{ x }}</div>
</template>

<style scoped>
.myclass {
color: red;
}
</style>
`)
).toMatchInlineSnapshot(`
[
{
"range": {
"end": {
"character": 23,
"line": 10,
},
"start": {
"character": 16,
"line": 10,
},
},
"target": "file://\${testWorkspacePath}/fixture.vue#L19%2C4-L19%2C12",
},
{
"range": {
"end": {
"character": 42,
"line": 3,
},
"start": {
"character": 32,
"line": 3,
},
},
"target": "file://\${testWorkspacePath}/fixture.vue#L12%2C15-L12%2C25",
},
{
"range": {
"end": {
"character": 41,
"line": 4,
},
"start": {
"character": 32,
"line": 4,
},
},
"target": "file://\${testWorkspacePath}/fixture.vue#L13%2C15-L13%2C24",
},
{
"range": {
"end": {
"character": 41,
"line": 4,
},
"start": {
"character": 32,
"line": 4,
},
},
"target": "file://\${testWorkspacePath}/fixture.vue#L14%2C16-L14%2C25",
},
{
"range": {
"end": {
"character": 39,
"line": 5,
},
"start": {
"character": 32,
"line": 5,
},
},
"target": "file://\${testWorkspacePath}/fixture.vue#L15%2C38-L15%2C45",
},
]
`);
});

const openedDocuments: TextDocument[] = [];

afterEach(async () => {
const server = await getLanguageServer();
for (const document of openedDocuments) {
await server.close(document.uri);
}
openedDocuments.length = 0;
});

async function requestDocumentLinks(fileName: string, languageId: string, content: string) {
const server = await getLanguageServer();
let document = await prepareDocument(fileName, languageId, content);

const documentLinks = await server.vueserver.sendDocumentLinkRequest(document.uri);
expect(documentLinks).toBeDefined();
expect(documentLinks!.length).greaterThan(0);

for (const documentLink of documentLinks!) {
documentLink.target = 'file://${testWorkspacePath}' + documentLink.target!.slice(URI.file(testWorkspacePath).toString().length)
}

return documentLinks!;
}

async function prepareDocument(fileName: string, languageId: string, content: string) {
const server = await getLanguageServer();
const uri = URI.file(`${testWorkspacePath}/${fileName}`);
const document = await server.open(uri.toString(), languageId, content);
if (openedDocuments.every(d => d.uri !== document.uri)) {
openedDocuments.push(document);
}
return document;
}
110 changes: 74 additions & 36 deletions packages/language-service/lib/plugins/vue-document-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function create(): LanguageServicePlugin {
const decoded = context.decodeEmbeddedDocumentUri(uri);
const sourceScript = decoded && context.language.scripts.get(decoded[0]);
const virtualCode = decoded && sourceScript?.generated?.embeddedCodes.get(decoded[1]);
if (!sourceScript?.generated || virtualCode?.id !== 'template') {
if (!sourceScript?.generated || (virtualCode?.id !== 'template' && virtualCode?.id !== "scriptsetup_raw")) {
return;
}

Expand All @@ -26,52 +26,90 @@ export function create(): LanguageServicePlugin {
return;
}

const result: vscode.DocumentLink[] = [];

const { sfc } = root;
const codegen = tsCodegen.get(sfc);
const scopedClasses = codegen?.getGeneratedTemplate()?.scopedClasses ?? [];
const styleClasses = new Map<string, {
index: number;
style: Sfc['styles'][number];
classOffset: number;
}[]>();
const option = root.vueCompilerOptions.experimentalResolveStyleCssClasses;
const result: vscode.DocumentLink[] = [];

for (let i = 0; i < sfc.styles.length; i++) {
const style = sfc.styles[i];
if (option === 'always' || (option === 'scoped' && style.scoped)) {
for (const className of style.classNames) {
if (!styleClasses.has(className.text.slice(1))) {
styleClasses.set(className.text.slice(1), []);
if (virtualCode.id === 'template') {
const scopedClasses = codegen?.getGeneratedTemplate()?.scopedClasses ?? [];
const styleClasses = new Map<string, {
index: number;
style: Sfc['styles'][number];
classOffset: number;
}[]>();
const option = root.vueCompilerOptions.experimentalResolveStyleCssClasses;

for (let i = 0; i < sfc.styles.length; i++) {
const style = sfc.styles[i];
if (option === 'always' || (option === 'scoped' && style.scoped)) {
for (const className of style.classNames) {
if (!styleClasses.has(className.text.slice(1))) {
styleClasses.set(className.text.slice(1), []);
}
styleClasses.get(className.text.slice(1))!.push({
index: i,
style,
classOffset: className.offset,
});
}
styleClasses.get(className.text.slice(1))!.push({
index: i,
style,
classOffset: className.offset,
});
}
}
}

for (const { className, offset } of scopedClasses) {
const styles = styleClasses.get(className);
if (styles) {
for (const style of styles) {
const styleDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index);
const styleVirtualCode = sourceScript.generated.embeddedCodes.get('style_' + style.index);
if (!styleVirtualCode) {
continue;
for (const { className, offset } of scopedClasses) {
const styles = styleClasses.get(className);
if (styles) {
for (const style of styles) {
const styleDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index);
const styleVirtualCode = sourceScript.generated.embeddedCodes.get('style_' + style.index);
if (!styleVirtualCode) {
continue;
}
const styleDocument = context.documents.get(styleDocumentUri, styleVirtualCode.languageId, styleVirtualCode.snapshot);
const start = styleDocument.positionAt(style.classOffset);
const end = styleDocument.positionAt(style.classOffset + className.length + 1);
result.push({
range: {
start: document.positionAt(offset),
end: document.positionAt(offset + className.length),
},
target: context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index) + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
});
}
const styleDocument = context.documents.get(styleDocumentUri, styleVirtualCode.languageId, styleVirtualCode.snapshot);
const start = styleDocument.positionAt(style.classOffset);
const end = styleDocument.positionAt(style.classOffset + className.length + 1);
}
}
}
else if (virtualCode.id === 'scriptsetup_raw') {
if (!sfc.scriptSetup) {
return;
}

const templateVirtualCode = sourceScript.generated.embeddedCodes.get('template');
if (!templateVirtualCode) {
return;
}
const templateDocumentUri = context.encodeEmbeddedDocumentUri(decoded![0], 'template');
const templateDocument = context.documents.get(templateDocumentUri, templateVirtualCode.languageId, templateVirtualCode.snapshot);

const templateRefs = codegen?.getGeneratedTemplate()?.templateRefs;
const useTemplateRefs = codegen?.getScriptSetupRanges()?.useTemplateRef ?? [];

for (const { arg } of useTemplateRefs) {
if (!arg) {
continue;
}

const name = sfc.scriptSetup.content.slice(arg.start + 1, arg.end - 1);

for (const { offset } of templateRefs?.get(name) ?? []) {
const start = templateDocument.positionAt(offset);
const end = templateDocument.positionAt(offset + name.length);

result.push({
range: {
start: document.positionAt(offset),
end: document.positionAt(offset + className.length),
start: document.positionAt(arg.start + 1),
end: document.positionAt(arg.end - 1),
},
target: context.encodeEmbeddedDocumentUri(decoded![0], 'style_' + style.index) + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
target: templateDocumentUri + `#L${start.line + 1},${start.character + 1}-L${end.line + 1},${end.character + 1}`,
});
}
}
Expand Down
Loading