From 4720099eb85f2ee2a0baece0b040aae639db0af2 Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 11 Aug 2026 16:50:23 -0400 Subject: [PATCH] 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"