Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/vscode/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 1.133.0 (Unreleased)

- Add code symbols into outline (<https://github.com/quarto-dev/quarto/pull/972>).
- Added setting and command to show/hide cells in outline (<https://github.com/quarto-dev/quarto/pull/974>).
- Added custom pair colorization and highlighting for divs in qmds (<https://github.com/quarto-dev/quarto/pull/973>).

Expand Down
181 changes: 180 additions & 1 deletion apps/vscode/src/lsp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ import {
Uri,
Diagnostic,
window,
ColorThemeKind
ColorThemeKind,
DocumentSymbol,
Range,
SymbolKind,
} from "vscode";
import {
LanguageClient,
Expand All @@ -48,6 +51,7 @@ import {
ProvideDefinitionSignature,
ProvideHoverSignature,
ProvideSignatureHelpSignature,
ProvideDocumentSymbolsSignature,
State,
HandleDiagnosticsSignature
} from "vscode-languageclient";
Expand All @@ -57,6 +61,7 @@ import {
unadjustedRange,
virtualDoc,
withVirtualDocUri,
VirtualDocStyle,
} from "../vdoc/vdoc";
import { isVirtualDoc } from "../vdoc/vdoc-tempfile";
import { activateVirtualDocEmbeddedContent } from "../vdoc/vdoc-content";
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -364,6 +372,177 @@ 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<DocumentSymbol[] | SymbolInformation[] | undefined> => {
// 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])) {
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;
};
}

/**
* 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<DocumentSymbol[] | 'HadUndefined'> {
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,
...(cellSymbols || [])
];
} else {
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 hadUndefined ? 'HadUndefined' : 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
*/
async function getCodeCellSymbols(
document: TextDocument,
cellRange: Range,
engine: MarkdownEngine
): Promise<DocumentSymbol[] | undefined> {
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<DocumentSymbol[] | SymbolInformation[] | undefined>(
"vscode.executeDocumentSymbolProvider",
uri
);
if (result === undefined || result.length === 0) return undefined;

const documentSymbols = isDocumentSymbol(result[0]) ?
result as DocumentSymbol[] :
(result as SymbolInformation[]).map<DocumentSymbol>(symbolInformationToDocumentSymbol);

return unadjustSymbolRanges(documentSymbols, 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
*
Expand Down
Loading
Loading