Skip to content
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
72 changes: 29 additions & 43 deletions apps/lsp/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Diagnostic[]>> = [];
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<Diagnostic[]> => {
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 => {
Expand Down Expand Up @@ -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<Diagnostic[]> => {
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 {
Expand Down
17 changes: 3 additions & 14 deletions apps/lsp/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -148,13 +148,6 @@ export interface IMdLanguageService {
*/
getDocumentHighlights(document: Document, position: lsp.Position, token: CancellationToken): Promise<lsp.DocumentHighlight[]>;

/**
* Compute save diagnostics for a given file
*
* Compute diagnostics that should be scanned for on save (and cleared on edit)
*/
computeOnSaveDiagnostics(doc: Document): Promise<lsp.Diagnostic[]>;

/**
* Compute diagnostics for a given file.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -237,17 +229,14 @@ export function createLanguageService(init: LanguageServiceInitialization): IMdL
getDocumentHighlights: (document: Document, position: lsp.Position, token: CancellationToken): Promise<lsp.DocumentHighlight[]> => {
return documentHighlightProvider.getDocumentHighlights(document, position, token);
},
computeOnSaveDiagnostics: async (doc: Document) => {
return (await diagnosticOnSaveComputer.compute(doc));
},
computeDiagnostics: async (doc: Document, options: DiagnosticOptions, token: CancellationToken): Promise<lsp.Diagnostic[]> => {
return (await diagnosticsComputer.compute(doc, options, token))?.diagnostics;
},
createPullDiagnosticsManager: () => {
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);
}
});
}
20 changes: 11 additions & 9 deletions apps/lsp/src/service/providers/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,34 +171,29 @@ class FileLinkMap {
}
}

export class DiagnosticOnSaveComputer {
constructor(private readonly quarto_: Quarto) { }

public async compute(doc: Document): Promise<lsp.Diagnostic[]> {
return provideYamlDiagnostics(this.quarto_, doc);
}
}

export class DiagnosticComputer {

readonly #configuration: LsConfiguration;
readonly #workspace: IWorkspace;
readonly #linkProvider: MdLinkProvider;
readonly #tocProvider: MdTableOfContentsProvider;
readonly #logger: ILogger;
readonly #quarto: Quarto;

constructor(
configuration: LsConfiguration,
workspace: IWorkspace,
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(
Expand All @@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code creates the promise before the cancellation check on line 216. The early return on line 217 does not await it. If quarto.getYamlDiagnostics rejects, the rejection is unhandled. apps/lsp/src/index.ts does not install an unhandledRejection handler. As a result, IIUC it terminates the server process.

Could we do something like this instead?

const yamlDiagnostics = provideYamlDiagnostics(this.#quarto, doc)
  .catch(() => []);


const { links, definitions } = await this.#linkProvider.getLinks(doc);
const statCache = new ResourceMap<{ readonly exists: boolean; }>();
if (token.isCancellationRequested) {
Expand All @@ -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 {
Expand Down Expand Up @@ -643,6 +644,7 @@ export class DiagnosticsManager extends Disposable implements IPullDiagnosticsMa
linkProvider: MdLinkProvider,
tocProvider: MdTableOfContentsProvider,
logger: ILogger,
quarto: Quarto,
) {
super();

Expand Down Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion apps/quarto-utils/src/semantic-tokens-legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<https://github.com/quarto-dev/quarto/pull/1063>).
- Add semantic highlighting for `#|` comments in code cells (<https://github.com/quarto-dev/quarto/pull/1084>).

## 1.135.0 (Release on 2026-07-08)

Expand Down
46 changes: 45 additions & 1 deletion apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These five scopes all resolve to fully saturated code colors, so a cell option block really competes with the code below it. I think the highlighting needs to be quieter, because the background already shows where the block is.

I resolved the scopes against the default themes in the Positron repo:

Token Light+ / Positron Light Dark+ / Positron Dark
quartoYamlKey #800000 #569cd6
quartoYamlString #0000ff #ce9178
quartoYamlNumber #098658 #b5cea8
quartoYamlBoolean #0000ff #569cd6
(comment, for comparison) #008000 #6A9955

Two problems come out of this.

string.unquoted.plain.out.yaml is pure blue in the light themes. Light+ and Positron Light have a rule for this exact scope, which gives #0000ff. This is the most saturated color on the screen. The scope is also incorrect for quoted values, because it is the scope for plain scalars. Use string, which gives #a31515 and #ce9178, the same as strings in the code.

One line can show three different hues. #| fig-cap: "..." gives a maroon key and a blue value. #| echo: false gives maroon and blue. #| fig-width: 6 gives maroon and green. Five token types is more detail than cell options need, because almost all of them are one key and one short scalar.

My suggestion:

  • Collapse the five types to two: quartoYamlKey and quartoYamlValue. Then remove quartoYamlNumber, quartoYamlBoolean, and quartoYamlNull.
  • Map quartoYamlValue to comment, so values keep the comment color of the theme. Keep one accent color on the key.

This gives one accent per line instead of three. If you want the quietest result, map both types to comment and let the background do all of the work.

Note that the legend in apps/quarto-utils/src/semantic-tokens-legend.ts says to append only, so removals there need a check of the index order.

@vezwork vezwork Aug 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, nice catch. Looking into the mismatching highlighting you pointed out lead to me trying out this alternative approach #1092, which doesn't have mismatching highlighting, but might be a bit visually loud.

],
"quartoYamlNumber": [
"constant.numeric.yaml"
],
"quartoYamlBoolean": [
"constant.language.boolean.yaml"
],
"quartoYamlNull": [
"constant.language.null.yaml"
]
}
}
],
"snippets": [
{
"language": "quarto",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions apps/vscode/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -231,6 +232,9 @@ export async function activate(context: vscode.ExtensionContext): Promise<Quarto
// background highlighter
activateBackgroundHighlighter(context, engine);

// yaml highlighting for cell options (#| comments)
activateHashPipeYamlHighlighter(context, engine);

// yaml document links
activateYamlLinks(context);

Expand Down
46 changes: 45 additions & 1 deletion apps/vscode/src/providers/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isWholeLine: true and a bottom border do not compose the way this code expects. A #| line that is long enough to soft-wrap gets one rule under each of its visual rows, not one rule under the whole line:

Image

This is because of the mechanism in VS Code itself, so I don't think we can code around it here very easily.

I think I prefer to remove the separator and let the darker background show the boundary. The background already wraps correctly, and it is the only option here that is always correct. If you want to keep a rule, we could put it on the first line after the option block as a top border (borderWidth: "1px 0 0 0").

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured out how to keep the bottom line but fix this problem in #1092 with a css trick.

light: {
borderColor: "#00000025",
},
dark: {
borderColor: "#FFFFFF25",
},
});

enum CellBackgroundColor {
default = "default",
off = "off",
Expand Down
Loading
Loading