From f6fafb2fe13a592851f28ce06a017ac4b17fea3b Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 6 May 2026 16:03:54 -0400 Subject: [PATCH 1/6] Add code symbols into outline --- apps/vscode/src/lsp/client.ts | 124 +++++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 21e41cee..81ae6e13 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -26,7 +26,10 @@ import { Uri, Diagnostic, window, - ColorThemeKind + ColorThemeKind, + DocumentSymbol, + Range, + SymbolKind, } from "vscode"; import { LanguageClient, @@ -48,6 +51,7 @@ import { ProvideDefinitionSignature, ProvideHoverSignature, ProvideSignatureHelpSignature, + ProvideDocumentSymbolsSignature, State, HandleDiagnosticsSignature } from "vscode-languageclient"; @@ -57,6 +61,7 @@ import { unadjustedRange, virtualDoc, withVirtualDocUri, + VirtualDocStyle, } from "../vdoc/vdoc"; import { isVirtualDoc } from "../vdoc/vdoc-tempfile"; import { activateVirtualDocEmbeddedContent } from "../vdoc/vdoc-content"; @@ -72,6 +77,8 @@ import { imageHover } from "../providers/hover-image"; import { LspInitializationOptions, QuartoContext } from "quarto-core"; import { extensionHost } from "../host"; import semver from "semver"; +import { EmbeddedLanguage } from "../vdoc/languages"; +import { SymbolInformation } from "vscode"; let client: LanguageClient; @@ -113,6 +120,7 @@ export async function activateLsp( engine ), provideDocumentSemanticTokens: embeddedSemanticTokensProvider(engine), + provideDocumentSymbols: embeddedDocumentSymbolProvider(engine), }; if (config.get("cells.hoverHelp.enabled", true)) { middleware.provideHover = embeddedHoverProvider(engine); @@ -364,6 +372,120 @@ function isWithinYamlComment(doc: TextDocument, pos: Position) { return !!line.match(/^\s*#\s*\| /); } +const isDocumentSymbol = (a: Object): a is DocumentSymbol => { + return ('range' in a && 'selectionRange' in a); +}; + +/** + * Enhances document symbols by adding code symbols from embedded languages to code cells + */ +function embeddedDocumentSymbolProvider(engine: MarkdownEngine) { + return async ( + document: TextDocument, + token: CancellationToken, + next: ProvideDocumentSymbolsSignature + ): Promise => { + // Get base symbols from LSP (headers, code cells, etc.) + const baseSymbols = await next(document, token); + + if (!baseSymbols || token.isCancellationRequested) { + return baseSymbols ?? undefined; + } + + // Check if we got DocumentSymbol[] (can be enhanced) or SymbolInformation[] (cannot) + // 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])) { + return await enhanceSymbolsWithCodeCellContent(document, baseSymbols as DocumentSymbol[], engine, token); + } + + return baseSymbols; + }; +} + +/** + * Finds code cell symbols, makes vdocs for them, gets symbols from the vdoc, and nests those symbols + * under the code cell's symbol. + */ +async function enhanceSymbolsWithCodeCellContent( + document: TextDocument, + symbols: DocumentSymbol[], + engine: MarkdownEngine, + token: CancellationToken +): Promise { + const enhanced: DocumentSymbol[] = []; + + for (const symbol of symbols) { + if (token.isCancellationRequested) return symbols; + + // Check if this is a code cell symbol (SymbolKind.Function indicates code cells from toc.ts) + if (symbol.kind === SymbolKind.Function) { + symbol.children = [ + ...symbol.children, + ...(await getCodeCellSymbols(document, symbol.range, engine) || []) + ]; + } else { + symbol.children = + await enhanceSymbolsWithCodeCellContent(document, symbol.children, engine, token); + } + + enhanced.push(symbol); + } + + return enhanced; +} + +/** + * Gets symbols from an embedded language for a code cell + */ +async function getCodeCellSymbols( + document: TextDocument, + cellRange: Range, + engine: MarkdownEngine +): Promise { + try { + // Get position at the start of the code cell (skip the fence line) + const position = new Position(cellRange.start.line + 1, 0); + + // Create virtual document for ONLY this code block (not all blocks of the language) + const vdoc = await virtualDoc(document, position, engine, VirtualDocStyle.Block); + if (!vdoc) return undefined; + + // Get symbols from the embedded language server + return await withVirtualDocUri(vdoc, document.uri, "completion", async (uri: Uri) => { + try { + const result = await commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + uri + ); + if (result.length === 0) return undefined; + + if (isDocumentSymbol(result[0])) { + return unadjustSymbolRanges(result as DocumentSymbol[], vdoc.language, cellRange.start.line); + } + } catch (error) { } + }); + } catch (error) { } +} + +/** + * Adjusts symbol ranges from virtual document to real document coordinates + */ +function unadjustSymbolRanges( + symbols: DocumentSymbol[], + language: EmbeddedLanguage, + baseLineOffset: number +): DocumentSymbol[] { + return symbols.map(symbol => { + return { + ...symbol, + range: unadjustedRange(language, symbol.range), + selectionRange: unadjustedRange(language, symbol.selectionRange), + children: symbol.children ? unadjustSymbolRanges(symbol.children, language, baseLineOffset) : [] + }; + }); +} + /** * Creates a diagnostic handler middleware that filters out diagnostics from virtual documents * From d18863a7ecbc98814c18e25b83031446d030a486 Mon Sep 17 00:00:00 2001 From: elliot Date: Tue, 19 May 2026 16:20:32 -0400 Subject: [PATCH 2/6] Add changelog entry --- apps/vscode/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 984feb9b..70ff6247 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -2,6 +2,8 @@ ## 1.133.0 +- Add code symbols into outline (). + ## 1.132.0 (Release on 2026-05-05) - Added clickable document links for file paths in `_quarto.yml` files. File paths are now clickable and navigate directly to the referenced file (). From ed4207bdb68f56947f7f0d3814493fa538712734 Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 22 May 2026 13:59:55 -0400 Subject: [PATCH 3/6] Add handling for SymbolInformation in getCodeCellSymbols --- apps/vscode/src/lsp/client.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 81ae6e13..8f26d46c 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -435,6 +435,22 @@ async function enhanceSymbolsWithCodeCellContent( return enhanced; } +/** + * Converts SymbolInformation[] to DocumentSymbol[] format + * SymbolInformation is a flat list, so we convert each to a DocumentSymbol with no children + */ +function symbolInformationToDocumentSymbol( + symbol: SymbolInformation, +): DocumentSymbol { + return new DocumentSymbol( + symbol.name, + symbol.containerName || '', + symbol.kind, + symbol.location.range, + symbol.location.range + ); +} + /** * Gets symbols from an embedded language for a code cell */ @@ -460,9 +476,11 @@ async function getCodeCellSymbols( ); if (result.length === 0) return undefined; - if (isDocumentSymbol(result[0])) { - return unadjustSymbolRanges(result as DocumentSymbol[], vdoc.language, cellRange.start.line); - } + const documentSymbols = isDocumentSymbol(result[0]) ? + result as DocumentSymbol[] : + (result as SymbolInformation[]).map(symbolInformationToDocumentSymbol); + + return unadjustSymbolRanges(documentSymbols, vdoc.language, cellRange.start.line); } catch (error) { } }); } catch (error) { } From a09c41b03a5d9671615a315c776bab4884e6a112 Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 22 May 2026 16:38:09 -0400 Subject: [PATCH 4/6] Add code cell symbols rety logic --- apps/vscode/src/lsp/client.ts | 55 ++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 8f26d46c..57cda006 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -396,7 +396,31 @@ 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])) { - return await enhanceSymbolsWithCodeCellContent(document, baseSymbols as DocumentSymbol[], engine, token); + const enhanced = await enhanceSymbolsWithCodeCellContent( + document, + baseSymbols as DocumentSymbol[], + engine, + token + ); + + if (token.isCancellationRequested) return baseSymbols; + + // If any embedded LSP returned undefined, retry once after a brief delay + if (enhanced !== 'HadUndefined') { + return enhanced; + } else { + await new Promise(r => setTimeout(r, 500)); + if (token.isCancellationRequested) return baseSymbols; + const retried = await enhanceSymbolsWithCodeCellContent( + document, + baseSymbols as DocumentSymbol[], + engine, + token + ); + if (token.isCancellationRequested) return baseSymbols; + return retried === 'HadUndefined' ? baseSymbols : retried; + + } } return baseSymbols; @@ -412,27 +436,42 @@ async function enhanceSymbolsWithCodeCellContent( symbols: DocumentSymbol[], engine: MarkdownEngine, token: CancellationToken -): Promise { +): Promise { const enhanced: DocumentSymbol[] = []; + let hadUndefined = false; for (const symbol of symbols) { if (token.isCancellationRequested) return symbols; // Check if this is a code cell symbol (SymbolKind.Function indicates code cells from toc.ts) if (symbol.kind === SymbolKind.Function) { + const cellSymbols = await getCodeCellSymbols(document, symbol.range, engine); + if (cellSymbols === undefined) { + hadUndefined = true; + } symbol.children = [ ...symbol.children, - ...(await getCodeCellSymbols(document, symbol.range, engine) || []) + ...(cellSymbols || []) ]; } else { - symbol.children = - await enhanceSymbolsWithCodeCellContent(document, symbol.children, engine, token); + const childResult = await enhanceSymbolsWithCodeCellContent( + document, + symbol.children, + engine, + token + ); + if (childResult === 'HadUndefined') { + hadUndefined = true; + symbol.children = symbol.children; // Keep existing children + } else { + symbol.children = childResult; + } } enhanced.push(symbol); } - return enhanced; + return hadUndefined ? 'HadUndefined' : enhanced; } /** @@ -470,11 +509,11 @@ async function getCodeCellSymbols( // Get symbols from the embedded language server return await withVirtualDocUri(vdoc, document.uri, "completion", async (uri: Uri) => { try { - const result = await commands.executeCommand( + const result = await commands.executeCommand( "vscode.executeDocumentSymbolProvider", uri ); - if (result.length === 0) return undefined; + if (result === undefined || result.length === 0) return undefined; const documentSymbols = isDocumentSymbol(result[0]) ? result as DocumentSymbol[] : From b02a825904c17e9551287bd07517e746d3ab1c58 Mon Sep 17 00:00:00 2001 From: elliot Date: Fri, 22 May 2026 16:40:16 -0400 Subject: [PATCH 5/6] Add tests --- .../vscode/src/test/code-cell-symbols.test.ts | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 apps/vscode/src/test/code-cell-symbols.test.ts diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts new file mode 100644 index 00000000..8f070044 --- /dev/null +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -0,0 +1,209 @@ +import * as vscode from "vscode"; +import * as assert from "assert"; +import { openAndShowExamplesTextDocument, wait } from "./test-utils"; + +/** + * Creates a fake document symbol provider that returns DocumentSymbol[] for virtual docs. + */ +function createFakeDocumentSymbolProvider( + symbols: vscode.DocumentSymbol[] +): vscode.DocumentSymbolProvider { + return { + provideDocumentSymbols( + document: vscode.TextDocument + ): vscode.ProviderResult { + return symbols; + }, + }; +} + +/** + * Creates a fake document symbol provider that returns SymbolInformation[] for virtual docs. + */ +function createFakeSymbolInformationProvider( + symbolNames: string[] +): vscode.DocumentSymbolProvider { + return { + provideDocumentSymbols( + document: vscode.TextDocument + ): vscode.ProviderResult { + return symbolNames.map((name, index) => + new vscode.SymbolInformation( + name, + vscode.SymbolKind.Function, + "", + new vscode.Location( + document.uri, + new vscode.Range(index, 0, index, 10) + ) + ) + ); + }, + }; +} + +/** + * Creates a fake document symbol provider that returns undefined. + */ +function createUndefinedSymbolProvider(): vscode.DocumentSymbolProvider { + return { + provideDocumentSymbols(): vscode.ProviderResult { + return undefined; + }, + }; +} + +/** + * Recursively flattens symbol names from a DocumentSymbol tree. + */ +function flattenSymbolNames(symbols: vscode.DocumentSymbol[]): string[] { + const result: string[] = []; + const walk = (syms: vscode.DocumentSymbol[]) => { + for (const sym of syms) { + result.push(sym.name); + if (sym.children?.length) walk(sym.children); + } + }; + walk(symbols); + return result; +} + +suite("Code Cell Symbols", function () { + setup(async function () { + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", true); + await wait(500); + }); + + teardown(async function () { + await vscode.workspace + .getConfiguration("quarto") + .update("symbols.showCodeCellsInOutline", undefined); + }); + + test("handles DocumentSymbol[] from embedded provider", async function () { + const fakeSymbols = [ + new vscode.DocumentSymbol( + "my_function", + "", + vscode.SymbolKind.Function, + new vscode.Range(0, 0, 5, 0), + new vscode.Range(0, 0, 5, 0) + ), + new vscode.DocumentSymbol( + "my_variable", + "", + vscode.SymbolKind.Variable, + new vscode.Range(6, 0, 6, 10), + new vscode.Range(6, 0, 6, 10) + ), + ]; + + // Register BEFORE opening the document + // Use both scheme and language like the formatting tests + const provider = vscode.languages.registerDocumentSymbolProvider( + { scheme: "file", pattern: "**/.vdoc.*" }, + createFakeDocumentSymbolProvider(fakeSymbols) + ); + await wait(100); + + try { + const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); + await wait(800); + + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); + + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("my_function"), + `Expected 'my_function' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("my_variable"), + `Expected 'my_variable' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' in symbols, got: ${names.join(", ")}` + ); + } finally { + provider.dispose(); + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + await wait(500); // Long wait to ensure provider is fully disposed before next test + } + }); + + // TODO: this test passes in isolation, but not when run after the previous test + // it seems like provider.dispose does not properly remove the previous provider + // because it causes `my_function`, `my_variable` to show up in this test. + // TODO: this test, in isolation, shows duplicated code cell symbols!? + test.skip("handles SymbolInformation[] from embedded provider", async function () { + const symbolNames = ["info_function", "info_class"]; + + // Register BEFORE opening the document + const provider = vscode.languages.registerDocumentSymbolProvider( + { scheme: "file", pattern: "**/.vdoc.*" }, + createFakeSymbolInformationProvider(symbolNames) + ); + await wait(500); // Wait longer for provider to fully register + + try { + const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); + await wait(1200); // Wait longer to ensure LSPs are ready and retry logic completes + + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); + + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("info_function"), + `Expected 'info_function' in symbols, got: ${names.join(", ")}` + ); + assert.ok( + names.includes("info_class"), + `Expected 'info_class' in symbols, got: ${names.join(", ")}` + ); + } finally { + provider.dispose(); + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); + + test("handles undefined from embedded provider without error", async function () { + // Register BEFORE opening the document + const provider = vscode.languages.registerDocumentSymbolProvider( + { scheme: "file", pattern: "**/.vdoc.*" }, + createUndefinedSymbolProvider() + ); + await wait(100); + + try { + const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); + await wait(800); + + const symbols = await vscode.commands.executeCommand( + "vscode.executeDocumentSymbolProvider", + doc.uri + ); + + const names = flattenSymbolNames(symbols); + assert.ok( + names.includes("(code cell)"), + `Expected '(code cell)' to still appear even when embedded provider returns undefined, got: ${names.join(", ")}` + ); + } finally { + provider.dispose(); + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); +}); From a0506c9488f1304597afa0c6508a0425730f0aa6 Mon Sep 17 00:00:00 2001 From: Julia Silge Date: Sat, 30 May 2026 11:48:20 -0600 Subject: [PATCH 6/6] Fix flaky code cell symbol tests by using a unique file per test --- .../vscode/src/test/code-cell-symbols.test.ts | 22 ++++++------- apps/vscode/src/test/test-utils.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/apps/vscode/src/test/code-cell-symbols.test.ts b/apps/vscode/src/test/code-cell-symbols.test.ts index 8f070044..0607a357 100644 --- a/apps/vscode/src/test/code-cell-symbols.test.ts +++ b/apps/vscode/src/test/code-cell-symbols.test.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; import * as assert from "assert"; -import { openAndShowExamplesTextDocument, wait } from "./test-utils"; +import { openUniqueExampleDocument, wait } from "./test-utils"; /** * Creates a fake document symbol provider that returns DocumentSymbol[] for virtual docs. @@ -108,8 +108,8 @@ suite("Code Cell Symbols", function () { ); await wait(100); + const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); try { - const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); await wait(800); const symbols = await vscode.commands.executeCommand( @@ -133,15 +133,11 @@ suite("Code Cell Symbols", function () { } finally { provider.dispose(); await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); - await wait(500); // Long wait to ensure provider is fully disposed before next test + cleanup(); } }); - // TODO: this test passes in isolation, but not when run after the previous test - // it seems like provider.dispose does not properly remove the previous provider - // because it causes `my_function`, `my_variable` to show up in this test. - // TODO: this test, in isolation, shows duplicated code cell symbols!? - test.skip("handles SymbolInformation[] from embedded provider", async function () { + test("handles SymbolInformation[] from embedded provider", async function () { const symbolNames = ["info_function", "info_class"]; // Register BEFORE opening the document @@ -149,11 +145,11 @@ suite("Code Cell Symbols", function () { { scheme: "file", pattern: "**/.vdoc.*" }, createFakeSymbolInformationProvider(symbolNames) ); - await wait(500); // Wait longer for provider to fully register + await wait(100); + const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); try { - const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); - await wait(1200); // Wait longer to ensure LSPs are ready and retry logic completes + await wait(800); const symbols = await vscode.commands.executeCommand( "vscode.executeDocumentSymbolProvider", @@ -176,6 +172,7 @@ suite("Code Cell Symbols", function () { } finally { provider.dispose(); await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + cleanup(); } }); @@ -187,8 +184,8 @@ suite("Code Cell Symbols", function () { ); await wait(100); + const { doc, cleanup } = await openUniqueExampleDocument("format/basics.qmd"); try { - const { doc } = await openAndShowExamplesTextDocument("format/basics.qmd"); await wait(800); const symbols = await vscode.commands.executeCommand( @@ -204,6 +201,7 @@ suite("Code Cell Symbols", function () { } finally { provider.dispose(); await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + cleanup(); } }); }); diff --git a/apps/vscode/src/test/test-utils.ts b/apps/vscode/src/test/test-utils.ts index 62ef1b45..b3f7a449 100644 --- a/apps/vscode/src/test/test-utils.ts +++ b/apps/vscode/src/test/test-utils.ts @@ -1,3 +1,4 @@ +import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; @@ -40,6 +41,38 @@ async function openAndShowUri(uri: vscode.Uri) { return { doc, editor }; } +/** + * Opens a unique on-disk copy of an example file and returns it along with a + * `cleanup` function that deletes the copy. + * + * Use this instead of `openAndShowExamplesTextDocument` when a test exercises a + * provider command that caches results per document URI, such as + * `vscode.executeDocumentSymbolProvider` (VS Code's `OutlineModel` cache). If + * several tests reuse the same example file, a stale cached result from a + * previous test (or another suite that opened the same file) can be served + * instead of re-invoking the provider, leaking the previous test's results and + * dropping the current test's. A fresh URI per test guarantees the provider + * actually runs. + * + * The copy is created alongside the original example file so workspace-relative + * behavior (LSP, configuration) is preserved. Always call `cleanup()` in a + * `finally` block. + */ +export async function openUniqueExampleDocument(fileName: string) { + const sourcePath = path.join(WORKSPACE_PATH, fileName); + const extension = path.extname(fileName); + const uniqueName = `${path.basename(fileName, extension)}-${Date.now()}-${Math.random().toString(36).slice(2)}${extension}`; + const uniquePath = path.join(path.dirname(sourcePath), uniqueName); + fs.copyFileSync(sourcePath, uniquePath); + + const { doc, editor } = await openAndShowUri(vscode.Uri.file(uniquePath)); + return { + doc, + editor, + cleanup: () => fs.rmSync(uniquePath, { force: true }), + }; +} + const APPROX_TIME_TO_OPEN_VISUAL_EDITOR = 1700; export async function roundtrip(doc: vscode.TextDocument) { const before = doc.getText();