From be7d7476a2cd31210afb61104562036e8455adc3 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Fri, 4 Sep 2026 08:05:04 -0600 Subject: [PATCH 1/9] First draft of virtual notebook for LSP features --- apps/vscode/CHANGELOG.md | 1 + apps/vscode/src/host/native-features.ts | 129 +++++++++++++++ apps/vscode/src/lsp/cell-symbols.ts | 83 ++++++++++ apps/vscode/src/lsp/client.ts | 60 ++++++- apps/vscode/src/main.ts | 4 + apps/vscode/src/providers/diagnostics.ts | 9 ++ apps/vscode/src/providers/format.ts | 84 +++++++++- apps/vscode/src/providers/semantic-tokens.ts | 6 + .../vscode/src/test/code-cell-symbols.test.ts | 152 ++++++++++++++++++ apps/vscode/src/test/native-features.test.ts | 67 ++++++++ 10 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 apps/vscode/src/host/native-features.ts create mode 100644 apps/vscode/src/lsp/cell-symbols.ts create mode 100644 apps/vscode/src/test/native-features.test.ts diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 393e0d5f..7ffd4354 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.137.0 (Unreleased) +- 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. ## 1.136.0 (Release on 2026-08-25) diff --git a/apps/vscode/src/host/native-features.ts b/apps/vscode/src/host/native-features.ts new file mode 100644 index 00000000..c9ae88aa --- /dev/null +++ b/apps/vscode/src/host/native-features.ts @@ -0,0 +1,129 @@ +/* + * native-features.ts + * + * Copyright (C) 2026 by Posit Software, PBC + */ + +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 kNativeFeaturesSetting = "quarto.embeddedLanguageFeatures.native"; + +/** + * Commands whose presence says this host carries the virtual notebook (see + * {@link detectNativeEmbeddedFeatures}). + * + * 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 kNativeFeatureCommands = [ + "_executeQuartoCellSymbolProvider", + "_executeQuartoCellFormattingProvider", + "_executeQuartoCellRangeFormattingProvider", +]; + +/** + * Languages Positron is verified to serve natively. 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 kNativeLanguages = new Set(["r", "python"]); + +let nativeAvailable = false; + +/** + * Determine if this host can serve embedded language features natively. + * + * 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 serve natively" 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. + * + * Must be awaited during activation, before any gate can be consulted. + * + */ +export async function detectNativeEmbeddedFeatures( + outputChannel?: LogOutputChannel +): Promise { + if (!tryAcquirePositronApi()) { + nativeAvailable = false; + return; + } + + // `false` keeps the underscore-prefixed ids we are looking for + const all = await commands.getCommands(false); + nativeAvailable = kNativeFeatureCommands.every((command) => + all.includes(command) + ); + + if (nativeAvailable) { + outputChannel?.info( + "[NativeFeatures] Host serves Quarto cell language features. " + + `The extension stands down for ${[...kNativeLanguages].join(", ")} ` + + `while ${kNativeFeaturesSetting} is on.` + ); + } else if ( + workspace.getConfiguration().get(kNativeFeaturesSetting) === true + ) { + outputChannel?.warn( + `[NativeFeatures] ${kNativeFeaturesSetting} is on, but this host has no ` + + "Quarto cell commands. Serving embedded language features from virtual " + + "documents, which can duplicate what the host provides." + ); + } +} + +/** + * Whether a language is one we let the host serve natively. Pure, so the + * language set can be tested without an extension host. + */ +export function isNativeEmbeddedLanguage(language: EmbeddedLanguage): boolean { + return language.ids.some((id) => kNativeLanguages.has(id)); +} + +/** + * Whether the host serves 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 useNativeEmbeddedFeatures(language?: EmbeddedLanguage): boolean { + if (!nativeAvailable) { + return false; + } + if (workspace.getConfiguration().get(kNativeFeaturesSetting) !== true) { + return false; + } + return language === undefined || isNativeEmbeddedLanguage(language); +} diff --git a/apps/vscode/src/lsp/cell-symbols.ts b/apps/vscode/src/lsp/cell-symbols.ts new file mode 100644 index 00000000..c81d253b --- /dev/null +++ b/apps/vscode/src/lsp/cell-symbols.ts @@ -0,0 +1,83 @@ +/* + * 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 + * `useNativeEmbeddedFeatures()` 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 { + try { + const cells = await commands.executeCommand( + "positron.executeQuartoCellSymbolProvider", + uri + ); + return cells ?? []; + } catch (error) { + return []; + } +} + +/** + * 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; +} diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index b86b69ce..aee87952 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -68,6 +68,8 @@ import { LspInitializationOptions, QuartoContext } from "quarto-core"; import { lspClientTransport } from "core-node"; import { JsonRpcRequestTransport } from "core"; import { extensionHost } from "../host"; +import { kNativeFeaturesSetting, useNativeEmbeddedFeatures } from "../host/native-features"; +import { nestCellSymbols, quartoCellSymbols } from "./cell-symbols"; import semver from "semver"; import { EmbeddedLanguage } from "../vdoc/languages"; import { SymbolInformation } from "vscode"; @@ -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 serves cells + // natively 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 (!useNativeEmbeddedFeatures()) { + registerHostProviders(); + } + context.subscriptions.push( + new Disposable(() => hostProviders.forEach((d) => d.dispose())), + workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration(kNativeFeaturesSetting)) { + return; + } + if (useNativeEmbeddedFeatures()) { + hostProviders.forEach((d) => d.dispose()); + hostProviders = []; + } else if (hostProviders.length === 0) { + registerHostProviders(); + } + }) + ); // create client options const initializationOptions: LspInitializationOptions = { @@ -328,6 +358,11 @@ function embeddedCodeCompletionProvider(engine: MarkdownEngine) { const vdoc = await virtualDoc(document, position, engine); if (vdoc && !isWithinYamlComment(document, position)) { + // stand down when the host serves this language's cells itself + if (useNativeEmbeddedFeatures(vdoc.language)) { + return undefined; + } + // if there is a trigger character make sure the language supports it const language = vdoc.language; if (context.triggerCharacter) { @@ -372,6 +407,10 @@ function embeddedHoverProvider(engine: MarkdownEngine) { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { + if (useNativeEmbeddedFeatures(vdoc.language)) { + return undefined; + } + return await withVirtualDocUri(vdoc, document.uri, "hover", async (uri: Uri) => { try { return await getHover(uri, vdoc.language, position); @@ -396,6 +435,10 @@ function embeddedSignatureHelpProvider(engine: MarkdownEngine) { ) => { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { + if (useNativeEmbeddedFeatures(vdoc.language)) { + return undefined; + } + return await withVirtualDocUri(vdoc, document.uri, "signature", async (uri: Uri) => { try { return await getSignatureHelpHover(uri, vdoc.language, position, context.triggerCharacter); @@ -418,6 +461,10 @@ function embeddedGoToDefinitionProvider(engine: MarkdownEngine) { ): Promise => { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { + if (useNativeEmbeddedFeatures(vdoc.language)) { + return undefined; + } + return await withVirtualDocUri(vdoc, document.uri, "definition", async (uri: Uri) => { try { const definitions = await commands.executeCommand< @@ -508,6 +555,15 @@ 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 serves 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 (useNativeEmbeddedFeatures()) { + const cells = await quartoCellSymbols(document.uri); + if (token.isCancellationRequested) return baseSymbols; + return nestCellSymbols(baseSymbols as DocumentSymbol[], cells); + } + const enhanced = await enhanceSymbolsWithCodeCellContent( document, baseSymbols as DocumentSymbol[], diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 0c016330..19dc8544 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -26,6 +26,7 @@ import { activateEditor } from "./providers/editor/editor"; import { activateCopyFiles } from "./providers/copyfiles"; import { activateZotero } from "./providers/zotero/zotero"; import { extensionHost } from "./host"; +import { detectNativeEmbeddedFeatures } from "./host/native-features"; import { isInlineOutputEnabled, kInlineOutputEnabledSetting, kInlineOutputEnabledSettingDeprecated } from "./host/positron"; import { initQuartoContext, getSourceDescription } from "quarto-core"; import { configuredQuartoPath } from "./core/quarto"; @@ -61,6 +62,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { + try { + const result = await commands.executeCommand( + "positron.executeQuartoCellFormattingProvider", + uri + ); + return result ?? kNoCellFormattingEdits; + } catch (error) { + return kNoCellFormattingEdits; + } +} + +async function executeCellRangeFormattingProvider( + uri: Uri, + range: Range +): Promise { + try { + const result = await commands.executeCommand( + "positron.executeQuartoCellRangeFormattingProvider", + uri, + range + ); + return result ?? kNoCellFormattingEdits; + } catch (error) { + return kNoCellFormattingEdits; + } +} + export function embeddedDocumentFormattingProvider(engine: MarkdownEngine) { return async ( document: TextDocument, @@ -65,6 +115,17 @@ export function embeddedDocumentFormattingProvider(engine: MarkdownEngine) { return []; } + if (useNativeEmbeddedFeatures()) { + const result = await executeCellFormattingProvider(document.uri); + if (result.vetoedCells > 0) { + window.showInformationMessage( + `Formatting edits could not be applied to ${result.vetoedCells} code cell${result.vetoedCells === 1 ? "" : "s"}; document was not modified.` + ); + return []; + } + return result.edits; + } + const tokens = engine.parse(document); // Figure out language to use. Try selection's block, then fall back to main doc language. @@ -137,6 +198,17 @@ export function embeddedDocumentRangeFormattingProvider( return next(document, range, options, token); } + if (useNativeEmbeddedFeatures()) { + const result = await executeCellRangeFormattingProvider(document.uri, range); + if (result.vetoedCells > 0) { + window.showInformationMessage( + "Formatting edits could not be applied to the code cell." + ); + return []; + } + return result.edits; + } + const includeFence = false; const tokens = engine.parse(document); @@ -325,12 +397,12 @@ async function formatBlock( const eol = doc.eol === EndOfLine.CRLF ? "\r\n" : "\n"; const normalizeEdit: TextEdit | undefined = leadingEmptyLines > 1 ? new TextEdit( - new Range( - new Position(block.range.start.line + 1 + optionLines, 0), - new Position(block.range.start.line + 1 + optionLines + leadingEmptyLines, 0) - ), - eol - ) + new Range( + new Position(block.range.start.line + 1 + optionLines, 0), + new Position(block.range.start.line + 1 + optionLines + leadingEmptyLines, 0) + ), + eol + ) : undefined; // Skip the formatter if the block is entirely option directives (or only diff --git a/apps/vscode/src/providers/semantic-tokens.ts b/apps/vscode/src/providers/semantic-tokens.ts index 128b686c..9a5dca0f 100644 --- a/apps/vscode/src/providers/semantic-tokens.ts +++ b/apps/vscode/src/providers/semantic-tokens.ts @@ -25,6 +25,7 @@ import { mainLanguage } from "../vdoc/vdoc"; import { EmbeddedLanguage } from "../vdoc/languages"; +import { useNativeEmbeddedFeatures } from "../host/native-features"; import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-utils"; /** @@ -207,6 +208,11 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { return await next(document, token); } + // Stand down when the host serves this language's cells itself + if (useNativeEmbeddedFeatures(language)) { + return undefined; + } + // Create virtual doc for all blocks of this language const vdoc = virtualDocForLanguage(document, tokens, language); diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts index f229d60e..f197ab50 100644 --- a/apps/vscode/src/test/code-cell-symbols.test.ts +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import * as assert from "assert"; import { openAndShowUniqueExamplesDocument, wait } from "./test-utils"; import { DisposableStore } from "core"; +import { nestCellSymbols, QuartoCellSymbols } from "../lsp/cell-symbols"; /** * Creates a fake document symbol provider that returns DocumentSymbol[] for virtual docs. @@ -191,3 +192,154 @@ suite("Code Cell Symbols", function () { ); }); }); + +/** + * Builds a chunk symbol the way the Quarto language server's `toc.ts` does: + * `SymbolKind.Function`, over a range that covers the fences too. + */ +function chunkSymbol( + name: string, + startLine: number, + endLine: number +): vscode.DocumentSymbol { + return new vscode.DocumentSymbol( + name, + "", + vscode.SymbolKind.Function, + new vscode.Range(startLine, 0, endLine, 3), + new vscode.Range(startLine, 0, startLine, 3) + ); +} + +function headingSymbol( + name: string, + startLine: number, + endLine: number, + children: vscode.DocumentSymbol[] +): vscode.DocumentSymbol { + const symbol = new vscode.DocumentSymbol( + name, + "", + vscode.SymbolKind.String, + new vscode.Range(startLine, 0, endLine, 0), + new vscode.Range(startLine, 0, startLine, 0) + ); + symbol.children = children; + return symbol; +} + +/** One cell's answer from `positron.executeQuartoCellSymbolProvider`. */ +function cellAnswer( + startLine: number, + endLine: number, + names: string[] +): QuartoCellSymbols { + return { + range: new vscode.Range(startLine, 0, endLine, 0), + symbols: names.map( + (name) => + new vscode.DocumentSymbol( + name, + "", + vscode.SymbolKind.Variable, + new vscode.Range(startLine, 0, startLine, 5), + new vscode.Range(startLine, 0, startLine, 5) + ) + ), + }; +} + +suite("Native Cell Symbol Nesting", function () { + test("nests a cell's symbols under the chunk that contains it", function () { + // Chunk fences on lines 2 and 5, so the cell's code span is lines 3 to 4. + const symbols = [chunkSymbol("{r}", 2, 5)]; + const cells = [cellAnswer(3, 4, ["my_function"])]; + + const nested = nestCellSymbols(symbols, cells); + + assert.deepStrictEqual(flattenSymbolNames(nested), ["{r}", "my_function"]); + }); + + test("leaves a chunk alone when no cell's code sits inside it", function () { + const symbols = [chunkSymbol("{r}", 2, 5)]; + // A cell from a different chunk further down the document. + const cells = [cellAnswer(11, 12, ["other_function"])]; + + const nested = nestCellSymbols(symbols, cells); + + assert.deepStrictEqual(flattenSymbolNames(nested), ["{r}"]); + }); + + test("gives each chunk only its own cell's symbols", function () { + const symbols = [chunkSymbol("{r}", 2, 5), chunkSymbol("{python}", 7, 10)]; + const cells = [ + cellAnswer(3, 4, ["r_thing"]), + cellAnswer(8, 9, ["python_thing"]), + ]; + + const nested = nestCellSymbols(symbols, cells); + + assert.deepStrictEqual(flattenSymbolNames(nested), [ + "{r}", + "r_thing", + "{python}", + "python_thing", + ]); + }); + + test("finds chunks nested under headings", function () { + const symbols = [ + headingSymbol("Section", 0, 11, [ + chunkSymbol("{r}", 2, 5), + headingSymbol("Subsection", 6, 11, [chunkSymbol("{python}", 7, 10)]), + ]), + ]; + const cells = [ + cellAnswer(3, 4, ["r_thing"]), + cellAnswer(8, 9, ["python_thing"]), + ]; + + const nested = nestCellSymbols(symbols, cells); + + assert.deepStrictEqual(flattenSymbolNames(nested), [ + "Section", + "{r}", + "r_thing", + "Subsection", + "{python}", + "python_thing", + ]); + }); + + test("keeps children the language server already nested under a chunk", function () { + const chunk = chunkSymbol("{r}", 2, 5); + chunk.children = [ + new vscode.DocumentSymbol( + "existing", + "", + vscode.SymbolKind.Field, + new vscode.Range(3, 0, 3, 4), + new vscode.Range(3, 0, 3, 4) + ), + ]; + const cells = [cellAnswer(3, 4, ["my_function"])]; + + const nested = nestCellSymbols([chunk], cells); + + assert.deepStrictEqual(flattenSymbolNames(nested), [ + "{r}", + "existing", + "my_function", + ]); + }); + + test("returns the tree unchanged when no cell has symbols", function () { + const symbols = [ + headingSymbol("Section", 0, 6, [chunkSymbol("{r}", 2, 5)]), + ]; + + const nested = nestCellSymbols(symbols, []); + + assert.deepStrictEqual(flattenSymbolNames(nested), ["Section", "{r}"]); + }); +}); diff --git a/apps/vscode/src/test/native-features.test.ts b/apps/vscode/src/test/native-features.test.ts new file mode 100644 index 00000000..1fa6d9f4 --- /dev/null +++ b/apps/vscode/src/test/native-features.test.ts @@ -0,0 +1,67 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; + +import { + detectNativeEmbeddedFeatures, + isNativeEmbeddedLanguage, + useNativeEmbeddedFeatures, +} from "../host/native-features"; +import { embeddedLanguage } from "../vdoc/languages"; + +function language(name: string) { + const found = embeddedLanguage(name); + assert.ok(found, `Expected ${name} to be an embedded language`); + return found; +} + +suite("Native Embedded Features", function () { + test("recognizes the languages Positron serves natively", function () { + assert.strictEqual(isNativeEmbeddedLanguage(language("r")), true); + assert.strictEqual(isNativeEmbeddedLanguage(language("python")), true); + }); + + test("does not recognize languages that keep their virtual document", function () { + assert.strictEqual(isNativeEmbeddedLanguage(language("julia")), false); + assert.strictEqual(isNativeEmbeddedLanguage(language("typescript")), false); + assert.strictEqual(isNativeEmbeddedLanguage(language("sql")), false); + }); + + test("matches every alias of a native language", function () { + // `embeddedLanguage` strips a leading engine prefix, so a `{r}` chunk and an + // `{ojs-r}` one resolve to the same language object; the gate matches on the + // language's own `ids` rather than on the chunk's text. + assert.deepStrictEqual(language("r").ids, ["r"]); + assert.deepStrictEqual(language("python").ids, ["python"]); + }); + + test("a probe can only see the internal command ids, not the public ones", async function () { + const filtered = await vscode.commands.getCommands(true); + const unfiltered = await vscode.commands.getCommands(false); + + assert.strictEqual( + unfiltered.includes("vscode.executeDocumentSymbolProvider"), + false, + "API commands are not expected to be visible to getCommands" + ); + assert.strictEqual( + unfiltered.includes("_executeDocumentSymbolProvider"), + true, + "the internal command is expected to be visible when nothing is filtered" + ); + assert.strictEqual( + filtered.includes("_executeDocumentSymbolProvider"), + false, + "getCommands(true) is expected to filter underscore-prefixed ids" + ); + }); + + test("stays off when the host has no native cell commands", async function () { + // Vanilla VS Code, which is what these tests run in: the Positron commands + // are absent, so the gate is off no matter what the setting says. + await detectNativeEmbeddedFeatures(); + + assert.strictEqual(useNativeEmbeddedFeatures(), false); + assert.strictEqual(useNativeEmbeddedFeatures(language("r")), false); + assert.strictEqual(useNativeEmbeddedFeatures(language("python")), false); + }); +}); From bbec2bb8ea28acd6f528d15937bb923e87c471a7 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Mon, 7 Sep 2026 12:08:33 -0600 Subject: [PATCH 2/9] Add some logging for vdocs, to make it easier to find them --- apps/vscode/src/main.ts | 2 ++ apps/vscode/src/vdoc/vdoc-tempfile.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 19dc8544..33062da1 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -27,6 +27,7 @@ import { activateCopyFiles } from "./providers/copyfiles"; import { activateZotero } from "./providers/zotero/zotero"; import { extensionHost } from "./host"; import { detectNativeEmbeddedFeatures } from "./host/native-features"; +import { setVdocTempFileLogger } from "./vdoc/vdoc-tempfile"; import { isInlineOutputEnabled, kInlineOutputEnabledSetting, kInlineOutputEnabledSettingDeprecated } from "./host/positron"; import { initQuartoContext, getSourceDescription } from "quarto-core"; import { configuredQuartoPath } from "./core/quarto"; @@ -56,6 +57,7 @@ let notebookExportService: NotebookExportService | undefined; export async function activate(context: vscode.ExtensionContext): Promise { // create output channel for extension logs and lsp client logs const outputChannel = vscode.window.createOutputChannel("Quarto", { log: true }); + setVdocTempFileLogger(outputChannel); outputChannel.info("Activating Quarto extension."); diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index 2e4dbccd..55e2b864 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -13,6 +13,7 @@ import { commands, Hover, languages, + LogOutputChannel, Position, TextDocument, Uri, @@ -20,6 +21,16 @@ import { } from "vscode"; import { VirtualDoc, VirtualDocUri } from "./vdoc"; +/** + * Where vdoc temp file creation and deletion are logged. Wired to the Quarto + * output channel at activation; a no-op before that. Debug level, because a + * vdoc is created per language-feature request. + */ +let logChannel: LogOutputChannel | undefined; +export function setVdocTempFileLogger(channel: LogOutputChannel): void { + logChannel = channel; +} + interface VirtualDocTempFileOptions { /** Fire a "dummy" hover request to cause the language server to start */ warmup: boolean; @@ -38,6 +49,7 @@ export async function virtualDocUriFromTempFile( ): Promise { const filepath = generateVirtualDocFilepath(directory, virtualDoc.language.extension); createVirtualDoc(filepath, virtualDoc.content); + logChannel?.debug(`[vdoc] Created ${filepath}`); const virtualDocUri = Uri.file(filepath); const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); @@ -89,6 +101,7 @@ export async function deleteDocument(doc: TextDocument) { await workspace.fs.delete(doc.uri, { useTrash: false }); + logChannel?.debug(`[vdoc] Deleted ${doc.fileName}`); } catch (error) { // It's okay if the file is already deleted. if (error instanceof Error && error.message.includes("ENOENT")) { From b37b31968b45bd15dfcd1911230f6e016733fc32 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Mon, 7 Sep 2026 12:40:18 -0600 Subject: [PATCH 3/9] Update CHANGELOG --- apps/vscode/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index b97d54ea..adb2b723 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,13 +2,12 @@ ## 1.138.0 (Unreleased) +- 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 (). ## 1.137.0 (Release on 2026-09-04) - Relicensed the extension to MIT (). -- 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. - ## 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). From 62598c7eab7de1fa76c1529c19f83bf9de3e3ee0 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 19:15:59 -0600 Subject: [PATCH 4/9] Extract native feature detection logic into separate function --- apps/vscode/src/host/native-features.ts | 28 ++++++++++++++----------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/vscode/src/host/native-features.ts b/apps/vscode/src/host/native-features.ts index c9ae88aa..81bfeba0 100644 --- a/apps/vscode/src/host/native-features.ts +++ b/apps/vscode/src/host/native-features.ts @@ -48,7 +48,7 @@ const kNativeLanguages = new Set(["r", "python"]); let nativeAvailable = false; /** - * Determine if this host can serve embedded language features natively. + * 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 @@ -56,23 +56,27 @@ let nativeAvailable = false; * tracks "this build can serve natively" 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. - * - * Must be awaited during activation, before any gate can be consulted. - * */ -export async function detectNativeEmbeddedFeatures( - outputChannel?: LogOutputChannel -): Promise { +async function isNativeAvailable(): Promise { if (!tryAcquirePositronApi()) { - nativeAvailable = false; - return; + return false; } // `false` keeps the underscore-prefixed ids we are looking for const all = await commands.getCommands(false); - nativeAvailable = kNativeFeatureCommands.every((command) => - all.includes(command) - ); + return kNativeFeatureCommands.every((command) => all.includes(command)); +} + +/** + * Determine if this host can serve embedded language features natively, and + * record the answer where the gates can read it. + * + * Must be awaited during activation, before any gate can be consulted. + */ +export async function detectNativeEmbeddedFeatures( + outputChannel?: LogOutputChannel +): Promise { + nativeAvailable = await isNativeAvailable(); if (nativeAvailable) { outputChannel?.info( From 6d8b51183c890f546aee4cd87abe757644c05781 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 19:38:35 -0600 Subject: [PATCH 5/9] Gate semantic tokens per document instead of per cursor language --- apps/vscode/src/providers/semantic-tokens.ts | 30 ++++++--- apps/vscode/src/test/native-features.test.ts | 64 ++++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/apps/vscode/src/providers/semantic-tokens.ts b/apps/vscode/src/providers/semantic-tokens.ts index 9a5dca0f..ad63f0be 100644 --- a/apps/vscode/src/providers/semantic-tokens.ts +++ b/apps/vscode/src/providers/semantic-tokens.ts @@ -15,6 +15,7 @@ import { window, } from "vscode"; import { DocumentSemanticsTokensSignature } from "vscode-languageclient"; +import { Token } from "quarto-core"; import { MarkdownEngine } from "../markdown/engine"; import { isQuartoDoc } from "../core/doc"; import { @@ -25,7 +26,7 @@ import { mainLanguage } from "../vdoc/vdoc"; import { EmbeddedLanguage } from "../vdoc/languages"; -import { useNativeEmbeddedFeatures } from "../host/native-features"; +import { isNativeEmbeddedLanguage, useNativeEmbeddedFeatures } from "../host/native-features"; import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-utils"; /** @@ -173,6 +174,17 @@ export function remapTokenIndices( return encodeSemanticTokens(remapped, tokens.resultId); } +/** + * Whether any of a document's cells are in a language the host serves natively. + * + * Pure, so it can be tested without an extension host: the setting and + * capability half of the decision is `useNativeEmbeddedFeatures()`, which the + * caller checks separately. + */ +export function hasNativeCells(tokens: Token[]): boolean { + return mainLanguage(tokens, isNativeEmbeddedLanguage) !== undefined; +} + export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { return async ( document: TextDocument, @@ -184,6 +196,14 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { return await next(document, token); } + // Parse the document to get all tokens + const tokens = engine.parse(document); + + // Stand down when the host serves any of this document's cells. + if (useNativeEmbeddedFeatures() && hasNativeCells(tokens)) { + return undefined; + } + // Ensure we are dealing with the active document const editor = window.activeTextEditor; const activeDocument = editor?.document; @@ -192,9 +212,6 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { return await next(document, token); } - // Parse the document to get all tokens - const tokens = engine.parse(document); - // Try to find language at cursor position, otherwise use main language const line = editor.selection.active.line; const position = new Position(line, 0); @@ -208,11 +225,6 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { return await next(document, token); } - // Stand down when the host serves this language's cells itself - if (useNativeEmbeddedFeatures(language)) { - return undefined; - } - // Create virtual doc for all blocks of this language const vdoc = virtualDocForLanguage(document, tokens, language); diff --git a/apps/vscode/src/test/native-features.test.ts b/apps/vscode/src/test/native-features.test.ts index 1fa6d9f4..8db027ba 100644 --- a/apps/vscode/src/test/native-features.test.ts +++ b/apps/vscode/src/test/native-features.test.ts @@ -6,7 +6,9 @@ import { isNativeEmbeddedLanguage, useNativeEmbeddedFeatures, } from "../host/native-features"; +import { hasNativeCells } from "../providers/semantic-tokens"; import { embeddedLanguage } from "../vdoc/languages"; +import { MarkdownEngine } from "../markdown/engine"; function language(name: string) { const found = embeddedLanguage(name); @@ -65,3 +67,65 @@ suite("Native Embedded Features", function () { assert.strictEqual(useNativeEmbeddedFeatures(language("python")), false); }); }); + +suite("Native Cells In A Document", function () { + const engine = new MarkdownEngine(); + + /** + * Parses `content` as a Quarto document and asks whether any of its cells are + * in a native language. + * + * The document is in memory and never shown. A fixture on disk would be worse + * here: showing one wakes the providers, which litter the workspace folder + * with `.vdoc.*` temp files that other suites then trip over while copying it. + */ + async function hasNativeCellsIn(content: string): Promise { + const doc = await vscode.workspace.openTextDocument({ + language: "quarto", + content, + }); + return hasNativeCells(engine.parse(doc)); + } + + const kJulia = "```{julia}\nx = 1\n```"; + const kPython = "```{python}\nx = 1\n```"; + const kR = "```{r}\nx <- 1\n```"; + const kTypescript = "```{typescript}\nconst x = 1;\n```"; + + test("sees an R cell", async function () { + assert.strictEqual(await hasNativeCellsIn(kR), true); + }); + + test("sees a Python cell", async function () { + assert.strictEqual(await hasNativeCellsIn(kPython), true); + }); + + test("sees one native cell among cells of another language", async function () { + // One native cell is enough. Semantic tokens are answered for the whole + // document and only one provider's answer survives, so the host takes the + // document as soon as it owns any of it. + assert.strictEqual( + await hasNativeCellsIn(`${kJulia}\n\n${kPython}`), + true + ); + }); + + test("sees no native cells among only julia and typescript", async function () { + assert.strictEqual( + await hasNativeCellsIn(`${kJulia}\n\n${kTypescript}`), + false + ); + }); + + test("sees no native cells in a document with no code", async function () { + assert.strictEqual( + await hasNativeCellsIn("# Heading\n\nJust prose, no cells.\n"), + false + ); + }); + + test("ignores a non-executable block that names a native language", async function () { + // ```r is a display block, not a cell; the host has nothing to serve in it. + assert.strictEqual(await hasNativeCellsIn("```r\nx <- 1\n```"), false); + }); +}); From 98c398870fbf13f9632f300d997a3ec6fefbd71d Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 19:44:17 -0600 Subject: [PATCH 6/9] Skip the cell symbol request when the outline has no chunk symbols --- apps/vscode/src/lsp/cell-symbols.ts | 16 +++++++++ apps/vscode/src/lsp/client.ts | 11 ++++-- .../vscode/src/test/code-cell-symbols.test.ts | 34 ++++++++++++++++++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/lsp/cell-symbols.ts b/apps/vscode/src/lsp/cell-symbols.ts index c81d253b..ac67b6f1 100644 --- a/apps/vscode/src/lsp/cell-symbols.ts +++ b/apps/vscode/src/lsp/cell-symbols.ts @@ -51,6 +51,22 @@ export async function quartoCellSymbols( } } +/** + * 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. * diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index aee87952..ffaf8e88 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -69,7 +69,7 @@ import { lspClientTransport } from "core-node"; import { JsonRpcRequestTransport } from "core"; import { extensionHost } from "../host"; import { kNativeFeaturesSetting, useNativeEmbeddedFeatures } from "../host/native-features"; -import { nestCellSymbols, quartoCellSymbols } from "./cell-symbols"; +import { hasChunkSymbols, nestCellSymbols, quartoCellSymbols } from "./cell-symbols"; import semver from "semver"; import { EmbeddedLanguage } from "../vdoc/languages"; import { SymbolInformation } from "vscode"; @@ -559,9 +559,16 @@ function embeddedDocumentSymbolProvider(engine: MarkdownEngine) { // document, so it is fetched once per request and the chunks are matched // to it by range. if (useNativeEmbeddedFeatures()) { + 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(baseSymbols as DocumentSymbol[], cells); + return nestCellSymbols(symbols, cells); } const enhanced = await enhanceSymbolsWithCodeCellContent( diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts index f197ab50..902f5040 100644 --- a/apps/vscode/src/test/code-cell-symbols.test.ts +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import * as assert from "assert"; import { openAndShowUniqueExamplesDocument, wait } from "./test-utils"; import { DisposableStore } from "core"; -import { nestCellSymbols, QuartoCellSymbols } from "../lsp/cell-symbols"; +import { hasChunkSymbols, nestCellSymbols, QuartoCellSymbols } from "../lsp/cell-symbols"; /** * Creates a fake document symbol provider that returns DocumentSymbol[] for virtual docs. @@ -343,3 +343,35 @@ suite("Native Cell Symbol Nesting", function () { assert.deepStrictEqual(flattenSymbolNames(nested), ["Section", "{r}"]); }); }); + +suite("Chunk Symbol Detection", function () { + test("finds a chunk at the top level", function () { + assert.strictEqual(hasChunkSymbols([chunkSymbol("{r}", 2, 5)]), true); + }); + + test("finds a chunk nested under headings", function () { + const symbols = [ + headingSymbol("Section", 0, 11, [ + headingSymbol("Subsection", 6, 11, [chunkSymbol("{python}", 7, 10)]), + ]), + ]; + + assert.strictEqual(hasChunkSymbols(symbols), true); + }); + + test("finds no chunk in a tree of headings only", function () { + // What the outline looks like while `showCodeCellsInOutline` is off: the + // language server has filtered every chunk out of the tree. + const symbols = [ + headingSymbol("Section", 0, 11, [ + headingSymbol("Subsection", 6, 11, []), + ]), + ]; + + assert.strictEqual(hasChunkSymbols(symbols), false); + }); + + test("finds no chunk in an empty tree", function () { + assert.strictEqual(hasChunkSymbols([]), false); + }); +}); From ef8e71788a32fd68b614e74d9706f400763784a2 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 19:48:47 -0600 Subject: [PATCH 7/9] Update apps/vscode/src/lsp/client.ts Co-authored-by: Elliot --- apps/vscode/src/lsp/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index ffaf8e88..3bc9ead8 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -358,7 +358,7 @@ function embeddedCodeCompletionProvider(engine: MarkdownEngine) { const vdoc = await virtualDoc(document, position, engine); if (vdoc && !isWithinYamlComment(document, position)) { - // stand down when the host serves this language's cells itself + // when the host is Positron, it may serve the language's cells itself and the extension should stand down (not try to provide them) if (useNativeEmbeddedFeatures(vdoc.language)) { return undefined; } From dd52fdc4bf42f02247c49d41618d916fd54eb879 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 20:05:38 -0600 Subject: [PATCH 8/9] Update vocabulary to "host ownership" --- .../{native-features.ts => cell-features.ts} | 56 +++++++-------- apps/vscode/src/lsp/cell-symbols.ts | 2 +- apps/vscode/src/lsp/client.ts | 26 +++---- apps/vscode/src/main.ts | 6 +- apps/vscode/src/providers/diagnostics.ts | 6 +- apps/vscode/src/providers/format.ts | 6 +- apps/vscode/src/providers/semantic-tokens.ts | 14 ++-- ...features.test.ts => cell-features.test.ts} | 68 +++++++++---------- .../vscode/src/test/code-cell-symbols.test.ts | 2 +- 9 files changed, 93 insertions(+), 93 deletions(-) rename apps/vscode/src/host/{native-features.ts => cell-features.ts} (68%) rename apps/vscode/src/test/{native-features.test.ts => cell-features.test.ts} (58%) diff --git a/apps/vscode/src/host/native-features.ts b/apps/vscode/src/host/cell-features.ts similarity index 68% rename from apps/vscode/src/host/native-features.ts rename to apps/vscode/src/host/cell-features.ts index 81bfeba0..6f64eb90 100644 --- a/apps/vscode/src/host/native-features.ts +++ b/apps/vscode/src/host/cell-features.ts @@ -1,5 +1,5 @@ /* - * native-features.ts + * cell-features.ts * * Copyright (C) 2026 by Posit Software, PBC */ @@ -14,11 +14,11 @@ import { EmbeddedLanguage } from "../vdoc/languages"; * Positron core, not by this extension, so it is read through the full * configuration rather than the `quarto` section we contribute. */ -export const kNativeFeaturesSetting = "quarto.embeddedLanguageFeatures.native"; +export const kHostCellFeaturesSetting = "quarto.embeddedLanguageFeatures.native"; /** * Commands whose presence says this host carries the virtual notebook (see - * {@link detectNativeEmbeddedFeatures}). + * {@link detectCellFeatureOwnership}). * * These are the INTERNAL ids, and the code calls the public * `positron.executeQuartoCell*` ones. The internal ids are what a probe can @@ -29,23 +29,23 @@ export const kNativeFeaturesSetting = "quarto.embeddedLanguageFeatures.native"; * The internal commands are registered in the workbench, so they are visible, * as long as the probe does not filter underscore-prefixed ids. */ -const kNativeFeatureCommands = [ +const kCellOwnershipCommands = [ "_executeQuartoCellSymbolProvider", "_executeQuartoCellFormattingProvider", "_executeQuartoCellRangeFormattingProvider", ]; /** - * Languages Positron is verified to serve natively. Matched against + * 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 kNativeLanguages = new Set(["r", "python"]); +const kHostOwnedLanguages = new Set(["r", "python"]); -let nativeAvailable = false; +let cellCommandsAvailable = false; /** * Whether this host carries the virtual notebook. @@ -53,42 +53,42 @@ let nativeAvailable = false; * 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 serve natively" exactly. Vanilla VS Code and older + * 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 isNativeAvailable(): Promise { +async function hostHasCellCommands(): Promise { if (!tryAcquirePositronApi()) { return false; } // `false` keeps the underscore-prefixed ids we are looking for const all = await commands.getCommands(false); - return kNativeFeatureCommands.every((command) => all.includes(command)); + return kCellOwnershipCommands.every((command) => all.includes(command)); } /** - * Determine if this host can serve embedded language features natively, and + * 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 detectNativeEmbeddedFeatures( +export async function detectCellFeatureOwnership( outputChannel?: LogOutputChannel ): Promise { - nativeAvailable = await isNativeAvailable(); + cellCommandsAvailable = await hostHasCellCommands(); - if (nativeAvailable) { + if (cellCommandsAvailable) { outputChannel?.info( - "[NativeFeatures] Host serves Quarto cell language features. " + - `The extension stands down for ${[...kNativeLanguages].join(", ")} ` + - `while ${kNativeFeaturesSetting} is on.` + "[CellFeatures] Host owns Quarto cell language features. " + + `The extension stands down for ${[...kHostOwnedLanguages].join(", ")} ` + + `while ${kHostCellFeaturesSetting} is on.` ); } else if ( - workspace.getConfiguration().get(kNativeFeaturesSetting) === true + workspace.getConfiguration().get(kHostCellFeaturesSetting) === true ) { outputChannel?.warn( - `[NativeFeatures] ${kNativeFeaturesSetting} is on, but this host has no ` + + `[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." ); @@ -96,15 +96,15 @@ export async function detectNativeEmbeddedFeatures( } /** - * Whether a language is one we let the host serve natively. Pure, so the - * language set can be tested without an extension host. + * Whether the host owns the cells of a language. Pure, so the language set + * can be tested without an extension host. */ -export function isNativeEmbeddedLanguage(language: EmbeddedLanguage): boolean { - return language.ids.some((id) => kNativeLanguages.has(id)); +export function hostOwnsLanguage(language: EmbeddedLanguage): boolean { + return language.ids.some((id) => kHostOwnedLanguages.has(id)); } /** - * Whether the host serves embedded language features for `language`, meaning + * 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 @@ -122,12 +122,12 @@ export function isNativeEmbeddedLanguage(language: EmbeddedLanguage): boolean { * 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 useNativeEmbeddedFeatures(language?: EmbeddedLanguage): boolean { - if (!nativeAvailable) { +export function hostOwnsCellFeatures(language?: EmbeddedLanguage): boolean { + if (!cellCommandsAvailable) { return false; } - if (workspace.getConfiguration().get(kNativeFeaturesSetting) !== true) { + if (workspace.getConfiguration().get(kHostCellFeaturesSetting) !== true) { return false; } - return language === undefined || isNativeEmbeddedLanguage(language); + return language === undefined || hostOwnsLanguage(language); } diff --git a/apps/vscode/src/lsp/cell-symbols.ts b/apps/vscode/src/lsp/cell-symbols.ts index ac67b6f1..06e8b39b 100644 --- a/apps/vscode/src/lsp/cell-symbols.ts +++ b/apps/vscode/src/lsp/cell-symbols.ts @@ -33,7 +33,7 @@ export interface QuartoCellSymbols { * 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 - * `useNativeEmbeddedFeatures()` rather than treat an empty answer as a reason to + * `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. */ diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 3bc9ead8..23358ba3 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -68,7 +68,7 @@ import { LspInitializationOptions, QuartoContext } from "quarto-core"; import { lspClientTransport } from "core-node"; import { JsonRpcRequestTransport } from "core"; import { extensionHost } from "../host"; -import { kNativeFeaturesSetting, useNativeEmbeddedFeatures } from "../host/native-features"; +import { kHostCellFeaturesSetting, hostOwnsCellFeatures } from "../host/cell-features"; import { hasChunkSymbols, nestCellSymbols, quartoCellSymbols } from "./cell-symbols"; import semver from "semver"; import { EmbeddedLanguage } from "../vdoc/languages"; @@ -160,8 +160,8 @@ export function activateLsp( middleware.provideSignatureHelp = embeddedSignatureHelpProvider(engine); } // Statement range and help topic are single-answer features: whichever - // provider registered last owns Cmd+Enter and F1. When the host serves cells - // natively we must not compete with it, so these registrations follow the + // 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. @@ -172,16 +172,16 @@ export function activateLsp( extensionHost().registerHelpTopicProvider(engine), ]; }; - if (!useNativeEmbeddedFeatures()) { + if (!hostOwnsCellFeatures()) { registerHostProviders(); } context.subscriptions.push( new Disposable(() => hostProviders.forEach((d) => d.dispose())), workspace.onDidChangeConfiguration((e) => { - if (!e.affectsConfiguration(kNativeFeaturesSetting)) { + if (!e.affectsConfiguration(kHostCellFeaturesSetting)) { return; } - if (useNativeEmbeddedFeatures()) { + if (hostOwnsCellFeatures()) { hostProviders.forEach((d) => d.dispose()); hostProviders = []; } else if (hostProviders.length === 0) { @@ -358,8 +358,8 @@ function embeddedCodeCompletionProvider(engine: MarkdownEngine) { const vdoc = await virtualDoc(document, position, engine); if (vdoc && !isWithinYamlComment(document, position)) { - // when the host is Positron, it may serve the language's cells itself and the extension should stand down (not try to provide them) - if (useNativeEmbeddedFeatures(vdoc.language)) { + // 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; } @@ -407,7 +407,7 @@ function embeddedHoverProvider(engine: MarkdownEngine) { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { - if (useNativeEmbeddedFeatures(vdoc.language)) { + if (hostOwnsCellFeatures(vdoc.language)) { return undefined; } @@ -435,7 +435,7 @@ function embeddedSignatureHelpProvider(engine: MarkdownEngine) { ) => { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { - if (useNativeEmbeddedFeatures(vdoc.language)) { + if (hostOwnsCellFeatures(vdoc.language)) { return undefined; } @@ -461,7 +461,7 @@ function embeddedGoToDefinitionProvider(engine: MarkdownEngine) { ): Promise => { const vdoc = await virtualDoc(document, position, engine); if (vdoc) { - if (useNativeEmbeddedFeatures(vdoc.language)) { + if (hostOwnsCellFeatures(vdoc.language)) { return undefined; } @@ -555,10 +555,10 @@ 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 serves the cells, one command answers for the whole + // 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 (useNativeEmbeddedFeatures()) { + 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 diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 33062da1..e985e5c6 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -26,7 +26,7 @@ import { activateEditor } from "./providers/editor/editor"; import { activateCopyFiles } from "./providers/copyfiles"; import { activateZotero } from "./providers/zotero/zotero"; import { extensionHost } from "./host"; -import { detectNativeEmbeddedFeatures } from "./host/native-features"; +import { detectCellFeatureOwnership } from "./host/cell-features"; import { setVdocTempFileLogger } from "./vdoc/vdoc-tempfile"; import { isInlineOutputEnabled, kInlineOutputEnabledSetting, kInlineOutputEnabledSettingDeprecated } from "./host/positron"; import { initQuartoContext, getSourceDescription } from "quarto-core"; @@ -64,8 +64,8 @@ export async function activate(context: vscode.ExtensionContext): Promise 0) { window.showInformationMessage( @@ -198,7 +198,7 @@ export function embeddedDocumentRangeFormattingProvider( return next(document, range, options, token); } - if (useNativeEmbeddedFeatures()) { + if (hostOwnsCellFeatures()) { const result = await executeCellRangeFormattingProvider(document.uri, range); if (result.vetoedCells > 0) { window.showInformationMessage( diff --git a/apps/vscode/src/providers/semantic-tokens.ts b/apps/vscode/src/providers/semantic-tokens.ts index ad63f0be..20174a30 100644 --- a/apps/vscode/src/providers/semantic-tokens.ts +++ b/apps/vscode/src/providers/semantic-tokens.ts @@ -26,7 +26,7 @@ import { mainLanguage } from "../vdoc/vdoc"; import { EmbeddedLanguage } from "../vdoc/languages"; -import { isNativeEmbeddedLanguage, useNativeEmbeddedFeatures } from "../host/native-features"; +import { hostOwnsLanguage, hostOwnsCellFeatures } from "../host/cell-features"; import { QUARTO_SEMANTIC_TOKEN_LEGEND } from "quarto-utils"; /** @@ -175,14 +175,14 @@ export function remapTokenIndices( } /** - * Whether any of a document's cells are in a language the host serves natively. + * Whether the host owns any of a document's cells. * * Pure, so it can be tested without an extension host: the setting and - * capability half of the decision is `useNativeEmbeddedFeatures()`, which the + * capability half of the decision is `hostOwnsCellFeatures()`, which the * caller checks separately. */ -export function hasNativeCells(tokens: Token[]): boolean { - return mainLanguage(tokens, isNativeEmbeddedLanguage) !== undefined; +export function hostOwnsAnyCell(tokens: Token[]): boolean { + return mainLanguage(tokens, hostOwnsLanguage) !== undefined; } export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { @@ -199,8 +199,8 @@ export function embeddedSemanticTokensProvider(engine: MarkdownEngine) { // Parse the document to get all tokens const tokens = engine.parse(document); - // Stand down when the host serves any of this document's cells. - if (useNativeEmbeddedFeatures() && hasNativeCells(tokens)) { + // Stand down when the host owns any of this document's cells. + if (hostOwnsCellFeatures() && hostOwnsAnyCell(tokens)) { return undefined; } diff --git a/apps/vscode/src/test/native-features.test.ts b/apps/vscode/src/test/cell-features.test.ts similarity index 58% rename from apps/vscode/src/test/native-features.test.ts rename to apps/vscode/src/test/cell-features.test.ts index 8db027ba..86d01a22 100644 --- a/apps/vscode/src/test/native-features.test.ts +++ b/apps/vscode/src/test/cell-features.test.ts @@ -2,11 +2,11 @@ import * as vscode from "vscode"; import * as assert from "assert"; import { - detectNativeEmbeddedFeatures, - isNativeEmbeddedLanguage, - useNativeEmbeddedFeatures, -} from "../host/native-features"; -import { hasNativeCells } from "../providers/semantic-tokens"; + detectCellFeatureOwnership, + hostOwnsLanguage, + hostOwnsCellFeatures, +} from "../host/cell-features"; +import { hostOwnsAnyCell } from "../providers/semantic-tokens"; import { embeddedLanguage } from "../vdoc/languages"; import { MarkdownEngine } from "../markdown/engine"; @@ -16,19 +16,19 @@ function language(name: string) { return found; } -suite("Native Embedded Features", function () { - test("recognizes the languages Positron serves natively", function () { - assert.strictEqual(isNativeEmbeddedLanguage(language("r")), true); - assert.strictEqual(isNativeEmbeddedLanguage(language("python")), true); +suite("Cell Feature Ownership", function () { + test("recognizes the languages the host owns", function () { + assert.strictEqual(hostOwnsLanguage(language("r")), true); + assert.strictEqual(hostOwnsLanguage(language("python")), true); }); test("does not recognize languages that keep their virtual document", function () { - assert.strictEqual(isNativeEmbeddedLanguage(language("julia")), false); - assert.strictEqual(isNativeEmbeddedLanguage(language("typescript")), false); - assert.strictEqual(isNativeEmbeddedLanguage(language("sql")), false); + assert.strictEqual(hostOwnsLanguage(language("julia")), false); + assert.strictEqual(hostOwnsLanguage(language("typescript")), false); + assert.strictEqual(hostOwnsLanguage(language("sql")), false); }); - test("matches every alias of a native language", function () { + test("matches every alias of a host owned language", function () { // `embeddedLanguage` strips a leading engine prefix, so a `{r}` chunk and an // `{ojs-r}` one resolve to the same language object; the gate matches on the // language's own `ids` rather than on the chunk's text. @@ -57,34 +57,34 @@ suite("Native Embedded Features", function () { ); }); - test("stays off when the host has no native cell commands", async function () { + test("stays off when the host has no cell commands", async function () { // Vanilla VS Code, which is what these tests run in: the Positron commands // are absent, so the gate is off no matter what the setting says. - await detectNativeEmbeddedFeatures(); + await detectCellFeatureOwnership(); - assert.strictEqual(useNativeEmbeddedFeatures(), false); - assert.strictEqual(useNativeEmbeddedFeatures(language("r")), false); - assert.strictEqual(useNativeEmbeddedFeatures(language("python")), false); + assert.strictEqual(hostOwnsCellFeatures(), false); + assert.strictEqual(hostOwnsCellFeatures(language("r")), false); + assert.strictEqual(hostOwnsCellFeatures(language("python")), false); }); }); -suite("Native Cells In A Document", function () { +suite("Host Owned Cells In A Document", function () { const engine = new MarkdownEngine(); /** * Parses `content` as a Quarto document and asks whether any of its cells are - * in a native language. + * in a language the host owns. * * The document is in memory and never shown. A fixture on disk would be worse * here: showing one wakes the providers, which litter the workspace folder * with `.vdoc.*` temp files that other suites then trip over while copying it. */ - async function hasNativeCellsIn(content: string): Promise { + async function hostOwnsAnyCellIn(content: string): Promise { const doc = await vscode.workspace.openTextDocument({ language: "quarto", content, }); - return hasNativeCells(engine.parse(doc)); + return hostOwnsAnyCell(engine.parse(doc)); } const kJulia = "```{julia}\nx = 1\n```"; @@ -93,39 +93,39 @@ suite("Native Cells In A Document", function () { const kTypescript = "```{typescript}\nconst x = 1;\n```"; test("sees an R cell", async function () { - assert.strictEqual(await hasNativeCellsIn(kR), true); + assert.strictEqual(await hostOwnsAnyCellIn(kR), true); }); test("sees a Python cell", async function () { - assert.strictEqual(await hasNativeCellsIn(kPython), true); + assert.strictEqual(await hostOwnsAnyCellIn(kPython), true); }); - test("sees one native cell among cells of another language", async function () { - // One native cell is enough. Semantic tokens are answered for the whole + test("sees one host owned cell among cells of another language", async function () { + // One host owned cell is enough. Semantic tokens are answered for the whole // document and only one provider's answer survives, so the host takes the // document as soon as it owns any of it. assert.strictEqual( - await hasNativeCellsIn(`${kJulia}\n\n${kPython}`), + await hostOwnsAnyCellIn(`${kJulia}\n\n${kPython}`), true ); }); - test("sees no native cells among only julia and typescript", async function () { + test("sees no host owned cells among only julia and typescript", async function () { assert.strictEqual( - await hasNativeCellsIn(`${kJulia}\n\n${kTypescript}`), + await hostOwnsAnyCellIn(`${kJulia}\n\n${kTypescript}`), false ); }); - test("sees no native cells in a document with no code", async function () { + test("sees no host owned cells in a document with no code", async function () { assert.strictEqual( - await hasNativeCellsIn("# Heading\n\nJust prose, no cells.\n"), + await hostOwnsAnyCellIn("# Heading\n\nJust prose, no cells.\n"), false ); }); - test("ignores a non-executable block that names a native language", async function () { - // ```r is a display block, not a cell; the host has nothing to serve in it. - assert.strictEqual(await hasNativeCellsIn("```r\nx <- 1\n```"), false); + test("ignores a non-executable block that names a host owned language", async function () { + // ```r is a display block, not a cell; the host has nothing to own in it. + assert.strictEqual(await hostOwnsAnyCellIn("```r\nx <- 1\n```"), false); }); }); diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts index 902f5040..67ce3cd6 100644 --- a/apps/vscode/src/test/code-cell-symbols.test.ts +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -249,7 +249,7 @@ function cellAnswer( }; } -suite("Native Cell Symbol Nesting", function () { +suite("Host Cell Symbol Nesting", function () { test("nests a cell's symbols under the chunk that contains it", function () { // Chunk fences on lines 2 and 5, so the cell's code span is lines 3 to 4. const symbols = [chunkSymbol("{r}", 2, 5)]; From bbb46cf0b08e36dcb2030648d494a743fb0838e8 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Wed, 9 Sep 2026 20:12:44 -0600 Subject: [PATCH 9/9] Add some documentation for cell features module --- apps/vscode/src/host/cell-features.ts | 33 +++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/apps/vscode/src/host/cell-features.ts b/apps/vscode/src/host/cell-features.ts index 6f64eb90..fc5c3c04 100644 --- a/apps/vscode/src/host/cell-features.ts +++ b/apps/vscode/src/host/cell-features.ts @@ -4,6 +4,39 @@ * Copyright (C) 2026 by Posit Software, PBC */ +/** + * 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";