Skip to content

Commit d082ba8

Browse files
committed
First draft of semantic token support for Quarto files
1 parent 9838c73 commit d082ba8

File tree

6 files changed

+327
-9
lines changed

6 files changed

+327
-9
lines changed

apps/lsp/src/middleware.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* middleware.ts
33
*
4-
* Copyright (C) 2023 by Posit Software, PBC
4+
* Copyright (C) 2023-2025 by Posit Software, PBC
55
* Copyright (c) Microsoft Corporation. All rights reserved.
66
*
77
* Unless you have received this program directly from Posit Software pursuant
@@ -14,7 +14,8 @@
1414
*
1515
*/
1616

17-
import { Connection, ServerCapabilities } from "vscode-languageserver"
17+
import { Connection, ServerCapabilities } from "vscode-languageserver";
18+
import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-core";
1819

1920

2021
// capabilities provided just so we can intercept them w/ middleware on the client
@@ -28,8 +29,12 @@ export function middlewareCapabilities(): ServerCapabilities {
2829
},
2930
documentFormattingProvider: true,
3031
documentRangeFormattingProvider: true,
31-
definitionProvider: true
32-
}
32+
definitionProvider: true,
33+
semanticTokensProvider: {
34+
legend: QUARTO_SEMANTIC_TOKEN_LEGEND,
35+
full: true
36+
}
37+
};
3338
};
3439

3540
// methods provided just so we can intercept them w/ middleware on the client
@@ -51,4 +56,8 @@ export function middlewareRegister(connection: Connection) {
5156
return null;
5257
});
5358

59+
connection.languages.semanticTokens.on(async () => {
60+
return { data: [] };
61+
});
62+
5463
}

