From be7d7476a2cd31210afb61104562036e8455adc3 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Fri, 4 Sep 2026 08:05:04 -0600 Subject: [PATCH 1/3] 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/3] 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/3] 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).