From f6fafb2fe13a592851f28ce06a017ac4b17fea3b Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 6 May 2026 16:03:54 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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"); + } + }); +});