apps/vscode/src/lsp/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* client.ts
33
*
4-
* Copyright (C) 2022 by Posit Software, PBC
4+
* Copyright (C) 2022-2025 by Posit Software, PBC
55
*
66
* Unless you have received this program directly from Posit Software pursuant
77
* to the terms of a commercial license agreement with Posit Software, then
@@ -64,6 +64,7 @@ import {
6464
embeddedDocumentFormattingProvider,
6565
embeddedDocumentRangeFormattingProvider,
6666
} from "../providers/format";
67+
import { embeddedSemanticTokensProvider } from "../providers/semantic-tokens";
6768
import { getHover, getSignatureHelpHover } from "../core/hover";
6869
import { imageHover } from "../providers/hover-image";
6970
import { LspInitializationOptions, QuartoContext } from "quarto-core";
@@ -109,6 +110,7 @@ export async function activateLsp(
109110
provideDocumentRangeFormattingEdits: embeddedDocumentRangeFormattingProvider(
110111
engine
111112
),
113+
provideDocumentSemanticTokens: embeddedSemanticTokensProvider(engine),
112114
};
113115
if (config.get("cells.hoverHelp.enabled", true)) {
114116
middleware.provideHover = embeddedHoverProvider(engine);
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* semantic-tokens.ts
3+
*
4+
* Copyright (C) 2025 by Posit Software, PBC
5+
*
6+
* Unless you have received this program directly from Posit Software pursuant
7+
* to the terms of a commercial license agreement with Posit Software, then
8+
* this program is licensed to you under the terms of version 3 of the
9+
* GNU Affero General Public License. This program is distributed WITHOUT
10+
* ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11+
* MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12+
* AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13+
*
14+
*/
15+
16+
import {
17+
CancellationToken,
18+
commands,
19+
Position,
20+
SemanticTokens,
21+
SemanticTokensBuilder,
22+
TextDocument,
23+
Uri,
24+
window,
25+
} from "vscode";
26+
import { DocumentSemanticsTokensSignature } from "vscode-languageclient";
27+
import { MarkdownEngine } from "../markdown/engine";
28+
import { isQuartoDoc } from "../core/doc";
29+
import { unadjustedSemanticTokens, virtualDoc, withVirtualDocUri } from "../vdoc/vdoc";
30+
import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-core";
31+
32+
/**
33+
* Decode semantic tokens from delta-encoded format to absolute positions
34+
*
35+
* Semantic tokens are encoded as [deltaLine, deltaStartChar, length, tokenType, tokenModifiers, ...]
36+
* This function converts them to absolute line/character positions for easier manipulation.
37+
*/
38+
export function decodeSemanticTokens(tokens: SemanticTokens): Array<{
39+
line: number;
40+
startChar: number;
41+
length: number;
42+
tokenType: number;
43+
tokenModifiers: number;
44+
}> {
45+
const decoded: Array<{
46+
line: number;
47+
startChar: number;
48+
length: number;
49+
tokenType: number;
50+
tokenModifiers: number;
51+
}> = [];
52+
53+
let currentLine = 0;
54+
let currentChar = 0;
55+
56+
for (let i = 0; i < tokens.data.length; i += 5) {
57+
const deltaLine = tokens.data[i];
58+
const deltaStartChar = tokens.data[i + 1];
59+
const length = tokens.data[i + 2];
60+
const tokenType = tokens.data[i + 3];
61+
const tokenModifiers = tokens.data[i + 4];
62+
63+
// Update absolute position
64+
currentLine += deltaLine;
65+
if (deltaLine > 0) {
66+
currentChar = deltaStartChar;
67+
} else {
68+
currentChar += deltaStartChar;
69+
}
70+
71+
decoded.push({
72+
line: currentLine,
73+
startChar: currentChar,
74+
length,
75+
tokenType,
76+
tokenModifiers
77+
});
78+
}
79+
80+
return decoded;
81+
}
82+
83+
/**
84+
* Encode semantic tokens from absolute positions to delta-encoded format
85+
*
86+
* Uses VS Code's built-in SemanticTokensBuilder for proper delta encoding.
87+
*/
88+
export function encodeSemanticTokens(
89+
tokens: Array<{
90+
line: number;
91+
startChar: number;
92+
length: number;
93+
tokenType: number;
94+
tokenModifiers: number;
95+
}>,
96+
resultId?: string
97+
): SemanticTokens {
98+
const builder = new SemanticTokensBuilder();
99+
100+
for (const token of tokens) {
101+
builder.push(
102+
token.line,
103+
token.startChar,
104+
token.length,
105+
token.tokenType,
106+
token.tokenModifiers
107+
);
108+
}
109+
110+
return builder.build(resultId);
111+
}
112+
113+
/**
114+
* Build a map from source type/modifier names to target indices
115+
*/
116+
function buildLegendMap(
117+
sourceNames: string[],
118+
targetNames: string[]
119+
): Map<number, number> {
120+
const map = new Map<number, number>();
121+
122+
for (let i = 0; i < sourceNames.length; i++) {
123+
const targetIndex = targetNames.indexOf(sourceNames[i]);
124+
if (targetIndex >= 0) {
125+
map.set(i, targetIndex);
126+
}
127+
}
128+
129+
return map;
130+
}
131+
132+
/**
133+
* Remap a modifier bitfield from source indices to target indices
134+
*/
135+
function remapModifierBitfield(
136+
sourceModifiers: number,
137+
modifierMap: Map<number, number>
138+
): number {
139+
let targetModifiers = 0;
140+
141+
// Check each bit in the source bitfield
142+
for (const [sourceBit, targetBit] of modifierMap) {
143+
if (sourceModifiers & (1 << sourceBit)) {
144+
targetModifiers |= (1 << targetBit);
145+
}
146+
}
147+
148+
return targetModifiers;
149+
}
150+
151+
/**
152+
* Remap token type/modifier indices from source legend to target legend
153+
* Only maps types that exist in both legends (standard types only)
154+
*/
155+
function remapTokenIndices(
156+
tokens: SemanticTokens,
157+
sourceLegend: { tokenTypes: string[]; tokenModifiers: string[]; },
158+
targetLegend: { tokenTypes: string[]; tokenModifiers: string[]; }
159+
): SemanticTokens {
160+
// Build mappings once
161+
const typeMap = buildLegendMap(sourceLegend.tokenTypes, targetLegend.tokenTypes);
162+
const modifierMap = buildLegendMap(sourceLegend.tokenModifiers, targetLegend.tokenModifiers);
163+
164+
// Decode, filter, and remap tokens
165+
const decoded = decodeSemanticTokens(tokens);
166+
const remapped = decoded
167+
.filter(token => typeMap.has(token.tokenType))
168+
.map(token => ({
169+
...token,
170+
tokenType: typeMap.get(token.tokenType)!,
171+
tokenModifiers: remapModifierBitfield(token.tokenModifiers, modifierMap)
172+
}));
173+
174+
return encodeSemanticTokens(remapped, tokens.resultId);
175+
}
176+
177+
export function embeddedSemanticTokensProvider(engine: MarkdownEngine) {
178+
return async (
179+
document: TextDocument,
180+
token: CancellationToken,
181+
next: DocumentSemanticsTokensSignature
182+
): Promise<SemanticTokens | null | undefined> => {
183+
// Only handle Quarto documents
184+
if (!isQuartoDoc(document, true)) {
185+
return await next(document, token);
186+
}
187+
188+
const editor = window.activeTextEditor;
189+
const activeDocument = editor?.document;
190+
if (!editor || activeDocument?.uri.toString() !== document.uri.toString()) {
191+
// Not the active document, delegate to default
192+
return await next(document, token);
193+
}
194+
195+
const line = editor.selection.active.line;
196+
const position = new Position(line, 0);
197+
const vdoc = await virtualDoc(document, position, engine);
198+
199+
if (!vdoc) {
200+
return await next(document, token);
201+
}
202+
203+
return await withVirtualDocUri(vdoc, document.uri, "semanticTokens", async (uri: Uri) => {
204+
try {
205+
// Get the legend from the embedded language provider
206+
const legend = await commands.executeCommand<any>(
207+
"vscode.provideDocumentSemanticTokensLegend",
208+
uri
209+
);
210+
211+
const tokens = await commands.executeCommand<SemanticTokens>(
212+
"vscode.provideDocumentSemanticTokens",
213+
uri
214+
);
215+
216+
if (!tokens || tokens.data.length === 0) {
217+
return tokens;
218+
}
219+
220+
// Remap token indices from embedded provider's legend to our universal legend
221+
let remappedTokens = tokens;
222+
if (legend) {
223+
remappedTokens = remapTokenIndices(tokens, legend, QUARTO_SEMANTIC_TOKEN_LEGEND);
224+
}
225+
226+
// Adjust token positions from virtual doc to real doc coordinates
227+
return unadjustedSemanticTokens(vdoc.language, remappedTokens);
228+
} catch (error) {
229+
return undefined;
230+
}
231+
});
232+
};
233+
}

apps/vscode/src/vdoc/vdoc.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* vdoc.ts
33
*
4-
* Copyright (C) 2022 by Posit Software, PBC
4+
* Copyright (C) 2022-2025 by Posit Software, PBC
55
*
66
* Unless you have received this program directly from Posit Software pursuant
77
* to the terms of a commercial license agreement with Posit Software, then
@@ -13,14 +13,15 @@
1313
*
1414
*/
1515

16-
import { Position, TextDocument, Uri, Range } from "vscode";
16+
import { Position, TextDocument, Uri, Range, SemanticTokens } from "vscode";
1717
import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core";
1818

1919
import { isQuartoDoc } from "../core/doc";
2020
import { MarkdownEngine } from "../markdown/engine";
2121
import { embeddedLanguage, EmbeddedLanguage } from "./languages";
2222
import { virtualDocUriFromEmbeddedContent } from "./vdoc-content";
2323
import { virtualDocUriFromTempFile } from "./vdoc-tempfile";
24+
import { decodeSemanticTokens, encodeSemanticTokens } from "../providers/semantic-tokens";
2425

2526
export interface VirtualDoc {
2627
language: EmbeddedLanguage;
@@ -118,9 +119,10 @@ export type VirtualDocAction =
118119
"definition" |
119120
"format" |
120121
"statementRange" |
121-
"helpTopic";
122+
"helpTopic" |
123+
"semanticTokens";
122124

123-
export type VirtualDocUri = { uri: Uri, cleanup?: () => Promise<void> };
125+
export type VirtualDocUri = { uri: Uri, cleanup?: () => Promise<void>; };
124126

125127
/**
126128
* Execute a callback on a virtual document's temporary URI
@@ -232,3 +234,35 @@ export function unadjustedRange(language: EmbeddedLanguage, range: Range) {
232234
unadjustedPosition(language, range.end)
233235
);
234236
}
237+
238+
/**
239+
* Adjust semantic tokens from virtual document coordinates to real document coordinates
240+
*
241+
* This function decodes the tokens, adjusts each token's position using unadjustedRange,
242+
* and re-encodes them back to delta format.
243+
*/
244+
export function unadjustedSemanticTokens(
245+
language: EmbeddedLanguage,
246+
tokens: SemanticTokens
247+
): SemanticTokens {
248+
// Decode tokens to absolute positions
249+
const decoded = decodeSemanticTokens(tokens);
250+
251+
// Adjust each token's position
252+
const adjusted = decoded.map(t => {
253+
const range = unadjustedRange(language, new Range(
254+
new Position(t.line, t.startChar),
255+
new Position(t.line, t.startChar + t.length)
256+
));
257+
return {
258+
line: range.start.line,
259+
startChar: range.start.character,
260+
length: range.end.character - range.start.character,
261+
tokenType: t.tokenType,
262+
tokenModifiers: t.tokenModifiers
263+
};
264+
});
265+
266+
// Re-encode to delta format
267+
return encodeSemanticTokens(adjusted, tokens.resultId);
268+
}

packages/quarto-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export * from './position';
2424
export * from './range';
2525
export * from './document';
2626
export * from './lsp';
27+
export * from './semantic-tokens-legend';

0 commit comments

Comments
 (0)