diff --git a/apps/lsp/src/diagnostics.ts b/apps/lsp/src/diagnostics.ts index 725eb54f..e9cc5607 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 6544891c..75e201ab 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 9fd1eae2..265dd80a 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/quarto-utils/src/semantic-tokens-legend.ts b/apps/quarto-utils/src/semantic-tokens-legend.ts index 9a450863..5b93a27a 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/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 444cf4e7..420369e5 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) diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 71f587ac..53cad972 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 658532e1..34124504 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) { + 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 new file mode 100644 index 00000000..5a88f77b --- /dev/null +++ b/apps/vscode/src/providers/hash-pipe-yaml.ts @@ -0,0 +1,193 @@ +/* + * hash-pipe-yaml.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import * as vscode from "vscode"; +import { parseDocument, visit } from "yaml"; + +import { isExecutableLanguageBlock } from "quarto-core"; + +import { kQuartoDocSelector } from "../core/doc"; +import { vscRange } from "../core/range"; +import { MarkdownEngine } from "../markdown/engine"; + +// token types are mapped to the same textmate scopes used by source.yaml +// (see semanticTokenScopes in package.json) so that cell options pick up +// the theme's yaml frontmatter colors +const kTokenTypes = [ + "quartoYamlKey", + "quartoYamlString", + "quartoYamlNumber", + "quartoYamlBoolean", + "quartoYamlNull", +] as const; +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 +) { + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + kQuartoDocSelector, + new HashPipeYamlTokensProvider(engine), + kLegend + ) + ); +} + +// 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 { + yamlStart: number; + yamlEnd: number; + docLine: number; + docCharBase: number; +} + +class HashPipeYamlTokensProvider + implements vscode.DocumentSemanticTokensProvider { + constructor(private readonly engine_: MarkdownEngine) { } + + public provideDocumentSemanticTokens( + document: vscode.TextDocument + ): vscode.SemanticTokens { + const builder = new vscode.SemanticTokensBuilder(kLegend); + 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(); + } +} + +// 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 +export 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[], + yamlTokens: HashPipeYamlToken[] +) { + 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, yamlTokens); + } + }, + }); +} + +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 positions, +// splitting across lines (e.g. for block scalars) +function pushTokens( + lines: HashPipeLine[], + start: number, + end: number, + 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) { + 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 fee7f83c..d84e525c 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; +} diff --git a/yarn.lock b/yarn.lock index d3bfd3a3..3c6b76eb 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"