Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
## 1.138.0 (Unreleased)

- In Positron, running a Python cell in a knitr document now respects the `quarto.cells.useReticulate` setting, instead of always routing it through reticulate on the R console (<https://github.com/quarto-dev/quarto/pull/1116>).
- In Positron, when Positron serves language features for code cells itself (the `quarto.embeddedLanguageFeatures.native` setting), the extension no longer serves them from virtual document temp files (<https://github.com/quarto-dev/quarto/pull/1115>).

## 1.137.0 (Release on 2026-09-04)

- Relicensed the extension to MIT (<https://github.com/quarto-dev/quarto/pull/1032>).


## 1.136.0 (Release on 2026-08-25)

- Reduce memory usage by only starting the language server (LSP) in projects containing Quarto documents (https://github.com/quarto-dev/quarto/pull/1059).
Expand Down
166 changes: 166 additions & 0 deletions apps/vscode/src/host/cell-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* cell-features.ts
*
* Copyright (C) 2026 by Posit Software, PBC
*/
Comment thread
juliasilge marked this conversation as resolved.

/**
* Who answers language feature requests inside Quarto code cells: this
* extension, from a `.vdoc.*` temp file, or the host, from an in memory
* virtual notebook. Both answers reach the editor for one request, so only one
* can answer it.
*
* This module is the entry point for that decision, and only the decision. The
* features stay in their own providers. Each one asks here, then stands down or
* carries on.
*
* Gated per language, so a cell the host does not cover keeps its virtual
* document:
*
* - completion, hover, signature help, go to definition (`lsp/client.ts`)
* - diagnostics (`providers/diagnostics.ts`)
*
* Gated per document, because one request covers the whole file and only one
* answer survives. Any cell the host owns hands it every cell, including ones
* in languages the host does not cover:
*
* - document symbols (`lsp/client.ts`)
* - document and range formatting (`providers/format.ts`)
* - semantic tokens (`providers/semantic-tokens.ts`)
* - statement range and help topic (`lsp/client.ts`), which are registrations,
* so they are disposed and registered again rather than returning early
*
* Not gated: `quarto.formatCell` still uses a virtual document.
*
* The host owns a feature when it has the cell commands (probed at activation
* by {@link detectCellFeatureOwnership}), the setting is on, and, for a
* per-language gate, the language is one of `kHostOwnedLanguages`.
*/

import { commands, LogOutputChannel, workspace } from "vscode";
import { tryAcquirePositronApi } from "@posit-dev/positron";

import { EmbeddedLanguage } from "../vdoc/languages";

/**
* The Positron setting that turns the virtual notebook on. Contributed by
* Positron core, not by this extension, so it is read through the full
* configuration rather than the `quarto` section we contribute.
*/
export const kHostCellFeaturesSetting = "quarto.embeddedLanguageFeatures.native";

/**
* Commands whose presence says this host carries the virtual notebook (see
* {@link detectCellFeatureOwnership}).
*
* These are the INTERNAL ids, and the code calls the public
* `positron.executeQuartoCell*` ones. The internal ids are what a probe can
* see. The public ones are API commands, registered inside the extension host
* and deliberately never mirrored into the registry that `getCommands` reads,
* so they do not appear there at all. Neither does any
* `vscode.executeDocumentSymbolProvider`-style command, for the same reason.
* The internal commands are registered in the workbench, so they are visible,
* as long as the probe does not filter underscore-prefixed ids.
*/
const kCellOwnershipCommands = [
"_executeQuartoCellSymbolProvider",
"_executeQuartoCellFormattingProvider",
"_executeQuartoCellRangeFormattingProvider",
];

/**
* Languages whose cells the host is verified to own. Matched against
* {@link EmbeddedLanguage.ids}, so an alias of a listed language counts too.
*
* Add one language at a time, once its cell providers have been verified end to
* end: a document that is not covered here keeps its virtual document, which is
* the safe direction.
*/
const kHostOwnedLanguages = new Set(["r", "python"]);

let cellCommandsAvailable = false;

/**
* Whether this host carries the virtual notebook.
*
* Capability detection is command presence rather than a Positron API flag or a
* version comparison. Positron registers these commands unconditionally: with
* the setting off there are no cells and they answer empty, so their presence
* tracks "this build can own the cells" exactly. Vanilla VS Code and older
* Positron builds have no such commands, so a user who pastes the setting key
* into their own `settings.json` there stays on virtual documents.
*/
async function hostHasCellCommands(): Promise<boolean> {
if (!tryAcquirePositronApi()) {
return false;
}

// `false` keeps the underscore-prefixed ids we are looking for
const all = await commands.getCommands(false);
return kCellOwnershipCommands.every((command) => all.includes(command));
}

/**
* Determine whether the host owns language features for code cells, and
* record the answer where the gates can read it.
*
* Must be awaited during activation, before any gate can be consulted.
*/
export async function detectCellFeatureOwnership(
outputChannel?: LogOutputChannel
): Promise<void> {
cellCommandsAvailable = await hostHasCellCommands();

if (cellCommandsAvailable) {
outputChannel?.info(
"[CellFeatures] Host owns Quarto cell language features. " +
`The extension stands down for ${[...kHostOwnedLanguages].join(", ")} ` +
`while ${kHostCellFeaturesSetting} is on.`
);
} else if (
workspace.getConfiguration().get<boolean>(kHostCellFeaturesSetting) === true
) {
outputChannel?.warn(
`[CellFeatures] ${kHostCellFeaturesSetting} is on, but this host has no ` +
"Quarto cell commands. Serving embedded language features from virtual " +
"documents, which can duplicate what the host provides."
);
Comment on lines +115 to +127

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nice logs. These were very helpful in testing.

}
}

/**
* Whether the host owns the cells of a language. Pure, so the language set
* can be tested without an extension host.
*/
export function hostOwnsLanguage(language: EmbeddedLanguage): boolean {
return language.ids.some((id) => kHostOwnedLanguages.has(id));
}

/**
* Whether the host owns embedded language features for `language`, meaning
* this extension should stand down and not serve them from a virtual document.
*
* Pass no language to ask about the document as a whole, which is what the
* whole-document commands (symbols, formatting) cover.
*
* The setting is read live on every call so that toggling it takes effect
* without a window reload. The statement range and help topic registrations in
* `lsp/client.ts` follow the setting live too, via a configuration listener.
*
* A gated pull feature answers `undefined` rather than delegating to the Quarto
* language server with `next()`. The server has nothing real to say about a code
* cell: it declares the signature help, definition, and semantic tokens
* capabilities only so that the client can intercept them with middleware, and
* its handlers answer null (see `apps/lsp/src/middleware.ts`). For semantic
* tokens delegating is worse than pointless, because the server's empty token
* stream counts as an answer and would suppress the host's own provider.
*/
export function hostOwnsCellFeatures(language?: EmbeddedLanguage): boolean {
if (!cellCommandsAvailable) {
return false;
}
if (workspace.getConfiguration().get<boolean>(kHostCellFeaturesSetting) !== true) {
return false;
}
return language === undefined || hostOwnsLanguage(language);
}
99 changes: 99 additions & 0 deletions apps/vscode/src/lsp/cell-symbols.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* cell-symbols.ts
*
* Copyright (C) 2026 by Posit Software, PBC
*/

import {
commands,
DocumentSymbol,
Range,
SymbolKind,
Uri,
} from "vscode";

/**
* One code cell's symbols, as answered by
* `positron.executeQuartoCellSymbolProvider`.
*/
export interface QuartoCellSymbols {
/** The cell's code span in source coordinates, fences excluded. */
readonly range: Range;

/** Already in source coordinates. Never empty. */
readonly symbols: DocumentSymbol[];
}

/**
* The symbols of every code cell in a Quarto document, grouped by cell.
*
* One request for the whole document, so callers walking a symbol tree should
* ask once and then look cells up by range with {@link nestCellSymbols}.
*
* Answers `[]` for every unservable state: a host without the command, a
* document with no cells, and a document whose cells have no language server
* attached yet. That last case is why the caller must gate on
* `hostOwnsCellFeatures()` rather than treat an empty answer as a reason to
* fall back, and it needs no retry: when a server does register, the editor
* re-requests document symbols on its own.
*/
export async function quartoCellSymbols(
uri: Uri
): Promise<QuartoCellSymbols[]> {
try {
const cells = await commands.executeCommand<QuartoCellSymbols[] | undefined>(
"positron.executeQuartoCellSymbolProvider",
uri
);
return cells ?? [];
} catch (error) {
return [];
}
}

/**
* Whether a symbol tree holds any chunk symbol for a cell to nest under.
*
* The language server marks chunks with `SymbolKind.Function` (its `toc.ts`),
* and drops every one of them when `quarto.symbols.showCodeCellsInOutline` is
* off. A `_quarto.yml`, which this client's document selector also covers, never
* has one either. In both cases {@link nestCellSymbols} would have nothing to
* attach to, so the caller can answer without asking the host for cell symbols.
*/
export function hasChunkSymbols(symbols: readonly DocumentSymbol[]): boolean {
return symbols.some(
(symbol) =>
symbol.kind === SymbolKind.Function || hasChunkSymbols(symbol.children)
);
}

/**
* Nests each cell's symbols under the chunk symbol it came from.
*
* Chunks are matched to cells by range containment: a chunk symbol's range
* covers its fences, so the cell's code span sits inside it. Chunks are the
* `SymbolKind.Function` symbols the Quarto language server's `toc.ts` emits,
* which is the same marker the virtual document path uses.
*
* Symbols the language server already nested under a chunk are kept, and a
* chunk with no matching cell is left as it is.
*/
export function nestCellSymbols(
symbols: DocumentSymbol[],
cells: readonly QuartoCellSymbols[]
): DocumentSymbol[] {
for (const symbol of symbols) {
if (symbol.kind === SymbolKind.Function) {
const cell = cells.find((candidate) =>
symbol.range.contains(candidate.range)
);
if (cell) {
symbol.children = [...symbol.children, ...cell.symbols];
}
} else {
symbol.children = nestCellSymbols(symbol.children, cells);
}
}

return symbols;
}
67 changes: 65 additions & 2 deletions apps/vscode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ import { LspInitializationOptions, QuartoContext } from "quarto-core";
import { lspClientTransport } from "core-node";
import { JsonRpcRequestTransport } from "core";
import { extensionHost } from "../host";
import { kHostCellFeaturesSetting, hostOwnsCellFeatures } from "../host/cell-features";
import { hasChunkSymbols, nestCellSymbols, quartoCellSymbols } from "./cell-symbols";
import semver from "semver";
import { EmbeddedLanguage } from "../vdoc/languages";
import { SymbolInformation } from "vscode";
Expand Down Expand Up @@ -157,8 +159,36 @@ export function activateLsp(
if (config.get("cells.signatureHelp.enabled", true)) {
middleware.provideSignatureHelp = embeddedSignatureHelpProvider(engine);
}
extensionHost().registerStatementRangeProvider(engine);
extensionHost().registerHelpTopicProvider(engine);
// Statement range and help topic are single-answer features: whichever
// provider registered last owns Cmd+Enter and F1. When the host owns the
// cells we must not compete with it, so these registrations follow the
// setting live rather than being made once. Disposing on enable hands the
// features to the host; re-registering on disable wins the race because this
// registration is then the most recent.
let hostProviders: Disposable[] = [];
const registerHostProviders = () => {
hostProviders = [
extensionHost().registerStatementRangeProvider(engine),
extensionHost().registerHelpTopicProvider(engine),
];
};
if (!hostOwnsCellFeatures()) {
registerHostProviders();
}
context.subscriptions.push(
new Disposable(() => hostProviders.forEach((d) => d.dispose())),
workspace.onDidChangeConfiguration((e) => {
if (!e.affectsConfiguration(kHostCellFeaturesSetting)) {
return;
}
if (hostOwnsCellFeatures()) {
hostProviders.forEach((d) => d.dispose());
hostProviders = [];
} else if (hostProviders.length === 0) {
registerHostProviders();
}
})
);

// create client options
const initializationOptions: LspInitializationOptions = {
Expand Down Expand Up @@ -328,6 +358,11 @@ function embeddedCodeCompletionProvider(engine: MarkdownEngine) {
const vdoc = await virtualDoc(document, position, engine);

if (vdoc && !isWithinYamlComment(document, position)) {
// when the host is Positron, it may own the language's cells and the extension should stand down (not try to provide them)
if (hostOwnsCellFeatures(vdoc.language)) {
return undefined;
}

// if there is a trigger character make sure the language supports it
const language = vdoc.language;
if (context.triggerCharacter) {
Expand Down Expand Up @@ -372,6 +407,10 @@ function embeddedHoverProvider(engine: MarkdownEngine) {

const vdoc = await virtualDoc(document, position, engine);
if (vdoc) {
if (hostOwnsCellFeatures(vdoc.language)) {
return undefined;
}

return await withVirtualDocUri(vdoc, document.uri, "hover", async (uri: Uri) => {
try {
return await getHover(uri, vdoc.language, position);
Expand All @@ -396,6 +435,10 @@ function embeddedSignatureHelpProvider(engine: MarkdownEngine) {
) => {
const vdoc = await virtualDoc(document, position, engine);
if (vdoc) {
if (hostOwnsCellFeatures(vdoc.language)) {
return undefined;
}

return await withVirtualDocUri(vdoc, document.uri, "signature", async (uri: Uri) => {
try {
return await getSignatureHelpHover(uri, vdoc.language, position, context.triggerCharacter);
Expand All @@ -418,6 +461,10 @@ function embeddedGoToDefinitionProvider(engine: MarkdownEngine) {
): Promise<Definition | LocationLink[] | null | undefined> => {
const vdoc = await virtualDoc(document, position, engine);
if (vdoc) {
if (hostOwnsCellFeatures(vdoc.language)) {
return undefined;
}

return await withVirtualDocUri(vdoc, document.uri, "definition", async (uri: Uri) => {
try {
const definitions = await commands.executeCommand<
Expand Down Expand Up @@ -508,6 +555,22 @@ function embeddedDocumentSymbolProvider(engine: MarkdownEngine) {
// I don't think we actually ever get SymbolInformation[] here, but I'm not certain
// so this is defensively coded.
if (baseSymbols.length > 0 && isDocumentSymbol(baseSymbols[0])) {
// When the host owns the cells, one command answers for the whole
// document, so it is fetched once per request and the chunks are matched
// to it by range.
if (hostOwnsCellFeatures()) {
const symbols = baseSymbols as DocumentSymbol[];
// Nothing to ask the host about when the outline carries no chunk
// symbol to nest under, which is every request for a `_quarto.yml` and
// every request at all while `showCodeCellsInOutline` is off.
if (!hasChunkSymbols(symbols)) {
return baseSymbols;
}
const cells = await quartoCellSymbols(document.uri);
if (token.isCancellationRequested) return baseSymbols;
return nestCellSymbols(symbols, cells);
}

const enhanced = await enhanceSymbolsWithCodeCellContent(
document,
baseSymbols as DocumentSymbol[],
Expand Down
Loading
Loading