From 4720099eb85f2ee2a0baece0b040aae639db0af2 Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 11 Aug 2026 16:50:23 -0400 Subject: [PATCH 1/4] Add hash-pipe semantic highlighting, make validation live --- apps/lsp/src/diagnostics.ts | 72 +++----- apps/lsp/src/service/index.ts | 17 +- apps/lsp/src/service/providers/diagnostics.ts | 20 ++- apps/vscode/package.json | 46 ++++- apps/vscode/src/main.ts | 4 + apps/vscode/src/providers/hash-pipe-yaml.ts | 165 ++++++++++++++++++ yarn.lock | 5 + 7 files changed, 262 insertions(+), 67 deletions(-) create mode 100644 apps/vscode/src/providers/hash-pipe-yaml.ts diff --git a/apps/lsp/src/diagnostics.ts b/apps/lsp/src/diagnostics.ts index 725eb54f5..e9cc5607d 100644 --- a/apps/lsp/src/diagnostics.ts +++ b/apps/lsp/src/diagnostics.ts @@ -55,44 +55,8 @@ export async function registerDiagnostics( const subs: Disposable[] = []; - - - // baseline diagnostics sent on save (and cleared on change) - const saveDiagnosticsSources: Array<(doc: Document) => Promise> = []; - saveDiagnosticsSources.push((doc: Document) => { - return mdLs.computeOnSaveDiagnostics(doc); - }); - // diagnostics on open and save (clear on doc modified) - subs.push( - documents.onDidOpen(async (e) => { - sendDiagnostics(e.document, await computeDiagnostics(e.document)); - }) - ); - subs.push( - documents.onDidSave(async (e) => { - sendDiagnostics(e.document, await computeDiagnostics(e.document)); - }) - ); - subs.push( - documents.onDidChangeContent(async (e) => { - sendDiagnostics(e.document, []); - }) - ); - const computeDiagnostics = async ( - doc: Document - ): Promise => { - return (await Promise.all(saveDiagnosticsSources.map(src => src(doc)))).flat(); - }; - const sendDiagnostics = (doc: Document, diagnostics: Diagnostic[]) => { - connection.sendDiagnostics({ - uri: doc.uri, - version: doc.version, - diagnostics, - }); - }; - - - // if we can watch files then register a pull source for markdown + // if we can watch files then register a pull source (diagnostics are + // computed as the user types) if (isWorkspaceWithFileWatching(workspace)) { let diagnosticOptions: DiagnosticOptions = kDefaultDiagnosticOptions; const updateDiagnosticsSetting = (): void => { @@ -169,14 +133,36 @@ export async function registerDiagnostics( }) ); } else { - // run diagnostics on save (and clear on edit) - saveDiagnosticsSources.push((doc: Document) => { - return mdLs?.computeDiagnostics( + // no file watching, so run diagnostics on open and save (and clear on edit) + const computeDiagnostics = (doc: Document): Promise => { + return mdLs.computeDiagnostics( doc, getDiagnosticsOptions(configManager), CancellationToken.None - ) - }); + ); + }; + const sendDiagnostics = (doc: Document, diagnostics: Diagnostic[]) => { + connection.sendDiagnostics({ + uri: doc.uri, + version: doc.version, + diagnostics, + }); + }; + subs.push( + documents.onDidOpen(async (e) => { + sendDiagnostics(e.document, await computeDiagnostics(e.document)); + }) + ); + subs.push( + documents.onDidSave(async (e) => { + sendDiagnostics(e.document, await computeDiagnostics(e.document)); + }) + ); + subs.push( + documents.onDidChangeContent(async (e) => { + sendDiagnostics(e.document, []); + }) + ); } return { diff --git a/apps/lsp/src/service/index.ts b/apps/lsp/src/service/index.ts index 6544891c9..75e201ab9 100644 --- a/apps/lsp/src/service/index.ts +++ b/apps/lsp/src/service/index.ts @@ -20,7 +20,7 @@ import { URI } from 'vscode-uri'; import { Document, Parser } from "quarto-core"; import { LsConfiguration } from './config'; import { MdDefinitionProvider } from './providers/definitions'; -import { DiagnosticComputer, DiagnosticOnSaveComputer, DiagnosticOptions, DiagnosticsManager, IPullDiagnosticsManager } from './providers/diagnostics'; +import { DiagnosticComputer, DiagnosticOptions, DiagnosticsManager, IPullDiagnosticsManager } from './providers/diagnostics'; import { MdDocumentHighlightProvider } from './providers/document-highlights'; import { createWorkspaceLinkCache, MdLinkProvider, ResolvedDocumentLinkTarget } from './providers/document-links'; import { MdDocumentSymbolProvider } from './providers/document-symbols'; @@ -148,13 +148,6 @@ export interface IMdLanguageService { */ getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise; - /** - * Compute save diagnostics for a given file - * - * Compute diagnostics that should be scanned for on save (and cleared on edit) - */ - computeOnSaveDiagnostics(doc: Document): Promise; - /** * Compute diagnostics for a given file. * @@ -206,8 +199,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL const linkCache = createWorkspaceLinkCache(init.parser, init.workspace); const referencesProvider = new MdReferencesProvider(config, init.parser, init.workspace, tocProvider, linkCache, logger); const definitionsProvider = new MdDefinitionProvider(config, init.workspace, tocProvider, linkCache); - const diagnosticOnSaveComputer = new DiagnosticOnSaveComputer(init.quarto); - const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger); + const diagnosticsComputer = new DiagnosticComputer(config, init.workspace, linkProvider, tocProvider, logger, init.quarto); const docSymbolProvider = new MdDocumentSymbolProvider(config, tocProvider, linkProvider, logger); const workspaceSymbolProvider = new MdWorkspaceSymbolProvider(init.workspace, init.config, docSymbolProvider); const documentHighlightProvider = new MdDocumentHighlightProvider(config, tocProvider, linkProvider); @@ -237,9 +229,6 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL getDocumentHighlights: (document: Document, position: lsp.Position, token: CancellationToken): Promise => { return documentHighlightProvider.getDocumentHighlights(document, position, token); }, - computeOnSaveDiagnostics: async (doc: Document) => { - return (await diagnosticOnSaveComputer.compute(doc)); - }, computeDiagnostics: async (doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise => { return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics; }, @@ -247,7 +236,7 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL if (!isWorkspaceWithFileWatching(init.workspace)) { throw new Error(`Workspace does not support file watching. Diagnostics manager not supported`); } - return new DiagnosticsManager(config, init.workspace, linkProvider, tocProvider, logger); + return new DiagnosticsManager(config, init.workspace, linkProvider, tocProvider, logger, init.quarto); } }); } diff --git a/apps/lsp/src/service/providers/diagnostics.ts b/apps/lsp/src/service/providers/diagnostics.ts index 9fd1eae29..265dd80aa 100644 --- a/apps/lsp/src/service/providers/diagnostics.ts +++ b/apps/lsp/src/service/providers/diagnostics.ts @@ -171,14 +171,6 @@ class FileLinkMap { } } -export class DiagnosticOnSaveComputer { - constructor(private readonly quarto_: Quarto) { } - - public async compute(doc: Document): Promise { - return provideYamlDiagnostics(this.quarto_, doc); - } -} - export class DiagnosticComputer { readonly #configuration: LsConfiguration; @@ -186,6 +178,7 @@ export class DiagnosticComputer { readonly #linkProvider: MdLinkProvider; readonly #tocProvider: MdTableOfContentsProvider; readonly #logger: ILogger; + readonly #quarto: Quarto; constructor( configuration: LsConfiguration, @@ -193,12 +186,14 @@ export class DiagnosticComputer { linkProvider: MdLinkProvider, tocProvider: MdTableOfContentsProvider, logger: ILogger, + quarto: Quarto, ) { this.#configuration = configuration; this.#workspace = workspace; this.#linkProvider = linkProvider; this.#tocProvider = tocProvider; this.#logger = logger; + this.#quarto = quarto; } public async compute( @@ -212,6 +207,10 @@ export class DiagnosticComputer { }> { this.#logger.logDebug('DiagnosticComputer.compute', { document: doc.uri, version: doc.version }); + // yaml diagnostics (frontmatter and cell options) -- kicked off + // concurrently with link resolution below + const yamlDiagnostics = provideYamlDiagnostics(this.#quarto, doc); + const { links, definitions } = await this.#linkProvider.getLinks(doc); const statCache = new ResourceMap<{ readonly exists: boolean; }>(); if (token.isCancellationRequested) { @@ -235,6 +234,8 @@ export class DiagnosticComputer { ])).flat()); } + diagnostics.push(...(await yamlDiagnostics)); + this.#logger.logTrace('DiagnosticComputer.compute finished', { document: doc.uri, version: doc.version, diagnostics }); return { @@ -643,6 +644,7 @@ export class DiagnosticsManager extends Disposable implements IPullDiagnosticsMa linkProvider: MdLinkProvider, tocProvider: MdTableOfContentsProvider, logger: ILogger, + quarto: Quarto, ) { super(); @@ -679,7 +681,7 @@ export class DiagnosticsManager extends Disposable implements IPullDiagnosticsMa }, }); - this.#computer = new DiagnosticComputer(configuration, stateCachedWorkspace, linkProvider, tocProvider, logger); + this.#computer = new DiagnosticComputer(configuration, stateCachedWorkspace, linkProvider, tocProvider, logger, quarto); this._register(workspace.onDidDeleteMarkdownDocument(uri => { this.#linkWatcher.deleteDocument(uri); diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 71f587ac8..53cad9725 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -170,6 +170,49 @@ "path": "./languages/mermaid/mermaid.tmLanguage.json" } ], + "semanticTokenTypes": [ + { + "id": "quartoYamlKey", + "description": "YAML key in a Quarto cell option (#|) comment" + }, + { + "id": "quartoYamlString", + "description": "YAML string value in a Quarto cell option (#|) comment" + }, + { + "id": "quartoYamlNumber", + "description": "YAML number value in a Quarto cell option (#|) comment" + }, + { + "id": "quartoYamlBoolean", + "description": "YAML boolean value in a Quarto cell option (#|) comment" + }, + { + "id": "quartoYamlNull", + "description": "YAML null value in a Quarto cell option (#|) comment" + } + ], + "semanticTokenScopes": [ + { + "scopes": { + "quartoYamlKey": [ + "entity.name.tag.yaml" + ], + "quartoYamlString": [ + "string.unquoted.plain.out.yaml" + ], + "quartoYamlNumber": [ + "constant.numeric.yaml" + ], + "quartoYamlBoolean": [ + "constant.language.boolean.yaml" + ], + "quartoYamlNull": [ + "constant.language.null.yaml" + ] + } + } + ], "snippets": [ { "language": "quarto", @@ -1529,7 +1572,8 @@ "vscode-languageclient": "^8.1.0", "vscode-languageserver-types": "^3.17.3", "vscode-nls": "^5.2.0", - "which": "^3.0.0" + "which": "^3.0.0", + "yaml": "^2.8.1" }, "devDependencies": { "@types/axios": "^0.14.0", diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 658532e19..341245046 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -46,6 +46,7 @@ import { activateDiagram } from "./providers/diagram/diagram"; import { activateCodeFormatting } from "./providers/format"; import { activateOptionEnterProvider } from "./providers/option"; import { activateBackgroundHighlighter } from "./providers/background"; +import { activateHashPipeYamlHighlighter } from "./providers/hash-pipe-yaml"; import { activateYamlLinks } from "./providers/yaml-links"; import { activateYamlFilepathCompletions } from "./providers/yaml-filepath-completions"; import { activateContextKeySetter } from "./providers/context-keys"; @@ -231,6 +232,9 @@ export async function activate(context: vscode.ExtensionContext): Promise 0) { + emitYamlTokens(source, lines, builder); + } + } + return builder.build(); + } +} + +// collect the leading run of #| lines in a cell and assemble their +// content into a single yaml source string +// +// note: this only handles #-comment languages (r, python, julia, etc.). +// to generalize to all languages (//| for js, --| for sql, /*| ... */ +// for c, etc.), derive the prefix from the block's language using +// kLangCommentChars/optionCommentPattern in packages/core/src/jupyter/options.ts +function hashPipeYaml( + document: vscode.TextDocument, + blockRange: vscode.Range +) { + const lines: HashPipeLine[] = []; + let source = ""; + const lastLine = Math.min(blockRange.end.line, document.lineCount - 1); + for (let i = blockRange.start.line + 1; i <= lastLine; i++) { + const text = document.lineAt(i).text; + const match = text.match(/^\s*#\|/); + if (!match) { + break; + } + const content = text.slice(match[0].length); + lines.push({ + yamlStart: source.length, + yamlEnd: source.length + content.length, + docLine: i, + docCharBase: match[0].length, + }); + source += content + "\n"; + } + return { lines, source }; +} + +function emitYamlTokens( + source: string, + lines: HashPipeLine[], + builder: vscode.SemanticTokensBuilder +) { + const yaml = parseDocument(source); + visit(yaml, { + Scalar: (key, node) => { + if (node.range) { + const type: HashPipeTokenType = + key === "key" ? "quartoYamlKey" : scalarTokenType(node.value); + pushTokens(lines, node.range[0], node.range[1], type, builder); + } + }, + }); +} + +function scalarTokenType(value: unknown): HashPipeTokenType { + switch (typeof value) { + case "number": + case "bigint": + return "quartoYamlNumber"; + case "boolean": + return "quartoYamlBoolean"; + default: + return value === null ? "quartoYamlNull" : "quartoYamlString"; + } +} + +// map a [start, end) range in the yaml source back to document ranges, +// splitting across lines (e.g. for block scalars) +function pushTokens( + lines: HashPipeLine[], + start: number, + end: number, + type: HashPipeTokenType, + builder: vscode.SemanticTokensBuilder +) { + for (const line of lines) { + const tokenStart = Math.max(start, line.yamlStart); + const tokenEnd = Math.min(end, line.yamlEnd); + if (tokenStart < tokenEnd) { + builder.push( + new vscode.Range( + line.docLine, + line.docCharBase + (tokenStart - line.yamlStart), + line.docLine, + line.docCharBase + (tokenEnd - line.yamlStart) + ), + type + ); + } + } +} diff --git a/yarn.lock b/yarn.lock index d3bfd3a3f..3c6b76eb5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9854,6 +9854,11 @@ yaml@^1.10.0, yaml@^1.10.2: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.8.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + yargs-parser@20.2.4: version "20.2.4" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz" From 8c70cfb532b6d7ab318d83604b5abaed7cf06d3c Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 13:58:55 -0400 Subject: [PATCH 2/4] add background to #| lines --- apps/vscode/src/providers/background.ts | 46 ++++++++++++++++++++- apps/vscode/src/providers/hash-pipe-yaml.ts | 4 +- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/providers/background.ts b/apps/vscode/src/providers/background.ts index 7b25a45b9..e296b01a4 100644 --- a/apps/vscode/src/providers/background.ts +++ b/apps/vscode/src/providers/background.ts @@ -22,6 +22,7 @@ import { MarkdownEngine } from "../markdown/engine"; import { isExecutableLanguageBlock } from "quarto-core"; import { vscRange } from "../core/range"; import { createThrottle } from "../core/throttle"; +import { hashPipeYaml } from "./hash-pipe-yaml"; export function activateBackgroundHighlighter( context: vscode.ExtensionContext, @@ -153,13 +154,28 @@ async function setEditorHighlightDecorations( // ranges to highlight const blockRanges: vscode.Range[] = []; const inlineRanges: vscode.Range[] = []; + const optionLineRanges: vscode.Range[] = []; + const optionSeparatorRanges: vscode.Range[] = []; if (highlightingConfig.enabled()) { // find code blocks const tokens = engine.parse(editor.document); for (const block of tokens.filter(isExecutableLanguageBlock)) { - blockRanges.push(vscRange(block.range)); + const blockRange = vscRange(block.range); + blockRanges.push(blockRange); + + // cell options (#| comments) get a darker background, and the last + // option line gets a separator (rendered as a bottom border) + const { lines } = hashPipeYaml(editor.document, blockRange); + for (const line of lines) { + optionLineRanges.push(editor.document.lineAt(line.docLine).range); + } + if (lines.length > 0) { + optionSeparatorRanges.push( + editor.document.lineAt(lines[lines.length - 1].docLine).range + ); + } } // find inline executable code @@ -186,12 +202,40 @@ async function setEditorHighlightDecorations( highlightingConfig.inlineBackgroundDecoration(), inlineRanges ); + editor.setDecorations(cellOptionsBackgroundDecoration, optionLineRanges); + editor.setDecorations(cellOptionsSeparatorDecoration, optionSeparatorRanges); } function clearEditorHighlightDecorations(editor: vscode.TextEditor) { editor.setDecorations(highlightingConfig.backgroundDecoration(), []); + editor.setDecorations(cellOptionsBackgroundDecoration, []); + editor.setDecorations(cellOptionsSeparatorDecoration, []); } +// these composite on top of the cell background decoration, so a +// translucent black overlay reads as "slightly darker" in both themes +const cellOptionsBackgroundDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + light: { + backgroundColor: "#00000012", + }, + dark: { + backgroundColor: "#00000033", + }, +}); + +const cellOptionsSeparatorDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + borderStyle: "solid", + borderWidth: "0 0 1px 0", + light: { + borderColor: "#00000025", + }, + dark: { + borderColor: "#FFFFFF25", + }, +}); + enum CellBackgroundColor { default = "default", off = "off", diff --git a/apps/vscode/src/providers/hash-pipe-yaml.ts b/apps/vscode/src/providers/hash-pipe-yaml.ts index 71c191aef..3b5ece34c 100644 --- a/apps/vscode/src/providers/hash-pipe-yaml.ts +++ b/apps/vscode/src/providers/hash-pipe-yaml.ts @@ -51,7 +51,7 @@ export function activateHashPipeYamlHighlighter( // a single #| line: where its yaml content lives within the assembled // yaml source, and where that content starts in the document -interface HashPipeLine { +export interface HashPipeLine { yamlStart: number; yamlEnd: number; docLine: number; @@ -84,7 +84,7 @@ class HashPipeYamlTokensProvider // to generalize to all languages (//| for js, --| for sql, /*| ... */ // for c, etc.), derive the prefix from the block's language using // kLangCommentChars/optionCommentPattern in packages/core/src/jupyter/options.ts -function hashPipeYaml( +export function hashPipeYaml( document: vscode.TextDocument, blockRange: vscode.Range ) { From 4fdc0256faabfc7ce1f517f661795bd8ef6b6002 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 13:59:50 -0400 Subject: [PATCH 3/4] Merge language and #| semantic tokens --- .../src/semantic-tokens-legend.ts | 7 +- apps/vscode/src/providers/hash-pipe-yaml.ts | 70 +++++++++++++------ apps/vscode/src/providers/semantic-tokens.ts | 48 ++++++++++++- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/apps/quarto-utils/src/semantic-tokens-legend.ts b/apps/quarto-utils/src/semantic-tokens-legend.ts index 9a450863e..5b93a27a6 100644 --- a/apps/quarto-utils/src/semantic-tokens-legend.ts +++ b/apps/quarto-utils/src/semantic-tokens-legend.ts @@ -30,7 +30,12 @@ export const QUARTO_SEMANTIC_TOKEN_LEGEND = { 'macro', 'label', 'comment', 'string', 'keyword', 'number', 'regexp', 'operator', // Commonly used by language servers, widely supported by themes - 'module' + 'module', + // Custom types for yaml in cell options (#| comments), themed via + // semanticTokenScopes in the vscode extension's package.json + // (only append here: existing indices must not shift) + 'quartoYamlKey', 'quartoYamlString', 'quartoYamlNumber', + 'quartoYamlBoolean', 'quartoYamlNull' ], tokenModifiers: [ 'declaration', 'definition', 'readonly', 'static', 'deprecated', diff --git a/apps/vscode/src/providers/hash-pipe-yaml.ts b/apps/vscode/src/providers/hash-pipe-yaml.ts index 3b5ece34c..5a88f77b9 100644 --- a/apps/vscode/src/providers/hash-pipe-yaml.ts +++ b/apps/vscode/src/providers/hash-pipe-yaml.ts @@ -32,10 +32,18 @@ const kTokenTypes = [ "quartoYamlBoolean", "quartoYamlNull", ] as const; -type HashPipeTokenType = (typeof kTokenTypes)[number]; +export type HashPipeTokenType = (typeof kTokenTypes)[number]; const kLegend = new vscode.SemanticTokensLegend([...kTokenTypes]); +// a single semantic token for yaml in cell options (absolute position) +export interface HashPipeYamlToken { + line: number; + startChar: number; + length: number; + tokenType: HashPipeTokenType; +} + export function activateHashPipeYamlHighlighter( context: vscode.ExtensionContext, engine: MarkdownEngine @@ -49,6 +57,24 @@ export function activateHashPipeYamlHighlighter( ); } +// compute yaml semantic tokens for the cell options (#| comments) of all +// executable blocks in a document (also used by the embedded semantic +// tokens middleware, which merges these with embedded language tokens) +export function hashPipeYamlTokens( + engine: MarkdownEngine, + document: vscode.TextDocument +): HashPipeYamlToken[] { + const yamlTokens: HashPipeYamlToken[] = []; + const tokens = engine.parse(document); + for (const block of tokens.filter(isExecutableLanguageBlock)) { + const { lines, source } = hashPipeYaml(document, vscRange(block.range)); + if (lines.length > 0) { + emitYamlTokens(source, lines, yamlTokens); + } + } + return yamlTokens; +} + // a single #| line: where its yaml content lives within the assembled // yaml source, and where that content starts in the document export interface HashPipeLine { @@ -66,12 +92,17 @@ class HashPipeYamlTokensProvider document: vscode.TextDocument ): vscode.SemanticTokens { const builder = new vscode.SemanticTokensBuilder(kLegend); - const tokens = this.engine_.parse(document); - for (const block of tokens.filter(isExecutableLanguageBlock)) { - const { lines, source } = hashPipeYaml(document, vscRange(block.range)); - if (lines.length > 0) { - emitYamlTokens(source, lines, builder); - } + const yamlTokens = hashPipeYamlTokens(this.engine_, document); + for (const token of yamlTokens) { + builder.push( + new vscode.Range( + token.line, + token.startChar, + token.line, + token.startChar + token.length + ), + token.tokenType + ); } return builder.build(); } @@ -112,7 +143,7 @@ export function hashPipeYaml( function emitYamlTokens( source: string, lines: HashPipeLine[], - builder: vscode.SemanticTokensBuilder + yamlTokens: HashPipeYamlToken[] ) { const yaml = parseDocument(source); visit(yaml, { @@ -120,7 +151,7 @@ function emitYamlTokens( if (node.range) { const type: HashPipeTokenType = key === "key" ? "quartoYamlKey" : scalarTokenType(node.value); - pushTokens(lines, node.range[0], node.range[1], type, builder); + pushTokens(lines, node.range[0], node.range[1], type, yamlTokens); } }, }); @@ -138,28 +169,25 @@ function scalarTokenType(value: unknown): HashPipeTokenType { } } -// map a [start, end) range in the yaml source back to document ranges, +// map a [start, end) range in the yaml source back to document positions, // splitting across lines (e.g. for block scalars) function pushTokens( lines: HashPipeLine[], start: number, end: number, - type: HashPipeTokenType, - builder: vscode.SemanticTokensBuilder + tokenType: HashPipeTokenType, + yamlTokens: HashPipeYamlToken[] ) { for (const line of lines) { const tokenStart = Math.max(start, line.yamlStart); const tokenEnd = Math.min(end, line.yamlEnd); if (tokenStart < tokenEnd) { - builder.push( - new vscode.Range( - line.docLine, - line.docCharBase + (tokenStart - line.yamlStart), - line.docLine, - line.docCharBase + (tokenEnd - line.yamlStart) - ), - type - ); + yamlTokens.push({ + line: line.docLine, + startChar: line.docCharBase + (tokenStart - line.yamlStart), + length: tokenEnd - tokenStart, + tokenType, + }); } } } diff --git a/apps/vscode/src/providers/semantic-tokens.ts b/apps/vscode/src/providers/semantic-tokens.ts index fee7f83c6..d84e525cd 100644 --- a/apps/vscode/src/providers/semantic-tokens.ts +++ b/apps/vscode/src/providers/semantic-tokens.ts @@ -34,6 +34,7 @@ import { mainLanguage } from "../vdoc/vdoc"; import { EmbeddedLanguage } from "../vdoc/languages"; +import { hashPipeYamlTokens } from "./hash-pipe-yaml"; import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-utils"; /** @@ -232,8 +233,17 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { uri ); + // yaml tokens for cell options (#| comments) -- merged into the + // result so that they aren't lost when this provider wins the + // document over the standalone provider in hash-pipe-yaml.ts + // (vscode uses a single semantic tokens provider per document) + const yamlTokens = hashPipeYamlEntries(engine, document); + if (!tokens || tokens.data.length === 0) { - return tokens; + // no embedded tokens: return null (rather than an empty result, + // which would still claim the document) so vscode can fall through + // to other semantic token providers + return yamlTokens.length > 0 ? encodeSemanticTokens(yamlTokens) : null; } // Remap token indices from embedded provider's legend to our universal legend @@ -243,10 +253,44 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { } // Adjust token positions from virtual doc to real doc coordinates - return unadjustedSemanticTokens(vdoc.language, remappedTokens); + const adjustedTokens = unadjustedSemanticTokens(vdoc.language, remappedTokens); + + // Merge in the cell option yaml tokens (encoding requires tokens + // sorted by document position) + if (yamlTokens.length === 0) { + return adjustedTokens; + } + const merged = decodeSemanticTokens(adjustedTokens).concat(yamlTokens); + merged.sort((a, b) => a.line - b.line || a.startChar - b.startChar); + return encodeSemanticTokens(merged); } catch (error) { return undefined; } }); }; } + +// cell option yaml tokens with token types resolved against the quarto +// semantic token legend +function hashPipeYamlEntries(engine: MarkdownEngine, document: TextDocument) { + const entries: Array<{ + line: number; + startChar: number; + length: number; + tokenType: number; + tokenModifiers: number; + }> = []; + for (const token of hashPipeYamlTokens(engine, document)) { + const tokenType = QUARTO_SEMANTIC_TOKEN_LEGEND.tokenTypes.indexOf(token.tokenType); + if (tokenType >= 0) { + entries.push({ + line: token.line, + startChar: token.startChar, + length: token.length, + tokenType, + tokenModifiers: 0, + }); + } + } + return entries; +} From ef93957937e87b76f0bc2d9df83273775aa5f436 Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 12 Aug 2026 15:07:18 -0400 Subject: [PATCH 4/4] Add CHANGELOG --- apps/vscode/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 444cf4e7b..420369e57 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -4,6 +4,7 @@ - Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059). - Fixed a bug where single-line display math with a cross-reference label (e.g. `$$1+1$$ {#eq-spec0}`), or an unclosed `$$`, stopped the rest of the document from being parsed, so headings went missing from the outline, LaTeX preview was unavailable, and code cells below could not be run (). +- Add semantic highlighting for `#|` comments in code cells (). ## 1.135.0 (Release on 2026-07-08)