From ecff8a1978ab71e1cc530acbc77ee275604b524c Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 29 Apr 2026 13:51:48 -0400 Subject: [PATCH 01/63] Add diagnostics for cells --- apps/vscode/package.json | 16 +- apps/vscode/src/lsp/client.ts | 16 +- apps/vscode/src/main.ts | 35 ++- .../src/providers/embedded-diagnostics.ts | 280 ++++++++++++++++++ 4 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 apps/vscode/src/providers/embedded-diagnostics.ts diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 596dbe84..74a4d332 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1003,6 +1003,20 @@ "default": true, "markdownDescription": "Show parameter help when editing function calls." }, + "quarto.cells.diagnostics.enabled": { + "order": 25, + "scope": "window", + "type": "boolean", + "default": true, + "markdownDescription": "Enable diagnostics (linting) for code blocks from language servers." + }, + "quarto.cells.diagnostics.debounceDelay": { + "order": 26, + "scope": "window", + "type": "number", + "default": 500, + "markdownDescription": "Delay in milliseconds before updating diagnostics after document changes." + }, "quarto.cells.background.enabled": { "type": "boolean", "description": "Enable coloring the background of executable code cells.", @@ -1049,7 +1063,7 @@ "markdownDescription": "Millisecond delay between background color updates." }, "quarto.cells.useReticulate": { - "order": 25, + "order": 27, "scope": "window", "type": "boolean", "default": true, diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 21e41cee..1fb2f741 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -72,6 +72,7 @@ import { imageHover } from "../providers/hover-image"; import { LspInitializationOptions, QuartoContext } from "quarto-core"; import { extensionHost } from "../host"; import semver from "semver"; +import { EmbeddedDiagnosticsManager } from "../providers/embedded-diagnostics"; let client: LanguageClient; @@ -79,7 +80,8 @@ export async function activateLsp( context: ExtensionContext, quartoContext: QuartoContext, engine: MarkdownEngine, - outputChannel: LogOutputChannel + outputChannel: LogOutputChannel, + diagnosticsManager?: EmbeddedDiagnosticsManager ) { // The server is implemented in node @@ -105,7 +107,7 @@ export async function activateLsp( const config = workspace.getConfiguration("quarto"); activateVirtualDocEmbeddedContent(); const middleware: Middleware = { - handleDiagnostics: createDiagnosticFilter(), + handleDiagnostics: createDiagnosticFilter(diagnosticsManager), provideCompletionItem: embeddedCodeCompletionProvider(engine), provideDefinition: embeddedGoToDefinitionProvider(engine), provideDocumentFormattingEdits: embeddedDocumentFormattingProvider(engine), @@ -369,7 +371,7 @@ function isWithinYamlComment(doc: TextDocument, pos: Position) { * * @returns A handler function for the middleware */ -export function createDiagnosticFilter() { +export function createDiagnosticFilter(diagnosticsManager?: EmbeddedDiagnosticsManager) { return (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { // If this is not a virtual document, pass through all diagnostics if (!isVirtualDoc(uri)) { @@ -377,7 +379,11 @@ export function createDiagnosticFilter() { return; } - // For virtual documents, filter out all diagnostics - next(uri, []); + // For virtual docs from Quarto LSP, let diagnostics manager handle them + // (but most diagnostics come from other language servers via onDidChangeDiagnostics) + const remapped = diagnosticsManager?.handleDiagnostics(uri, diagnostics); + + // Suppress vdoc diagnostics from being published by the LSP + next(uri, remapped ?? []); }; } diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index e4eca1d7..f736e7e4 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -17,8 +17,9 @@ import * as vscode from "vscode"; import * as path from "path"; import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; -import { kQuartoDocSelector } from "./core/doc"; +import { kQuartoDocSelector, isQuartoDoc } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; +import { EmbeddedDiagnosticsManager } from "./providers/embedded-diagnostics"; import { cellCommands } from "./providers/cell/commands"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; @@ -117,8 +118,38 @@ export async function activate(context: vscode.ExtensionContext): Promise { + if (isQuartoDoc(doc)) { + diagnosticsManager.handleDocumentOpen(doc); + } + }), + vscode.workspace.onDidChangeTextDocument((e) => { + if (isQuartoDoc(e.document)) { + diagnosticsManager.handleDocumentChange(e.document); + } + }), + vscode.workspace.onDidCloseTextDocument((doc) => { + if (isQuartoDoc(doc)) { + diagnosticsManager.handleDocumentClose(doc); + } + }) + ); + + // Process already-open documents + vscode.workspace.textDocuments.forEach((doc) => { + if (isQuartoDoc(doc)) { + diagnosticsManager.handleDocumentOpen(doc); + } + }); + // lsp - const lspClient = await activateLsp(context, quartoContext, engine, outputChannel); + const lspClient = await activateLsp(context, quartoContext, engine, outputChannel, diagnosticsManager); // provide visual editor const editorCommands = activateEditor(context, host, quartoContext, lspClient, engine); diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts new file mode 100644 index 00000000..0a689a4f --- /dev/null +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -0,0 +1,280 @@ +/* + * embedded-diagnostics.ts + * + * Copyright (C) 2022-2026 by Posit Software, PBC + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { + Diagnostic, + DiagnosticCollection, + Disposable, + TextDocument, + Uri, + languages, + workspace, +} from "vscode"; +import { + Token, + isExecutableLanguageBlock, + languageBlockAtPosition, + languageNameFromBlock, +} from "quarto-core"; + +import { MarkdownEngine } from "../markdown/engine"; +import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; +import { VirtualDoc } from "../vdoc/vdoc"; +import * as fs from "fs"; +import * as path from "path"; +import * as uuid from "uuid"; + +interface VirtualDocInfo { + realDocUri: Uri; + tokens: Token[]; + cleanup: () => void; +} + +export class EmbeddedDiagnosticsManager implements Disposable { + private diagnosticCollection: DiagnosticCollection; + private vdocToReal = new Map(); + private disposables: Disposable[] = []; + private debounceTimers = new Map(); + + constructor(private engine: MarkdownEngine) { + this.diagnosticCollection = languages.createDiagnosticCollection("quarto-embedded"); + this.disposables.push(this.diagnosticCollection); + + // Clean up any leftover virtual docs from previous session + this.cleanupAllVirtualDocs(); + + // TODO: can we listen more specifically to particular vdocs? + // Listen to diagnostic changes from all language servers + this.disposables.push( + languages.onDidChangeDiagnostics((event) => { + for (const uri of event.uris) { + const vdocInfo = this.vdocToReal.get(uri.toString()); + if (vdocInfo) { + this.handleDiagnosticsForVirtualDoc(uri, vdocInfo); + } + } + }) + ); + } + + private async cleanupAllVirtualDocs(): Promise { + const workspaceFolders = workspace.workspaceFolders; + if (!workspaceFolders) return; + + for (const folder of workspaceFolders) { + try { + const quartoDir = Uri.joinPath(folder.uri, ".quarto"); + const files = await workspace.fs.readDirectory(quartoDir); + + for (const [filename, fileType] of files) { + if (fileType === 1 && filename.startsWith(".vdoc.")) { + await workspace.fs.delete(Uri.joinPath(quartoDir, filename), { useTrash: false }); + } + } + } catch { + // Directory doesn't exist, that's fine + } + } + } + + async handleDocumentOpen(document: TextDocument): Promise { + if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { + return; + } + this.createVirtualDocs(document); + } + + handleDocumentChange(document: TextDocument): void { + if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { + return; + } + + const docKey = document.uri.toString(); + const existingTimer = this.debounceTimers.get(docKey); + if (existingTimer) clearTimeout(existingTimer); + + const debounceDelay = workspace.getConfiguration("quarto.cells.diagnostics").get("debounceDelay", 500); + const timer = setTimeout(async () => { + this.debounceTimers.delete(docKey); + await this.recreateVirtualDocs(document); + }, debounceDelay); + + this.debounceTimers.set(docKey, timer); + } + + handleDocumentClose(document: TextDocument): void { + const docKey = document.uri.toString(); + + const timer = this.debounceTimers.get(docKey); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(docKey); + } + + this.cleanupVirtualDocsForDocument(docKey); + this.diagnosticCollection.delete(document.uri); + } + + private cleanupVirtualDocsForDocument(docKey: string): void { + for (const [vdocKey, vdocInfo] of this.vdocToReal.entries()) { + if (vdocInfo.realDocUri.toString() === docKey) { + vdocInfo.cleanup(); + this.vdocToReal.delete(vdocKey); + } + } + } + + private async recreateVirtualDocs(document: TextDocument): Promise { + this.cleanupVirtualDocsForDocument(document.uri.toString()); + this.diagnosticCollection.delete(document.uri); + this.createVirtualDocs(document); + } + + private async createVirtualDocs(document: TextDocument): Promise { + const tokens = this.engine.parse(document); + + // Group code blocks by language + const languageMap = new Map(); + for (const token of tokens) { + if (isExecutableLanguageBlock(token)) { + const lang = languageNameFromBlock(token); + if (lang) { + const blocks = languageMap.get(lang) ?? []; + blocks.push(token); + languageMap.set(lang, blocks); + } + } + } + + // Create one virtual doc per language + for (const [langName] of languageMap) { + const language = embeddedLanguage(langName); + if (!language) continue; + + try { + const vdocContent = this.createVirtualDocContent(document, tokens, language); + const { uri, cleanup } = await this.writeVirtualDocFile(vdocContent, document.uri, language); + + this.vdocToReal.set(uri.toString(), { + realDocUri: document.uri, + tokens, + cleanup, + }); + } catch (error) { + console.debug(`Failed to create virtual doc for ${langName}:`, error); + } + } + } + + // TODO: this maybe shouldn't be implemented here, + // this creates a virtual doc without the inject + // lines that i.e. in python disable linting like + // `# type: ignore`. We should co-locate this with + // where vdoc content is usually created in `virtualDocForCode` + // in vdoc.ts + + private createVirtualDocContent( + document: TextDocument, + tokens: Token[], + language: EmbeddedLanguage + ): VirtualDoc { + const lines: string[] = []; + for (let i = 0; i < document.lineCount; i++) { + lines.push(language.emptyLine || ""); + } + + for (const block of tokens.filter( + (token) => isExecutableLanguageBlock(token) && languageNameFromBlock(token) === language.ids[0] + )) { + for (let line = block.range.start.line + 1; line < block.range.end.line && line < document.lineCount; line++) { + lines[line] = document.lineAt(line).text; + } + } + + return { + language, + content: lines.join("\n") + "\n", + }; + } + + // creates a virtual doc in the workspace under a `.quarto` folder. + // This probably isn't a good user experience, + // but its how I got it to work for now (LSPs don't seem to + // want to give diagnostics for files that aren't in the workspace). + private async writeVirtualDocFile( + vdocContent: VirtualDoc, + documentUri: Uri, + language: EmbeddedLanguage + ): Promise<{ uri: Uri; cleanup: () => void; }> { + const docDir = path.dirname(documentUri.fsPath); + const quartoDir = path.join(docDir, ".quarto"); + + if (!fs.existsSync(quartoDir)) { + fs.mkdirSync(quartoDir, { recursive: true }); + } + + const filename = `.vdoc.${uuid.v4()}.${language.extension}`; + const filepath = path.join(quartoDir, filename); + + fs.writeFileSync(filepath, vdocContent.content); + + const uri = Uri.file(filepath); + await workspace.openTextDocument(uri); + + return { + uri, + cleanup: async () => { + try { + await workspace.fs.delete(uri, { useTrash: false }); + } catch (error) { + console.debug(`Failed to delete virtual doc: ${filepath}`, error); + } + } + }; + } + + private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: VirtualDocInfo): void { + const diagnostics = languages.getDiagnostics(uri); + const mappedDiagnostics: Diagnostic[] = []; + + for (const diagnostic of diagnostics) { + const block = languageBlockAtPosition(vdocInfo.tokens, diagnostic.range.start); + if (block) { + mappedDiagnostics.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); + } + } + + this.diagnosticCollection.set(vdocInfo.realDocUri, mappedDiagnostics); + } + + dispose(): void { + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + + for (const vdocInfo of this.vdocToReal.values()) { + vdocInfo.cleanup(); + } + this.vdocToReal.clear(); + + this.cleanupAllVirtualDocs(); + + for (const disposable of this.disposables) { + disposable.dispose(); + } + this.disposables = []; + } +} From 85858e0cb090f6d3eb2b003aa88df6a6cda2ccf2 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 5 May 2026 18:05:56 +0200 Subject: [PATCH 02/63] move document listeners to diagnostics manager --- apps/vscode/src/main.ts | 28 +-------------- .../src/providers/embedded-diagnostics.ts | 35 ++++++++++++++++--- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index f736e7e4..9aef8c0b 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -17,7 +17,7 @@ import * as vscode from "vscode"; import * as path from "path"; import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; -import { kQuartoDocSelector, isQuartoDoc } from "./core/doc"; +import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; import { EmbeddedDiagnosticsManager } from "./providers/embedded-diagnostics"; import { cellCommands } from "./providers/cell/commands"; @@ -122,32 +122,6 @@ export async function activate(context: vscode.ExtensionContext): Promise { - if (isQuartoDoc(doc)) { - diagnosticsManager.handleDocumentOpen(doc); - } - }), - vscode.workspace.onDidChangeTextDocument((e) => { - if (isQuartoDoc(e.document)) { - diagnosticsManager.handleDocumentChange(e.document); - } - }), - vscode.workspace.onDidCloseTextDocument((doc) => { - if (isQuartoDoc(doc)) { - diagnosticsManager.handleDocumentClose(doc); - } - }) - ); - - // Process already-open documents - vscode.workspace.textDocuments.forEach((doc) => { - if (isQuartoDoc(doc)) { - diagnosticsManager.handleDocumentOpen(doc); - } - }); - // lsp const lspClient = await activateLsp(context, quartoContext, engine, outputChannel, diagnosticsManager); diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts index 0a689a4f..3f1a9ef1 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -35,6 +35,7 @@ import { VirtualDoc } from "../vdoc/vdoc"; import * as fs from "fs"; import * as path from "path"; import * as uuid from "uuid"; +import { isQuartoDoc } from "../core/doc"; interface VirtualDocInfo { realDocUri: Uri; @@ -55,9 +56,9 @@ export class EmbeddedDiagnosticsManager implements Disposable { // Clean up any leftover virtual docs from previous session this.cleanupAllVirtualDocs(); - // TODO: can we listen more specifically to particular vdocs? - // Listen to diagnostic changes from all language servers this.disposables.push( + // TODO: can we listen more specifically to particular vdocs? + // Listen to diagnostic changes from all language servers languages.onDidChangeDiagnostics((event) => { for (const uri of event.uris) { const vdocInfo = this.vdocToReal.get(uri.toString()); @@ -65,8 +66,32 @@ export class EmbeddedDiagnosticsManager implements Disposable { this.handleDiagnosticsForVirtualDoc(uri, vdocInfo); } } + }), + + // Register document listeners + workspace.onDidOpenTextDocument((doc) => { + if (isQuartoDoc(doc)) { + this.handleDocumentOpen(doc); + } + }), + workspace.onDidChangeTextDocument((e) => { + if (isQuartoDoc(e.document)) { + this.handleDocumentChange(e.document); + } + }), + workspace.onDidCloseTextDocument((doc) => { + if (isQuartoDoc(doc)) { + this.handleDocumentClose(doc); + } }) ); + + // Process already-open documents + workspace.textDocuments.forEach((doc) => { + if (isQuartoDoc(doc)) { + this.handleDocumentOpen(doc); + } + }); } private async cleanupAllVirtualDocs(): Promise { @@ -89,14 +114,14 @@ export class EmbeddedDiagnosticsManager implements Disposable { } } - async handleDocumentOpen(document: TextDocument): Promise { + private async handleDocumentOpen(document: TextDocument): Promise { if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { return; } this.createVirtualDocs(document); } - handleDocumentChange(document: TextDocument): void { + private handleDocumentChange(document: TextDocument): void { if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { return; } @@ -114,7 +139,7 @@ export class EmbeddedDiagnosticsManager implements Disposable { this.debounceTimers.set(docKey, timer); } - handleDocumentClose(document: TextDocument): void { + private handleDocumentClose(document: TextDocument): void { const docKey = document.uri.toString(); const timer = this.debounceTimers.get(docKey); From 42aa8d95328737571252eb31d5ec70087c24283f Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 5 May 2026 19:25:47 +0200 Subject: [PATCH 03/63] clear diagnostics when vdocs are closed --- apps/vscode/src/providers/embedded-diagnostics.ts | 8 +++++++- apps/vscode/src/vdoc/vdoc-tempfile.ts | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts index 3f1a9ef1..02f6421e 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -256,12 +256,18 @@ export class EmbeddedDiagnosticsManager implements Disposable { fs.writeFileSync(filepath, vdocContent.content); const uri = Uri.file(filepath); - await workspace.openTextDocument(uri); + const doc = await workspace.openTextDocument(uri); return { uri, cleanup: async () => { try { + // First set the language to 'raw' so that the language client + // closes the text document in the language server, which clears + // diagnostics for the file. This stops diagnostics from building + // up even after virtual docs are cleaned up. + await languages.setTextDocumentLanguage(doc, "raw"); + await workspace.fs.delete(uri, { useTrash: false }); } catch (error) { console.debug(`Failed to delete virtual doc: ${filepath}`, error); diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index 0a0bb337..8d23dd06 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -21,11 +21,11 @@ import * as uuid from "uuid"; import { commands, Hover, + languages, Position, TextDocument, Uri, workspace, - WorkspaceEdit, } from "vscode"; import { VirtualDoc, VirtualDocUri } from "./vdoc"; @@ -87,6 +87,12 @@ export async function virtualDocUriFromTempFile( */ async function deleteDocument(doc: TextDocument) { try { + // First set the language to 'raw' so that the language client + // closes the text document in the language server, which clears + // diagnostics for the file. This stops diagnostics from building + // up even after virtual docs are cleaned up. + await languages.setTextDocumentLanguage(doc, "raw"); + await workspace.fs.delete(doc.uri, { useTrash: false }); From a55aff08e728d2c9046657c39ddf47b38cc3cb58 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 5 May 2026 19:30:34 +0200 Subject: [PATCH 04/63] cleanup vdoc after receiving its diagnostics --- apps/vscode/src/providers/embedded-diagnostics.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts index 02f6421e..bb6874e1 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -288,6 +288,11 @@ export class EmbeddedDiagnosticsManager implements Disposable { } this.diagnosticCollection.set(vdocInfo.realDocUri, mappedDiagnostics); + + // We have diagnostics, so we can clean up the virtual doc. + // This ensures that the virtual doc's diagnostics don't show + // in the problems pane (or only show momentarily). + this.cleanupVirtualDocsForDocument(vdocInfo.realDocUri.toString()); } dispose(): void { From bfb558d6a232df6e07f7ad90fbc7ec7407b78186 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 5 May 2026 19:39:52 +0200 Subject: [PATCH 05/63] use existing tempfile vdocs --- .../src/providers/embedded-diagnostics.ts | 95 ++++--------------- apps/vscode/src/vdoc/vdoc.ts | 3 +- 2 files changed, 20 insertions(+), 78 deletions(-) diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts index bb6874e1..9809c81f 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -31,10 +31,7 @@ import { import { MarkdownEngine } from "../markdown/engine"; import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; -import { VirtualDoc } from "../vdoc/vdoc"; -import * as fs from "fs"; -import * as path from "path"; -import * as uuid from "uuid"; +import { VirtualDoc, withVirtualDocUri } from "../vdoc/vdoc"; import { isQuartoDoc } from "../core/doc"; interface VirtualDocInfo { @@ -53,9 +50,6 @@ export class EmbeddedDiagnosticsManager implements Disposable { this.diagnosticCollection = languages.createDiagnosticCollection("quarto-embedded"); this.disposables.push(this.diagnosticCollection); - // Clean up any leftover virtual docs from previous session - this.cleanupAllVirtualDocs(); - this.disposables.push( // TODO: can we listen more specifically to particular vdocs? // Listen to diagnostic changes from all language servers @@ -94,26 +88,6 @@ export class EmbeddedDiagnosticsManager implements Disposable { }); } - private async cleanupAllVirtualDocs(): Promise { - const workspaceFolders = workspace.workspaceFolders; - if (!workspaceFolders) return; - - for (const folder of workspaceFolders) { - try { - const quartoDir = Uri.joinPath(folder.uri, ".quarto"); - const files = await workspace.fs.readDirectory(quartoDir); - - for (const [filename, fileType] of files) { - if (fileType === 1 && filename.startsWith(".vdoc.")) { - await workspace.fs.delete(Uri.joinPath(quartoDir, filename), { useTrash: false }); - } - } - } catch { - // Directory doesn't exist, that's fine - } - } - } - private async handleDocumentOpen(document: TextDocument): Promise { if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { return; @@ -164,7 +138,7 @@ export class EmbeddedDiagnosticsManager implements Disposable { private async recreateVirtualDocs(document: TextDocument): Promise { this.cleanupVirtualDocsForDocument(document.uri.toString()); this.diagnosticCollection.delete(document.uri); - this.createVirtualDocs(document); + await this.createVirtualDocs(document); } private async createVirtualDocs(document: TextDocument): Promise { @@ -190,12 +164,23 @@ export class EmbeddedDiagnosticsManager implements Disposable { try { const vdocContent = this.createVirtualDocContent(document, tokens, language); - const { uri, cleanup } = await this.writeVirtualDocFile(vdocContent, document.uri, language); - this.vdocToReal.set(uri.toString(), { - realDocUri: document.uri, - tokens, - cleanup, + await withVirtualDocUri(vdocContent, document.uri, "diagnostics", async (uri: Uri) => { + // Create a deferred promise. + // It'll resolve when the vdoc info cleanup function is called + // e.g. after we receive the vdoc's diagnostics. + let resolve!: () => void; + const promise = new Promise((res) => resolve = res); + + this.vdocToReal.set(uri.toString(), { + realDocUri: document.uri, + tokens, + cleanup: resolve, + }); + + // Wait for the promise to resolve. + // Once this callback ends, the virtual document will be cleaned up. + await promise; }); } catch (error) { console.debug(`Failed to create virtual doc for ${langName}:`, error); @@ -234,48 +219,6 @@ export class EmbeddedDiagnosticsManager implements Disposable { }; } - // creates a virtual doc in the workspace under a `.quarto` folder. - // This probably isn't a good user experience, - // but its how I got it to work for now (LSPs don't seem to - // want to give diagnostics for files that aren't in the workspace). - private async writeVirtualDocFile( - vdocContent: VirtualDoc, - documentUri: Uri, - language: EmbeddedLanguage - ): Promise<{ uri: Uri; cleanup: () => void; }> { - const docDir = path.dirname(documentUri.fsPath); - const quartoDir = path.join(docDir, ".quarto"); - - if (!fs.existsSync(quartoDir)) { - fs.mkdirSync(quartoDir, { recursive: true }); - } - - const filename = `.vdoc.${uuid.v4()}.${language.extension}`; - const filepath = path.join(quartoDir, filename); - - fs.writeFileSync(filepath, vdocContent.content); - - const uri = Uri.file(filepath); - const doc = await workspace.openTextDocument(uri); - - return { - uri, - cleanup: async () => { - try { - // First set the language to 'raw' so that the language client - // closes the text document in the language server, which clears - // diagnostics for the file. This stops diagnostics from building - // up even after virtual docs are cleaned up. - await languages.setTextDocumentLanguage(doc, "raw"); - - await workspace.fs.delete(uri, { useTrash: false }); - } catch (error) { - console.debug(`Failed to delete virtual doc: ${filepath}`, error); - } - } - }; - } - private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: VirtualDocInfo): void { const diagnostics = languages.getDiagnostics(uri); const mappedDiagnostics: Diagnostic[] = []; @@ -306,8 +249,6 @@ export class EmbeddedDiagnosticsManager implements Disposable { } this.vdocToReal.clear(); - this.cleanupAllVirtualDocs(); - for (const disposable of this.disposables) { disposable.dispose(); } diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index c17720e4..666480cf 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -141,7 +141,8 @@ export type VirtualDocAction = "statementRange" | "helpTopic" | "executeSelectionAtPositionInteractive" | - "semanticTokens"; + "semanticTokens" | + "diagnostics"; export type VirtualDocUri = { uri: Uri, cleanup?: () => Promise; }; From 98ddce5d7addbf098b699b8890546cc7b714edc5 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 5 May 2026 19:49:54 +0200 Subject: [PATCH 06/63] remove unused filter this wasn't doing anything since the diagnostics for vdocs are handled by external language clients - not ours --- apps/vscode/src/lsp/client.ts | 28 ---------------------------- apps/vscode/src/main.ts | 2 +- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/apps/vscode/src/lsp/client.ts b/apps/vscode/src/lsp/client.ts index 1fb2f741..c5840ae1 100644 --- a/apps/vscode/src/lsp/client.ts +++ b/apps/vscode/src/lsp/client.ts @@ -24,7 +24,6 @@ import { Definition, LogOutputChannel, Uri, - Diagnostic, window, ColorThemeKind } from "vscode"; @@ -49,7 +48,6 @@ import { ProvideHoverSignature, ProvideSignatureHelpSignature, State, - HandleDiagnosticsSignature } from "vscode-languageclient"; import { MarkdownEngine } from "../markdown/engine"; import { @@ -58,7 +56,6 @@ import { virtualDoc, withVirtualDocUri, } from "../vdoc/vdoc"; -import { isVirtualDoc } from "../vdoc/vdoc-tempfile"; import { activateVirtualDocEmbeddedContent } from "../vdoc/vdoc-content"; import { vdocCompletions } from "../vdoc/vdoc-completion"; @@ -72,7 +69,6 @@ import { imageHover } from "../providers/hover-image"; import { LspInitializationOptions, QuartoContext } from "quarto-core"; import { extensionHost } from "../host"; import semver from "semver"; -import { EmbeddedDiagnosticsManager } from "../providers/embedded-diagnostics"; let client: LanguageClient; @@ -81,7 +77,6 @@ export async function activateLsp( quartoContext: QuartoContext, engine: MarkdownEngine, outputChannel: LogOutputChannel, - diagnosticsManager?: EmbeddedDiagnosticsManager ) { // The server is implemented in node @@ -107,7 +102,6 @@ export async function activateLsp( const config = workspace.getConfiguration("quarto"); activateVirtualDocEmbeddedContent(); const middleware: Middleware = { - handleDiagnostics: createDiagnosticFilter(diagnosticsManager), provideCompletionItem: embeddedCodeCompletionProvider(engine), provideDefinition: embeddedGoToDefinitionProvider(engine), provideDocumentFormattingEdits: embeddedDocumentFormattingProvider(engine), @@ -365,25 +359,3 @@ function isWithinYamlComment(doc: TextDocument, pos: Position) { const line = doc.lineAt(pos.line).text; return !!line.match(/^\s*#\s*\| /); } - -/** - * Creates a diagnostic handler middleware that filters out diagnostics from virtual documents - * - * @returns A handler function for the middleware - */ -export function createDiagnosticFilter(diagnosticsManager?: EmbeddedDiagnosticsManager) { - return (uri: Uri, diagnostics: Diagnostic[], next: HandleDiagnosticsSignature) => { - // If this is not a virtual document, pass through all diagnostics - if (!isVirtualDoc(uri)) { - next(uri, diagnostics); - return; - } - - // For virtual docs from Quarto LSP, let diagnostics manager handle them - // (but most diagnostics come from other language servers via onDidChangeDiagnostics) - const remapped = diagnosticsManager?.handleDiagnostics(uri, diagnostics); - - // Suppress vdoc diagnostics from being published by the LSP - next(uri, remapped ?? []); - }; -} diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 9aef8c0b..3f8bbee4 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -123,7 +123,7 @@ export async function activate(context: vscode.ExtensionContext): Promise Date: Tue, 5 May 2026 20:56:16 +0200 Subject: [PATCH 07/63] use a local tempfile when the vscode-R extension is handling diagnostics --- apps/vscode/src/vdoc/vdoc.ts | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 666480cf..a3af9842 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -13,7 +13,7 @@ * */ -import { Position, TextDocument, Uri, Range, SemanticTokens } from "vscode"; +import { Position, TextDocument, Uri, Range, SemanticTokens, extensions, workspace } from "vscode"; import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; import { isQuartoDoc } from "../core/doc"; @@ -175,6 +175,37 @@ export async function withVirtualDocUri( } } +/** + * Whether to use a local temporary file for a given virtual document and action. + */ +function shouldUseLocalTempFile(virtualDoc: VirtualDoc, action: VirtualDocAction): boolean { + // Format and definition actions use a transient local vdoc + // (so they can get project-specific paths and formatting config) + if (["format", "definition"].includes(action)) { + return true; + } + + // The vscode-R extension uses the languageserver R package + // which does not provide diagnostics for temp files. + // Use a local temp file in that case. + if ( + virtualDoc.language.ids.includes("r") && + action === "diagnostics" && + extensions.getExtension("REditorSupport.r")?.isActive + ) { + const rLspConfig = workspace.getConfiguration("r.lsp"); + if ( + rLspConfig.get("enabled", false) && + rLspConfig.get("diagnostics", false) + ) { + return true; + } + } + + // Default to a non-local temp file - it's less invasive + return false; +} + // To be used through `withVirtualDocUri()`. Not safe to export on its own! The // cleanup hook must be called, and relying on the caller to do this is a huge // footgun. @@ -184,9 +215,7 @@ async function virtualDocUri( action: VirtualDocAction ): Promise { - // format and definition actions use a transient local vdoc - // (so they can get project-specific paths and formatting config) - const local = ["format", "definition"].includes(action); + const local = shouldUseLocalTempFile(virtualDoc, action); return virtualDoc.language.type === "content" ? { uri: virtualDocUriFromEmbeddedContent(virtualDoc, parentUri) } From 03c5a1e13855e9b7a9accee5e11c5c2a9e0bccd1 Mon Sep 17 00:00:00 2001 From: seem Date: Thu, 7 May 2026 17:19:28 +0200 Subject: [PATCH 08/63] also watch tests in dev build --- apps/vscode/build.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/vscode/build.ts b/apps/vscode/build.ts index 8595c118..82e2e1f8 100644 --- a/apps/vscode/build.ts +++ b/apps/vscode/build.ts @@ -26,6 +26,7 @@ const testBuildOptions = { outdir: 'test-out', external: ['vscode', 'mocha', 'glob'], sourcemap: true, + dev, }; const defaultBuildOptions = { @@ -36,4 +37,11 @@ const defaultBuildOptions = { dev }; -runBuild(test ? testBuildOptions : defaultBuildOptions); +if (test) { + runBuild(testBuildOptions); +} else if (dev) { + runBuild(defaultBuildOptions); + runBuild(testBuildOptions); +} else { + runBuild(defaultBuildOptions); +} From 27dd6c2a59057548b3086ea85c4bee8bbf013b3e Mon Sep 17 00:00:00 2001 From: seem Date: Thu, 7 May 2026 17:19:37 +0200 Subject: [PATCH 09/63] forgot to remove this --- .../src/test/diagnosticFiltering.test.ts | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 apps/vscode/src/test/diagnosticFiltering.test.ts diff --git a/apps/vscode/src/test/diagnosticFiltering.test.ts b/apps/vscode/src/test/diagnosticFiltering.test.ts deleted file mode 100644 index 673bcf7b..00000000 --- a/apps/vscode/src/test/diagnosticFiltering.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as vscode from "vscode"; -import * as assert from "assert"; -import { createDiagnosticFilter } from "../lsp/client"; - -suite("Diagnostic Filtering", function () { - - test("Diagnostic filter removes diagnostics for virtual documents", async function () { - // Create mocks - const virtualDocUri = vscode.Uri.file("/tmp/.vdoc.12345678-1234-1234-1234-123456789abc.py"); - const regularDocUri = vscode.Uri.file("/tmp/regular-file.py"); - - // Create some test diagnostics - const testDiagnostics = [ - new vscode.Diagnostic( - new vscode.Range(0, 0, 0, 10), - "Test diagnostic message", - vscode.DiagnosticSeverity.Error - ) - ]; - - // Create a mock diagnostics handler function to verify behavior - let capturedUri: vscode.Uri | undefined; - let capturedDiagnostics: vscode.Diagnostic[] | undefined; - - const mockHandler = (uri: vscode.Uri, diagnostics: vscode.Diagnostic[]) => { - capturedUri = uri; - capturedDiagnostics = diagnostics; - }; - - // Create the filter function - const diagnosticFilter = createDiagnosticFilter(); - - // Test with a virtual document - diagnosticFilter(virtualDocUri, testDiagnostics, mockHandler); - - // Verify diagnostics were filtered (empty array) - assert.strictEqual(capturedUri, virtualDocUri, "URI should be passed through"); - assert.strictEqual(capturedDiagnostics!.length, 0, "Diagnostics should be empty for virtual documents"); - - // Reset captured values - capturedUri = undefined; - capturedDiagnostics = undefined; - - // Test with a regular document - diagnosticFilter(regularDocUri, testDiagnostics, mockHandler); - - // Verify diagnostics were not filtered - assert.strictEqual(capturedUri, regularDocUri, "URI should be passed through"); - assert.strictEqual(capturedDiagnostics!.length, testDiagnostics.length, "Diagnostics should not be filtered for regular documents"); - assert.deepStrictEqual(capturedDiagnostics!, testDiagnostics, "Original diagnostics should be passed through unchanged"); - }); - -}); From 3d646ab14bf747de0d01caa5ea18b9812f16011b Mon Sep 17 00:00:00 2001 From: seem Date: Thu, 7 May 2026 18:43:41 +0200 Subject: [PATCH 10/63] add some logs --- apps/vscode/src/main.ts | 2 +- .../src/providers/embedded-diagnostics.ts | 43 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 3f8bbee4..b2c6c0b9 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -119,7 +119,7 @@ export async function activate(context: vscode.ExtensionContext): Promise(); - constructor(private engine: MarkdownEngine) { + constructor( + private engine: MarkdownEngine, + private outputChannel: LogOutputChannel, + ) { this.diagnosticCollection = languages.createDiagnosticCollection("quarto-embedded"); this.disposables.push(this.diagnosticCollection); @@ -166,6 +170,16 @@ export class EmbeddedDiagnosticsManager implements Disposable { const vdocContent = this.createVirtualDocContent(document, tokens, language); await withVirtualDocUri(vdocContent, document.uri, "diagnostics", async (uri: Uri) => { + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Created virtual document ${uri.toString()} ` + + `for document ${document.uri.toString()} ` + + `(language: ${langName})` + ); + this.outputChannel.trace( + `[EmbeddedDiagnosticsManager] Virtual document content:\n` + + vdocContent.content + ); + // Create a deferred promise. // It'll resolve when the vdoc info cleanup function is called // e.g. after we receive the vdoc's diagnostics. @@ -175,15 +189,25 @@ export class EmbeddedDiagnosticsManager implements Disposable { this.vdocToReal.set(uri.toString(), { realDocUri: document.uri, tokens, - cleanup: resolve, + cleanup: () => { + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Cleaning up virtual document ${uri.toString()} ` + + `for document ${document.uri.toString()}` + ); + resolve(); + }, }); // Wait for the promise to resolve. // Once this callback ends, the virtual document will be cleaned up. + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Waiting for diagnostics for virtual document ${uri.toString()} ` + + `for document ${document.uri.toString()} ` + ); await promise; }); } catch (error) { - console.debug(`Failed to create virtual doc for ${langName}:`, error); + this.outputChannel.error(`[EmbeddedDiagnosticsManager] Failed to create virtual document; for ${langName}:`, error); } } } @@ -223,10 +247,23 @@ export class EmbeddedDiagnosticsManager implements Disposable { const diagnostics = languages.getDiagnostics(uri); const mappedDiagnostics: Diagnostic[] = []; + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Received $;{ diagnostics.length; } diagnostics for ` + + ` ${vdocInfo.realDocUri.toString()} ` + + ` (virtual doc: ${uri.toString()})` + ); + for (const diagnostic of diagnostics) { const block = languageBlockAtPosition(vdocInfo.tokens, diagnostic.range.start); if (block) { mappedDiagnostics.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); + } else { + this.outputChannel.error( + `[EmbeddedDiagnosticsManager] Could not find language block; for diagnostic at ` + + `[${diagnostic.range.start.line}, ${diagnostic.range.start.character}] ` + + `in ${vdocInfo.realDocUri.toString()} ` + + `(virtual doc: ${uri.toString()})` + ); } } From dd7bf0ef19dc2791690564503258afc4739e5df0 Mon Sep 17 00:00:00 2001 From: seem Date: Thu, 7 May 2026 18:44:25 +0200 Subject: [PATCH 11/63] fix files not deleting because of an unknown language --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index 8d23dd06..d5115913 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -91,7 +91,7 @@ async function deleteDocument(doc: TextDocument) { // closes the text document in the language server, which clears // diagnostics for the file. This stops diagnostics from building // up even after virtual docs are cleaned up. - await languages.setTextDocumentLanguage(doc, "raw"); + await languages.setTextDocumentLanguage(doc, "plaintext"); await workspace.fs.delete(doc.uri, { useTrash: false From c8c579dbf4a3cdb81128d58853add1abf759048a Mon Sep 17 00:00:00 2001 From: seem Date: Thu, 7 May 2026 18:56:35 +0200 Subject: [PATCH 12/63] wip: diagnostics tests --- apps/vscode/src/test/diagnostics.test.ts | 292 +++++++++++++++++++ apps/vscode/src/test/test-language-client.ts | 37 +++ apps/vscode/src/test/test-language-server.ts | 73 +++++ apps/vscode/src/test/test-utils.ts | 2 +- 4 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 apps/vscode/src/test/diagnostics.test.ts create mode 100644 apps/vscode/src/test/test-language-client.ts create mode 100644 apps/vscode/src/test/test-language-server.ts diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts new file mode 100644 index 00000000..db3c84fb --- /dev/null +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -0,0 +1,292 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { LanguageClient } from "vscode-languageclient/node"; +import { examplesOutUri, openAndShowUri, wait } from "./test-utils"; +import { testLanguageClient } from "./test-language-client"; +import { VIRTUAL_DOC_TEMP_DIRECTORY } from "./../vdoc/vdoc-tempfile"; + +suite("Diagnostics", function () { + const exampleUri = examplesOutUri("diagnostics.qmd"); + const diagnosticsSettledEvent = debounceEvent(onDidReceiveTestDiagnosticsForDocument(exampleUri), 100); + + let client: LanguageClient; + let disposables: vscode.Disposable[]; + + suiteSetup(async function () { + client = testLanguageClient(); + await client.start(); + disposables = []; + }); + + suiteTeardown(async function () { + await client.stop(); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + disposables.forEach((d) => d.dispose()); + }); + + teardown(async function () { + await assertNoLocalVirtualDocs(); + await assertNoTempFileVirtualDocs(); + }); + + test("maps diagnostics from virtual doc back to the .qmd", async function () { + // Create an event that fires when test diagnostics are received for the document. + const promise = eventToPromise(diagnosticsSettledEvent); + + // Open the document - the language server should respond with diagnostics. + await openAndShowUri(exampleUri); + + // Wait for diagnostics to settle. + const diagnostics = await promise; + assert.ok( + diagnostics.length > 0, + "Expected at least one diagnostic on the .qmd file" + ); + const diag = diagnostics.find((d) => + d.message.includes("test-diagnostic") + )!; + assert.strictEqual( + diag.range.start.line, + 8, + `Diagnostic should be on line 8, got line ${diag.range.start.line}` + ); + }); + + test("updates diagnostics when document is edited", async function () { + // Create an event that fires when test diagnostics are received for the document. + let promise = eventToPromise(diagnosticsSettledEvent); + + // Open the document - the language server should respond with diagnostics. + const doc = await vscode.workspace.openTextDocument({ + language: "quarto", + content: '```{python}\nprint("Hello")\n```', + }); + + // TODO: Could also just edit the file + // Ignore initial diagnostics. + console.log('Waiting for initial diagnostics...'); + let diagnostics = await promise; + assert.ok(diagnostics.length > 0, "Expected no initial diagnostics"); + + // Edit: add a second code cell with undefined_var + promise = eventToPromise(diagnosticsSettledEvent); + const editor = await vscode.window.showTextDocument(doc); + await editor.edit((editBuilder) => { + const lastLine = doc.lineCount; + editBuilder.insert( + new vscode.Position(lastLine, 0), + "\n```{python}\nundefined_var\n```\n" + ); + }); + + // Wait for debounce + new diagnostics + console.log('Waiting for updated diagnostics...'); + diagnostics = await promise; + const testDiags = diagnostics.filter((d) => + d.message.includes("test-diagnostic") + ); + assert.strictEqual( + testDiags.length, + 2, + `Expected two diagnostics after adding a second cell, got ${testDiags.length}` + ); + }); + + test("clears diagnostics when document is closed", async function () { + const promise = eventToPromise(diagnosticsSettledEvent); + + // Close the document - the language server should clear diagnostics for the document. + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + + // Wait for diagnostics to be cleared. + const diagnostics = await promise; + assert.strictEqual( + diagnostics.length, + 0, + "Diagnostics should be cleared after closing the document" + ); + }); +}); + +function onDidReceiveDiagnosticsForDocument( + uri: vscode.Uri, +): vscode.Event { + return (listener, thisArgs?, disposables?) => { + return vscode.languages.onDidChangeDiagnostics((e) => { + for (const diagnosticsUri of e.uris) { + if (diagnosticsUri.toString() === uri.toString()) { + const diagnostics = vscode.languages.getDiagnostics(diagnosticsUri); + listener.call(thisArgs, diagnostics); + } + } + }, thisArgs, disposables); + }; +} + +function onDidReceiveTestDiagnosticsForDocument(uri: vscode.Uri): vscode.Event { + return (listener, thisArgs?, disposables?) => { + // TODO: Challenge: we dont know when this was our embedded diagnostics or something else... + return onDidReceiveDiagnosticsForDocument(uri)(diagnostics => { + if (diagnostics.some(isTestDiagnostic)) { + console.log(`Received ${diagnostics.length} diagnostics for ${uri.toString()}`); + diagnostics.forEach((d) => { + console.log(`- ${d.message} at [${d.range.start.line}, ${d.range.start.character}]`); + }); + listener.call(thisArgs, diagnostics); + } + }, thisArgs, disposables); + }; +} + +function isTestDiagnostic(diagnostic: vscode.Diagnostic): boolean { + return /^test-diagnostic:/.test(diagnostic.message); +} + +export function onceEvent(event: vscode.Event): vscode.Event { + return (listener: (e: T) => any, thisArgs?: any, disposables?: vscode.Disposable[]) => { + const result = event(e => { + result.dispose(); + return listener.call(thisArgs, e); + }, null, disposables); + + return result; + }; +} + +export function debounceEvent(event: vscode.Event, delay: number): vscode.Event { + return (listener: (e: T) => any, thisArgs?: any, disposables?: vscode.Disposable[]) => { + let timer: NodeJS.Timeout; + return event(e => { + clearTimeout(timer); + timer = setTimeout(() => listener.call(thisArgs, e), delay); + }, null, disposables); + }; +} + +export function eventToPromise(event: vscode.Event): Promise { + return new Promise(c => onceEvent(event)(c)); +} + +// async function eventToPromise( +// event: vscode.Event, +// disposables: vscode.Disposable[], +// ) { +// return new Promise((resolve) => { +// const disposable = event((e) => { +// disposable.dispose(); +// resolve(e); +// }, null, disposables); +// }); +// } + +// function filterEvent( +// event: vscode.Event, +// filter: (e: T) => boolean, +// disposables: vscode.Disposable[], +// ): vscode.Event { +// return (listener, thisArgs?, disposables?) => { +// return event((e) => { +// if (filter(e)) { +// listener.call(thisArgs, e); +// } +// }, null, disposables); +// }; +// } + +// async function waitForDiagnostics( +// uri: vscode.Uri, +// predicate: (d: vscode.Diagnostic) => boolean, +// timeoutMs: number +// ): Promise { +// const start = Date.now(); + +// while (Date.now() - start < timeoutMs) { +// const diagnostics = vscode.languages.getDiagnostics(uri); +// if (diagnostics.some(predicate)) { +// return diagnostics; +// } +// await wait(200); +// } + +// return vscode.languages.getDiagnostics(uri); +// } + +// async function waitForDiagnosticsCleared( +// uri: vscode.Uri, +// timeoutMs: number +// ): Promise { +// const start = Date.now(); + +// while (Date.now() - start < timeoutMs) { +// const diagnostics = vscode.languages.getDiagnostics(uri); +// if (diagnostics.length === 0) { +// return diagnostics; +// } +// await wait(200); +// } + +// return vscode.languages.getDiagnostics(uri); +// } + +// async function poll( +// assertion: () => Promise, +// message: string, +// timeoutMs: number +// ): Promise { +// const start = Date.now(); + +// let finalError: unknown | null = null; +// while (Date.now() - start < timeoutMs) { +// try { +// return await assertion(); +// } catch (error) { +// console.error(`${message}: ${error instanceof Error ? error.message : JSON.stringify(error)}`); +// finalError = error; +// // Ignore and retry until timeout +// } +// await wait(200); +// } + +// if (finalError) { +// throw finalError; +// } +// } + +/** + * Check that there are no virtual doc files lingering in the workspace. + */ +async function assertNoLocalVirtualDocs() { + const vdocFiles = await vscode.workspace.findFiles("**/.vdoc.*"); + assert.strictEqual( + vdocFiles.length, + 0, + `Expected no virtual doc files, but found ${vdocFiles.length}` + ); +} + +/** + * Check that there are no virtual doc files lingering in the temp folder. + */ +async function assertNoTempFileVirtualDocs() { + const tempDir = await vscode.workspace.fs.readDirectory(vscode.Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)); + const tempVdocFiles = tempDir.filter(([name]) => name.startsWith(".vdoc.")); + assert.strictEqual( + tempVdocFiles.length, + 0, + `Expected no virtual doc files in temp directory, but found ${tempVdocFiles.length}` + ); +} + +async function withEmbeddedDiagnostics( + uri: vscode.Uri, + callback: () => Promise, +) { + // Create an event that fires when test diagnostics are received for the document. + const diagnosticsSettledEvent = debounceEvent(onDidReceiveTestDiagnosticsForDocument(uri), 100); + const promise = eventToPromise(diagnosticsSettledEvent); + + await callback(); + + // Wait for diagnostics to settle. + return await promise; +} diff --git a/apps/vscode/src/test/test-language-client.ts b/apps/vscode/src/test/test-language-client.ts new file mode 100644 index 00000000..aa9c1aca --- /dev/null +++ b/apps/vscode/src/test/test-language-client.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { OutputChannel } from "vscode"; +import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; + +function testOutputChannel(name: string): OutputChannel { + return { + name, + append: (value) => console.log(`[${name}] ${value}`), + appendLine: (value) => console.log(`[${name}] ${value}`), + clear: () => { }, + show: () => { }, + hide: () => { }, + dispose: () => { }, + replace: (_value) => { }, + }; +} + +export function testLanguageClient(): LanguageClient { + const serverModule = path.join(__dirname, "test-language-server.js"); + + const serverOptions: ServerOptions = { + run: { module: serverModule, transport: TransportKind.ipc }, + debug: { module: serverModule, transport: TransportKind.ipc }, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ language: "python" }], + outputChannel: testOutputChannel("Test Language Client"), + }; + + return new LanguageClient( + "test-language-server", + "Test Language Server", + serverOptions, + clientOptions + ); +} diff --git a/apps/vscode/src/test/test-language-server.ts b/apps/vscode/src/test/test-language-server.ts new file mode 100644 index 00000000..d4877eb8 --- /dev/null +++ b/apps/vscode/src/test/test-language-server.ts @@ -0,0 +1,73 @@ +import { + createConnection, + DiagnosticSeverity, + TextDocuments, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; + +/** + * This module defines a language server for testing. + */ + +const undefinedVarRegExp = /undefined_var/; + +const connection = createConnection(); +const documents = new TextDocuments(TextDocument); +const { console } = connection; + +/** + * Publish diagnostics for a text document. + */ +function publishDiagnostics(document: TextDocument) { + // Get the document's lines. + const allText = document.getText(); + const lines = allText.split("\n"); + + // Find instances of "undefined_var" and create diagnostics for them. + const diagnostics = []; + for (const [line, text] of lines.entries()) { + const match = text.match(undefinedVarRegExp); + if (match && match.index !== undefined) { + diagnostics.push({ + range: { + start: { line, character: match.index }, + end: { line, character: match.index + match[0].length }, + }, + message: "test-diagnostic: undefined_var is not defined", + severity: DiagnosticSeverity.Warning, + }); + } + } + + // Publish the diagnostics to the client. + console.log(`Publishing ${diagnostics.length} diagnostics for ${document.uri}\n` + + diagnostics.map(d => `- ${d.message} at [${d.range.start.line}, ${d.range.start.character}]`).join("\n") + ); + connection.sendDiagnostics({ uri: document.uri, diagnostics }); +} + +// Initialize the server. +connection.onInitialize(() => { + console.log(`Initialized!`);; + return { + capabilities: {}, + }; +}); + +// Publish diagnostics on document open. +documents.onDidOpen(({ document }) => { + console.log(`Document opened: ${document.uri}`); + publishDiagnostics(document); +}); + +// Publish diagnostics on document change. +documents.onDidChangeContent(({ document }) => { + console.log(`Document changed: ${document.uri}`); + publishDiagnostics(document); +}); + +// Connect the text document manager. +documents.listen(connection); + +// Listen on the connection. +connection.listen(); diff --git a/apps/vscode/src/test/test-utils.ts b/apps/vscode/src/test/test-utils.ts index 62ef1b45..a861728e 100644 --- a/apps/vscode/src/test/test-utils.ts +++ b/apps/vscode/src/test/test-utils.ts @@ -34,7 +34,7 @@ export async function openAndShowExamplesOutTextDocument(fileName: string) { return openAndShowUri(examplesOutUri(fileName)); } -async function openAndShowUri(uri: vscode.Uri) { +export async function openAndShowUri(uri: vscode.Uri) { const doc = await vscode.workspace.openTextDocument(uri); const editor = await vscode.window.showTextDocument(doc); return { doc, editor }; From 58d2986f4ee14c6595072bd158f7e44732731fef Mon Sep 17 00:00:00 2001 From: seem Date: Mon, 11 May 2026 17:39:17 +0200 Subject: [PATCH 13/63] add disposablestore.clear and format --- packages/core/src/dispose.ts | 138 +++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 62 deletions(-) diff --git a/packages/core/src/dispose.ts b/packages/core/src/dispose.ts index 2cef98ea..a31463d6 100644 --- a/packages/core/src/dispose.ts +++ b/packages/core/src/dispose.ts @@ -15,81 +15,95 @@ */ export interface IDisposable { - dispose(): void; + dispose(): void; } export class MultiDisposeError extends Error { - constructor( - public readonly errors: unknown[] - ) { - super(`Encountered errors while disposing of store. Errors: [${errors.join(', ')}]`); - } + constructor( + public readonly errors: unknown[] + ) { + super(`Encountered errors while disposing of store. Errors: [${errors.join(', ')}]`); + } } export function disposeAll(disposables: Iterable) { - const errors: unknown[] = []; - - for (const disposable of disposables) { - try { - disposable.dispose(); - } catch (e) { - errors.push(e); - } - } - - if (errors.length === 1) { - throw errors[0]; - } else if (errors.length > 1) { - throw new MultiDisposeError(errors); - } + const errors: unknown[] = []; + + for (const disposable of disposables) { + try { + disposable.dispose(); + } catch (e) { + errors.push(e); + } + } + + if (errors.length === 1) { + throw errors[0]; + } else if (errors.length > 1) { + throw new MultiDisposeError(errors); + } } export interface IDisposable { - dispose(): void; + dispose(): void; } export abstract class Disposable { - #isDisposed = false; - - protected _disposables: IDisposable[] = []; - - public dispose() { - if (this.#isDisposed) { - return; - } - this.#isDisposed = true; - disposeAll(this._disposables); - } - - protected _register(value: T): T { - if (this.#isDisposed) { - value.dispose(); - } else { - this._disposables.push(value); - } - return value; - } - - protected get isDisposed() { - return this.#isDisposed; - } + #isDisposed = false; + + protected _disposables: IDisposable[] = []; + + public dispose() { + if (this.#isDisposed) { + return; + } + this.#isDisposed = true; + disposeAll(this._disposables); + } + + protected _register(value: T): T { + if (this.#isDisposed) { + value.dispose(); + } else { + this._disposables.push(value); + } + return value; + } + + protected get isDisposed() { + return this.#isDisposed; + } } export class DisposableStore extends Disposable { - readonly #items = new Set(); - - public override dispose() { - super.dispose(); - disposeAll(this.#items); - this.#items.clear(); - } - - public add(item: T): T { - if (this.isDisposed) { - console.warn('Adding to disposed store. Item will be leaked'); - } - - this.#items.add(item); - return item; - } + readonly #items = new Set(); + + public override dispose() { + super.dispose(); + this.clear(); + } + + public add(item: T): T { + if (this.isDisposed) { + console.warn('Adding to disposed store. Item will be leaked'); + } + + this.#items.add(item); + return item; + } + + /** + * Dispose of all registered disposables but do not mark this object as disposed. + */ + public clear(): void { + if (this.#items.size === 0) { + return; + } + + try { + disposeAll(this.#items); + } finally { + this.#items.clear(); + } + } } From 7df25e67a1cae17c124bf397a2fc9b1bca00616b Mon Sep 17 00:00:00 2001 From: seem Date: Mon, 11 May 2026 17:40:12 +0200 Subject: [PATCH 14/63] refining tests + cleaning up the implementation --- apps/vscode/src/core/event.ts | 56 +++ apps/vscode/src/core/resource-map.ts | 79 ++++ .../src/providers/embedded-diagnostics.ts | 267 ++++++++----- apps/vscode/src/test/diagnostics.test.ts | 377 +++++++----------- .../test/examples/diagnostics-python-none.qmd | 10 + .../examples/diagnostics-python-undefined.qmd | 10 + .../{ => fixtures}/test-language-client.ts | 20 +- .../{ => fixtures}/test-language-server.ts | 7 + .../test/fixtures/test-log-output-channel.ts | 20 + .../src/test/fixtures/test-output-channel.ts | 13 + apps/vscode/src/test/test-utils.ts | 17 +- apps/vscode/src/test/utils/vdoc.ts | 40 ++ apps/vscode/src/vdoc/vdoc-tempfile.ts | 3 +- apps/vscode/src/vdoc/vdoc.ts | 4 +- 14 files changed, 567 insertions(+), 356 deletions(-) create mode 100644 apps/vscode/src/core/event.ts create mode 100644 apps/vscode/src/core/resource-map.ts create mode 100644 apps/vscode/src/test/examples/diagnostics-python-none.qmd create mode 100644 apps/vscode/src/test/examples/diagnostics-python-undefined.qmd rename apps/vscode/src/test/{ => fixtures}/test-language-client.ts (61%) rename apps/vscode/src/test/{ => fixtures}/test-language-server.ts (88%) create mode 100644 apps/vscode/src/test/fixtures/test-log-output-channel.ts create mode 100644 apps/vscode/src/test/fixtures/test-output-channel.ts create mode 100644 apps/vscode/src/test/utils/vdoc.ts diff --git a/apps/vscode/src/core/event.ts b/apps/vscode/src/core/event.ts new file mode 100644 index 00000000..672ded0c --- /dev/null +++ b/apps/vscode/src/core/event.ts @@ -0,0 +1,56 @@ +/* + * event.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import { Event } from "vscode"; + +export function filterEvent( + event: Event, + filter: (e: T) => boolean, +): Event { + return (listener, thisArgs?, disposables?) => { + return event((e) => { + if (filter(e)) { + listener.call(thisArgs, e); + } + }, null, disposables); + }; +} + +export function onceEvent(event: Event): Event { + return (listener, thisArgs?, disposables?) => { + const result = event(e => { + result.dispose(); + return listener.call(thisArgs, e); + }, null, disposables); + + return result; + }; +} + +export function debounceEvent(event: Event, delay: number): Event { + return (listener, thisArgs?, disposables?) => { + let timer: number; + return event(e => { + clearTimeout(timer); + timer = setTimeout(() => listener.call(thisArgs, e), delay); + }, null, disposables); + }; +} + +export function eventToPromise(event: Event): Promise { + const once = onceEvent(event); + return new Promise(resolve => once(e => resolve(e))); +} diff --git a/apps/vscode/src/core/resource-map.ts b/apps/vscode/src/core/resource-map.ts new file mode 100644 index 00000000..34c155c9 --- /dev/null +++ b/apps/vscode/src/core/resource-map.ts @@ -0,0 +1,79 @@ +/* + * resource-map.ts + * + * Copyright (C) 2026 by Posit Software, PBC + * Copyright (c) Microsoft Corporation. All rights reserved. + * + * Unless you have received this program directly from Posit Software pursuant + * to the terms of a commercial license agreement with Posit Software, then + * this program is licensed to you under the terms of version 3 of the + * GNU Affero General Public License. This program is distributed WITHOUT + * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, + * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the + * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. + * + */ + +import * as vscode from 'vscode'; + +type ResourceToKey = (uri: vscode.Uri) => string; + +const defaultResourceToKey = (resource: vscode.Uri): string => resource.toString(); + +export class ResourceMap { + + private readonly _map = new Map(); + + private readonly _toKey: ResourceToKey; + + constructor(toKey: ResourceToKey = defaultResourceToKey) { + this._toKey = toKey; + } + + public set(uri: vscode.Uri, value: T): this { + this._map.set(this._toKey(uri), { uri, value }); + return this; + } + + public get(resource: vscode.Uri): T | undefined { + return this._map.get(this._toKey(resource))?.value; + } + + public has(resource: vscode.Uri): boolean { + return this._map.has(this._toKey(resource)); + } + + public get size(): number { + return this._map.size; + } + + public clear(): void { + this._map.clear(); + } + + public delete(resource: vscode.Uri): boolean { + return this._map.delete(this._toKey(resource)); + } + + public *values(): IterableIterator { + for (const entry of this._map.values()) { + yield entry.value; + } + } + + public *keys(): IterableIterator { + for (const entry of this._map.values()) { + yield entry.uri; + } + } + + public *entries(): IterableIterator<[vscode.Uri, T]> { + for (const entry of this._map.values()) { + yield [entry.uri, entry.value]; + } + } + + public [Symbol.iterator](): IterableIterator<[vscode.Uri, T]> { + return this.entries(); + } +} diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/embedded-diagnostics.ts index 2034b7a6..597c5569 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/embedded-diagnostics.ts @@ -15,8 +15,7 @@ import { Diagnostic, - DiagnosticCollection, - Disposable, + EventEmitter, TextDocument, Uri, languages, @@ -31,60 +30,103 @@ import { import { MarkdownEngine } from "../markdown/engine"; import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; -import { VirtualDoc, withVirtualDocUri } from "../vdoc/vdoc"; +import { VirtualDoc, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; import { isQuartoDoc } from "../core/doc"; import { LogOutputChannel } from "vscode"; - -interface VirtualDocInfo { - realDocUri: Uri; +import path from "node:path"; +import { Disposable } from "core"; +import { ResourceMap } from "../core/resource-map"; + +interface DiagnosticsVirtualDocument { + uri: Uri; + language: string; + quartoDocumentUri: Uri; tokens: Token[]; cleanup: () => void; } -export class EmbeddedDiagnosticsManager implements Disposable { - private diagnosticCollection: DiagnosticCollection; - private vdocToReal = new Map(); - private disposables: Disposable[] = []; - private debounceTimers = new Map(); +/** Event fired when embedded diagnostics are updated for a document. */ +export interface DidUpdateDiagnosticsEvent { + /** The URI of the Quarto document for which diagnostics were updated. */ + uri: Uri; + + /** The updated diagnostics for the Quarto document. */ + diagnostics: Diagnostic[]; +} + +export class EmbeddedDiagnosticsManager extends Disposable { + private readonly _onDidUpdateDiagnostics = this._register( + new EventEmitter() + ); + + /** Event fired when embedded diagnostics are updated for a document. */ + public readonly onDidUpdateDiagnostics = this._onDidUpdateDiagnostics.event; + + /** Diagnostic collection for Quarto documents. */ + private readonly diagnosticCollection = this._register( + languages.createDiagnosticCollection("quarto-embedded") + ); + + /** Map of virtual document info keyed by virtual document URI. */ + private readonly vdocToReal = new ResourceMap(); + + /** + * Map of debounce timers keyed by Quarto document URI. + * Document changes are debounced to avoid thrashing the language server + * with rapid updates as the user types. + */ + private readonly changeDebounceTimers = new ResourceMap(); constructor( private engine: MarkdownEngine, private outputChannel: LogOutputChannel, ) { - this.diagnosticCollection = languages.createDiagnosticCollection("quarto-embedded"); - this.disposables.push(this.diagnosticCollection); - - this.disposables.push( - // TODO: can we listen more specifically to particular vdocs? - // Listen to diagnostic changes from all language servers - languages.onDidChangeDiagnostics((event) => { - for (const uri of event.uris) { - const vdocInfo = this.vdocToReal.get(uri.toString()); - if (vdocInfo) { - this.handleDiagnosticsForVirtualDoc(uri, vdocInfo); - } + super(); + + // Listen for diagnostics for known virtual documents. + this._register(languages.onDidChangeDiagnostics((event) => { + for (const uri of event.uris) { + const vdocInfo = this.vdocToReal.get(uri); + if (vdocInfo) { + this.handleDiagnosticsForVirtualDoc(uri, vdocInfo); } - }), + } + })); - // Register document listeners - workspace.onDidOpenTextDocument((doc) => { - if (isQuartoDoc(doc)) { - this.handleDocumentOpen(doc); - } - }), - workspace.onDidChangeTextDocument((e) => { - if (isQuartoDoc(e.document)) { - this.handleDocumentChange(e.document); - } - }), - workspace.onDidCloseTextDocument((doc) => { - if (isQuartoDoc(doc)) { - this.handleDocumentClose(doc); - } - }) - ); + // Listen for Quarto documents opening. + this._register(workspace.onDidOpenTextDocument((doc) => { + if (isQuartoDoc(doc)) { + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Quarto document opened: ` + + `${formatQuartoDocUri(doc.uri)}` + ); + this.handleDocumentOpen(doc); + } + })); + + // Listen for Quarto documents changing. + this._register(workspace.onDidChangeTextDocument((e) => { + if (isQuartoDoc(e.document)) { + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Quarto document changed: ` + + `${formatQuartoDocUri(e.document.uri)}` + ); + this.handleDocumentChange(e.document); + } + })); + + // Listen for Quarto documents closing. + this._register(workspace.onDidCloseTextDocument((doc) => { + if (isQuartoDoc(doc)) { + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Quarto document closed: ` + + `${formatQuartoDocUri(doc.uri)}` + ); + this.handleDocumentClose(doc); + } + })); - // Process already-open documents + // Process already-open documents. workspace.textDocuments.forEach((doc) => { if (isQuartoDoc(doc)) { this.handleDocumentOpen(doc); @@ -93,46 +135,46 @@ export class EmbeddedDiagnosticsManager implements Disposable { } private async handleDocumentOpen(document: TextDocument): Promise { - if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { - return; - } + // TODO: Could an open event fire again for a known document? this.createVirtualDocs(document); } private handleDocumentChange(document: TextDocument): void { - if (!workspace.getConfiguration("quarto.cells.diagnostics").get("enabled", true)) { - return; + const existingTimer = this.changeDebounceTimers.get(document.uri); + if (existingTimer) { + clearTimeout(existingTimer); } - const docKey = document.uri.toString(); - const existingTimer = this.debounceTimers.get(docKey); - if (existingTimer) clearTimeout(existingTimer); - const debounceDelay = workspace.getConfiguration("quarto.cells.diagnostics").get("debounceDelay", 500); const timer = setTimeout(async () => { - this.debounceTimers.delete(docKey); + this.changeDebounceTimers.delete(document.uri); await this.recreateVirtualDocs(document); }, debounceDelay); - this.debounceTimers.set(docKey, timer); + this.changeDebounceTimers.set(document.uri, timer); } private handleDocumentClose(document: TextDocument): void { - const docKey = document.uri.toString(); - - const timer = this.debounceTimers.get(docKey); + const timer = this.changeDebounceTimers.get(document.uri); if (timer) { clearTimeout(timer); - this.debounceTimers.delete(docKey); + this.changeDebounceTimers.delete(document.uri); } - this.cleanupVirtualDocsForDocument(docKey); - this.diagnosticCollection.delete(document.uri); + this.cleanupVirtualDocsForDocument(document.uri); + + // TODO: We shouldn't actually need to clear the diagnostic collection... + // Although it's arguably the right call. + // But we could also wait for the language server to clear the document's + // diagnostics. + this.deleteDiagnostics(document.uri); } - private cleanupVirtualDocsForDocument(docKey: string): void { + private cleanupVirtualDocsForDocument(uri: Uri): void { + const docKey = uri.toString(); + for (const [vdocKey, vdocInfo] of this.vdocToReal.entries()) { - if (vdocInfo.realDocUri.toString() === docKey) { + if (vdocInfo.quartoDocumentUri.toString() === docKey) { vdocInfo.cleanup(); this.vdocToReal.delete(vdocKey); } @@ -140,8 +182,8 @@ export class EmbeddedDiagnosticsManager implements Disposable { } private async recreateVirtualDocs(document: TextDocument): Promise { - this.cleanupVirtualDocsForDocument(document.uri.toString()); - this.diagnosticCollection.delete(document.uri); + this.cleanupVirtualDocsForDocument(document.uri); + // TODO: Should we delete the diagnostic collection between waiting? await this.createVirtualDocs(document); } @@ -164,50 +206,60 @@ export class EmbeddedDiagnosticsManager implements Disposable { // Create one virtual doc per language for (const [langName] of languageMap) { const language = embeddedLanguage(langName); - if (!language) continue; + if (!language) { + continue; + } try { - const vdocContent = this.createVirtualDocContent(document, tokens, language); + // const vdocContent = this.createVirtualDocContent(document, tokens, language); + const vdocContent = virtualDocForLanguage(document, tokens, language); await withVirtualDocUri(vdocContent, document.uri, "diagnostics", async (uri: Uri) => { - this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Created virtual document ${uri.toString()} ` + - `for document ${document.uri.toString()} ` + - `(language: ${langName})` - ); - this.outputChannel.trace( - `[EmbeddedDiagnosticsManager] Virtual document content:\n` + - vdocContent.content - ); - // Create a deferred promise. // It'll resolve when the vdoc info cleanup function is called // e.g. after we receive the vdoc's diagnostics. let resolve!: () => void; const promise = new Promise((res) => resolve = res); - this.vdocToReal.set(uri.toString(), { - realDocUri: document.uri, + const vdocInfo = { + uri, + language: langName, + quartoDocumentUri: document.uri, tokens, cleanup: () => { this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Cleaning up virtual document ${uri.toString()} ` + - `for document ${document.uri.toString()}` + "[EmbeddedDiagnosticsManager] Cleaning up virtual document: " + + formatVirtualDoc(vdocInfo) ); resolve(); }, - }); + }; + this.vdocToReal.set(uri, vdocInfo); + + this.outputChannel.debug( + `[EmbeddedDiagnosticsManager] Created virtual document: ` + + formatVirtualDoc(vdocInfo, true) + ); + this.outputChannel.trace( + `[EmbeddedDiagnosticsManager] Virtual document content:\n` + + vdocContent.content + ); // Wait for the promise to resolve. // Once this callback ends, the virtual document will be cleaned up. this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Waiting for diagnostics for virtual document ${uri.toString()} ` + - `for document ${document.uri.toString()} ` + "[EmbeddedDiagnosticsManager] Waiting for diagnostics for virtual document: " + + formatVirtualDoc(vdocInfo) ); await promise; }); } catch (error) { - this.outputChannel.error(`[EmbeddedDiagnosticsManager] Failed to create virtual document; for ${langName}:`, error); + this.outputChannel.error( + `[EmbeddedDiagnosticsManager] Failed to create virtual document ` + + `for ${formatQuartoDocUri(document.uri)} ` + + `(language: ${langName}): ` + + JSON.stringify(error) + ); } } } @@ -243,14 +295,13 @@ export class EmbeddedDiagnosticsManager implements Disposable { }; } - private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: VirtualDocInfo): void { + private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: DiagnosticsVirtualDocument): void { const diagnostics = languages.getDiagnostics(uri); const mappedDiagnostics: Diagnostic[] = []; this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Received $;{ diagnostics.length; } diagnostics for ` + - ` ${vdocInfo.realDocUri.toString()} ` + - ` (virtual doc: ${uri.toString()})` + `[EmbeddedDiagnosticsManager] Received ${diagnostics.length} diagnostics for ` + + `virtual document: ${formatVirtualDoc(vdocInfo)}` ); for (const diagnostic of diagnostics) { @@ -261,34 +312,54 @@ export class EmbeddedDiagnosticsManager implements Disposable { this.outputChannel.error( `[EmbeddedDiagnosticsManager] Could not find language block; for diagnostic at ` + `[${diagnostic.range.start.line}, ${diagnostic.range.start.character}] ` + - `in ${vdocInfo.realDocUri.toString()} ` + - `(virtual doc: ${uri.toString()})` + `in virtual document: ${formatVirtualDoc(vdocInfo)}` ); } } - this.diagnosticCollection.set(vdocInfo.realDocUri, mappedDiagnostics); + this.setDiagnostics(vdocInfo.quartoDocumentUri, mappedDiagnostics); // We have diagnostics, so we can clean up the virtual doc. // This ensures that the virtual doc's diagnostics don't show // in the problems pane (or only show momentarily). - this.cleanupVirtualDocsForDocument(vdocInfo.realDocUri.toString()); + this.cleanupVirtualDocsForDocument(vdocInfo.quartoDocumentUri); + } + + private setDiagnostics(uri: Uri, diagnostics: Diagnostic[]): void { + this.diagnosticCollection.set(uri, diagnostics); + this._onDidUpdateDiagnostics.fire({ + uri, + diagnostics, + }); + } + + private deleteDiagnostics(uri: Uri): void { + this.diagnosticCollection.delete(uri); + this._onDidUpdateDiagnostics.fire({ + uri, + diagnostics: [], + }); } dispose(): void { - for (const timer of this.debounceTimers.values()) { + for (const timer of this.changeDebounceTimers.values()) { clearTimeout(timer); } - this.debounceTimers.clear(); + this.changeDebounceTimers.clear(); for (const vdocInfo of this.vdocToReal.values()) { vdocInfo.cleanup(); } this.vdocToReal.clear(); - - for (const disposable of this.disposables) { - disposable.dispose(); - } - this.disposables = []; } } + +function formatVirtualDoc(info: DiagnosticsVirtualDocument, fullUri = false) { + return `${fullUri ? info.uri.toString() : path.basename(info.uri.fsPath)} ` + + `(language: ${info.language}, ` + + `quartoDocument: ${formatQuartoDocUri(info.quartoDocumentUri)})`; +} + +function formatQuartoDocUri(uri: Uri) { + return workspace.asRelativePath(uri); +} diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index db3c84fb..0865afec 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -1,292 +1,191 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; -import { examplesOutUri, openAndShowUri, wait } from "./test-utils"; -import { testLanguageClient } from "./test-language-client"; -import { VIRTUAL_DOC_TEMP_DIRECTORY } from "./../vdoc/vdoc-tempfile"; +import { examplesUri, raceTimeout } from "./test-utils"; +import { testLanguageClient } from "./fixtures/test-language-client"; +import { EmbeddedDiagnosticsManager } from "../providers/embedded-diagnostics"; +import { MarkdownEngine } from "../markdown/engine"; +import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; +import { assertNoLeakedVirtualDocs } from "./utils/vdoc"; +import { eventToPromise, filterEvent } from "../core/event"; +import { DisposableStore } from "core"; suite("Diagnostics", function () { - const exampleUri = examplesOutUri("diagnostics.qmd"); - const diagnosticsSettledEvent = debounceEvent(onDidReceiveTestDiagnosticsForDocument(exampleUri), 100); - + const disposables = new DisposableStore(); let client: LanguageClient; - let disposables: vscode.Disposable[]; + let manager: EmbeddedDiagnosticsManager; + + setup(async function () { + // Create our own diagnostics manager rather than using the extension's + // so that we can directly listen for diagnostics changed events + // and see the output channel logs in the test output. + const engine = new MarkdownEngine(); + const outputChannel = new TestLogOutputChannel(); + manager = disposables.add(new EmbeddedDiagnosticsManager(engine, outputChannel)); - suiteSetup(async function () { + // Start a test language server. client = testLanguageClient(); await client.start(); - disposables = []; }); - suiteTeardown(async function () { + teardown(async function () { + disposables.clear(); await client.stop(); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - disposables.forEach((d) => d.dispose()); - }); - - teardown(async function () { - await assertNoLocalVirtualDocs(); - await assertNoTempFileVirtualDocs(); + await assertNoLeakedVirtualDocs(); }); - test("maps diagnostics from virtual doc back to the .qmd", async function () { - // Create an event that fires when test diagnostics are received for the document. - const promise = eventToPromise(diagnosticsSettledEvent); + test("receives diagnostics in the .qmd for embedded languages", async function () { + const uri = examplesUri("diagnostics-python-undefined.qmd"); + const event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { + await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + }, + "initial diagnostics on document open" + ); - // Open the document - the language server should respond with diagnostics. - await openAndShowUri(exampleUri); + assert.strictEqual( + event.uri.toString(), + uri.toString(), + "Expected diagnostics for the opened document" + ); - // Wait for diagnostics to settle. - const diagnostics = await promise; - assert.ok( - diagnostics.length > 0, - "Expected at least one diagnostic on the .qmd file" + const diagnostics = event.diagnostics; + assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); + assert.strictEqual( + diagnostics[0].message, + "test-diagnostic: undefined_var is not defined", + "Expected diagnostic message to match" ); - const diag = diagnostics.find((d) => - d.message.includes("test-diagnostic") - )!; assert.strictEqual( - diag.range.start.line, + diagnostics[0].range.start.line, 8, - `Diagnostic should be on line 8, got line ${diag.range.start.line}` + `Diagnostic should be on line 8, got line ${diagnostics[0].range.start.line}` ); }); - test("updates diagnostics when document is edited", async function () { - // Create an event that fires when test diagnostics are received for the document. - let promise = eventToPromise(diagnosticsSettledEvent); - + test("updates diagnostics when .qmd edited", async function () { + const uri = examplesUri("diagnostics-python-none.qmd"); // Open the document - the language server should respond with diagnostics. - const doc = await vscode.workspace.openTextDocument({ - language: "quarto", - content: '```{python}\nprint("Hello")\n```', - }); + let doc!: vscode.TextDocument; + let event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { + doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + }, + "initial diagnostics on document open" + ); - // TODO: Could also just edit the file - // Ignore initial diagnostics. - console.log('Waiting for initial diagnostics...'); - let diagnostics = await promise; - assert.ok(diagnostics.length > 0, "Expected no initial diagnostics"); + assert.strictEqual( + event.uri.toString(), + uri.toString(), + "Expected diagnostics for the opened document" + ); - // Edit: add a second code cell with undefined_var - promise = eventToPromise(diagnosticsSettledEvent); - const editor = await vscode.window.showTextDocument(doc); - await editor.edit((editBuilder) => { - const lastLine = doc.lineCount; - editBuilder.insert( - new vscode.Position(lastLine, 0), - "\n```{python}\nundefined_var\n```\n" - ); - }); + assert.strictEqual( + event.diagnostics.length, + 0, + `Expected no initial diagnostics, got ${JSON.stringify(event.diagnostics)}` + + ); + + event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { + const editor = await vscode.window.showTextDocument(doc); + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), "```{python}\nundefined_var\n```\n"); + }); + }, + "updated diagnostics on document change" + ); - // Wait for debounce + new diagnostics - console.log('Waiting for updated diagnostics...'); - diagnostics = await promise; - const testDiags = diagnostics.filter((d) => - d.message.includes("test-diagnostic") + assert.strictEqual( + event.uri.toString(), + uri.toString(), + "Expected diagnostics for the opened document" ); + assert.strictEqual( - testDiags.length, - 2, - `Expected two diagnostics after adding a second cell, got ${testDiags.length}` + event.diagnostics.length, + 1, + `Expected one diagnostic after adding a cell, got ${event.diagnostics.length}` ); }); test("clears diagnostics when document is closed", async function () { - const promise = eventToPromise(diagnosticsSettledEvent); + console.log("STARTING CLEAR DIAGNOSTICS TEST ****************"); + const uri = examplesUri("diagnostics-python-undefined.qmd"); + let doc!: vscode.TextDocument; + await withEmbeddedDiagnostics( + manager, + uri, + async () => { + doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + }, + "initial diagnostics on document open" + ); // Close the document - the language server should clear diagnostics for the document. - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + // TODO: Delete files if diagnostics never arrive - first a test case + // TODO: Think of more test cases and ask Claude too + const event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { + await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + }, + "diagnostics cleared on document close" + ); - // Wait for diagnostics to be cleared. - const diagnostics = await promise; assert.strictEqual( - diagnostics.length, + event.uri.toString(), + uri.toString(), + "Expected diagnostics for the closed document" + ); + + assert.strictEqual( + event.diagnostics.length, 0, "Diagnostics should be cleared after closing the document" ); }); }); -function onDidReceiveDiagnosticsForDocument( - uri: vscode.Uri, -): vscode.Event { - return (listener, thisArgs?, disposables?) => { - return vscode.languages.onDidChangeDiagnostics((e) => { - for (const diagnosticsUri of e.uris) { - if (diagnosticsUri.toString() === uri.toString()) { - const diagnostics = vscode.languages.getDiagnostics(diagnosticsUri); - listener.call(thisArgs, diagnostics); - } - } - }, thisArgs, disposables); - }; -} - -function onDidReceiveTestDiagnosticsForDocument(uri: vscode.Uri): vscode.Event { - return (listener, thisArgs?, disposables?) => { - // TODO: Challenge: we dont know when this was our embedded diagnostics or something else... - return onDidReceiveDiagnosticsForDocument(uri)(diagnostics => { - if (diagnostics.some(isTestDiagnostic)) { - console.log(`Received ${diagnostics.length} diagnostics for ${uri.toString()}`); - diagnostics.forEach((d) => { - console.log(`- ${d.message} at [${d.range.start.line}, ${d.range.start.character}]`); - }); - listener.call(thisArgs, diagnostics); - } - }, thisArgs, disposables); - }; -} - -function isTestDiagnostic(diagnostic: vscode.Diagnostic): boolean { - return /^test-diagnostic:/.test(diagnostic.message); -} - -export function onceEvent(event: vscode.Event): vscode.Event { - return (listener: (e: T) => any, thisArgs?: any, disposables?: vscode.Disposable[]) => { - const result = event(e => { - result.dispose(); - return listener.call(thisArgs, e); - }, null, disposables); - - return result; - }; -} - -export function debounceEvent(event: vscode.Event, delay: number): vscode.Event { - return (listener: (e: T) => any, thisArgs?: any, disposables?: vscode.Disposable[]) => { - let timer: NodeJS.Timeout; - return event(e => { - clearTimeout(timer); - timer = setTimeout(() => listener.call(thisArgs, e), delay); - }, null, disposables); - }; -} - -export function eventToPromise(event: vscode.Event): Promise { - return new Promise(c => onceEvent(event)(c)); -} - -// async function eventToPromise( -// event: vscode.Event, -// disposables: vscode.Disposable[], -// ) { -// return new Promise((resolve) => { -// const disposable = event((e) => { -// disposable.dispose(); -// resolve(e); -// }, null, disposables); -// }); -// } - -// function filterEvent( -// event: vscode.Event, -// filter: (e: T) => boolean, -// disposables: vscode.Disposable[], -// ): vscode.Event { -// return (listener, thisArgs?, disposables?) => { -// return event((e) => { -// if (filter(e)) { -// listener.call(thisArgs, e); -// } -// }, null, disposables); -// }; -// } - -// async function waitForDiagnostics( -// uri: vscode.Uri, -// predicate: (d: vscode.Diagnostic) => boolean, -// timeoutMs: number -// ): Promise { -// const start = Date.now(); - -// while (Date.now() - start < timeoutMs) { -// const diagnostics = vscode.languages.getDiagnostics(uri); -// if (diagnostics.some(predicate)) { -// return diagnostics; -// } -// await wait(200); -// } - -// return vscode.languages.getDiagnostics(uri); -// } - -// async function waitForDiagnosticsCleared( -// uri: vscode.Uri, -// timeoutMs: number -// ): Promise { -// const start = Date.now(); - -// while (Date.now() - start < timeoutMs) { -// const diagnostics = vscode.languages.getDiagnostics(uri); -// if (diagnostics.length === 0) { -// return diagnostics; -// } -// await wait(200); -// } - -// return vscode.languages.getDiagnostics(uri); -// } - -// async function poll( -// assertion: () => Promise, -// message: string, -// timeoutMs: number -// ): Promise { -// const start = Date.now(); - -// let finalError: unknown | null = null; -// while (Date.now() - start < timeoutMs) { -// try { -// return await assertion(); -// } catch (error) { -// console.error(`${message}: ${error instanceof Error ? error.message : JSON.stringify(error)}`); -// finalError = error; -// // Ignore and retry until timeout -// } -// await wait(200); -// } - -// if (finalError) { -// throw finalError; -// } -// } - -/** - * Check that there are no virtual doc files lingering in the workspace. - */ -async function assertNoLocalVirtualDocs() { - const vdocFiles = await vscode.workspace.findFiles("**/.vdoc.*"); - assert.strictEqual( - vdocFiles.length, - 0, - `Expected no virtual doc files, but found ${vdocFiles.length}` - ); -} - -/** - * Check that there are no virtual doc files lingering in the temp folder. - */ -async function assertNoTempFileVirtualDocs() { - const tempDir = await vscode.workspace.fs.readDirectory(vscode.Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)); - const tempVdocFiles = tempDir.filter(([name]) => name.startsWith(".vdoc.")); - assert.strictEqual( - tempVdocFiles.length, - 0, - `Expected no virtual doc files in temp directory, but found ${tempVdocFiles.length}` - ); +function isUriEqual(a: vscode.Uri, b: vscode.Uri) { + return a.toString() === b.toString(); } async function withEmbeddedDiagnostics( + manager: EmbeddedDiagnosticsManager, uri: vscode.Uri, callback: () => Promise, + action: string, + timeout = 4000, ) { - // Create an event that fires when test diagnostics are received for the document. - const diagnosticsSettledEvent = debounceEvent(onDidReceiveTestDiagnosticsForDocument(uri), 100); - const promise = eventToPromise(diagnosticsSettledEvent); + // Create a promise that resolves when diagnostics update for `uri`. + const promise = eventToPromise( + filterEvent( + manager.onDidUpdateDiagnostics, + (e) => isUriEqual(e.uri, uri) + ) + ); + + console.log(`Waiting for ${action}...`); await callback(); - // Wait for diagnostics to settle. - return await promise; + const result = await raceTimeout(promise, timeout); + if (!result) { + throw new Error(`Timed out waiting for ${action}`); + } + return result; } diff --git a/apps/vscode/src/test/examples/diagnostics-python-none.qmd b/apps/vscode/src/test/examples/diagnostics-python-none.qmd new file mode 100644 index 00000000..4f361a16 --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-python-none.qmd @@ -0,0 +1,10 @@ +--- +title: "Diagnostics test" +format: html +--- + +## Code + +```{python} +x = 0 +``` diff --git a/apps/vscode/src/test/examples/diagnostics-python-undefined.qmd b/apps/vscode/src/test/examples/diagnostics-python-undefined.qmd new file mode 100644 index 00000000..063ccc9d --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-python-undefined.qmd @@ -0,0 +1,10 @@ +--- +title: "Diagnostics test" +format: html +--- + +## Code + +```{python} +x = undefined_var +``` diff --git a/apps/vscode/src/test/test-language-client.ts b/apps/vscode/src/test/fixtures/test-language-client.ts similarity index 61% rename from apps/vscode/src/test/test-language-client.ts rename to apps/vscode/src/test/fixtures/test-language-client.ts index aa9c1aca..2e902048 100644 --- a/apps/vscode/src/test/test-language-client.ts +++ b/apps/vscode/src/test/fixtures/test-language-client.ts @@ -1,20 +1,10 @@ import path from "node:path"; -import { OutputChannel } from "vscode"; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; +import { TestOutputChannel } from "./test-output-channel"; -function testOutputChannel(name: string): OutputChannel { - return { - name, - append: (value) => console.log(`[${name}] ${value}`), - appendLine: (value) => console.log(`[${name}] ${value}`), - clear: () => { }, - show: () => { }, - hide: () => { }, - dispose: () => { }, - replace: (_value) => { }, - }; -} - +/** + * A {@link LanguageClient} for testing, which connects to `test-language-server.js`. + */ export function testLanguageClient(): LanguageClient { const serverModule = path.join(__dirname, "test-language-server.js"); @@ -25,7 +15,7 @@ export function testLanguageClient(): LanguageClient { const clientOptions: LanguageClientOptions = { documentSelector: [{ language: "python" }], - outputChannel: testOutputChannel("Test Language Client"), + outputChannel: new TestOutputChannel("Test Language Client"), }; return new LanguageClient( diff --git a/apps/vscode/src/test/test-language-server.ts b/apps/vscode/src/test/fixtures/test-language-server.ts similarity index 88% rename from apps/vscode/src/test/test-language-server.ts rename to apps/vscode/src/test/fixtures/test-language-server.ts index d4877eb8..eda7d3f4 100644 --- a/apps/vscode/src/test/test-language-server.ts +++ b/apps/vscode/src/test/fixtures/test-language-server.ts @@ -66,6 +66,13 @@ documents.onDidChangeContent(({ document }) => { publishDiagnostics(document); }); +// Clear diagnostics on document close. +documents.onDidClose(({ document }) => { + console.log(`Document closed: ${document.uri}`); + console.log(`Publishing 0 diagnostics for ${document.uri}`); + connection.sendDiagnostics({ uri: document.uri, diagnostics: [] }); +}); + // Connect the text document manager. documents.listen(connection); diff --git a/apps/vscode/src/test/fixtures/test-log-output-channel.ts b/apps/vscode/src/test/fixtures/test-log-output-channel.ts new file mode 100644 index 00000000..65cc6cc2 --- /dev/null +++ b/apps/vscode/src/test/fixtures/test-log-output-channel.ts @@ -0,0 +1,20 @@ +import { EventEmitter, LogLevel, LogOutputChannel } from "vscode"; + +/** A {@link LogOutputChannel} that logs to the console. */ +export class TestLogOutputChannel implements LogOutputChannel { + logLevel = LogLevel.Trace; + onDidChangeLogLevel = new EventEmitter().event; + constructor(public readonly name = "") { } + append(value: string) { console.log(this.name ? `[${this.name}] ${value}` : value); } + appendLine(value: string) { this.append(value); } + clear() { } + show() { } + hide() { } + dispose() { } + replace(_value: any) { } + trace(value: string) { this.append(value); } + debug(value: string) { this.append(value); } + info(value: string) { this.append(value); } + warn(value: string) { this.append(value); } + error(value: string) { this.append(value); } +} diff --git a/apps/vscode/src/test/fixtures/test-output-channel.ts b/apps/vscode/src/test/fixtures/test-output-channel.ts new file mode 100644 index 00000000..46da7cb2 --- /dev/null +++ b/apps/vscode/src/test/fixtures/test-output-channel.ts @@ -0,0 +1,13 @@ +import { OutputChannel } from "vscode"; + +/** An {@link OutputChannel} that logs to the console. */ +export class TestOutputChannel implements OutputChannel { + constructor(public readonly name: string) { } + append(value: string) { console.log(`[${this.name}] ${value}`); } + appendLine(value: string) { this.append(value); } + clear() { } + show() { } + hide() { } + dispose() { } + replace(_value: string) { } +} diff --git a/apps/vscode/src/test/test-utils.ts b/apps/vscode/src/test/test-utils.ts index a861728e..152a18c6 100644 --- a/apps/vscode/src/test/test-utils.ts +++ b/apps/vscode/src/test/test-utils.ts @@ -15,7 +15,7 @@ export const TEST_PATH = path.join(EXTENSION_ROOT_DIR, "src", "test"); export const WORKSPACE_PATH = path.join(TEST_PATH, "examples"); export const WORKSPACE_OUT_PATH = path.join(TEST_PATH, "examples-out"); -function examplesUri(fileName: string = ''): vscode.Uri { +export function examplesUri(fileName: string = ''): vscode.Uri { return vscode.Uri.file(path.join(WORKSPACE_PATH, fileName)); } export function examplesOutUri(fileName: string = ''): vscode.Uri { @@ -83,3 +83,18 @@ ${RESET_COLOR_ESCAPE_CODE}`); return content; } } + +/** + * Races a promise against a timeout, returning `undefined` if + * the timeout is reached before the promise resolves. + */ +export async function raceTimeout(promise: Promise, ms: number): Promise { + let timeout: NodeJS.Timeout; + const timeoutPromise = new Promise((resolve) => { + timeout = setTimeout(() => resolve(undefined), ms); + }); + return Promise.race([ + promise.finally(() => clearTimeout(timeout)), + timeoutPromise + ]); +} diff --git a/apps/vscode/src/test/utils/vdoc.ts b/apps/vscode/src/test/utils/vdoc.ts new file mode 100644 index 00000000..36495048 --- /dev/null +++ b/apps/vscode/src/test/utils/vdoc.ts @@ -0,0 +1,40 @@ +import assert from "assert"; +import { Uri, workspace } from "vscode"; +import { VIRTUAL_DOC_TEMP_DIRECTORY } from "../../vdoc/vdoc-tempfile"; + + +/** + * Assert that there are no virtual documents leaked after tests. + */ +export async function assertNoLeakedVirtualDocs() { + await assertNoLocalVirtualDocs(); + await assertNoTempFileVirtualDocs(); +} + +/** + * Assert that there are no virtual documents leaked in the workspace. + */ +async function assertNoLocalVirtualDocs() { + const vdocFiles = await workspace.findFiles("**/.vdoc.*"); + assert.strictEqual( + vdocFiles.length, + 0, + `Expected no virtual doc files, but found ${vdocFiles.length}: ` + + vdocFiles.map((uri) => uri.fsPath).join(", ") + ); +} + +/** + * Assert that there are no virtual documents leaked in the temp folder. + */ +async function assertNoTempFileVirtualDocs() { + const tempDir = await workspace.fs.readDirectory(Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)); + const tempVdocFiles = tempDir.filter(([name]) => name.startsWith(".vdoc.")); + assert.strictEqual( + tempVdocFiles.length, + 0, + `Expected no virtual doc files in temp directory, ` + + `but found ${tempVdocFiles.length}: ` + + tempVdocFiles.map(([name]) => name).join(", ") + ); +} diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index d5115913..abc36c65 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -59,6 +59,7 @@ export async function virtualDocUriFromTempFile( const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); if (!useLocal) { + // TODO: I think we can remove this. But maybe we can finish tests first // TODO: Reevaluate whether this is necessary. Old comment: // > if this is the first time getting a virtual doc for this // > language then execute a dummy request to cause it to load @@ -103,7 +104,7 @@ async function deleteDocument(doc: TextDocument) { } tmp.setGracefulCleanup(); -const VIRTUAL_DOC_TEMP_DIRECTORY = tmp.dirSync().name; +export const VIRTUAL_DOC_TEMP_DIRECTORY = tmp.dirSync().name; /** * Creates a virtual document in a temporary directory diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index a3af9842..2e46e4fd 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -84,7 +84,7 @@ function virtualDocForBlock(document: TextDocument, block: Token, language: Embe export function virtualDocForLanguage( document: TextDocument, tokens: Token[], - language: EmbeddedLanguage + language: EmbeddedLanguage, ): VirtualDoc { const lines = linesForLanguage(document, language); for (const languageBlock of tokens.filter(isBlockOfLanguage(language))) { @@ -198,7 +198,7 @@ function shouldUseLocalTempFile(virtualDoc: VirtualDoc, action: VirtualDocAction rLspConfig.get("enabled", false) && rLspConfig.get("diagnostics", false) ) { - return true; + return true; } } From d9383301add87109390fdb55957a7edd1c3552f2 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 12 May 2026 20:38:45 +0200 Subject: [PATCH 15/63] don't inject for diagnostic vdocs; rename to `diagnostics.ts` --- apps/vscode/src/main.ts | 2 +- ...embedded-diagnostics.ts => diagnostics.ts} | 42 +++---------------- apps/vscode/src/test/diagnostics.test.ts | 2 +- apps/vscode/src/vdoc/languages.ts | 10 +++++ apps/vscode/src/vdoc/vdoc.ts | 35 ++++++++++++---- 5 files changed, 45 insertions(+), 46 deletions(-) rename apps/vscode/src/providers/{embedded-diagnostics.ts => diagnostics.ts} (90%) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index b2c6c0b9..4b60b677 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -19,7 +19,7 @@ import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; -import { EmbeddedDiagnosticsManager } from "./providers/embedded-diagnostics"; +import { EmbeddedDiagnosticsManager } from "./providers/diagnostics"; import { cellCommands } from "./providers/cell/commands"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; diff --git a/apps/vscode/src/providers/embedded-diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts similarity index 90% rename from apps/vscode/src/providers/embedded-diagnostics.ts rename to apps/vscode/src/providers/diagnostics.ts index 597c5569..b5a4bdbf 100644 --- a/apps/vscode/src/providers/embedded-diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -135,7 +135,6 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private async handleDocumentOpen(document: TextDocument): Promise { - // TODO: Could an open event fire again for a known document? this.createVirtualDocs(document); } @@ -211,8 +210,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { } try { - // const vdocContent = this.createVirtualDocContent(document, tokens, language); - const vdocContent = virtualDocForLanguage(document, tokens, language); + const vdocContent = virtualDocForLanguage(document, tokens, language, "diagnostics"); await withVirtualDocUri(vdocContent, document.uri, "diagnostics", async (uri: Uri) => { // Create a deferred promise. @@ -264,53 +262,23 @@ export class EmbeddedDiagnosticsManager extends Disposable { } } - // TODO: this maybe shouldn't be implemented here, - // this creates a virtual doc without the inject - // lines that i.e. in python disable linting like - // `# type: ignore`. We should co-locate this with - // where vdoc content is usually created in `virtualDocForCode` - // in vdoc.ts - - private createVirtualDocContent( - document: TextDocument, - tokens: Token[], - language: EmbeddedLanguage - ): VirtualDoc { - const lines: string[] = []; - for (let i = 0; i < document.lineCount; i++) { - lines.push(language.emptyLine || ""); - } - - for (const block of tokens.filter( - (token) => isExecutableLanguageBlock(token) && languageNameFromBlock(token) === language.ids[0] - )) { - for (let line = block.range.start.line + 1; line < block.range.end.line && line < document.lineCount; line++) { - lines[line] = document.lineAt(line).text; - } - } - - return { - language, - content: lines.join("\n") + "\n", - }; - } - private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: DiagnosticsVirtualDocument): void { const diagnostics = languages.getDiagnostics(uri); - const mappedDiagnostics: Diagnostic[] = []; this.outputChannel.debug( `[EmbeddedDiagnosticsManager] Received ${diagnostics.length} diagnostics for ` + `virtual document: ${formatVirtualDoc(vdocInfo)}` ); + // Filter out diagnostics that don't map to a language block in the original document. + const mappedDiagnostics: Diagnostic[] = []; for (const diagnostic of diagnostics) { const block = languageBlockAtPosition(vdocInfo.tokens, diagnostic.range.start); - if (block) { + if (block !== undefined) { mappedDiagnostics.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); } else { this.outputChannel.error( - `[EmbeddedDiagnosticsManager] Could not find language block; for diagnostic at ` + + `[EmbeddedDiagnosticsManager] Could not find language block for diagnostic at ` + `[${diagnostic.range.start.line}, ${diagnostic.range.start.character}] ` + `in virtual document: ${formatVirtualDoc(vdocInfo)}` ); diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 0865afec..d0cbb852 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; import { examplesUri, raceTimeout } from "./test-utils"; import { testLanguageClient } from "./fixtures/test-language-client"; -import { EmbeddedDiagnosticsManager } from "../providers/embedded-diagnostics"; +import { EmbeddedDiagnosticsManager } from "../providers/diagnostics"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; import { assertNoLeakedVirtualDocs } from "./utils/vdoc"; diff --git a/apps/vscode/src/vdoc/languages.ts b/apps/vscode/src/vdoc/languages.ts index dbce368b..8c999895 100644 --- a/apps/vscode/src/vdoc/languages.ts +++ b/apps/vscode/src/vdoc/languages.ts @@ -23,6 +23,11 @@ export interface EmbeddedLanguage { emptyLine?: string; comment?: string; trigger?: string[]; + /** + * Lines of code to inject at the top of the virtual document. + * Used to disable diagnostics for virtual documents that were + * created for non-diagnostic actions. + */ inject?: string[]; canFormat?: boolean; canFormatDocument?: boolean; @@ -88,6 +93,11 @@ interface LanguageOptions { type?: "content" | "tempfile"; localTempFile?: boolean; emptyLine?: string; + /** + * Lines of code to inject at the top of the virtual document. + * Used to disable diagnostics for virtual documents that were + * created for non-diagnostic actions. + */ inject?: string[]; canFormat?: boolean; canFormatDocument?: boolean; diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 2e46e4fd..158ef42e 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -12,6 +12,7 @@ * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. * */ +/* eslint-disable @typescript-eslint/naming-convention */ import { Position, TextDocument, Uri, Range, SemanticTokens, extensions, workspace } from "vscode"; import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; @@ -29,10 +30,10 @@ export interface VirtualDoc { } export enum VirtualDocStyle { - /// Every block corresponding to the current position's language + /** Every block corresponding to the current position's language */ Language, - /// Only the block corresponding to the current position + /** Only the block corresponding to the current position */ Block } @@ -81,17 +82,25 @@ function virtualDocForBlock(document: TextDocument, block: Token, language: Embe return virtualDocForCode(lines, language); } +/** + * Create a virtual document from a text document. + * + * @param document The text document to create a virtual document from + * @param language The language of the virtual document + * @param action The action for which the virtual document is being created, if known + */ export function virtualDocForLanguage( document: TextDocument, tokens: Token[], language: EmbeddedLanguage, + action?: VirtualDocAction, ): VirtualDoc { const lines = linesForLanguage(document, language); for (const languageBlock of tokens.filter(isBlockOfLanguage(language))) { fillLinesFromBlock(lines, document, languageBlock); } padLinesForLanguage(lines, language); - return virtualDocForCode(lines, language); + return virtualDocForCode(lines, language, action); } function linesForLanguage(document: TextDocument, language: EmbeddedLanguage) { @@ -118,11 +127,23 @@ function padLinesForLanguage(lines: string[], language: EmbeddedLanguage) { } } -export function virtualDocForCode(code: string[], language: EmbeddedLanguage) { +/** + * Create a virtual document from code and language. + * + * @param code The lines of code to include in the virtual document + * @param language The language of the virtual document + * @param action The action for which the virtual document is being created, if known + */ +export function virtualDocForCode( + code: string[], + language: EmbeddedLanguage, + action?: VirtualDocAction, +) { const lines = [...code]; - if (language.inject) { + // For non-diagnostic actions, inject lines of code to disable diagnostics. + if (language.inject && action !== "diagnostics") { lines.unshift(...language.inject); } @@ -195,8 +216,8 @@ function shouldUseLocalTempFile(virtualDoc: VirtualDoc, action: VirtualDocAction ) { const rLspConfig = workspace.getConfiguration("r.lsp"); if ( - rLspConfig.get("enabled", false) && - rLspConfig.get("diagnostics", false) + rLspConfig.get("enabled", false) && + rLspConfig.get("diagnostics", false) ) { return true; } From 559eceb340e531af3db3a759e2f34ae4ea6d5d38 Mon Sep 17 00:00:00 2001 From: seem Date: Tue, 12 May 2026 20:53:12 +0200 Subject: [PATCH 16/63] extract `allLanguages` helper function --- apps/vscode/src/providers/diagnostics.ts | 40 ++++++------------------ apps/vscode/src/vdoc/vdoc.ts | 12 +++++++ 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index b5a4bdbf..f1333cea 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -23,14 +23,12 @@ import { } from "vscode"; import { Token, - isExecutableLanguageBlock, languageBlockAtPosition, - languageNameFromBlock, } from "quarto-core"; import { MarkdownEngine } from "../markdown/engine"; -import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; -import { VirtualDoc, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; +import { EmbeddedLanguage } from "../vdoc/languages"; +import { allLanguages, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; import { isQuartoDoc } from "../core/doc"; import { LogOutputChannel } from "vscode"; import path from "node:path"; @@ -39,7 +37,7 @@ import { ResourceMap } from "../core/resource-map"; interface DiagnosticsVirtualDocument { uri: Uri; - language: string; + language: EmbeddedLanguage; quartoDocumentUri: Uri; tokens: Token[]; cleanup: () => void; @@ -187,28 +185,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private async createVirtualDocs(document: TextDocument): Promise { + // Create a virtual document per language. const tokens = this.engine.parse(document); - - // Group code blocks by language - const languageMap = new Map(); - for (const token of tokens) { - if (isExecutableLanguageBlock(token)) { - const lang = languageNameFromBlock(token); - if (lang) { - const blocks = languageMap.get(lang) ?? []; - blocks.push(token); - languageMap.set(lang, blocks); - } - } - } - - // Create one virtual doc per language - for (const [langName] of languageMap) { - const language = embeddedLanguage(langName); - if (!language) { - continue; - } - + const languages = allLanguages(tokens); + for (const language of languages) { try { const vdocContent = virtualDocForLanguage(document, tokens, language, "diagnostics"); @@ -221,7 +201,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { const vdocInfo = { uri, - language: langName, + language, quartoDocumentUri: document.uri, tokens, cleanup: () => { @@ -231,7 +211,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { ); resolve(); }, - }; + } satisfies DiagnosticsVirtualDocument; this.vdocToReal.set(uri, vdocInfo); this.outputChannel.debug( @@ -255,7 +235,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.error( `[EmbeddedDiagnosticsManager] Failed to create virtual document ` + `for ${formatQuartoDocUri(document.uri)} ` + - `(language: ${langName}): ` + + `(language: ${language.ids[0]}): ` + JSON.stringify(error) ); } @@ -324,7 +304,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { function formatVirtualDoc(info: DiagnosticsVirtualDocument, fullUri = false) { return `${fullUri ? info.uri.toString() : path.basename(info.uri.fsPath)} ` + - `(language: ${info.language}, ` + + `(language: ${info.language.ids[0]}, ` + `quartoDocument: ${formatQuartoDocUri(info.quartoDocumentUri)})`; } diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 158ef42e..9b9c79b8 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -252,6 +252,18 @@ export function languageAtPosition(tokens: Token[], position: Position) { } } +/** Get all languages with code blocks in a token stream. */ +export function allLanguages(tokens: Token[]): EmbeddedLanguage[] { + const names = new Set( + tokens.filter(isExecutableLanguageBlock) + .map(languageNameFromBlock) + .filter(Boolean) + ); + return [...names] + .map(embeddedLanguage) + .filter((l): l is EmbeddedLanguage => l !== undefined); +} + export function mainLanguage( tokens: Token[], filter?: (language: EmbeddedLanguage) => boolean From 14cfe83d39b8eeccd40dfa2328adbeac43435ab8 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 14:19:42 +0200 Subject: [PATCH 17/63] cleaning up --- apps/vscode/src/providers/diagnostics.ts | 33 ++++++++++++------- apps/vscode/src/vdoc/vdoc.ts | 16 ++++++++- packages/quarto-core/src/markdown/language.ts | 10 +++--- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index f1333cea..4d51b609 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -22,25 +22,29 @@ import { workspace, } from "vscode"; import { - Token, + TokenCodeBlock, + TokenMath, languageBlockAtPosition, } from "quarto-core"; import { MarkdownEngine } from "../markdown/engine"; -import { EmbeddedLanguage } from "../vdoc/languages"; -import { allLanguages, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; +import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; +import { languageBlocksByLanguage, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; import { isQuartoDoc } from "../core/doc"; import { LogOutputChannel } from "vscode"; import path from "node:path"; import { Disposable } from "core"; import { ResourceMap } from "../core/resource-map"; +/** + * An ephemeral virtual document for language diagnostics. + */ interface DiagnosticsVirtualDocument { uri: Uri; language: EmbeddedLanguage; quartoDocumentUri: Uri; - tokens: Token[]; - cleanup: () => void; + languageBlocks: (TokenMath | TokenCodeBlock)[]; + dispose: () => void; } /** Event fired when embedded diagnostics are updated for a document. */ @@ -172,7 +176,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { for (const [vdocKey, vdocInfo] of this.vdocToReal.entries()) { if (vdocInfo.quartoDocumentUri.toString() === docKey) { - vdocInfo.cleanup(); + vdocInfo.dispose(); this.vdocToReal.delete(vdocKey); } } @@ -187,8 +191,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { private async createVirtualDocs(document: TextDocument): Promise { // Create a virtual document per language. const tokens = this.engine.parse(document); - const languages = allLanguages(tokens); - for (const language of languages) { + const languageBlocksMap = languageBlocksByLanguage(tokens); + for (const [languageName, languageBlocks] of languageBlocksMap) { + const language = embeddedLanguage(languageName); + if (!language) { + continue; + } + try { const vdocContent = virtualDocForLanguage(document, tokens, language, "diagnostics"); @@ -203,8 +212,8 @@ export class EmbeddedDiagnosticsManager extends Disposable { uri, language, quartoDocumentUri: document.uri, - tokens, - cleanup: () => { + languageBlocks, + dispose: () => { this.outputChannel.debug( "[EmbeddedDiagnosticsManager] Cleaning up virtual document: " + formatVirtualDoc(vdocInfo) @@ -253,7 +262,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { // Filter out diagnostics that don't map to a language block in the original document. const mappedDiagnostics: Diagnostic[] = []; for (const diagnostic of diagnostics) { - const block = languageBlockAtPosition(vdocInfo.tokens, diagnostic.range.start); + const block = languageBlockAtPosition(vdocInfo.languageBlocks, diagnostic.range.start); if (block !== undefined) { mappedDiagnostics.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); } else { @@ -296,7 +305,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.changeDebounceTimers.clear(); for (const vdocInfo of this.vdocToReal.values()) { - vdocInfo.cleanup(); + vdocInfo.dispose(); } this.vdocToReal.clear(); } diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 9b9c79b8..856fc263 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -15,7 +15,7 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Position, TextDocument, Uri, Range, SemanticTokens, extensions, workspace } from "vscode"; -import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; +import { Token, TokenCodeBlock, TokenMath, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; import { isQuartoDoc } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; @@ -264,6 +264,20 @@ export function allLanguages(tokens: Token[]): EmbeddedLanguage[] { .filter((l): l is EmbeddedLanguage => l !== undefined); } +export function languageBlocksByLanguage(tokens: Token[]): Map { + const result = new Map(); + for (const token of tokens.filter(isExecutableLanguageBlock)) { + const language = languageNameFromBlock(token); + if (language) { + if (!result.has(language)) { + result.set(language, []); + } + result.get(language)?.push(token as TokenMath | TokenCodeBlock); + } + } + return result; +} + export function mainLanguage( tokens: Token[], filter?: (language: EmbeddedLanguage) => boolean diff --git a/packages/quarto-core/src/markdown/language.ts b/packages/quarto-core/src/markdown/language.ts index f14c5fda..640035ce 100644 --- a/packages/quarto-core/src/markdown/language.ts +++ b/packages/quarto-core/src/markdown/language.ts @@ -15,7 +15,7 @@ import { Position } from "../position"; -import { Token, TokenCodeBlock, TokenMath, isCodeBlock, isMath, kAttrClasses } from "./token"; +import { Token, TokenCodeBlock, TokenMath, isCodeBlock, isMath, kAttrClasses } from "./token"; export function isLanguageBlock(token: Token) { return isCodeBlock(token) || isDisplayMath(token); @@ -24,7 +24,7 @@ export function isLanguageBlock(token: Token) { // a language block that will be executed with its results // inclued in the document (either by an engine or because // it is a raw or display math block) -export function isExecutableLanguageBlock(token: Token) : token is TokenMath | TokenCodeBlock { +export function isExecutableLanguageBlock(token: Token): token is TokenMath | TokenCodeBlock { if (isDisplayMath(token)) { return true; } else if (isCodeBlock(token) && token.attr?.[kAttrClasses].length) { @@ -87,7 +87,7 @@ export function isDisplayMath(token: Token): token is TokenMath { } } -export function isDiagram(token: Token) : token is TokenCodeBlock { +export function isDiagram(token: Token): token is TokenCodeBlock { return ( isExecutableLanguageBlockOf("mermaid")(token) || isExecutableLanguageBlockOf("dot")(token) @@ -110,10 +110,10 @@ export function languageNameFromBlock(token: Token) { } export function isExecutableLanguageBlockOf(language: string) { - return (token: Token) : token is TokenMath | TokenCodeBlock => { + return (token: Token): token is TokenMath | TokenCodeBlock => { return ( isExecutableLanguageBlock(token) && languageNameFromBlock(token) === language ); }; -} \ No newline at end of file +} From 9076469f712c988481fdc9bee33a549f1034f2ae Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 18:00:25 +0200 Subject: [PATCH 18/63] extract createVirtualDocFile from virtualDocUriFromTempFile --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 57 ++++++++++++++++++--------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index abc36c65..047a46d6 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -30,18 +30,17 @@ import { import { VirtualDoc, VirtualDocUri } from "./vdoc"; /** - * Create an on disk temporary file containing the contents of the virtual document + * Create a virtual document temp file and open it as a text document. * - * @param virtualDoc The document to use when populating the temporary file - * @param docPath The path to the original document the virtual document is - * based on. When `local` is `true`, this is used to determine the directory - * to create the temporary file in. - * @param local Whether or not the temporary file should be created "locally" in - * the workspace next to `docPath` or in a temporary directory outside the - * workspace. - * @returns A `VirtualDocUri` + * Unlike `virtualDocUriFromTempFile`, this does not perform a hover warmup. + * The returned `cleanup` function deletes the temp file and resets the + * document's language so the language server clears its diagnostics. + * + * @param virtualDoc The virtual document content + * @param docPath Path to the parent document (used for local file placement) + * @param local Whether to create the file alongside the parent document */ -export async function virtualDocUriFromTempFile( +export async function createVirtualDocFile( virtualDoc: VirtualDoc, docPath: string, local: boolean @@ -58,22 +57,42 @@ export async function virtualDocUriFromTempFile( const virtualDocUri = Uri.file(virtualDocFilepath); const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); + return { + uri: virtualDocTextDocument.uri, + cleanup: async () => await deleteDocument(virtualDocTextDocument), + }; +} + +/** + * Create an on disk temporary file containing the contents of the virtual document + * + * @param virtualDoc The document to use when populating the temporary file + * @param docPath The path to the original document the virtual document is + * based on. When `local` is `true`, this is used to determine the directory + * to create the temporary file in. + * @param local Whether or not the temporary file should be created "locally" in + * the workspace next to `docPath` or in a temporary directory outside the + * workspace. + * @returns A `VirtualDocUri` + */ +export async function virtualDocUriFromTempFile( + virtualDoc: VirtualDoc, + docPath: string, + local: boolean +): Promise { + const result = await createVirtualDocFile(virtualDoc, docPath, local); + const useLocal = local || virtualDoc.language.localTempFile; + if (!useLocal) { - // TODO: I think we can remove this. But maybe we can finish tests first - // TODO: Reevaluate whether this is necessary. Old comment: - // > if this is the first time getting a virtual doc for this - // > language then execute a dummy request to cause it to load + // TODO: Reevaluate whether this warmup is necessary. await commands.executeCommand( "vscode.executeHoverProvider", - virtualDocUri, + result.uri, new Position(0, 0) ); } - return { - uri: virtualDocTextDocument.uri, - cleanup: async () => await deleteDocument(virtualDocTextDocument), - }; + return result; } /** From d17727c6fc1e77ad62e5ba41b3db334abbed3b37 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 18:10:19 +0200 Subject: [PATCH 19/63] rewrite diagnostics manager with flat per-language sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deferred-promise/withVirtualDocUri architecture with independent DiagnosticSession objects — one per language per document. Each session manages its own vdoc lifecycle and timeout, so a non-responsive language server for one language doesn't block or interfere with another language's diagnostics. - Delete resource-map.ts (no longer needed) - Add optional timeoutMs constructor param (defaults to 10s) - Sessions merge diagnostics across languages when publishing - Add diagnostics-multilang.qmd test fixture - Update test language client to handle R documents - Add multi-language test verifying independent per-language diagnostics --- apps/vscode/src/core/resource-map.ts | 79 ---- apps/vscode/src/providers/diagnostics.ts | 355 +++++++++--------- apps/vscode/src/test/diagnostics.test.ts | 36 +- .../test/examples/diagnostics-multilang.qmd | 16 + .../src/test/fixtures/test-language-client.ts | 5 +- 5 files changed, 239 insertions(+), 252 deletions(-) delete mode 100644 apps/vscode/src/core/resource-map.ts create mode 100644 apps/vscode/src/test/examples/diagnostics-multilang.qmd diff --git a/apps/vscode/src/core/resource-map.ts b/apps/vscode/src/core/resource-map.ts deleted file mode 100644 index 34c155c9..00000000 --- a/apps/vscode/src/core/resource-map.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * resource-map.ts - * - * Copyright (C) 2026 by Posit Software, PBC - * Copyright (c) Microsoft Corporation. All rights reserved. - * - * Unless you have received this program directly from Posit Software pursuant - * to the terms of a commercial license agreement with Posit Software, then - * this program is licensed to you under the terms of version 3 of the - * GNU Affero General Public License. This program is distributed WITHOUT - * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, - * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the - * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. - * - */ - -import * as vscode from 'vscode'; - -type ResourceToKey = (uri: vscode.Uri) => string; - -const defaultResourceToKey = (resource: vscode.Uri): string => resource.toString(); - -export class ResourceMap { - - private readonly _map = new Map(); - - private readonly _toKey: ResourceToKey; - - constructor(toKey: ResourceToKey = defaultResourceToKey) { - this._toKey = toKey; - } - - public set(uri: vscode.Uri, value: T): this { - this._map.set(this._toKey(uri), { uri, value }); - return this; - } - - public get(resource: vscode.Uri): T | undefined { - return this._map.get(this._toKey(resource))?.value; - } - - public has(resource: vscode.Uri): boolean { - return this._map.has(this._toKey(resource)); - } - - public get size(): number { - return this._map.size; - } - - public clear(): void { - this._map.clear(); - } - - public delete(resource: vscode.Uri): boolean { - return this._map.delete(this._toKey(resource)); - } - - public *values(): IterableIterator { - for (const entry of this._map.values()) { - yield entry.value; - } - } - - public *keys(): IterableIterator { - for (const entry of this._map.values()) { - yield entry.uri; - } - } - - public *entries(): IterableIterator<[vscode.Uri, T]> { - for (const entry of this._map.values()) { - yield [entry.uri, entry.value]; - } - } - - public [Symbol.iterator](): IterableIterator<[vscode.Uri, T]> { - return this.entries(); - } -} diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 4d51b609..45a8adad 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -1,5 +1,5 @@ /* - * embedded-diagnostics.ts + * diagnostics.ts * * Copyright (C) 2022-2026 by Posit Software, PBC * @@ -16,8 +16,10 @@ import { Diagnostic, EventEmitter, + LogOutputChannel, TextDocument, Uri, + extensions, languages, workspace, } from "vscode"; @@ -28,24 +30,13 @@ import { } from "quarto-core"; import { MarkdownEngine } from "../markdown/engine"; -import { embeddedLanguage, EmbeddedLanguage } from "../vdoc/languages"; -import { languageBlocksByLanguage, virtualDocForLanguage, withVirtualDocUri } from "../vdoc/vdoc"; +import { EmbeddedLanguage, embeddedLanguage } from "../vdoc/languages"; +import { languageBlocksByLanguage, virtualDocForLanguage } from "../vdoc/vdoc"; +import { createVirtualDocFile } from "../vdoc/vdoc-tempfile"; import { isQuartoDoc } from "../core/doc"; -import { LogOutputChannel } from "vscode"; -import path from "node:path"; import { Disposable } from "core"; -import { ResourceMap } from "../core/resource-map"; -/** - * An ephemeral virtual document for language diagnostics. - */ -interface DiagnosticsVirtualDocument { - uri: Uri; - language: EmbeddedLanguage; - quartoDocumentUri: Uri; - languageBlocks: (TokenMath | TokenCodeBlock)[]; - dispose: () => void; -} +const DEFAULT_TIMEOUT_MS = 10_000; /** Event fired when embedded diagnostics are updated for a document. */ export interface DidUpdateDiagnosticsEvent { @@ -56,6 +47,20 @@ export interface DidUpdateDiagnosticsEvent { diagnostics: Diagnostic[]; } +interface ActiveVdoc { + uri: Uri; + cleanup: () => Promise; + timeout: NodeJS.Timeout; +} + +interface DiagnosticSession { + docUri: Uri; + language: EmbeddedLanguage; + languageBlocks: (TokenMath | TokenCodeBlock)[]; + activeVdoc?: ActiveVdoc; + diagnostics: Diagnostic[]; +} + export class EmbeddedDiagnosticsManager extends Disposable { private readonly _onDidUpdateDiagnostics = this._register( new EventEmitter() @@ -69,61 +74,43 @@ export class EmbeddedDiagnosticsManager extends Disposable { languages.createDiagnosticCollection("quarto-embedded") ); - /** Map of virtual document info keyed by virtual document URI. */ - private readonly vdocToReal = new ResourceMap(); - - /** - * Map of debounce timers keyed by Quarto document URI. - * Document changes are debounced to avoid thrashing the language server - * with rapid updates as the user types. - */ - private readonly changeDebounceTimers = new ResourceMap(); + private readonly sessions: DiagnosticSession[] = []; + private readonly debounceTimers = new Map(); + private readonly timeoutMs: number; constructor( private engine: MarkdownEngine, private outputChannel: LogOutputChannel, + timeoutMs?: number, ) { super(); + this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; - // Listen for diagnostics for known virtual documents. + // Listen for diagnostics arriving on virtual documents. this._register(languages.onDidChangeDiagnostics((event) => { for (const uri of event.uris) { - const vdocInfo = this.vdocToReal.get(uri); - if (vdocInfo) { - this.handleDiagnosticsForVirtualDoc(uri, vdocInfo); + const session = this.findSessionByVdocUri(uri); + if (session) { + this.handleDiagnosticsReceived(session, uri); } } })); - // Listen for Quarto documents opening. + // Document lifecycle. this._register(workspace.onDidOpenTextDocument((doc) => { if (isQuartoDoc(doc)) { - this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Quarto document opened: ` + - `${formatQuartoDocUri(doc.uri)}` - ); this.handleDocumentOpen(doc); } })); - // Listen for Quarto documents changing. this._register(workspace.onDidChangeTextDocument((e) => { if (isQuartoDoc(e.document)) { - this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Quarto document changed: ` + - `${formatQuartoDocUri(e.document.uri)}` - ); this.handleDocumentChange(e.document); } })); - // Listen for Quarto documents closing. this._register(workspace.onDidCloseTextDocument((doc) => { if (isQuartoDoc(doc)) { - this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Quarto document closed: ` + - `${formatQuartoDocUri(doc.uri)}` - ); this.handleDocumentClose(doc); } })); @@ -136,187 +123,213 @@ export class EmbeddedDiagnosticsManager extends Disposable { }); } - private async handleDocumentOpen(document: TextDocument): Promise { - this.createVirtualDocs(document); + // --- Document lifecycle --- + + private handleDocumentOpen(document: TextDocument): void { + this.createSessionsForDocument(document); } private handleDocumentChange(document: TextDocument): void { - const existingTimer = this.changeDebounceTimers.get(document.uri); + const key = document.uri.toString(); + const existingTimer = this.debounceTimers.get(key); if (existingTimer) { clearTimeout(existingTimer); } - const debounceDelay = workspace.getConfiguration("quarto.cells.diagnostics").get("debounceDelay", 500); - const timer = setTimeout(async () => { - this.changeDebounceTimers.delete(document.uri); - await this.recreateVirtualDocs(document); + const debounceDelay = workspace + .getConfiguration("quarto.cells.diagnostics") + .get("debounceDelay", 500); + + const timer = setTimeout(() => { + this.debounceTimers.delete(key); + this.recreateSessionsForDocument(document); }, debounceDelay); - this.changeDebounceTimers.set(document.uri, timer); + this.debounceTimers.set(key, timer); } private handleDocumentClose(document: TextDocument): void { - const timer = this.changeDebounceTimers.get(document.uri); + const key = document.uri.toString(); + + // Cancel pending debounce. + const timer = this.debounceTimers.get(key); if (timer) { clearTimeout(timer); - this.changeDebounceTimers.delete(document.uri); + this.debounceTimers.delete(key); } - this.cleanupVirtualDocsForDocument(document.uri); + // Dispose all sessions for this document. + this.removeSessionsForDocument(document.uri); - // TODO: We shouldn't actually need to clear the diagnostic collection... - // Although it's arguably the right call. - // But we could also wait for the language server to clear the document's - // diagnostics. - this.deleteDiagnostics(document.uri); + // Clear published diagnostics. + this.diagnosticCollection.delete(document.uri); + this._onDidUpdateDiagnostics.fire({ uri: document.uri, diagnostics: [] }); } - private cleanupVirtualDocsForDocument(uri: Uri): void { - const docKey = uri.toString(); + // --- Session management --- - for (const [vdocKey, vdocInfo] of this.vdocToReal.entries()) { - if (vdocInfo.quartoDocumentUri.toString() === docKey) { - vdocInfo.dispose(); - this.vdocToReal.delete(vdocKey); + private async createSessionsForDocument(document: TextDocument): Promise { + const tokens = this.engine.parse(document); + const blocksByLanguage = languageBlocksByLanguage(tokens); + + for (const [languageName, languageBlocks] of blocksByLanguage) { + const language = embeddedLanguage(languageName); + if (!language) { + continue; } + + const session: DiagnosticSession = { + docUri: document.uri, + language, + languageBlocks, + diagnostics: [], + }; + this.sessions.push(session); + + await this.activateSession(session, document); } } - private async recreateVirtualDocs(document: TextDocument): Promise { - this.cleanupVirtualDocsForDocument(document.uri); - // TODO: Should we delete the diagnostic collection between waiting? - await this.createVirtualDocs(document); + private async recreateSessionsForDocument(document: TextDocument): Promise { + // Dispose active vdocs but preserve stale diagnostics conceptually + // (we remove sessions but the new ones start empty — publishDiagnostics + // will use whatever the new sessions have). + this.removeSessionsForDocument(document.uri); + await this.createSessionsForDocument(document); } - private async createVirtualDocs(document: TextDocument): Promise { - // Create a virtual document per language. - const tokens = this.engine.parse(document); - const languageBlocksMap = languageBlocksByLanguage(tokens); - for (const [languageName, languageBlocks] of languageBlocksMap) { - const language = embeddedLanguage(languageName); - if (!language) { - continue; + private removeSessionsForDocument(docUri: Uri): void { + const docKey = docUri.toString(); + for (let i = this.sessions.length - 1; i >= 0; i--) { + if (this.sessions[i].docUri.toString() === docKey) { + this.disposeActiveVdoc(this.sessions[i]); + this.sessions.splice(i, 1); } + } + } - try { - const vdocContent = virtualDocForLanguage(document, tokens, language, "diagnostics"); - - await withVirtualDocUri(vdocContent, document.uri, "diagnostics", async (uri: Uri) => { - // Create a deferred promise. - // It'll resolve when the vdoc info cleanup function is called - // e.g. after we receive the vdoc's diagnostics. - let resolve!: () => void; - const promise = new Promise((res) => resolve = res); - - const vdocInfo = { - uri, - language, - quartoDocumentUri: document.uri, - languageBlocks, - dispose: () => { - this.outputChannel.debug( - "[EmbeddedDiagnosticsManager] Cleaning up virtual document: " + - formatVirtualDoc(vdocInfo) - ); - resolve(); - }, - } satisfies DiagnosticsVirtualDocument; - this.vdocToReal.set(uri, vdocInfo); - - this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Created virtual document: ` + - formatVirtualDoc(vdocInfo, true) - ); - this.outputChannel.trace( - `[EmbeddedDiagnosticsManager] Virtual document content:\n` + - vdocContent.content - ); - - // Wait for the promise to resolve. - // Once this callback ends, the virtual document will be cleaned up. - this.outputChannel.debug( - "[EmbeddedDiagnosticsManager] Waiting for diagnostics for virtual document: " + - formatVirtualDoc(vdocInfo) - ); - await promise; - }); - } catch (error) { - this.outputChannel.error( - `[EmbeddedDiagnosticsManager] Failed to create virtual document ` + - `for ${formatQuartoDocUri(document.uri)} ` + - `(language: ${language.ids[0]}): ` + - JSON.stringify(error) - ); - } + private async activateSession(session: DiagnosticSession, document: TextDocument): Promise { + try { + const tokens = this.engine.parse(document); + const vdocContent = virtualDocForLanguage( + document, tokens, session.language, "diagnostics" + ); + + const shouldUseLocal = this.shouldUseLocalTempFile(session.language); + const { uri, cleanup } = await createVirtualDocFile( + vdocContent, document.uri.fsPath, shouldUseLocal + ); + + const timeout = setTimeout(() => { + this.handleTimeout(session); + }, this.timeoutMs); + + session.activeVdoc = { uri, cleanup: cleanup!, timeout }; + + this.outputChannel.debug( + `[EmbeddedDiagnostics] Activated vdoc for ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` + ); + } catch (error) { + this.outputChannel.error( + `[EmbeddedDiagnostics] Failed to create vdoc for ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + JSON.stringify(error) + ); } } - private handleDiagnosticsForVirtualDoc(uri: Uri, vdocInfo: DiagnosticsVirtualDocument): void { - const diagnostics = languages.getDiagnostics(uri); + // --- Diagnostics handling --- + + private handleDiagnosticsReceived(session: DiagnosticSession, vdocUri: Uri): void { + const rawDiagnostics = languages.getDiagnostics(vdocUri); this.outputChannel.debug( - `[EmbeddedDiagnosticsManager] Received ${diagnostics.length} diagnostics for ` + - `virtual document: ${formatVirtualDoc(vdocInfo)}` + `[EmbeddedDiagnostics] Received ${rawDiagnostics.length} diagnostics for ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` ); - // Filter out diagnostics that don't map to a language block in the original document. - const mappedDiagnostics: Diagnostic[] = []; - for (const diagnostic of diagnostics) { - const block = languageBlockAtPosition(vdocInfo.languageBlocks, diagnostic.range.start); + // Filter: only keep diagnostics that map to a real language block. + const mapped: Diagnostic[] = []; + for (const diagnostic of rawDiagnostics) { + const block = languageBlockAtPosition(session.languageBlocks, diagnostic.range.start); if (block !== undefined) { - mappedDiagnostics.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); + mapped.push(new Diagnostic(diagnostic.range, diagnostic.message, diagnostic.severity)); } else { this.outputChannel.error( - `[EmbeddedDiagnosticsManager] Could not find language block for diagnostic at ` + + `[EmbeddedDiagnostics] Could not find language block for diagnostic at ` + `[${diagnostic.range.start.line}, ${diagnostic.range.start.character}] ` + - `in virtual document: ${formatVirtualDoc(vdocInfo)}` + `for ${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` ); } } - this.setDiagnostics(vdocInfo.quartoDocumentUri, mappedDiagnostics); + session.diagnostics = mapped; + this.disposeActiveVdoc(session); + this.publishDiagnostics(session.docUri); + } - // We have diagnostics, so we can clean up the virtual doc. - // This ensures that the virtual doc's diagnostics don't show - // in the problems pane (or only show momentarily). - this.cleanupVirtualDocsForDocument(vdocInfo.quartoDocumentUri); + private handleTimeout(session: DiagnosticSession): void { + this.outputChannel.warn( + `[EmbeddedDiagnostics] Language server for ${session.language.ids[0]} ` + + `did not respond within ${this.timeoutMs}ms ` + + `for ${workspace.asRelativePath(session.docUri)}` + ); + this.disposeActiveVdoc(session); } - private setDiagnostics(uri: Uri, diagnostics: Diagnostic[]): void { - this.diagnosticCollection.set(uri, diagnostics); - this._onDidUpdateDiagnostics.fire({ - uri, - diagnostics, - }); + private publishDiagnostics(docUri: Uri): void { + const docKey = docUri.toString(); + const allDiagnostics = this.sessions + .filter(s => s.docUri.toString() === docKey) + .flatMap(s => s.diagnostics); + + this.diagnosticCollection.set(docUri, allDiagnostics); + this._onDidUpdateDiagnostics.fire({ uri: docUri, diagnostics: allDiagnostics }); } - private deleteDiagnostics(uri: Uri): void { - this.diagnosticCollection.delete(uri); - this._onDidUpdateDiagnostics.fire({ - uri, - diagnostics: [], - }); + // --- Helpers --- + + private findSessionByVdocUri(uri: Uri): DiagnosticSession | undefined { + const key = uri.toString(); + return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } - dispose(): void { - for (const timer of this.changeDebounceTimers.values()) { - clearTimeout(timer); + private disposeActiveVdoc(session: DiagnosticSession): void { + if (session.activeVdoc) { + clearTimeout(session.activeVdoc.timeout); + session.activeVdoc.cleanup(); + session.activeVdoc = undefined; } - this.changeDebounceTimers.clear(); + } - for (const vdocInfo of this.vdocToReal.values()) { - vdocInfo.dispose(); + private shouldUseLocalTempFile(language: EmbeddedLanguage): boolean { + if (language.ids.includes("r")) { + const rExt = extensions.getExtension("REditorSupport.r"); + if (rExt?.isActive) { + const rLspConfig = workspace.getConfiguration("r.lsp"); + if ( + rLspConfig.get("enabled", false) && + rLspConfig.get("diagnostics", false) + ) { + return true; + } + } } - this.vdocToReal.clear(); + return false; } -} -function formatVirtualDoc(info: DiagnosticsVirtualDocument, fullUri = false) { - return `${fullUri ? info.uri.toString() : path.basename(info.uri.fsPath)} ` + - `(language: ${info.language.ids[0]}, ` + - `quartoDocument: ${formatQuartoDocUri(info.quartoDocumentUri)})`; -} + public override dispose(): void { + super.dispose(); + + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); -function formatQuartoDocUri(uri: Uri) { - return workspace.asRelativePath(uri); + for (const session of this.sessions) { + this.disposeActiveVdoc(session); + } + this.sessions.length = 0; + } } diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index d0cbb852..26720299 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; import { examplesUri, raceTimeout } from "./test-utils"; import { testLanguageClient } from "./fixtures/test-language-client"; -import { EmbeddedDiagnosticsManager } from "../providers/diagnostics"; +import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager } from "../providers/diagnostics"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; import { assertNoLeakedVirtualDocs } from "./utils/vdoc"; @@ -119,6 +119,40 @@ suite("Diagnostics", function () { ); }); + test("receives diagnostics for multiple languages independently", async function () { + this.timeout(15000); + + const uri = examplesUri("diagnostics-multilang.qmd"); + + // Subscribe before opening so we don't miss events fired during document open. + const events: DidUpdateDiagnosticsEvent[] = []; + const gotBoth = new Promise((resolve) => { + const sub = manager.onDidUpdateDiagnostics((e) => { + if (isUriEqual(e.uri, uri)) { + events.push(e); + if (events.length >= 2) { + sub.dispose(); + resolve(true); + } + } + }); + }); + + // Open the document - should eventually get diagnostics for both languages. + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + + const result = await raceTimeout(gotBoth, 12000); + assert.strictEqual(result, true, "Timed out waiting for multi-language diagnostics"); + + // The final published diagnostics should contain entries from both languages. + const finalDiagnostics = vscode.languages.getDiagnostics(uri); + assert.ok( + finalDiagnostics.length >= 2, + `Expected at least 2 diagnostics (one per language), got ${finalDiagnostics.length}` + ); + }); + test("clears diagnostics when document is closed", async function () { console.log("STARTING CLEAR DIAGNOSTICS TEST ****************"); const uri = examplesUri("diagnostics-python-undefined.qmd"); diff --git a/apps/vscode/src/test/examples/diagnostics-multilang.qmd b/apps/vscode/src/test/examples/diagnostics-multilang.qmd new file mode 100644 index 00000000..5166db55 --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-multilang.qmd @@ -0,0 +1,16 @@ +--- +title: "Multi-language diagnostics" +format: html +--- + +## Python + +```{python} +x = undefined_var +``` + +## R + +```{r} +y <- undefined_var +``` diff --git a/apps/vscode/src/test/fixtures/test-language-client.ts b/apps/vscode/src/test/fixtures/test-language-client.ts index 2e902048..689d96d0 100644 --- a/apps/vscode/src/test/fixtures/test-language-client.ts +++ b/apps/vscode/src/test/fixtures/test-language-client.ts @@ -14,7 +14,10 @@ export function testLanguageClient(): LanguageClient { }; const clientOptions: LanguageClientOptions = { - documentSelector: [{ language: "python" }], + documentSelector: [ + { language: "python" }, + { language: "r" }, + ], outputChannel: new TestOutputChannel("Test Language Client"), }; From ba951e5a3bd577001ed17fe800ddd21aad997e69 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 18:12:36 +0200 Subject: [PATCH 20/63] simplify: add warmup parameter to virtualDocUriFromTempFile instead of separate function --- apps/vscode/src/providers/diagnostics.ts | 6 +-- apps/vscode/src/vdoc/vdoc-tempfile.ts | 60 +++++++++--------------- 2 files changed, 24 insertions(+), 42 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 45a8adad..a89382b1 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -32,7 +32,7 @@ import { import { MarkdownEngine } from "../markdown/engine"; import { EmbeddedLanguage, embeddedLanguage } from "../vdoc/languages"; import { languageBlocksByLanguage, virtualDocForLanguage } from "../vdoc/vdoc"; -import { createVirtualDocFile } from "../vdoc/vdoc-tempfile"; +import { virtualDocUriFromTempFile } from "../vdoc/vdoc-tempfile"; import { isQuartoDoc } from "../core/doc"; import { Disposable } from "core"; @@ -216,8 +216,8 @@ export class EmbeddedDiagnosticsManager extends Disposable { ); const shouldUseLocal = this.shouldUseLocalTempFile(session.language); - const { uri, cleanup } = await createVirtualDocFile( - vdocContent, document.uri.fsPath, shouldUseLocal + const { uri, cleanup } = await virtualDocUriFromTempFile( + vdocContent, document.uri.fsPath, shouldUseLocal, false ); const timeout = setTimeout(() => { diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index 047a46d6..cb246870 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -29,40 +29,6 @@ import { } from "vscode"; import { VirtualDoc, VirtualDocUri } from "./vdoc"; -/** - * Create a virtual document temp file and open it as a text document. - * - * Unlike `virtualDocUriFromTempFile`, this does not perform a hover warmup. - * The returned `cleanup` function deletes the temp file and resets the - * document's language so the language server clears its diagnostics. - * - * @param virtualDoc The virtual document content - * @param docPath Path to the parent document (used for local file placement) - * @param local Whether to create the file alongside the parent document - */ -export async function createVirtualDocFile( - virtualDoc: VirtualDoc, - docPath: string, - local: boolean -): Promise { - const useLocal = local || virtualDoc.language.localTempFile; - - // If `useLocal`, then create the temporary document alongside the `docPath` - // so tools like formatters have access to workspace configuration. Otherwise, - // create it in a temp directory. - const virtualDocFilepath = useLocal - ? createVirtualDocLocalFile(virtualDoc, path.dirname(docPath)) - : createVirtualDocTempfile(virtualDoc); - - const virtualDocUri = Uri.file(virtualDocFilepath); - const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); - - return { - uri: virtualDocTextDocument.uri, - cleanup: async () => await deleteDocument(virtualDocTextDocument), - }; -} - /** * Create an on disk temporary file containing the contents of the virtual document * @@ -73,26 +39,42 @@ export async function createVirtualDocFile( * @param local Whether or not the temporary file should be created "locally" in * the workspace next to `docPath` or in a temporary directory outside the * workspace. + * @param warmup Whether to fire a hover request to prime the language server. + * Defaults to `true` for non-local files. Set to `false` for diagnostics + * where the language server responds to the file being opened. * @returns A `VirtualDocUri` */ export async function virtualDocUriFromTempFile( virtualDoc: VirtualDoc, docPath: string, - local: boolean + local: boolean, + warmup = true, ): Promise { - const result = await createVirtualDocFile(virtualDoc, docPath, local); const useLocal = local || virtualDoc.language.localTempFile; - if (!useLocal) { + // If `useLocal`, then create the temporary document alongside the `docPath` + // so tools like formatters have access to workspace configuration. Otherwise, + // create it in a temp directory. + const virtualDocFilepath = useLocal + ? createVirtualDocLocalFile(virtualDoc, path.dirname(docPath)) + : createVirtualDocTempfile(virtualDoc); + + const virtualDocUri = Uri.file(virtualDocFilepath); + const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); + + if (warmup && !useLocal) { // TODO: Reevaluate whether this warmup is necessary. await commands.executeCommand( "vscode.executeHoverProvider", - result.uri, + virtualDocUri, new Position(0, 0) ); } - return result; + return { + uri: virtualDocTextDocument.uri, + cleanup: async () => await deleteDocument(virtualDocTextDocument), + }; } /** From 5bf746b12203ff91518ca1f908dca89dda3c5e21 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 18:13:45 +0200 Subject: [PATCH 21/63] add JSDoc to diagnostics interfaces, class, and constant --- apps/vscode/src/providers/diagnostics.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index a89382b1..9819ef96 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -36,6 +36,7 @@ import { virtualDocUriFromTempFile } from "../vdoc/vdoc-tempfile"; import { isQuartoDoc } from "../core/doc"; import { Disposable } from "core"; +/** How long to wait for a language server to respond before giving up on a vdoc. */ const DEFAULT_TIMEOUT_MS = 10_000; /** Event fired when embedded diagnostics are updated for a document. */ @@ -47,20 +48,42 @@ export interface DidUpdateDiagnosticsEvent { diagnostics: Diagnostic[]; } +/** A virtual document that is actively waiting for diagnostics from a language server. */ interface ActiveVdoc { + /** URI of the temp file opened as a text document. */ uri: Uri; + /** Deletes the temp file and resets its language so the LS clears diagnostics. */ cleanup: () => Promise; + /** Fires if the language server doesn't respond in time. */ timeout: NodeJS.Timeout; } +/** + * Tracks the diagnostic state for one embedded language in one Quarto document. + * Each language operates independently — its timeout, vdoc lifecycle, and stored + * diagnostics don't interfere with other languages in the same document. + */ interface DiagnosticSession { + /** The Quarto document this session belongs to. */ docUri: Uri; + /** The embedded language (Python, R, etc.). */ language: EmbeddedLanguage; + /** Code blocks for this language, used to filter diagnostics by position. */ languageBlocks: (TokenMath | TokenCodeBlock)[]; + /** The active virtual document awaiting diagnostics, if any. */ activeVdoc?: ActiveVdoc; + /** Last received diagnostics for this language (stale-until-replaced on edits). */ diagnostics: Diagnostic[]; } +/** + * Surfaces language-server diagnostics from embedded code cells in Quarto documents. + * + * Creates a temporary virtual document per language, waits for the language server + * to publish diagnostics on it, then maps the diagnostics back onto the original + * `.qmd` file. Each language's lifecycle is independent — one slow or non-responsive + * language server won't block diagnostics from other languages. + */ export class EmbeddedDiagnosticsManager extends Disposable { private readonly _onDidUpdateDiagnostics = this._register( new EventEmitter() From c54c637124d6cf1a2c5d00bb8571630edb162123 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 18:14:36 +0200 Subject: [PATCH 22/63] add timeout test for non-responsive language servers --- apps/vscode/src/test/diagnostics.test.ts | 33 +++++++++++++++++++ .../src/test/examples/diagnostics-timeout.qmd | 16 +++++++++ 2 files changed, 49 insertions(+) create mode 100644 apps/vscode/src/test/examples/diagnostics-timeout.qmd diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 26720299..900738c9 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -153,6 +153,39 @@ suite("Diagnostics", function () { ); }); + test("times out for unresponsive language servers without blocking others", async function () { + // Use a separate manager with a short timeout so the test is fast. + disposables.clear(); + const engine = new MarkdownEngine(); + const outputChannel = new TestLogOutputChannel(); + manager = disposables.add(new EmbeddedDiagnosticsManager(engine, outputChannel, 200)); + + // Julia has no language server registered in tests, so it will time out. + // Python should still get its diagnostics independently. + const uri = examplesUri("diagnostics-timeout.qmd"); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + + // Wait for Python diagnostics to arrive (Julia will time out at 200ms). + const event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { /* doc already opened above */ }, + "python diagnostics while julia times out", + 2000, + ); + + // Python diagnostics should be present despite Julia timing out. + assert.ok( + event.diagnostics.length >= 1, + `Expected at least 1 diagnostic from Python, got ${event.diagnostics.length}` + ); + assert.ok( + event.diagnostics.some(d => d.message.includes("undefined_var")), + "Expected Python diagnostic about undefined_var" + ); + }); + test("clears diagnostics when document is closed", async function () { console.log("STARTING CLEAR DIAGNOSTICS TEST ****************"); const uri = examplesUri("diagnostics-python-undefined.qmd"); diff --git a/apps/vscode/src/test/examples/diagnostics-timeout.qmd b/apps/vscode/src/test/examples/diagnostics-timeout.qmd new file mode 100644 index 00000000..9762876f --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-timeout.qmd @@ -0,0 +1,16 @@ +--- +title: "Timeout test" +format: html +--- + +## Julia (no language server registered) + +```{julia} +undefined_var = 1 +``` + +## Python (has language server) + +```{python} +x = undefined_var +``` From 86807bca027c6b002af5d3f353552396a18b6adf Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 20:24:24 +0200 Subject: [PATCH 23/63] fix async handling --- apps/vscode/src/providers/diagnostics.ts | 88 ++++++++++++++---------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 9819ef96..678d8033 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -110,49 +110,54 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; // Listen for diagnostics arriving on virtual documents. - this._register(languages.onDidChangeDiagnostics((event) => { + this._register(languages.onDidChangeDiagnostics(async (event) => { for (const uri of event.uris) { const session = this.findSessionByVdocUri(uri); if (session) { - this.handleDiagnosticsReceived(session, uri); + await this.handleDiagnosticsReceived(session, uri); } } })); // Document lifecycle. - this._register(workspace.onDidOpenTextDocument((doc) => { + this._register(workspace.onDidOpenTextDocument(async (doc) => { if (isQuartoDoc(doc)) { - this.handleDocumentOpen(doc); + await this.handleDocumentOpen(doc); } })); - this._register(workspace.onDidChangeTextDocument((e) => { + this._register(workspace.onDidChangeTextDocument(async (e) => { if (isQuartoDoc(e.document)) { - this.handleDocumentChange(e.document); + await this.handleDocumentChange(e.document); } })); - this._register(workspace.onDidCloseTextDocument((doc) => { + this._register(workspace.onDidCloseTextDocument(async (doc) => { if (isQuartoDoc(doc)) { - this.handleDocumentClose(doc); + await this.handleDocumentClose(doc); } })); // Process already-open documents. - workspace.textDocuments.forEach((doc) => { + for (const doc of workspace.textDocuments) { if (isQuartoDoc(doc)) { - this.handleDocumentOpen(doc); + this.handleDocumentOpen(doc).catch((error) => { + this.outputChannel.error( + `[EmbeddedDiagnostics] Failed to initialize ${workspace.asRelativePath(doc.uri)}: ` + + JSON.stringify(error) + ); + }); } - }); + } } // --- Document lifecycle --- - private handleDocumentOpen(document: TextDocument): void { - this.createSessionsForDocument(document); + private async handleDocumentOpen(document: TextDocument): Promise { + await this.createSessionsForDocument(document); } - private handleDocumentChange(document: TextDocument): void { + private async handleDocumentChange(document: TextDocument): Promise { const key = document.uri.toString(); const existingTimer = this.debounceTimers.get(key); if (existingTimer) { @@ -165,13 +170,19 @@ export class EmbeddedDiagnosticsManager extends Disposable { const timer = setTimeout(() => { this.debounceTimers.delete(key); - this.recreateSessionsForDocument(document); + this.recreateSessionsForDocument(document).catch((error) => { + this.outputChannel.error( + `[EmbeddedDiagnostics] Failed to recreate sessions for ` + + `${workspace.asRelativePath(document.uri)}: ` + + JSON.stringify(error) + ); + }); }, debounceDelay); this.debounceTimers.set(key, timer); } - private handleDocumentClose(document: TextDocument): void { + private async handleDocumentClose(document: TextDocument): Promise { const key = document.uri.toString(); // Cancel pending debounce. @@ -182,7 +193,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { } // Dispose all sessions for this document. - this.removeSessionsForDocument(document.uri); + await this.removeSessionsForDocument(document.uri); // Clear published diagnostics. this.diagnosticCollection.delete(document.uri); @@ -217,15 +228,15 @@ export class EmbeddedDiagnosticsManager extends Disposable { // Dispose active vdocs but preserve stale diagnostics conceptually // (we remove sessions but the new ones start empty — publishDiagnostics // will use whatever the new sessions have). - this.removeSessionsForDocument(document.uri); + await this.removeSessionsForDocument(document.uri); await this.createSessionsForDocument(document); } - private removeSessionsForDocument(docUri: Uri): void { + private async removeSessionsForDocument(docUri: Uri): Promise { const docKey = docUri.toString(); for (let i = this.sessions.length - 1; i >= 0; i--) { if (this.sessions[i].docUri.toString() === docKey) { - this.disposeActiveVdoc(this.sessions[i]); + await this.disposeActiveVdoc(this.sessions[i]); this.sessions.splice(i, 1); } } @@ -243,15 +254,21 @@ export class EmbeddedDiagnosticsManager extends Disposable { vdocContent, document.uri.fsPath, shouldUseLocal, false ); - const timeout = setTimeout(() => { - this.handleTimeout(session); + const timeout = setTimeout(async () => { + this.outputChannel.warn( + `[EmbeddedDiagnostics] Language server for ${session.language.ids[0]} ` + + `did not respond within ${this.timeoutMs}ms ` + + `for ${workspace.asRelativePath(session.docUri)}` + ); + await this.disposeActiveVdoc(session); }, this.timeoutMs); session.activeVdoc = { uri, cleanup: cleanup!, timeout }; this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + uri.toString() ); } catch (error) { this.outputChannel.error( @@ -264,7 +281,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { // --- Diagnostics handling --- - private handleDiagnosticsReceived(session: DiagnosticSession, vdocUri: Uri): void { + private async handleDiagnosticsReceived(session: DiagnosticSession, vdocUri: Uri): Promise { const rawDiagnostics = languages.getDiagnostics(vdocUri); this.outputChannel.debug( @@ -288,19 +305,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { } session.diagnostics = mapped; - this.disposeActiveVdoc(session); + await this.disposeActiveVdoc(session); this.publishDiagnostics(session.docUri); } - private handleTimeout(session: DiagnosticSession): void { - this.outputChannel.warn( - `[EmbeddedDiagnostics] Language server for ${session.language.ids[0]} ` + - `did not respond within ${this.timeoutMs}ms ` + - `for ${workspace.asRelativePath(session.docUri)}` - ); - this.disposeActiveVdoc(session); - } - private publishDiagnostics(docUri: Uri): void { const docKey = docUri.toString(); const allDiagnostics = this.sessions @@ -318,10 +326,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } - private disposeActiveVdoc(session: DiagnosticSession): void { + private async disposeActiveVdoc(session: DiagnosticSession): Promise { if (session.activeVdoc) { clearTimeout(session.activeVdoc.timeout); - session.activeVdoc.cleanup(); + await session.activeVdoc.cleanup(); session.activeVdoc = undefined; } } @@ -351,7 +359,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.debounceTimers.clear(); for (const session of this.sessions) { - this.disposeActiveVdoc(session); + this.disposeActiveVdoc(session).catch((error) => { + this.outputChannel.error( + `[EmbeddedDiagnostics] Failed to dispose vdoc for ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + JSON.stringify(error) + ); + }); } this.sessions.length = 0; } From a9dafe163088f66ac529e34f80c5150c870f5bf7 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 20:26:23 +0200 Subject: [PATCH 24/63] delete all vdocs before tests --- apps/vscode/src/test/diagnostics.test.ts | 13 +++++-------- apps/vscode/src/test/utils/vdoc.ts | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 900738c9..f42d37a8 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -6,7 +6,7 @@ import { testLanguageClient } from "./fixtures/test-language-client"; import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager } from "../providers/diagnostics"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; -import { assertNoLeakedVirtualDocs } from "./utils/vdoc"; +import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; import { eventToPromise, filterEvent } from "../core/event"; import { DisposableStore } from "core"; @@ -26,6 +26,9 @@ suite("Diagnostics", function () { // Start a test language server. client = testLanguageClient(); await client.start(); + + // Delete all vdocs before starting tests. + await deleteAllVirtualDocs(); }); teardown(async function () { @@ -153,13 +156,7 @@ suite("Diagnostics", function () { ); }); - test("times out for unresponsive language servers without blocking others", async function () { - // Use a separate manager with a short timeout so the test is fast. - disposables.clear(); - const engine = new MarkdownEngine(); - const outputChannel = new TestLogOutputChannel(); - manager = disposables.add(new EmbeddedDiagnosticsManager(engine, outputChannel, 200)); - + test("times out for unresponsive/missing language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. const uri = examplesUri("diagnostics-timeout.qmd"); diff --git a/apps/vscode/src/test/utils/vdoc.ts b/apps/vscode/src/test/utils/vdoc.ts index 36495048..1e9ba0f3 100644 --- a/apps/vscode/src/test/utils/vdoc.ts +++ b/apps/vscode/src/test/utils/vdoc.ts @@ -3,6 +3,23 @@ import { Uri, workspace } from "vscode"; import { VIRTUAL_DOC_TEMP_DIRECTORY } from "../../vdoc/vdoc-tempfile"; +/** Delete all virtual documents from both the workspace and temp directory. */ +export async function deleteAllVirtualDocs() { + const [workspaceVdocs, tempDir] = await Promise.all([ + workspace.findFiles("**/.vdoc.*"), + workspace.fs.readDirectory(Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)), + ]); + + const deletes = workspaceVdocs.map((uri) => workspace.fs.delete(uri)); + for (const [name] of tempDir) { + if (name.startsWith(".vdoc.")) { + deletes.push(workspace.fs.delete(Uri.file(`${VIRTUAL_DOC_TEMP_DIRECTORY}/${name}`))); + } + } + + await Promise.all(deletes); +} + /** * Assert that there are no virtual documents leaked after tests. */ From a07361d332c6eed467403ac9eaecdf12bde66b5a Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 20:45:28 +0200 Subject: [PATCH 25/63] test that vdocs are cleaned up after no response timeout --- apps/vscode/src/providers/diagnostics.ts | 45 ++++++++++++++++++++--- apps/vscode/src/test/diagnostics.test.ts | 46 ++++++++++++++++++------ 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 678d8033..a907b7ef 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -48,6 +48,21 @@ export interface DidUpdateDiagnosticsEvent { diagnostics: Diagnostic[]; } +/** Why a virtual document was disposed. */ +export type VdocDisposeReason = 'diagnostics-received' | 'timeout' | 'session-removed'; + +/** Event fired when a virtual document is disposed. */ +export interface DidDisposeVdocEvent { + /** The Quarto document the vdoc belonged to. */ + docUri: Uri; + /** The language the vdoc was created for (e.g. "python", "r"). */ + language: string; + /** The URI of the virtual document that was disposed. */ + vdocUri: Uri; + /** Why the vdoc was disposed. */ + reason: VdocDisposeReason; +} + /** A virtual document that is actively waiting for diagnostics from a language server. */ interface ActiveVdoc { /** URI of the temp file opened as a text document. */ @@ -92,6 +107,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { /** Event fired when embedded diagnostics are updated for a document. */ public readonly onDidUpdateDiagnostics = this._onDidUpdateDiagnostics.event; + private readonly _onDidDisposeVdoc = this._register( + new EventEmitter() + ); + + /** Event fired when a virtual document is disposed (for any reason). */ + public readonly onDidDisposeVdoc = this._onDidDisposeVdoc.event; + /** Diagnostic collection for Quarto documents. */ private readonly diagnosticCollection = this._register( languages.createDiagnosticCollection("quarto-embedded") @@ -236,7 +258,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { const docKey = docUri.toString(); for (let i = this.sessions.length - 1; i >= 0; i--) { if (this.sessions[i].docUri.toString() === docKey) { - await this.disposeActiveVdoc(this.sessions[i]); + await this.disposeActiveVdoc(this.sessions[i], 'session-removed'); this.sessions.splice(i, 1); } } @@ -260,7 +282,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { `did not respond within ${this.timeoutMs}ms ` + `for ${workspace.asRelativePath(session.docUri)}` ); - await this.disposeActiveVdoc(session); + await this.disposeActiveVdoc(session, 'timeout'); }, this.timeoutMs); session.activeVdoc = { uri, cleanup: cleanup!, timeout }; @@ -305,7 +327,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { } session.diagnostics = mapped; - await this.disposeActiveVdoc(session); + await this.disposeActiveVdoc(session, 'diagnostics-received'); this.publishDiagnostics(session.docUri); } @@ -326,11 +348,24 @@ export class EmbeddedDiagnosticsManager extends Disposable { return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } - private async disposeActiveVdoc(session: DiagnosticSession): Promise { + private async disposeActiveVdoc(session: DiagnosticSession, reason: VdocDisposeReason): Promise { if (session.activeVdoc) { + const vdocUri = session.activeVdoc.uri; clearTimeout(session.activeVdoc.timeout); await session.activeVdoc.cleanup(); session.activeVdoc = undefined; + + this.outputChannel.debug( + `[EmbeddedDiagnostics] Disposed vdoc for ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)} ` + + `(reason: ${reason})` + ); + this._onDidDisposeVdoc.fire({ + docUri: session.docUri, + language: session.language.ids[0], + vdocUri, + reason, + }); } } @@ -359,7 +394,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.debounceTimers.clear(); for (const session of this.sessions) { - this.disposeActiveVdoc(session).catch((error) => { + this.disposeActiveVdoc(session, 'session-removed').catch((error) => { this.outputChannel.error( `[EmbeddedDiagnostics] Failed to dispose vdoc for ` + `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index f42d37a8..e917ab19 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -10,18 +10,20 @@ import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; import { eventToPromise, filterEvent } from "../core/event"; import { DisposableStore } from "core"; +/** Create a diagnostics manager for tests, registered with the given disposable store. */ +function createTestManager(disposables: DisposableStore, timeoutMs?: number) { + return disposables.add( + new EmbeddedDiagnosticsManager(new MarkdownEngine(), new TestLogOutputChannel(), timeoutMs) + ); +} + suite("Diagnostics", function () { const disposables = new DisposableStore(); let client: LanguageClient; let manager: EmbeddedDiagnosticsManager; setup(async function () { - // Create our own diagnostics manager rather than using the extension's - // so that we can directly listen for diagnostics changed events - // and see the output channel logs in the test output. - const engine = new MarkdownEngine(); - const outputChannel = new TestLogOutputChannel(); - manager = disposables.add(new EmbeddedDiagnosticsManager(engine, outputChannel)); + manager = createTestManager(disposables); // Start a test language server. client = testLanguageClient(); @@ -156,14 +158,14 @@ suite("Diagnostics", function () { ); }); - test("times out for unresponsive/missing language servers without blocking others", async function () { + test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. const uri = examplesUri("diagnostics-timeout.qmd"); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); - // Wait for Python diagnostics to arrive (Julia will time out at 200ms). + // Wait for Python diagnostics to arrive; should not be blocked by Julia timing out const event = await withEmbeddedDiagnostics( manager, uri, @@ -181,10 +183,34 @@ suite("Diagnostics", function () { event.diagnostics.some(d => d.message.includes("undefined_var")), "Expected Python diagnostic about undefined_var" ); + + }); + + test("cleans up vdoc after timeout when language server does not respond", async function () { + const shortTimeoutManager = createTestManager(disposables, 200); + + const uri = examplesUri("diagnostics-julia-only.qmd"); + + // Listen for the timeout dispose event on Julia's vdoc. + const timeoutEvent = eventToPromise( + filterEvent( + shortTimeoutManager.onDidDisposeVdoc, + (e) => e.reason === "timeout" && e.language === "julia" + ) + ); + + await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + + const result = await raceTimeout(timeoutEvent, 2000); + assert.ok(result, "Expected Julia vdoc to be disposed via timeout"); + + // The vdoc temp file should no longer exist. + const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + assert.strictEqual(exists, false, "Expected vdoc file to be deleted after timeout"); }); test("clears diagnostics when document is closed", async function () { - console.log("STARTING CLEAR DIAGNOSTICS TEST ****************"); const uri = examplesUri("diagnostics-python-undefined.qmd"); let doc!: vscode.TextDocument; await withEmbeddedDiagnostics( @@ -198,8 +224,6 @@ suite("Diagnostics", function () { ); // Close the document - the language server should clear diagnostics for the document. - // TODO: Delete files if diagnostics never arrive - first a test case - // TODO: Think of more test cases and ask Claude too const event = await withEmbeddedDiagnostics( manager, uri, From 710a4f45ed1984a441a882a96cbcd529e018b117 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 21:04:56 +0200 Subject: [PATCH 26/63] cleaner test output via QUIET env var --- apps/vscode/src/test/diagnostics.test.ts | 2 -- .../src/test/fixtures/test-language-client.ts | 4 +-- .../test/fixtures/test-log-output-channel.ts | 25 +++++++++++++------ .../src/test/fixtures/test-output-channel.ts | 13 ---------- claude.md | 4 ++- 5 files changed, 22 insertions(+), 26 deletions(-) delete mode 100644 apps/vscode/src/test/fixtures/test-output-channel.ts diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index e917ab19..3adfcf63 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -267,8 +267,6 @@ async function withEmbeddedDiagnostics( ) ); - console.log(`Waiting for ${action}...`); - await callback(); const result = await raceTimeout(promise, timeout); diff --git a/apps/vscode/src/test/fixtures/test-language-client.ts b/apps/vscode/src/test/fixtures/test-language-client.ts index 689d96d0..86cd736b 100644 --- a/apps/vscode/src/test/fixtures/test-language-client.ts +++ b/apps/vscode/src/test/fixtures/test-language-client.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; -import { TestOutputChannel } from "./test-output-channel"; +import { TestLogOutputChannel } from "./test-log-output-channel"; /** * A {@link LanguageClient} for testing, which connects to `test-language-server.js`. @@ -18,7 +18,7 @@ export function testLanguageClient(): LanguageClient { { language: "python" }, { language: "r" }, ], - outputChannel: new TestOutputChannel("Test Language Client"), + outputChannel: new TestLogOutputChannel("Test Language Client"), }; return new LanguageClient( diff --git a/apps/vscode/src/test/fixtures/test-log-output-channel.ts b/apps/vscode/src/test/fixtures/test-log-output-channel.ts index 65cc6cc2..6fa55f51 100644 --- a/apps/vscode/src/test/fixtures/test-log-output-channel.ts +++ b/apps/vscode/src/test/fixtures/test-log-output-channel.ts @@ -1,20 +1,29 @@ import { EventEmitter, LogLevel, LogOutputChannel } from "vscode"; -/** A {@link LogOutputChannel} that logs to the console. */ +/** + * A {@link LogOutputChannel} that logs to the console. + * Set `QUIET=1` to suppress all output. + */ export class TestLogOutputChannel implements LogOutputChannel { - logLevel = LogLevel.Trace; + logLevel: LogLevel = process.env.QUIET ? LogLevel.Off : LogLevel.Trace; onDidChangeLogLevel = new EventEmitter().event; + constructor(public readonly name = "") { } - append(value: string) { console.log(this.name ? `[${this.name}] ${value}` : value); } + + append(value: string) { + if (this.logLevel !== LogLevel.Off) { + console.log(this.name ? `[${this.name}] ${value}` : value); + } + } appendLine(value: string) { this.append(value); } clear() { } show() { } hide() { } dispose() { } replace(_value: any) { } - trace(value: string) { this.append(value); } - debug(value: string) { this.append(value); } - info(value: string) { this.append(value); } - warn(value: string) { this.append(value); } - error(value: string) { this.append(value); } + trace(value: string) { if (this.logLevel <= LogLevel.Trace) { this.append(value); } } + debug(value: string) { if (this.logLevel <= LogLevel.Debug) { this.append(value); } } + info(value: string) { if (this.logLevel <= LogLevel.Info) { this.append(value); } } + warn(value: string) { if (this.logLevel <= LogLevel.Warning) { this.append(value); } } + error(value: string) { if (this.logLevel <= LogLevel.Error) { this.append(value); } } } diff --git a/apps/vscode/src/test/fixtures/test-output-channel.ts b/apps/vscode/src/test/fixtures/test-output-channel.ts deleted file mode 100644 index 46da7cb2..00000000 --- a/apps/vscode/src/test/fixtures/test-output-channel.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { OutputChannel } from "vscode"; - -/** An {@link OutputChannel} that logs to the console. */ -export class TestOutputChannel implements OutputChannel { - constructor(public readonly name: string) { } - append(value: string) { console.log(`[${this.name}] ${value}`); } - appendLine(value: string) { this.append(value); } - clear() { } - show() { } - hide() { } - dispose() { } - replace(_value: string) { } -} diff --git a/claude.md b/claude.md index b96a6f5c..e5a61faf 100644 --- a/claude.md +++ b/claude.md @@ -50,7 +50,9 @@ The turborepo pipeline helps optimize build times by caching build artifacts and Testing procedures vary by component: -- VS Code extension: Run `yarn test-vscode [--label 'label' --grep 'pattern']` to compile test files and run them with the vscode-test CLI +- VS Code extension: Run `QUIET=1 yarn test-vscode [--label 'label' --grep 'pattern']` to compile test files and run them with the vscode-test CLI + - `QUIET=1` suppresses test log output; omit it when debugging failures + - The output is small enough to read directly — don't pipe through `tail` or `grep` - Read the [test configuration file](./apps/vscode/.vscode-test.mjs) for valid labels - Other components have specific test commands defined in their respective package.json files From 1ac2e1350545e200e88c9d3e1870306cac14d1d1 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 21:06:33 +0200 Subject: [PATCH 27/63] don't log when vdocs are already deleted --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index cb246870..b1ffb13a 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -99,8 +99,12 @@ async function deleteDocument(doc: TextDocument) { useTrash: false }); } catch (error) { + // It's okay if the file is already deleted. + if (error instanceof Error && error.message.includes("ENOENT")) { + return; + } const msg = error instanceof Error ? error.message : JSON.stringify(error); - console.log(`Error removing vdoc at ${doc.fileName}: ${msg}`); + console.error(`Error removing vdoc at ${doc.fileName}: ${msg}`); } } From 444f481871b45db04e396503a69bbc02acd07cb6 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 21:06:51 +0200 Subject: [PATCH 28/63] typo --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index b1ffb13a..bcae97d6 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -89,7 +89,7 @@ export async function virtualDocUriFromTempFile( */ async function deleteDocument(doc: TextDocument) { try { - // First set the language to 'raw' so that the language client + // First set the language to 'plaintext' so that the language client // closes the text document in the language server, which clears // diagnostics for the file. This stops diagnostics from building // up even after virtual docs are cleaned up. From 9889ee47dc2bebbae61cfbc532a8035b50bf9144 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 21:19:05 +0200 Subject: [PATCH 29/63] add test coverage for vdoc cleanup, error-fix clearing, and line offsets --- apps/vscode/src/test/diagnostics.test.ts | 154 ++++++++++++------ .../examples/diagnostics-python-offset.qmd | 15 ++ 2 files changed, 121 insertions(+), 48 deletions(-) create mode 100644 apps/vscode/src/test/examples/diagnostics-python-offset.qmd diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 3adfcf63..d02f8ace 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -41,16 +41,7 @@ suite("Diagnostics", function () { }); test("receives diagnostics in the .qmd for embedded languages", async function () { - const uri = examplesUri("diagnostics-python-undefined.qmd"); - const event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); - }, - "initial diagnostics on document open" - ); + const { uri, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); assert.strictEqual( event.uri.toString(), @@ -73,18 +64,7 @@ suite("Diagnostics", function () { }); test("updates diagnostics when .qmd edited", async function () { - const uri = examplesUri("diagnostics-python-none.qmd"); - // Open the document - the language server should respond with diagnostics. - let doc!: vscode.TextDocument; - let event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); - }, - "initial diagnostics on document open" - ); + const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd"); assert.strictEqual( event.uri.toString(), @@ -96,10 +76,9 @@ suite("Diagnostics", function () { event.diagnostics.length, 0, `Expected no initial diagnostics, got ${JSON.stringify(event.diagnostics)}` - ); - event = await withEmbeddedDiagnostics( + const updatedEvent = await withEmbeddedDiagnostics( manager, uri, async () => { @@ -112,15 +91,15 @@ suite("Diagnostics", function () { ); assert.strictEqual( - event.uri.toString(), + updatedEvent.uri.toString(), uri.toString(), "Expected diagnostics for the opened document" ); assert.strictEqual( - event.diagnostics.length, + updatedEvent.diagnostics.length, 1, - `Expected one diagnostic after adding a cell, got ${event.diagnostics.length}` + `Expected one diagnostic after adding a cell, got ${updatedEvent.diagnostics.length}` ); }); @@ -161,18 +140,7 @@ suite("Diagnostics", function () { test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. - const uri = examplesUri("diagnostics-timeout.qmd"); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); - - // Wait for Python diagnostics to arrive; should not be blocked by Julia timing out - const event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { /* doc already opened above */ }, - "python diagnostics while julia times out", - 2000, - ); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd"); // Python diagnostics should be present despite Julia timing out. assert.ok( @@ -183,7 +151,6 @@ suite("Diagnostics", function () { event.diagnostics.some(d => d.message.includes("undefined_var")), "Expected Python diagnostic about undefined_var" ); - }); test("cleans up vdoc after timeout when language server does not respond", async function () { @@ -210,20 +177,95 @@ suite("Diagnostics", function () { assert.strictEqual(exists, false, "Expected vdoc file to be deleted after timeout"); }); - test("clears diagnostics when document is closed", async function () { - const uri = examplesUri("diagnostics-python-undefined.qmd"); - let doc!: vscode.TextDocument; - await withEmbeddedDiagnostics( + test("clears diagnostics when error is fixed", async function () { + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + + // Replace `undefined_var` with a valid expression to fix the error. + const event = await withEmbeddedDiagnostics( manager, uri, async () => { - doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); + const editor = await vscode.window.showTextDocument(doc); + const line = doc.lineAt(8); + await editor.edit((editBuilder) => { + editBuilder.replace(line.range, "x = 0"); + }); }, - "initial diagnostics on document open" + "diagnostics cleared after fixing error" ); - // Close the document - the language server should clear diagnostics for the document. + assert.strictEqual( + event.diagnostics.length, + 0, + "Diagnostics should be cleared after fixing the error" + ); + }); + + test("cleans up vdoc after diagnostics are received", async function () { + // Listen for vdoc disposal with reason "diagnostics-received". + const disposeEvent = eventToPromise( + filterEvent( + manager.onDidDisposeVdoc, + (e) => e.reason === "diagnostics-received" && e.language === "python" + ) + ); + + const uri = examplesUri("diagnostics-python-undefined.qmd"); + await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + + const result = await raceTimeout(disposeEvent, 4000); + assert.ok(result, "Expected Python vdoc to be disposed after diagnostics received"); + + // The vdoc temp file should no longer exist. + const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + assert.strictEqual(exists, false, "Expected vdoc file to be deleted after diagnostics received"); + }); + + test("cleans up vdoc when document is closed", async function () { + // Use a file with Julia only (no LS in tests) so the vdoc stays alive + // long enough to be disposed by closing the document rather than by + // receiving diagnostics. + const uri = examplesUri("diagnostics-julia-only.qmd"); + + // Listen for vdoc disposal with reason "session-removed". + const disposeEvent = eventToPromise( + filterEvent( + manager.onDidDisposeVdoc, + (e) => e.reason === "session-removed" && e.language === "julia" + ) + ); + + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + + // Close the document before the default timeout fires. + await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + + const result = await raceTimeout(disposeEvent, 4000); + assert.ok(result, "Expected vdoc to be disposed when document is closed"); + + const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + assert.strictEqual(exists, false, "Expected vdoc file to be deleted after document close"); + }); + + test("maps diagnostic line numbers correctly with content above cell", async function () { + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); + + const diagnostics = event.diagnostics; + assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); + assert.strictEqual( + diagnostics[0].range.start.line, + 13, + `Diagnostic should be on line 13 (after extra content), got line ${diagnostics[0].range.start.line}` + ); + }); + + test("clears diagnostics when document is closed", async function () { + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); + + // Close the document - the manager should clear diagnostics for the document. const event = await withEmbeddedDiagnostics( manager, uri, @@ -252,6 +294,22 @@ function isUriEqual(a: vscode.Uri, b: vscode.Uri) { return a.toString() === b.toString(); } +/** Open a .qmd fixture and wait for its first diagnostics event. */ +async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string) { + const uri = examplesUri(fixture); + let doc!: vscode.TextDocument; + const event = await withEmbeddedDiagnostics( + manager, + uri, + async () => { + doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(uri); + }, + "initial diagnostics on document open" + ); + return { uri, event, doc }; +} + async function withEmbeddedDiagnostics( manager: EmbeddedDiagnosticsManager, uri: vscode.Uri, diff --git a/apps/vscode/src/test/examples/diagnostics-python-offset.qmd b/apps/vscode/src/test/examples/diagnostics-python-offset.qmd new file mode 100644 index 00000000..925cc2b7 --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-python-offset.qmd @@ -0,0 +1,15 @@ +--- +title: "Line offset test" +format: html +--- + +## Extra content above the code cell + +This paragraph adds lines above the cell so that `undefined_var` +appears at a different line offset than in simpler fixtures. + +More lines to push the cell down. + +```{python} +x = undefined_var +``` From 78d548ffc7893dd2d1a02e43b7bcf60c53934480 Mon Sep 17 00:00:00 2001 From: seem Date: Wed, 13 May 2026 21:22:37 +0200 Subject: [PATCH 30/63] test that multiple cells of the same language each produce diagnostics --- apps/vscode/src/test/diagnostics.test.ts | 14 ++++++++++++++ .../examples/diagnostics-python-multicell.qmd | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 apps/vscode/src/test/examples/diagnostics-python-multicell.qmd diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index d02f8ace..13fafd0a 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -250,6 +250,20 @@ suite("Diagnostics", function () { assert.strictEqual(exists, false, "Expected vdoc file to be deleted after document close"); }); + test("reports diagnostics from multiple cells of the same language", async function () { + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd"); + + const diagnostics = event.diagnostics; + assert.strictEqual(diagnostics.length, 2, "Expected one diagnostic per cell"); + + const lines = diagnostics.map(d => d.range.start.line).sort((a, b) => a - b); + assert.deepStrictEqual( + lines, + [8, 14], + `Expected diagnostics on lines 8 and 14, got ${JSON.stringify(lines)}` + ); + }); + test("maps diagnostic line numbers correctly with content above cell", async function () { const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); diff --git a/apps/vscode/src/test/examples/diagnostics-python-multicell.qmd b/apps/vscode/src/test/examples/diagnostics-python-multicell.qmd new file mode 100644 index 00000000..608b01ba --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-python-multicell.qmd @@ -0,0 +1,16 @@ +--- +title: "Multi-cell test" +format: html +--- + +## First cell + +```{python} +x = undefined_var +``` + +## Second cell + +```{python} +y = undefined_var +``` From e318eb00b810af4ab4e52a8f9c0aa219c94327b7 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 12:50:23 +0200 Subject: [PATCH 31/63] activate sessions concurrently --- apps/vscode/src/providers/diagnostics.ts | 68 ++++++++++++++++-------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index a907b7ef..ea30be53 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -26,7 +26,9 @@ import { import { TokenCodeBlock, TokenMath, + isExecutableLanguageBlock, languageBlockAtPosition, + languageNameFromBlock, } from "quarto-core"; import { MarkdownEngine } from "../markdown/engine"; @@ -134,7 +136,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { // Listen for diagnostics arriving on virtual documents. this._register(languages.onDidChangeDiagnostics(async (event) => { for (const uri of event.uris) { - const session = this.findSessionByVdocUri(uri); + const session = this.getSessionByVdocUri(uri); if (session) { await this.handleDiagnosticsReceived(session, uri); } @@ -225,25 +227,20 @@ export class EmbeddedDiagnosticsManager extends Disposable { // --- Session management --- private async createSessionsForDocument(document: TextDocument): Promise { + // Create or append blocks to each language's session for the document. const tokens = this.engine.parse(document); - const blocksByLanguage = languageBlocksByLanguage(tokens); - - for (const [languageName, languageBlocks] of blocksByLanguage) { + for (const block of tokens.filter(isExecutableLanguageBlock)) { + const languageName = languageNameFromBlock(block); + if (!languageName) { continue; } // No language, should not happen for a language block. const language = embeddedLanguage(languageName); - if (!language) { - continue; - } - - const session: DiagnosticSession = { - docUri: document.uri, - language, - languageBlocks, - diagnostics: [], - }; - this.sessions.push(session); - - await this.activateSession(session, document); + if (!language) { continue; } // Unknown language. + const session = this.getOrCreateSession(document.uri, language); + session.languageBlocks.push(block); } + + // Activate sessions for this document concurrently. + const docSessions = this.getSessionsForDocument(document.uri); + await Promise.all(docSessions.map(s => this.activateSession(s, document))); } private async recreateSessionsForDocument(document: TextDocument): Promise { @@ -299,7 +296,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { JSON.stringify(error) ); } - } + }; // --- Diagnostics handling --- @@ -332,18 +329,43 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private publishDiagnostics(docUri: Uri): void { - const docKey = docUri.toString(); - const allDiagnostics = this.sessions - .filter(s => s.docUri.toString() === docKey) + const allDiagnostics = this.getSessionsForDocument(docUri) .flatMap(s => s.diagnostics); this.diagnosticCollection.set(docUri, allDiagnostics); this._onDidUpdateDiagnostics.fire({ uri: docUri, diagnostics: allDiagnostics }); - } + }; // --- Helpers --- - private findSessionByVdocUri(uri: Uri): DiagnosticSession | undefined { + private getSession(uri: Uri, language: EmbeddedLanguage): DiagnosticSession | undefined { + const key = uri.toString(); + return this.sessions.find( + s => s.docUri.toString() === key && + s.language.ids[0] === language.ids[0] + ); + } + + private getOrCreateSession(uri: Uri, language: EmbeddedLanguage): DiagnosticSession { + let session = this.getSession(uri, language); + if (!session) { + session = { + docUri: uri, + language, + languageBlocks: [], + diagnostics: [] + }; + this.sessions.push(session); + } + return session; + } + + private getSessionsForDocument(uri: Uri): DiagnosticSession[] { + const key = uri.toString(); + return this.sessions.filter(s => s.docUri.toString() === key); + } + + private getSessionByVdocUri(uri: Uri): DiagnosticSession | undefined { const key = uri.toString(); return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } From 4d3ea32f9a62bd3402dddee67631aab0a4ac98cc Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 12:52:30 +0200 Subject: [PATCH 32/63] parse tokens once per document open instead of once per language --- apps/vscode/src/providers/diagnostics.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index ea30be53..abdb8aee 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -24,6 +24,7 @@ import { workspace, } from "vscode"; import { + Token, TokenCodeBlock, TokenMath, isExecutableLanguageBlock, @@ -227,20 +228,21 @@ export class EmbeddedDiagnosticsManager extends Disposable { // --- Session management --- private async createSessionsForDocument(document: TextDocument): Promise { - // Create or append blocks to each language's session for the document. const tokens = this.engine.parse(document); + + // Create or append blocks to each language's session for the document. for (const block of tokens.filter(isExecutableLanguageBlock)) { const languageName = languageNameFromBlock(block); - if (!languageName) { continue; } // No language, should not happen for a language block. + if (!languageName) { continue; } const language = embeddedLanguage(languageName); - if (!language) { continue; } // Unknown language. + if (!language) { continue; } const session = this.getOrCreateSession(document.uri, language); session.languageBlocks.push(block); } // Activate sessions for this document concurrently. const docSessions = this.getSessionsForDocument(document.uri); - await Promise.all(docSessions.map(s => this.activateSession(s, document))); + await Promise.all(docSessions.map(s => this.activateSession(s, document, tokens))); } private async recreateSessionsForDocument(document: TextDocument): Promise { @@ -261,9 +263,12 @@ export class EmbeddedDiagnosticsManager extends Disposable { } } - private async activateSession(session: DiagnosticSession, document: TextDocument): Promise { + private async activateSession( + session: DiagnosticSession, + document: TextDocument, + tokens: Token[], + ): Promise { try { - const tokens = this.engine.parse(document); const vdocContent = virtualDocForLanguage( document, tokens, session.language, "diagnostics" ); From 86146a04e420012e6e54a773754e4ec2abcde79c Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 13:57:04 +0200 Subject: [PATCH 33/63] fix type --- apps/vscode/src/providers/diagnostics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index abdb8aee..529068de 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -71,7 +71,7 @@ interface ActiveVdoc { /** URI of the temp file opened as a text document. */ uri: Uri; /** Deletes the temp file and resets its language so the LS clears diagnostics. */ - cleanup: () => Promise; + cleanup?: () => Promise; /** Fires if the language server doesn't respond in time. */ timeout: NodeJS.Timeout; } @@ -287,7 +287,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { await this.disposeActiveVdoc(session, 'timeout'); }, this.timeoutMs); - session.activeVdoc = { uri, cleanup: cleanup!, timeout }; + session.activeVdoc = { uri, cleanup, timeout }; this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + @@ -379,7 +379,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { if (session.activeVdoc) { const vdocUri = session.activeVdoc.uri; clearTimeout(session.activeVdoc.timeout); - await session.activeVdoc.cleanup(); + await session.activeVdoc.cleanup?.(); session.activeVdoc = undefined; this.outputChannel.debug( From e5403e8a50f6b6e312c0255f40014d6a8ae504c2 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 14:08:28 +0200 Subject: [PATCH 34/63] this code path was no longer used --- apps/vscode/src/providers/diagnostics.ts | 7 ++- apps/vscode/src/vdoc/vdoc.ts | 80 ++---------------------- 2 files changed, 11 insertions(+), 76 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 529068de..76c9f2df 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -34,7 +34,7 @@ import { import { MarkdownEngine } from "../markdown/engine"; import { EmbeddedLanguage, embeddedLanguage } from "../vdoc/languages"; -import { languageBlocksByLanguage, virtualDocForLanguage } from "../vdoc/vdoc"; +import { virtualDocForLanguage } from "../vdoc/vdoc"; import { virtualDocUriFromTempFile } from "../vdoc/vdoc-tempfile"; import { isQuartoDoc } from "../core/doc"; import { Disposable } from "core"; @@ -397,6 +397,9 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private shouldUseLocalTempFile(language: EmbeddedLanguage): boolean { + // The vscode-R extension uses the languageserver R package + // which does not provide diagnostics for files in the system + // temp directory. Use a local temp file in that case. if (language.ids.includes("r")) { const rExt = extensions.getExtension("REditorSupport.r"); if (rExt?.isActive) { @@ -409,6 +412,8 @@ export class EmbeddedDiagnosticsManager extends Disposable { } } } + + // Default to a non-local temp file - it's less invasive. return false; } diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 856fc263..50ce1f8f 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -14,8 +14,8 @@ */ /* eslint-disable @typescript-eslint/naming-convention */ -import { Position, TextDocument, Uri, Range, SemanticTokens, extensions, workspace } from "vscode"; -import { Token, TokenCodeBlock, TokenMath, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; +import { Position, TextDocument, Uri, Range, SemanticTokens } from "vscode"; +import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; import { isQuartoDoc } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; @@ -82,13 +82,6 @@ function virtualDocForBlock(document: TextDocument, block: Token, language: Embe return virtualDocForCode(lines, language); } -/** - * Create a virtual document from a text document. - * - * @param document The text document to create a virtual document from - * @param language The language of the virtual document - * @param action The action for which the virtual document is being created, if known - */ export function virtualDocForLanguage( document: TextDocument, tokens: Token[], @@ -127,13 +120,6 @@ function padLinesForLanguage(lines: string[], language: EmbeddedLanguage) { } } -/** - * Create a virtual document from code and language. - * - * @param code The lines of code to include in the virtual document - * @param language The language of the virtual document - * @param action The action for which the virtual document is being created, if known - */ export function virtualDocForCode( code: string[], language: EmbeddedLanguage, @@ -196,37 +182,6 @@ export async function withVirtualDocUri( } } -/** - * Whether to use a local temporary file for a given virtual document and action. - */ -function shouldUseLocalTempFile(virtualDoc: VirtualDoc, action: VirtualDocAction): boolean { - // Format and definition actions use a transient local vdoc - // (so they can get project-specific paths and formatting config) - if (["format", "definition"].includes(action)) { - return true; - } - - // The vscode-R extension uses the languageserver R package - // which does not provide diagnostics for temp files. - // Use a local temp file in that case. - if ( - virtualDoc.language.ids.includes("r") && - action === "diagnostics" && - extensions.getExtension("REditorSupport.r")?.isActive - ) { - const rLspConfig = workspace.getConfiguration("r.lsp"); - if ( - rLspConfig.get("enabled", false) && - rLspConfig.get("diagnostics", false) - ) { - return true; - } - } - - // Default to a non-local temp file - it's less invasive - return false; -} - // To be used through `withVirtualDocUri()`. Not safe to export on its own! The // cleanup hook must be called, and relying on the caller to do this is a huge // footgun. @@ -235,8 +190,9 @@ async function virtualDocUri( parentUri: Uri, action: VirtualDocAction ): Promise { - - const local = shouldUseLocalTempFile(virtualDoc, action); + // format and definition actions use a transient local vdoc + // (so they can get project-specific paths and formatting config) + const local = ["format", "definition"].includes(action); return virtualDoc.language.type === "content" ? { uri: virtualDocUriFromEmbeddedContent(virtualDoc, parentUri) } @@ -252,32 +208,6 @@ export function languageAtPosition(tokens: Token[], position: Position) { } } -/** Get all languages with code blocks in a token stream. */ -export function allLanguages(tokens: Token[]): EmbeddedLanguage[] { - const names = new Set( - tokens.filter(isExecutableLanguageBlock) - .map(languageNameFromBlock) - .filter(Boolean) - ); - return [...names] - .map(embeddedLanguage) - .filter((l): l is EmbeddedLanguage => l !== undefined); -} - -export function languageBlocksByLanguage(tokens: Token[]): Map { - const result = new Map(); - for (const token of tokens.filter(isExecutableLanguageBlock)) { - const language = languageNameFromBlock(token); - if (language) { - if (!result.has(language)) { - result.set(language, []); - } - result.get(language)?.push(token as TokenMath | TokenCodeBlock); - } - } - return result; -} - export function mainLanguage( tokens: Token[], filter?: (language: EmbeddedLanguage) => boolean From f5947089f739a459949b7ecf3cebc52d58542f83 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 14:09:41 +0200 Subject: [PATCH 35/63] fix type --- apps/vscode/src/core/event.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/core/event.ts b/apps/vscode/src/core/event.ts index 672ded0c..5a1a42b1 100644 --- a/apps/vscode/src/core/event.ts +++ b/apps/vscode/src/core/event.ts @@ -42,7 +42,7 @@ export function onceEvent(event: Event): Event { export function debounceEvent(event: Event, delay: number): Event { return (listener, thisArgs?, disposables?) => { - let timer: number; + let timer: NodeJS.Timeout; return event(e => { clearTimeout(timer); timer = setTimeout(() => listener.call(thisArgs, e), delay); From 5a21209f3e74fe777ac55b948e5e10c31eeda28a Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 14:11:34 +0200 Subject: [PATCH 36/63] revert formatting --- packages/quarto-core/src/markdown/language.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/quarto-core/src/markdown/language.ts b/packages/quarto-core/src/markdown/language.ts index 640035ce..f14c5fda 100644 --- a/packages/quarto-core/src/markdown/language.ts +++ b/packages/quarto-core/src/markdown/language.ts @@ -15,7 +15,7 @@ import { Position } from "../position"; -import { Token, TokenCodeBlock, TokenMath, isCodeBlock, isMath, kAttrClasses } from "./token"; +import { Token, TokenCodeBlock, TokenMath, isCodeBlock, isMath, kAttrClasses } from "./token"; export function isLanguageBlock(token: Token) { return isCodeBlock(token) || isDisplayMath(token); @@ -24,7 +24,7 @@ export function isLanguageBlock(token: Token) { // a language block that will be executed with its results // inclued in the document (either by an engine or because // it is a raw or display math block) -export function isExecutableLanguageBlock(token: Token): token is TokenMath | TokenCodeBlock { +export function isExecutableLanguageBlock(token: Token) : token is TokenMath | TokenCodeBlock { if (isDisplayMath(token)) { return true; } else if (isCodeBlock(token) && token.attr?.[kAttrClasses].length) { @@ -87,7 +87,7 @@ export function isDisplayMath(token: Token): token is TokenMath { } } -export function isDiagram(token: Token): token is TokenCodeBlock { +export function isDiagram(token: Token) : token is TokenCodeBlock { return ( isExecutableLanguageBlockOf("mermaid")(token) || isExecutableLanguageBlockOf("dot")(token) @@ -110,10 +110,10 @@ export function languageNameFromBlock(token: Token) { } export function isExecutableLanguageBlockOf(language: string) { - return (token: Token): token is TokenMath | TokenCodeBlock => { + return (token: Token) : token is TokenMath | TokenCodeBlock => { return ( isExecutableLanguageBlock(token) && languageNameFromBlock(token) === language ); }; -} +} \ No newline at end of file From 1f1b5103ecb85b4bb7723e5b4a99e30cddea3c03 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 14:16:10 +0200 Subject: [PATCH 37/63] simplify test log verbosity setting for claude --- apps/vscode/src/test/fixtures/test-log-output-channel.ts | 6 ++++-- claude.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/vscode/src/test/fixtures/test-log-output-channel.ts b/apps/vscode/src/test/fixtures/test-log-output-channel.ts index 6fa55f51..cecae940 100644 --- a/apps/vscode/src/test/fixtures/test-log-output-channel.ts +++ b/apps/vscode/src/test/fixtures/test-log-output-channel.ts @@ -2,10 +2,12 @@ import { EventEmitter, LogLevel, LogOutputChannel } from "vscode"; /** * A {@link LogOutputChannel} that logs to the console. - * Set `QUIET=1` to suppress all output. + * Quiet by default when run by Claude Code; set `VERBOSE=1` to override. */ export class TestLogOutputChannel implements LogOutputChannel { - logLevel: LogLevel = process.env.QUIET ? LogLevel.Off : LogLevel.Trace; + logLevel: LogLevel = (process.env.CLAUDE_CODE && !process.env.VERBOSE) + ? LogLevel.Off + : LogLevel.Trace; onDidChangeLogLevel = new EventEmitter().event; constructor(public readonly name = "") { } diff --git a/claude.md b/claude.md index e5a61faf..26b56e31 100644 --- a/claude.md +++ b/claude.md @@ -50,8 +50,8 @@ The turborepo pipeline helps optimize build times by caching build artifacts and Testing procedures vary by component: -- VS Code extension: Run `QUIET=1 yarn test-vscode [--label 'label' --grep 'pattern']` to compile test files and run them with the vscode-test CLI - - `QUIET=1` suppresses test log output; omit it when debugging failures +- VS Code extension: Run `yarn test-vscode [--label 'label' --grep 'pattern']` to compile test files and run them with the vscode-test CLI + - Test log output is suppressed automatically when run by Claude Code; set `VERBOSE=1` when debugging failures - The output is small enough to read directly — don't pipe through `tail` or `grep` - Read the [test configuration file](./apps/vscode/.vscode-test.mjs) for valid labels - Other components have specific test commands defined in their respective package.json files From 206305923d0cabd5e2ea6c1990f62a74743f8ac5 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 14:18:55 +0200 Subject: [PATCH 38/63] clarify --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 13 ++++++++++++- apps/vscode/src/vdoc/vdoc.ts | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index bcae97d6..e5fc3764 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -63,7 +63,9 @@ export async function virtualDocUriFromTempFile( const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); if (warmup && !useLocal) { - // TODO: Reevaluate whether this warmup is necessary. + // TODO: Reevaluate whether this is necessary. Old comment: + // > if this is the first time getting a virtual doc for this + // > language then execute a dummy request to cause it to load await commands.executeCommand( "vscode.executeHoverProvider", virtualDocUri, @@ -93,6 +95,15 @@ async function deleteDocument(doc: TextDocument) { // closes the text document in the language server, which clears // diagnostics for the file. This stops diagnostics from building // up even after virtual docs are cleaned up. + // + // Unfortunately, workspace.fs.delete does not trigger the + // vscode.window.onDidCloseTextDocument event, which the language + // client relies on to send the textDocument/didClose notification + // to the language server. + // + // vscode.WorkspaceEdit *does* trigger onDidCloseTextDocument, + // but doesn't support skipping the trash - see the note above + // re #708. await languages.setTextDocumentLanguage(doc, "plaintext"); await workspace.fs.delete(doc.uri, { diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 50ce1f8f..4e004b39 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -12,7 +12,6 @@ * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. * */ -/* eslint-disable @typescript-eslint/naming-convention */ import { Position, TextDocument, Uri, Range, SemanticTokens } from "vscode"; import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; @@ -31,9 +30,11 @@ export interface VirtualDoc { export enum VirtualDocStyle { /** Every block corresponding to the current position's language */ + // eslint-disable-next-line @typescript-eslint/naming-convention Language, /** Only the block corresponding to the current position */ + // eslint-disable-next-line @typescript-eslint/naming-convention Block } @@ -190,6 +191,7 @@ async function virtualDocUri( parentUri: Uri, action: VirtualDocAction ): Promise { + // format and definition actions use a transient local vdoc // (so they can get project-specific paths and formatting config) const local = ["format", "definition"].includes(action); From e7cf485bfc0e124345d9b967382342aa8a30ec0a Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 15:31:57 +0200 Subject: [PATCH 39/63] move diagnostic local tempfiles to `.quarto` dir --- apps/vscode/src/providers/diagnostics.ts | 8 ++- apps/vscode/src/vdoc/vdoc-tempfile.ts | 74 +++++++++--------------- apps/vscode/src/vdoc/vdoc.ts | 18 +++--- 3 files changed, 42 insertions(+), 58 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 76c9f2df..8147609d 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -35,7 +35,7 @@ import { import { MarkdownEngine } from "../markdown/engine"; import { EmbeddedLanguage, embeddedLanguage } from "../vdoc/languages"; import { virtualDocForLanguage } from "../vdoc/vdoc"; -import { virtualDocUriFromTempFile } from "../vdoc/vdoc-tempfile"; +import { virtualDocUriFromTempFile, quartoVdocDir, VIRTUAL_DOC_TEMP_DIRECTORY } from "../vdoc/vdoc-tempfile"; import { isQuartoDoc } from "../core/doc"; import { Disposable } from "core"; @@ -273,9 +273,11 @@ export class EmbeddedDiagnosticsManager extends Disposable { document, tokens, session.language, "diagnostics" ); - const shouldUseLocal = this.shouldUseLocalTempFile(session.language); + const dir = this.shouldUseLocalTempFile(session.language) + ? quartoVdocDir(document.uri.fsPath) + : VIRTUAL_DOC_TEMP_DIRECTORY; const { uri, cleanup } = await virtualDocUriFromTempFile( - vdocContent, document.uri.fsPath, shouldUseLocal, false + vdocContent, dir, { warmup: false } ); const timeout = setTimeout(async () => { diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index e5fc3764..f5912c89 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -29,40 +29,29 @@ import { } from "vscode"; import { VirtualDoc, VirtualDocUri } from "./vdoc"; +interface VirtualDocTempFileOptions { + /** Fire a hover request to prime the language server before returning. */ + warmup: boolean; +} + /** - * Create an on disk temporary file containing the contents of the virtual document + * Create an on-disk temporary file for a virtual document and open it. * - * @param virtualDoc The document to use when populating the temporary file - * @param docPath The path to the original document the virtual document is - * based on. When `local` is `true`, this is used to determine the directory - * to create the temporary file in. - * @param local Whether or not the temporary file should be created "locally" in - * the workspace next to `docPath` or in a temporary directory outside the - * workspace. - * @param warmup Whether to fire a hover request to prime the language server. - * Defaults to `true` for non-local files. Set to `false` for diagnostics - * where the language server responds to the file being opened. - * @returns A `VirtualDocUri` + * @param virtualDoc The virtual document content to write. + * @param directory The directory to create the file in. */ export async function virtualDocUriFromTempFile( virtualDoc: VirtualDoc, - docPath: string, - local: boolean, - warmup = true, + directory: string, + options: VirtualDocTempFileOptions, ): Promise { - const useLocal = local || virtualDoc.language.localTempFile; - - // If `useLocal`, then create the temporary document alongside the `docPath` - // so tools like formatters have access to workspace configuration. Otherwise, - // create it in a temp directory. - const virtualDocFilepath = useLocal - ? createVirtualDocLocalFile(virtualDoc, path.dirname(docPath)) - : createVirtualDocTempfile(virtualDoc); + const filepath = generateVirtualDocFilepath(directory, virtualDoc.language.extension); + createVirtualDoc(filepath, virtualDoc.content); - const virtualDocUri = Uri.file(virtualDocFilepath); + const virtualDocUri = Uri.file(filepath); const virtualDocTextDocument = await workspace.openTextDocument(virtualDocUri); - if (warmup && !useLocal) { + if (options.warmup) { // TODO: Reevaluate whether this is necessary. Old comment: // > if this is the first time getting a virtual doc for this // > language then execute a dummy request to cause it to load @@ -123,30 +112,19 @@ tmp.setGracefulCleanup(); export const VIRTUAL_DOC_TEMP_DIRECTORY = tmp.dirSync().name; /** - * Creates a virtual document in a temporary directory - * - * The temporary directory is automatically cleaned up on process exit. - * - * @param virtualDoc The document to use when populating the temporary file - * @returns The path to the temporary file - */ -function createVirtualDocTempfile(virtualDoc: VirtualDoc): string { - const filepath = generateVirtualDocFilepath(VIRTUAL_DOC_TEMP_DIRECTORY, virtualDoc.language.extension); - createVirtualDoc(filepath, virtualDoc.content); - return filepath; -} - -/** - * Creates a virtual document in the provided directory + * Resolves the `.quarto/vdoc/` directory for the workspace containing `docPath`. * - * @param virtualDoc The document to use when populating the temporary file - * @param directory The directory to create the temporary file in - * @returns The path to the temporary file + * Falls back to the source file's directory if no workspace folder is found + * (e.g., when working with a single file outside a workspace). */ -function createVirtualDocLocalFile(virtualDoc: VirtualDoc, directory: string): string { - const filepath = generateVirtualDocFilepath(directory, virtualDoc.language.extension); - createVirtualDoc(filepath, virtualDoc.content); - return filepath; +export function quartoVdocDir(docPath: string): string { + const sourceDirectory = path.dirname(docPath); + const workspaceFolder = workspace.workspaceFolders?.find( + (folder) => sourceDirectory.startsWith(folder.uri.fsPath) + ); + return workspaceFolder + ? Uri.joinPath(workspaceFolder.uri, ".quarto", "vdoc").fsPath + : sourceDirectory; } /** @@ -156,7 +134,7 @@ function createVirtualDoc(filepath: string, content: string): void { const directory = path.dirname(filepath); if (!fs.existsSync(directory)) { - fs.mkdirSync(directory); + fs.mkdirSync(directory, { recursive: true }); } fs.writeFileSync(filepath, content); diff --git a/apps/vscode/src/vdoc/vdoc.ts b/apps/vscode/src/vdoc/vdoc.ts index 4e004b39..3cd500b8 100644 --- a/apps/vscode/src/vdoc/vdoc.ts +++ b/apps/vscode/src/vdoc/vdoc.ts @@ -13,6 +13,7 @@ * */ +import * as path from "path"; import { Position, TextDocument, Uri, Range, SemanticTokens } from "vscode"; import { Token, isExecutableLanguageBlock, languageBlockAtPosition, languageNameFromBlock } from "quarto-core"; @@ -20,7 +21,7 @@ import { isQuartoDoc } from "../core/doc"; import { MarkdownEngine } from "../markdown/engine"; import { embeddedLanguage, EmbeddedLanguage } from "./languages"; import { virtualDocUriFromEmbeddedContent } from "./vdoc-content"; -import { virtualDocUriFromTempFile } from "./vdoc-tempfile"; +import { virtualDocUriFromTempFile, VIRTUAL_DOC_TEMP_DIRECTORY } from "./vdoc-tempfile"; import { decodeSemanticTokens, encodeSemanticTokens } from "../providers/semantic-tokens"; export interface VirtualDoc { @@ -192,13 +193,16 @@ async function virtualDocUri( action: VirtualDocAction ): Promise { - // format and definition actions use a transient local vdoc - // (so they can get project-specific paths and formatting config) - const local = ["format", "definition"].includes(action); + if (virtualDoc.language.type === "content") { + return { uri: virtualDocUriFromEmbeddedContent(virtualDoc, parentUri) }; + } + + // format and definition actions use a local vdoc alongside the source + // so tools like formatters have access to workspace configuration + const local = ["format", "definition"].includes(action) || virtualDoc.language.localTempFile; + const dir = local ? path.dirname(parentUri.fsPath) : VIRTUAL_DOC_TEMP_DIRECTORY; - return virtualDoc.language.type === "content" - ? { uri: virtualDocUriFromEmbeddedContent(virtualDoc, parentUri) } - : await virtualDocUriFromTempFile(virtualDoc, parentUri.fsPath, local); + return await virtualDocUriFromTempFile(virtualDoc, dir, { warmup: !local }); } export function languageAtPosition(tokens: Token[], position: Position) { From 5e6672584e2275c2f2ccca8d6331bf8d77c5ceb3 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 15:37:21 +0200 Subject: [PATCH 40/63] hook up quarto.cells.diagnostics.enabled setting --- apps/vscode/src/main.ts | 5 +-- apps/vscode/src/providers/diagnostics.ts | 50 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 4b60b677..8ed29d70 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -19,7 +19,7 @@ import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; -import { EmbeddedDiagnosticsManager } from "./providers/diagnostics"; +import { activateDiagnostics } from "./providers/diagnostics"; import { cellCommands } from "./providers/cell/commands"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; @@ -119,8 +119,7 @@ export async function activate(context: vscode.ExtensionContext): Promise("enabled", true); + } + + function createManager(): void { + if (!manager) { + manager = new EmbeddedDiagnosticsManager(engine, outputChannel); + } + } + + function disposeManager(): void { + if (manager) { + manager.dispose(); + manager = undefined; + } + } + + if (isEnabled()) { + createManager(); + } + + const configListener = workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("quarto.cells.diagnostics.enabled")) { + if (isEnabled()) { + createManager(); + } else { + disposeManager(); + } + } + }); + + return new VscodeDisposable(() => { + configListener.dispose(); + disposeManager(); + }); +} From a90ef9072ea0d351b5d1ea81e3bb31d3b396c835 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 15:44:00 +0200 Subject: [PATCH 41/63] rename --- apps/vscode/src/main.ts | 4 ++-- apps/vscode/src/providers/diagnostics.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index 8ed29d70..d257e785 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -19,7 +19,7 @@ import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; -import { activateDiagnostics } from "./providers/diagnostics"; +import { activateEmbeddedDiagnostics } from "./providers/diagnostics"; import { cellCommands } from "./providers/cell/commands"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; @@ -119,7 +119,7 @@ export async function activate(context: vscode.ExtensionContext): Promise Date: Fri, 15 May 2026 16:03:09 +0200 Subject: [PATCH 42/63] renames --- apps/vscode/src/main.ts | 2 +- apps/vscode/src/providers/diagnostics.ts | 143 ++++++++++++----------- apps/vscode/src/test/diagnostics.test.ts | 18 +-- 3 files changed, 86 insertions(+), 77 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index d257e785..dbbbf994 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -118,7 +118,7 @@ export async function activate(context: vscode.ExtensionContext): Promise Promise; + readonly uri: Uri; + + /** Deletes the temp file and resets its language so the language server clears diagnostics. */ + readonly cleanup?: () => Promise; + /** Fires if the language server doesn't respond in time. */ - timeout: NodeJS.Timeout; + readonly timeout: NodeJS.Timeout; } /** - * Tracks the diagnostic state for one embedded language in one Quarto document. - * Each language operates independently — its timeout, vdoc lifecycle, and stored - * diagnostics don't interfere with other languages in the same document. + * Tracks the diagnostic state for an embedded language in a document. */ interface DiagnosticSession { - /** The Quarto document this session belongs to. */ - docUri: Uri; + /** The document this session belongs to. */ + readonly documentUri: Uri; + /** The embedded language (Python, R, etc.). */ - language: EmbeddedLanguage; + readonly language: EmbeddedLanguage; + /** Code blocks for this language, used to filter diagnostics by position. */ - languageBlocks: (TokenMath | TokenCodeBlock)[]; + readonly languageBlocks: (TokenMath | TokenCodeBlock)[]; + /** The active virtual document awaiting diagnostics, if any. */ activeVdoc?: ActiveVdoc; + /** Last received diagnostics for this language (stale-until-replaced on edits). */ diagnostics: Diagnostic[]; } @@ -100,7 +107,7 @@ interface DiagnosticSession { * * Creates a temporary virtual document per language, waits for the language server * to publish diagnostics on it, then maps the diagnostics back onto the original - * `.qmd` file. Each language's lifecycle is independent — one slow or non-responsive + * `.qmd` file. Each language's lifecycle is independent - one slow or non-responsive * language server won't block diagnostics from other languages. */ export class EmbeddedDiagnosticsManager extends Disposable { @@ -115,30 +122,32 @@ export class EmbeddedDiagnosticsManager extends Disposable { new EventEmitter() ); - /** Event fired when a virtual document is disposed (for any reason). */ + /** Event fired when a virtual document is disposed. */ public readonly onDidDisposeVdoc = this._onDidDisposeVdoc.event; /** Diagnostic collection for Quarto documents. */ private readonly diagnosticCollection = this._register( - languages.createDiagnosticCollection("quarto-embedded") + languages.createDiagnosticCollection("quarto") ); + /** Active diagnostic sessions, one per document and language. */ private readonly sessions: DiagnosticSession[] = []; + + /** Debounce timers for document changes, keyed by URI string. */ private readonly debounceTimers = new Map(); - private readonly timeoutMs: number; constructor( - private engine: MarkdownEngine, - private outputChannel: LogOutputChannel, - timeoutMs?: number, + private readonly engine: MarkdownEngine, + private readonly outputChannel: LogOutputChannel, + /** Debounce delay before triggering diagnostics after a document change. */ + private readonly timeoutMs = DEFAULT_TIMEOUT_MS, ) { super(); - this.timeoutMs = timeoutMs ?? DEFAULT_TIMEOUT_MS; // Listen for diagnostics arriving on virtual documents. this._register(languages.onDidChangeDiagnostics(async (event) => { for (const uri of event.uris) { - const session = this.getSessionByVdocUri(uri); + const session = this.getSessionForVdoc(uri); if (session) { await this.handleDiagnosticsReceived(session, uri); } @@ -219,11 +228,11 @@ export class EmbeddedDiagnosticsManager extends Disposable { } // Dispose all sessions for this document. - await this.removeSessionsForDocument(document.uri); + await this.removeSessionsForDocument(document.uri, "session-removed"); // Clear published diagnostics. this.diagnosticCollection.delete(document.uri); - this._onDidUpdateDiagnostics.fire({ uri: document.uri, diagnostics: [] }); + this._onDidUpdateDiagnostics.fire({ documentUri: document.uri, diagnostics: [] }); } // --- Session management --- @@ -241,29 +250,19 @@ export class EmbeddedDiagnosticsManager extends Disposable { session.languageBlocks.push(block); } - // Activate sessions for this document concurrently. + // Concurrently activate sessions for this document. const docSessions = this.getSessionsForDocument(document.uri); await Promise.all(docSessions.map(s => this.activateSession(s, document, tokens))); } private async recreateSessionsForDocument(document: TextDocument): Promise { - // Dispose active vdocs but preserve stale diagnostics conceptually - // (we remove sessions but the new ones start empty — publishDiagnostics - // will use whatever the new sessions have). - await this.removeSessionsForDocument(document.uri); + // Dispose active vdocs but preserve diagnostics even though + // they may be stale, to avoid flickers. New diagnostics + // should arrive soon. + await this.removeSessionsForDocument(document.uri, "document-changed"); await this.createSessionsForDocument(document); } - private async removeSessionsForDocument(docUri: Uri): Promise { - const docKey = docUri.toString(); - for (let i = this.sessions.length - 1; i >= 0; i--) { - if (this.sessions[i].docUri.toString() === docKey) { - await this.disposeActiveVdoc(this.sessions[i], 'session-removed'); - this.sessions.splice(i, 1); - } - } - } - private async activateSession( session: DiagnosticSession, document: TextDocument, @@ -285,7 +284,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.warn( `[EmbeddedDiagnostics] Language server for ${session.language.ids[0]} ` + `did not respond within ${this.timeoutMs}ms ` + - `for ${workspace.asRelativePath(session.docUri)}` + `for ${workspace.asRelativePath(session.documentUri)}` ); await this.disposeActiveVdoc(session, 'timeout'); }, this.timeoutMs); @@ -294,13 +293,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + uri.toString() ); } catch (error) { this.outputChannel.error( `[EmbeddedDiagnostics] Failed to create vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + JSON.stringify(error) ); } @@ -313,7 +312,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.debug( `[EmbeddedDiagnostics] Received ${rawDiagnostics.length} diagnostics for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}` ); // Filter: only keep diagnostics that map to a real language block. @@ -326,14 +325,14 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.error( `[EmbeddedDiagnostics] Could not find language block for diagnostic at ` + `[${diagnostic.range.start.line}, ${diagnostic.range.start.character}] ` + - `for ${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}` + `for ${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}` ); } } session.diagnostics = mapped; await this.disposeActiveVdoc(session, 'diagnostics-received'); - this.publishDiagnostics(session.docUri); + this.publishDiagnostics(session.documentUri); } private publishDiagnostics(docUri: Uri): void { @@ -341,7 +340,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { .flatMap(s => s.diagnostics); this.diagnosticCollection.set(docUri, allDiagnostics); - this._onDidUpdateDiagnostics.fire({ uri: docUri, diagnostics: allDiagnostics }); + this._onDidUpdateDiagnostics.fire({ documentUri: docUri, diagnostics: allDiagnostics }); }; // --- Helpers --- @@ -349,16 +348,16 @@ export class EmbeddedDiagnosticsManager extends Disposable { private getSession(uri: Uri, language: EmbeddedLanguage): DiagnosticSession | undefined { const key = uri.toString(); return this.sessions.find( - s => s.docUri.toString() === key && + s => s.documentUri.toString() === key && s.language.ids[0] === language.ids[0] ); } - private getOrCreateSession(uri: Uri, language: EmbeddedLanguage): DiagnosticSession { - let session = this.getSession(uri, language); + private getOrCreateSession(documentUri: Uri, language: EmbeddedLanguage): DiagnosticSession { + let session = this.getSession(documentUri, language); if (!session) { session = { - docUri: uri, + documentUri, language, languageBlocks: [], diagnostics: [] @@ -368,16 +367,26 @@ export class EmbeddedDiagnosticsManager extends Disposable { return session; } - private getSessionsForDocument(uri: Uri): DiagnosticSession[] { - const key = uri.toString(); - return this.sessions.filter(s => s.docUri.toString() === key); + private getSessionsForDocument(documentUri: Uri): DiagnosticSession[] { + const key = documentUri.toString(); + return this.sessions.filter(s => s.documentUri.toString() === key); } - private getSessionByVdocUri(uri: Uri): DiagnosticSession | undefined { + private getSessionForVdoc(uri: Uri): DiagnosticSession | undefined { const key = uri.toString(); return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } + private async removeSessionsForDocument(docUri: Uri, reason: VdocDisposeReason): Promise { + const docKey = docUri.toString(); + for (let i = this.sessions.length - 1; i >= 0; i--) { + if (this.sessions[i].documentUri.toString() === docKey) { + await this.disposeActiveVdoc(this.sessions[i], reason); + this.sessions.splice(i, 1); + } + } + } + private async disposeActiveVdoc(session: DiagnosticSession, reason: VdocDisposeReason): Promise { if (session.activeVdoc) { const vdocUri = session.activeVdoc.uri; @@ -387,13 +396,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.outputChannel.debug( `[EmbeddedDiagnostics] Disposed vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)} ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)} ` + `(reason: ${reason})` ); this._onDidDisposeVdoc.fire({ - docUri: session.docUri, + documentUri: session.documentUri, language: session.language.ids[0], - vdocUri, + uri: vdocUri, reason, }); } @@ -432,7 +441,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.disposeActiveVdoc(session, 'session-removed').catch((error) => { this.outputChannel.error( `[EmbeddedDiagnostics] Failed to dispose vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.docUri)}: ` + + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + JSON.stringify(error) ); }); diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 13fafd0a..1e7f6e61 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -44,7 +44,7 @@ suite("Diagnostics", function () { const { uri, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); assert.strictEqual( - event.uri.toString(), + event.documentUri.toString(), uri.toString(), "Expected diagnostics for the opened document" ); @@ -67,7 +67,7 @@ suite("Diagnostics", function () { const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd"); assert.strictEqual( - event.uri.toString(), + event.documentUri.toString(), uri.toString(), "Expected diagnostics for the opened document" ); @@ -91,7 +91,7 @@ suite("Diagnostics", function () { ); assert.strictEqual( - updatedEvent.uri.toString(), + updatedEvent.documentUri.toString(), uri.toString(), "Expected diagnostics for the opened document" ); @@ -112,7 +112,7 @@ suite("Diagnostics", function () { const events: DidUpdateDiagnosticsEvent[] = []; const gotBoth = new Promise((resolve) => { const sub = manager.onDidUpdateDiagnostics((e) => { - if (isUriEqual(e.uri, uri)) { + if (isUriEqual(e.documentUri, uri)) { events.push(e); if (events.length >= 2) { sub.dispose(); @@ -173,7 +173,7 @@ suite("Diagnostics", function () { assert.ok(result, "Expected Julia vdoc to be disposed via timeout"); // The vdoc temp file should no longer exist. - const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); assert.strictEqual(exists, false, "Expected vdoc file to be deleted after timeout"); }); @@ -218,7 +218,7 @@ suite("Diagnostics", function () { assert.ok(result, "Expected Python vdoc to be disposed after diagnostics received"); // The vdoc temp file should no longer exist. - const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); assert.strictEqual(exists, false, "Expected vdoc file to be deleted after diagnostics received"); }); @@ -246,7 +246,7 @@ suite("Diagnostics", function () { const result = await raceTimeout(disposeEvent, 4000); assert.ok(result, "Expected vdoc to be disposed when document is closed"); - const exists = await vscode.workspace.fs.stat(result.vdocUri).then(() => true, () => false); + const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); assert.strictEqual(exists, false, "Expected vdoc file to be deleted after document close"); }); @@ -291,7 +291,7 @@ suite("Diagnostics", function () { ); assert.strictEqual( - event.uri.toString(), + event.documentUri.toString(), uri.toString(), "Expected diagnostics for the closed document" ); @@ -335,7 +335,7 @@ async function withEmbeddedDiagnostics( const promise = eventToPromise( filterEvent( manager.onDidUpdateDiagnostics, - (e) => isUriEqual(e.uri, uri) + (e) => isUriEqual(e.documentUri, uri) ) ); From 4d0c2de9014ae6cb5ceaf49538fbe3021735efb2 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 16:24:44 +0200 Subject: [PATCH 43/63] flatten test helpers: replace withEmbeddedDiagnostics callback with nextDiagnostics/nextVdocDisposal Subscribe-then-act pattern is now linear instead of nested via callbacks. --- apps/vscode/src/test/diagnostics.test.ts | 184 +++++++---------------- 1 file changed, 55 insertions(+), 129 deletions(-) diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 1e7f6e61..7854b5d7 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -30,6 +30,7 @@ suite("Diagnostics", function () { await client.start(); // Delete all vdocs before starting tests. + // We check for leaked vdocs in teardown. await deleteAllVirtualDocs(); }); @@ -41,60 +42,37 @@ suite("Diagnostics", function () { }); test("receives diagnostics in the .qmd for embedded languages", async function () { - const { uri, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + assert.strictEqual(event.diagnostics.length, 1, "Expected one diagnostic"); assert.strictEqual( - event.documentUri.toString(), - uri.toString(), - "Expected diagnostics for the opened document" - ); - - const diagnostics = event.diagnostics; - assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); - assert.strictEqual( - diagnostics[0].message, + event.diagnostics[0].message, "test-diagnostic: undefined_var is not defined", "Expected diagnostic message to match" ); assert.strictEqual( - diagnostics[0].range.start.line, + event.diagnostics[0].range.start.line, 8, - `Diagnostic should be on line 8, got line ${diagnostics[0].range.start.line}` + `Diagnostic should be on line 8, got line ${event.diagnostics[0].range.start.line}` ); }); test("updates diagnostics when .qmd edited", async function () { const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd"); - assert.strictEqual( - event.documentUri.toString(), - uri.toString(), - "Expected diagnostics for the opened document" - ); - assert.strictEqual( event.diagnostics.length, 0, `Expected no initial diagnostics, got ${JSON.stringify(event.diagnostics)}` ); - const updatedEvent = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - const editor = await vscode.window.showTextDocument(doc); - await editor.edit((editBuilder) => { - editBuilder.insert(new vscode.Position(0, 0), "```{python}\nundefined_var\n```\n"); - }); - }, - "updated diagnostics on document change" - ); - - assert.strictEqual( - updatedEvent.documentUri.toString(), - uri.toString(), - "Expected diagnostics for the opened document" - ); + const updated = nextDiagnostics(manager, uri); + const editor = await vscode.window.showTextDocument(doc); + await editor.edit((editBuilder) => { + editBuilder.insert(new vscode.Position(0, 0), "```{python}\nundefined_var\n```\n"); + }); + const updatedEvent = await raceTimeout(updated, 4000); + assert.ok(updatedEvent, "Timed out waiting for updated diagnostics"); assert.strictEqual( updatedEvent.diagnostics.length, @@ -155,24 +133,15 @@ suite("Diagnostics", function () { test("cleans up vdoc after timeout when language server does not respond", async function () { const shortTimeoutManager = createTestManager(disposables, 200); - const uri = examplesUri("diagnostics-julia-only.qmd"); - // Listen for the timeout dispose event on Julia's vdoc. - const timeoutEvent = eventToPromise( - filterEvent( - shortTimeoutManager.onDidDisposeVdoc, - (e) => e.reason === "timeout" && e.language === "julia" - ) - ); - + const disposal = nextVdocDisposal(shortTimeoutManager, "timeout", "julia"); await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(uri); - const result = await raceTimeout(timeoutEvent, 2000); + const result = await raceTimeout(disposal, 2000); assert.ok(result, "Expected Julia vdoc to be disposed via timeout"); - // The vdoc temp file should no longer exist. const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); assert.strictEqual(exists, false, "Expected vdoc file to be deleted after timeout"); }); @@ -180,19 +149,14 @@ suite("Diagnostics", function () { test("clears diagnostics when error is fixed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); - // Replace `undefined_var` with a valid expression to fix the error. - const event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - const editor = await vscode.window.showTextDocument(doc); - const line = doc.lineAt(8); - await editor.edit((editBuilder) => { - editBuilder.replace(line.range, "x = 0"); - }); - }, - "diagnostics cleared after fixing error" - ); + const cleared = nextDiagnostics(manager, uri); + const editor = await vscode.window.showTextDocument(doc); + const line = doc.lineAt(8); + await editor.edit((editBuilder) => { + editBuilder.replace(line.range, "x = 0"); + }); + const event = await raceTimeout(cleared, 4000); + assert.ok(event, "Timed out waiting for diagnostics to clear"); assert.strictEqual( event.diagnostics.length, @@ -202,48 +166,30 @@ suite("Diagnostics", function () { }); test("cleans up vdoc after diagnostics are received", async function () { - // Listen for vdoc disposal with reason "diagnostics-received". - const disposeEvent = eventToPromise( - filterEvent( - manager.onDidDisposeVdoc, - (e) => e.reason === "diagnostics-received" && e.language === "python" - ) - ); - const uri = examplesUri("diagnostics-python-undefined.qmd"); + const disposal = nextVdocDisposal(manager, "diagnostics-received", "python"); await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(uri); - const result = await raceTimeout(disposeEvent, 4000); + const result = await raceTimeout(disposal, 4000); assert.ok(result, "Expected Python vdoc to be disposed after diagnostics received"); - // The vdoc temp file should no longer exist. const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); assert.strictEqual(exists, false, "Expected vdoc file to be deleted after diagnostics received"); }); test("cleans up vdoc when document is closed", async function () { - // Use a file with Julia only (no LS in tests) so the vdoc stays alive - // long enough to be disposed by closing the document rather than by - // receiving diagnostics. + // Julia (no LS in tests) so the vdoc stays alive long enough to be + // disposed by closing the document rather than by receiving diagnostics. const uri = examplesUri("diagnostics-julia-only.qmd"); - - // Listen for vdoc disposal with reason "session-removed". - const disposeEvent = eventToPromise( - filterEvent( - manager.onDidDisposeVdoc, - (e) => e.reason === "session-removed" && e.language === "julia" - ) - ); + const disposal = nextVdocDisposal(manager, "session-removed", "julia"); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); - - // Close the document before the default timeout fires. await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - const result = await raceTimeout(disposeEvent, 4000); + const result = await raceTimeout(disposal, 4000); assert.ok(result, "Expected vdoc to be disposed when document is closed"); const exists = await vscode.workspace.fs.stat(result.uri).then(() => true, () => false); @@ -279,22 +225,11 @@ suite("Diagnostics", function () { test("clears diagnostics when document is closed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); - // Close the document - the manager should clear diagnostics for the document. - const event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - }, - "diagnostics cleared on document close" - ); - - assert.strictEqual( - event.documentUri.toString(), - uri.toString(), - "Expected diagnostics for the closed document" - ); + const cleared = nextDiagnostics(manager, uri); + await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); + await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + const event = await raceTimeout(cleared, 4000); + assert.ok(event, "Timed out waiting for diagnostics to clear on close"); assert.strictEqual( event.diagnostics.length, @@ -308,42 +243,33 @@ function isUriEqual(a: vscode.Uri, b: vscode.Uri) { return a.toString() === b.toString(); } -/** Open a .qmd fixture and wait for its first diagnostics event. */ -async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string) { - const uri = examplesUri(fixture); - let doc!: vscode.TextDocument; - const event = await withEmbeddedDiagnostics( - manager, - uri, - async () => { - doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); - }, - "initial diagnostics on document open" +/** Subscribe to the next diagnostics event for a URI. Call before the triggering action. */ +function nextDiagnostics(manager: EmbeddedDiagnosticsManager, uri: vscode.Uri) { + return eventToPromise( + filterEvent(manager.onDidUpdateDiagnostics, (e) => isUriEqual(e.documentUri, uri)) ); - return { uri, event, doc }; } -async function withEmbeddedDiagnostics( +/** Subscribe to the next vdoc disposal event matching reason and language. */ +function nextVdocDisposal( manager: EmbeddedDiagnosticsManager, - uri: vscode.Uri, - callback: () => Promise, - action: string, - timeout = 4000, + reason: string, + language: string ) { - // Create a promise that resolves when diagnostics update for `uri`. - const promise = eventToPromise( - filterEvent( - manager.onDidUpdateDiagnostics, - (e) => isUriEqual(e.documentUri, uri) - ) + return eventToPromise( + filterEvent(manager.onDidDisposeVdoc, (e) => e.reason === reason && e.language === language) ); +} - await callback(); - - const result = await raceTimeout(promise, timeout); - if (!result) { - throw new Error(`Timed out waiting for ${action}`); +/** Open a .qmd fixture and wait for its first diagnostics event. */ +async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string) { + const uri = examplesUri(fixture); + const diagnostics = nextDiagnostics(manager, uri); + const doc = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(doc); + const event = await raceTimeout(diagnostics, 4000); + if (!event) { + throw new Error(`Timed out waiting for diagnostics on ${fixture}`); } - return result; + return { uri, event, doc }; } From 44424972d06feea761e647385182821d07904e1d Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 16:31:34 +0200 Subject: [PATCH 44/63] cleaning up tests --- apps/vscode/src/test/diagnostics.test.ts | 34 +++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 7854b5d7..42133e10 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import { LanguageClient } from "vscode-languageclient/node"; import { examplesUri, raceTimeout } from "./test-utils"; import { testLanguageClient } from "./fixtures/test-language-client"; -import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager } from "../providers/diagnostics"; +import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager, VdocDisposeReason } from "../providers/diagnostics"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; @@ -71,7 +71,7 @@ suite("Diagnostics", function () { await editor.edit((editBuilder) => { editBuilder.insert(new vscode.Position(0, 0), "```{python}\nundefined_var\n```\n"); }); - const updatedEvent = await raceTimeout(updated, 4000); + const updatedEvent = await raceTimeout(updated, 3000); assert.ok(updatedEvent, "Timed out waiting for updated diagnostics"); assert.strictEqual( @@ -89,11 +89,11 @@ suite("Diagnostics", function () { // Subscribe before opening so we don't miss events fired during document open. const events: DidUpdateDiagnosticsEvent[] = []; const gotBoth = new Promise((resolve) => { - const sub = manager.onDidUpdateDiagnostics((e) => { + const listener = manager.onDidUpdateDiagnostics((e) => { if (isUriEqual(e.documentUri, uri)) { events.push(e); if (events.length >= 2) { - sub.dispose(); + listener.dispose(); resolve(true); } } @@ -226,6 +226,10 @@ suite("Diagnostics", function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); const cleared = nextDiagnostics(manager, uri); + // We have to set the language to plaintext, since closing + // documents/editors from an extension doesn't necessarily + // trigger onDidCloseTextDocument therefore doesn't notify + // the language server that textDocument/didClose. await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); const event = await raceTimeout(cleared, 4000); @@ -243,21 +247,33 @@ function isUriEqual(a: vscode.Uri, b: vscode.Uri) { return a.toString() === b.toString(); } -/** Subscribe to the next diagnostics event for a URI. Call before the triggering action. */ +/** + * Subscribe to the next diagnostics event for a URI. + * Call before the triggering action. + */ function nextDiagnostics(manager: EmbeddedDiagnosticsManager, uri: vscode.Uri) { return eventToPromise( - filterEvent(manager.onDidUpdateDiagnostics, (e) => isUriEqual(e.documentUri, uri)) + filterEvent( + manager.onDidUpdateDiagnostics, + (e) => isUriEqual(e.documentUri, uri) + ) ); } -/** Subscribe to the next vdoc disposal event matching reason and language. */ +/** + * Subscribe to the next vdoc disposal event matching reason and language. + * Call before the triggering action. + */ function nextVdocDisposal( manager: EmbeddedDiagnosticsManager, - reason: string, + reason: VdocDisposeReason, language: string ) { return eventToPromise( - filterEvent(manager.onDidDisposeVdoc, (e) => e.reason === reason && e.language === language) + filterEvent( + manager.onDidDisposeVdoc, + (e) => e.reason === reason && e.language === language + ) ); } From 21cfd50f7ed1f42354b9daaa2030069a66e9e7e6 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 16:42:22 +0200 Subject: [PATCH 45/63] fix stale diagnostics when last cell is removed --- apps/vscode/src/providers/diagnostics.ts | 27 ++++++++++++++---------- apps/vscode/src/test/diagnostics.test.ts | 23 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index fb459186..ab54b5a6 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -250,15 +250,20 @@ export class EmbeddedDiagnosticsManager extends Disposable { session.languageBlocks.push(block); } - // Concurrently activate sessions for this document. const docSessions = this.getSessionsForDocument(document.uri); - await Promise.all(docSessions.map(s => this.activateSession(s, document, tokens))); + if (docSessions.length === 0) { + // No executable cells - clear stale diagnostics that + // won't be superseded by a new vdoc round-trip. + this.diagnosticCollection.delete(document.uri); + this._onDidUpdateDiagnostics.fire({ documentUri: document.uri, diagnostics: [] }); + return; + } else { + // Concurrently activate sessions for this document. + await Promise.all(docSessions.map(s => this.activateSession(s, document, tokens))); + } } private async recreateSessionsForDocument(document: TextDocument): Promise { - // Dispose active vdocs but preserve diagnostics even though - // they may be stale, to avoid flickers. New diagnostics - // should arrive soon. await this.removeSessionsForDocument(document.uri, "document-changed"); await this.createSessionsForDocument(document); } @@ -335,12 +340,12 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.publishDiagnostics(session.documentUri); } - private publishDiagnostics(docUri: Uri): void { - const allDiagnostics = this.getSessionsForDocument(docUri) + private publishDiagnostics(documentUri: Uri): void { + const allDiagnostics = this.getSessionsForDocument(documentUri) .flatMap(s => s.diagnostics); - this.diagnosticCollection.set(docUri, allDiagnostics); - this._onDidUpdateDiagnostics.fire({ documentUri: docUri, diagnostics: allDiagnostics }); + this.diagnosticCollection.set(documentUri, allDiagnostics); + this._onDidUpdateDiagnostics.fire({ documentUri: documentUri, diagnostics: allDiagnostics }); }; // --- Helpers --- @@ -377,8 +382,8 @@ export class EmbeddedDiagnosticsManager extends Disposable { return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); } - private async removeSessionsForDocument(docUri: Uri, reason: VdocDisposeReason): Promise { - const docKey = docUri.toString(); + private async removeSessionsForDocument(documentUri: Uri, reason: VdocDisposeReason): Promise { + const docKey = documentUri.toString(); for (let i = this.sessions.length - 1; i >= 0; i--) { if (this.sessions[i].documentUri.toString() === docKey) { await this.disposeActiveVdoc(this.sessions[i], reason); diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 42133e10..934a6177 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -222,6 +222,29 @@ suite("Diagnostics", function () { ); }); + test("clears diagnostics when all executable cells are removed", async function () { + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + + // Remove the entire code cell, leaving only markdown. + const cleared = nextDiagnostics(manager, uri); + const editor = await vscode.window.showTextDocument(doc); + const fullRange = new vscode.Range( + new vscode.Position(7, 0), + new vscode.Position(doc.lineCount, 0) + ); + await editor.edit((editBuilder) => { + editBuilder.replace(fullRange, "No code here.\n"); + }); + const event = await raceTimeout(cleared, 4000); + assert.ok(event, "Timed out waiting for diagnostics to clear after removing all cells"); + + assert.strictEqual( + event.diagnostics.length, + 0, + "Diagnostics should be cleared when no executable cells remain" + ); + }); + test("clears diagnostics when document is closed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); From b6b4cde5361b5871911eb4b8eb0a0a30346b849d Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:01:22 +0200 Subject: [PATCH 46/63] fix jsdoc --- apps/vscode/src/providers/diagnostics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index ab54b5a6..f938270a 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -139,7 +139,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { constructor( private readonly engine: MarkdownEngine, private readonly outputChannel: LogOutputChannel, - /** Debounce delay before triggering diagnostics after a document change. */ + /** Timeout for waiting for the language server to publish diagnostics. */ private readonly timeoutMs = DEFAULT_TIMEOUT_MS, ) { super(); From c71e7674714895c92ff369c281ea0134ccba6ef1 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:02:13 +0200 Subject: [PATCH 47/63] add test fixture for julia-only diagnostics tests --- apps/vscode/src/test/examples/diagnostics-julia-only.qmd | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/vscode/src/test/examples/diagnostics-julia-only.qmd diff --git a/apps/vscode/src/test/examples/diagnostics-julia-only.qmd b/apps/vscode/src/test/examples/diagnostics-julia-only.qmd new file mode 100644 index 00000000..70fc2ba0 --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-julia-only.qmd @@ -0,0 +1,8 @@ +--- +title: "Julia only (no language server)" +format: html +--- + +```{julia} +undefined_var = 1 +``` From 684a7a85b91270676c6b0bd179e26fb9b62ca8cd Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:10:30 +0200 Subject: [PATCH 48/63] simplify active vdoc cleanup --- apps/vscode/src/providers/diagnostics.ts | 35 ++++++++++++++---------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index f938270a..09dd868f 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -75,11 +75,8 @@ interface ActiveVdoc { /** URI of the temp file opened as a text document. */ readonly uri: Uri; - /** Deletes the temp file and resets its language so the language server clears diagnostics. */ - readonly cleanup?: () => Promise; - - /** Fires if the language server doesn't respond in time. */ - readonly timeout: NodeJS.Timeout; + /** Clean up the virtual document. */ + readonly cleanup: () => Promise; } /** @@ -281,7 +278,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { const dir = this.shouldUseLocalTempFile(session.language) ? quartoVdocDir(document.uri.fsPath) : VIRTUAL_DOC_TEMP_DIRECTORY; - const { uri, cleanup } = await virtualDocUriFromTempFile( + const vdoc = await virtualDocUriFromTempFile( vdocContent, dir, { warmup: false } ); @@ -294,12 +291,20 @@ export class EmbeddedDiagnosticsManager extends Disposable { await this.disposeActiveVdoc(session, 'timeout'); }, this.timeoutMs); - session.activeVdoc = { uri, cleanup, timeout }; + session.activeVdoc = { + uri: vdoc.uri, + cleanup: async () => { + clearTimeout(timeout); + if (vdoc.cleanup) { + await vdoc.cleanup(); + } + }, + }; this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + - uri.toString() + vdoc.uri.toString() ); } catch (error) { this.outputChannel.error( @@ -346,7 +351,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this.diagnosticCollection.set(documentUri, allDiagnostics); this._onDidUpdateDiagnostics.fire({ documentUri: documentUri, diagnostics: allDiagnostics }); - }; + } // --- Helpers --- @@ -393,12 +398,14 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private async disposeActiveVdoc(session: DiagnosticSession, reason: VdocDisposeReason): Promise { - if (session.activeVdoc) { - const vdocUri = session.activeVdoc.uri; - clearTimeout(session.activeVdoc.timeout); - await session.activeVdoc.cleanup?.(); + const { activeVdoc } = session; + if (activeVdoc) { + // First unset the session's active vdoc so that we don't accidentally + // process diagnostics that arrive while we're cleaning up the old vdoc. session.activeVdoc = undefined; + await activeVdoc.cleanup(); + this.outputChannel.debug( `[EmbeddedDiagnostics] Disposed vdoc for ` + `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)} ` + @@ -407,7 +414,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { this._onDidDisposeVdoc.fire({ documentUri: session.documentUri, language: session.language.ids[0], - uri: vdocUri, + uri: activeVdoc.uri, reason, }); } From 3e2017b64b2e29bcfa79759baf8a7bddbcce44b8 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:29:17 +0200 Subject: [PATCH 49/63] await vdoc cleanup on extension deactivation --- apps/vscode/src/main.ts | 8 +++-- apps/vscode/src/providers/diagnostics.ts | 44 ++++++++++++++++-------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/vscode/src/main.ts b/apps/vscode/src/main.ts index dbbbf994..254645a7 100644 --- a/apps/vscode/src/main.ts +++ b/apps/vscode/src/main.ts @@ -19,7 +19,7 @@ import { tryAcquirePositronApi } from "@posit-dev/positron"; import { MarkdownEngine } from "./markdown/engine"; import { kQuartoDocSelector } from "./core/doc"; import { activateLsp, deactivate as deactivateLsp } from "./lsp/client"; -import { activateEmbeddedDiagnostics } from "./providers/diagnostics"; +import { activateEmbeddedDiagnostics, type EmbeddedDiagnosticsService } from "./providers/diagnostics"; import { cellCommands } from "./providers/cell/commands"; import { quartoCellExecuteCodeLensProvider } from "./providers/cell/codelens"; import { activateQuartoAssistPanel } from "./providers/assist/panel"; @@ -51,6 +51,8 @@ import { activateContextKeySetter } from "./providers/context-keys"; import { CommandManager } from "./core/command"; import { createQuartoExtensionApi, QuartoExtensionApi } from "./api"; +let embeddedDiagnostics: EmbeddedDiagnosticsService | undefined; + /** * Entry point for the entire extension! This initializes the LSP, quartoContext, extension host, and more... */ @@ -119,7 +121,8 @@ export async function activate(context: vscode.ExtensionContext): Promise[]>; + public override dispose(): void { super.dispose(); @@ -449,17 +452,27 @@ export class EmbeddedDiagnosticsManager extends Disposable { } this.debounceTimers.clear(); - for (const session of this.sessions) { - this.disposeActiveVdoc(session, 'session-removed').catch((error) => { - this.outputChannel.error( - `[EmbeddedDiagnostics] Failed to dispose vdoc for ` + - `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + - JSON.stringify(error) - ); - }); - } + // Best-effort async cleanup — awaited via deactivate() during extension deactivation. + this._disposePromise = Promise.allSettled( + this.sessions + .filter(s => s.activeVdoc) + .map(s => this.disposeActiveVdoc(s, 'session-removed')) + ); this.sessions.length = 0; } + + /** + * Awaitable cleanup for use during extension deactivation. + * Resolves when all active vdocs have been disposed (or failed). + */ + async deactivate(): Promise { + await this._disposePromise; + } +} + +export interface EmbeddedDiagnosticsService extends VscodeDisposable { + /** Awaitable cleanup for use during extension deactivation. */ + deactivate(): Promise; } /** @@ -469,7 +482,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { export function activateEmbeddedDiagnostics( engine: MarkdownEngine, outputChannel: LogOutputChannel, -): VscodeDisposable { +): EmbeddedDiagnosticsService { let manager: EmbeddedDiagnosticsManager | undefined; function isEnabled(): boolean { @@ -505,8 +518,11 @@ export function activateEmbeddedDiagnostics( } }); - return new VscodeDisposable(() => { - configListener.dispose(); - disposeManager(); - }); + return Object.assign( + new VscodeDisposable(() => { + configListener.dispose(); + disposeManager(); + }), + { deactivate: () => manager?.deactivate() ?? Promise.resolve() } + ); } From d66e27ebbb6c9c7e0f2e01635f62fa1df1377a08 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:34:40 +0200 Subject: [PATCH 50/63] move event utils --- apps/vscode/src/providers/diagnostics.ts | 2 +- apps/vscode/src/test/diagnostics.test.ts | 2 +- apps/vscode/src/{core => test/utils}/event.ts | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/vscode/src/{core => test/utils}/event.ts (100%) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 780edc1c..a2f83258 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -313,7 +313,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { JSON.stringify(error) ); } - }; + } // --- Diagnostics handling --- diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 934a6177..5da1a1c6 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -7,8 +7,8 @@ import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager, VdocDisposeReaso import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; -import { eventToPromise, filterEvent } from "../core/event"; import { DisposableStore } from "core"; +import { eventToPromise, filterEvent } from "./utils/event"; /** Create a diagnostics manager for tests, registered with the given disposable store. */ function createTestManager(disposables: DisposableStore, timeoutMs?: number) { diff --git a/apps/vscode/src/core/event.ts b/apps/vscode/src/test/utils/event.ts similarity index 100% rename from apps/vscode/src/core/event.ts rename to apps/vscode/src/test/utils/event.ts From 80eede69d9fb9c6fee60556eb4894a6105f7f2f2 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:34:46 +0200 Subject: [PATCH 51/63] remove unused function --- apps/vscode/src/vdoc/vdoc-tempfile.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index f5912c89..d1a75544 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -151,14 +151,3 @@ function createVirtualDoc(filepath: string, content: string): void { function generateVirtualDocFilepath(directory: string, extension: string): string { return path.join(directory, ".vdoc." + uuid.v4() + "." + extension); } - -export function isVirtualDoc(uri: Uri): boolean { - // Check for tempfile virtual docs - if (uri.scheme === "file") { - const filename = path.basename(uri.fsPath); - // Virtual docs have a specific filename pattern .vdoc.[uuid].[extension] - return filename.startsWith(".vdoc.") && filename.split(".").length > 3; - } - - return false; -} From 195acb64b516f88aaf1b6405c9262946942bea47 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 17:44:23 +0200 Subject: [PATCH 52/63] refactor sessions to nested Map> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat array with a two-level map plus a vdoc URI reverse index. All lookups are now O(1) and the two-level keying (document × language) is explicit in the type system. --- apps/vscode/src/providers/diagnostics.ts | 63 +++++++++++++----------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index a2f83258..9dc12db1 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -127,8 +127,11 @@ export class EmbeddedDiagnosticsManager extends Disposable { languages.createDiagnosticCollection("quarto") ); - /** Active diagnostic sessions, one per document and language. */ - private readonly sessions: DiagnosticSession[] = []; + /** Sessions keyed by document URI string, then language ID. */ + private readonly sessionsByDocument = new Map>(); + + /** Reverse index: vdoc URI string → session. */ + private readonly sessionByVdocUri = new Map(); /** Debounce timers for document changes, keyed by URI string. */ private readonly debounceTimers = new Map(); @@ -144,7 +147,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { // Listen for diagnostics arriving on virtual documents. this._register(languages.onDidChangeDiagnostics(async (event) => { for (const uri of event.uris) { - const session = this.getSessionForVdoc(uri); + const session = this.sessionByVdocUri.get(uri.toString()); if (session) { await this.handleDiagnosticsReceived(session, uri); } @@ -300,6 +303,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { } }, }; + this.sessionByVdocUri.set(vdoc.uri.toString(), session); this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + @@ -355,16 +359,17 @@ export class EmbeddedDiagnosticsManager extends Disposable { // --- Helpers --- - private getSession(uri: Uri, language: EmbeddedLanguage): DiagnosticSession | undefined { - const key = uri.toString(); - return this.sessions.find( - s => s.documentUri.toString() === key && - s.language.ids[0] === language.ids[0] - ); - } - private getOrCreateSession(documentUri: Uri, language: EmbeddedLanguage): DiagnosticSession { - let session = this.getSession(documentUri, language); + const docKey = documentUri.toString(); + const langKey = language.ids[0]; + + let langMap = this.sessionsByDocument.get(docKey); + if (!langMap) { + langMap = new Map(); + this.sessionsByDocument.set(docKey, langMap); + } + + let session = langMap.get(langKey); if (!session) { session = { documentUri, @@ -372,29 +377,25 @@ export class EmbeddedDiagnosticsManager extends Disposable { languageBlocks: [], diagnostics: [] }; - this.sessions.push(session); + langMap.set(langKey, session); } return session; } private getSessionsForDocument(documentUri: Uri): DiagnosticSession[] { - const key = documentUri.toString(); - return this.sessions.filter(s => s.documentUri.toString() === key); - } - - private getSessionForVdoc(uri: Uri): DiagnosticSession | undefined { - const key = uri.toString(); - return this.sessions.find(s => s.activeVdoc?.uri.toString() === key); + const langMap = this.sessionsByDocument.get(documentUri.toString()); + return langMap ? [...langMap.values()] : []; } private async removeSessionsForDocument(documentUri: Uri, reason: VdocDisposeReason): Promise { - const docKey = documentUri.toString(); - for (let i = this.sessions.length - 1; i >= 0; i--) { - if (this.sessions[i].documentUri.toString() === docKey) { - await this.disposeActiveVdoc(this.sessions[i], reason); - this.sessions.splice(i, 1); - } + const key = documentUri.toString(); + const langMap = this.sessionsByDocument.get(key); + if (!langMap) { return; } + + for (const session of langMap.values()) { + await this.disposeActiveVdoc(session, reason); } + this.sessionsByDocument.delete(key); } private async disposeActiveVdoc(session: DiagnosticSession, reason: VdocDisposeReason): Promise { @@ -403,6 +404,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { // First unset the session's active vdoc so that we don't accidentally // process diagnostics that arrive while we're cleaning up the old vdoc. session.activeVdoc = undefined; + this.sessionByVdocUri.delete(activeVdoc.uri.toString()); await activeVdoc.cleanup(); @@ -452,13 +454,18 @@ export class EmbeddedDiagnosticsManager extends Disposable { } this.debounceTimers.clear(); + const allSessions = [...this.sessionsByDocument.values()] + .flatMap(m => [...m.values()]); + // Best-effort async cleanup — awaited via deactivate() during extension deactivation. this._disposePromise = Promise.allSettled( - this.sessions + allSessions .filter(s => s.activeVdoc) .map(s => this.disposeActiveVdoc(s, 'session-removed')) ); - this.sessions.length = 0; + + this.sessionsByDocument.clear(); + this.sessionByVdocUri.clear(); } /** From 573a6e31570d07bc50e923a8658a9a1333064094 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 18:44:09 +0200 Subject: [PATCH 53/63] test vsdoc locations --- apps/vscode/src/providers/diagnostics.ts | 46 ++++++- apps/vscode/src/test/diagnostics.test.ts | 121 +++++++++++++++--- .../src/test/examples/.vscode/settings.json | 5 +- .../src/test/fixtures/test-language-server.ts | 2 +- apps/vscode/src/vdoc/vdoc-tempfile.ts | 2 +- 5 files changed, 148 insertions(+), 28 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 9dc12db1..9c78c515 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -52,6 +52,18 @@ export interface DidUpdateDiagnosticsEvent { readonly diagnostics: Diagnostic[]; } +/** Event fired when a virtual document is activated (created and opened). */ +export interface DidActivateVdocEvent { + /** The URI of the virtual document. */ + readonly uri: Uri; + + /** The document the vdoc belongs to. */ + readonly documentUri: Uri; + + /** The language the vdoc was created for (e.g. "python", "typescript"). */ + readonly language: string; +} + /** Why a virtual document was disposed. */ export type VdocDisposeReason = 'diagnostics-received' | 'timeout' | 'document-changed' | 'session-removed'; @@ -115,6 +127,13 @@ export class EmbeddedDiagnosticsManager extends Disposable { /** Event fired when embedded diagnostics are updated for a document. */ public readonly onDidUpdateDiagnostics = this._onDidUpdateDiagnostics.event; + private readonly _onDidActivateVdoc = this._register( + new EventEmitter() + ); + + /** Event fired when a virtual document is activated (created and opened). */ + public readonly onDidActivateVdoc = this._onDidActivateVdoc.event; + private readonly _onDidDisposeVdoc = this._register( new EventEmitter() ); @@ -304,6 +323,11 @@ export class EmbeddedDiagnosticsManager extends Disposable { }, }; this.sessionByVdocUri.set(vdoc.uri.toString(), session); + this._onDidActivateVdoc.fire({ + uri: vdoc.uri, + documentUri: session.documentUri, + language: session.language.ids[0], + }); this.outputChannel.debug( `[EmbeddedDiagnostics] Activated vdoc for ` + @@ -346,6 +370,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { session.diagnostics = mapped; await this.disposeActiveVdoc(session, 'diagnostics-received'); + if (this.isDisposed) { + // Manager got disposed while disposing the vdoc. + return; + } this.publishDiagnostics(session.documentUri); } @@ -423,6 +451,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { } private shouldUseLocalTempFile(language: EmbeddedLanguage): boolean { + if (language.localTempFile) { + return true; + } + // The vscode-R extension uses the languageserver R package // which does not provide diagnostics for files in the system // temp directory. Use a local temp file in that case. @@ -525,11 +557,15 @@ export function activateEmbeddedDiagnostics( } }); - return Object.assign( - new VscodeDisposable(() => { + return { + dispose() { configListener.dispose(); disposeManager(); - }), - { deactivate: () => manager?.deactivate() ?? Promise.resolve() } - ); + }, + async deactivate() { + if (manager) { + await manager.deactivate(); + } + }, + }; } diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 5da1a1c6..943f5813 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -1,14 +1,17 @@ import * as assert from "assert"; import * as vscode from "vscode"; +import { randomUUID } from "crypto"; import { LanguageClient } from "vscode-languageclient/node"; import { examplesUri, raceTimeout } from "./test-utils"; import { testLanguageClient } from "./fixtures/test-language-client"; import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager, VdocDisposeReason } from "../providers/diagnostics"; +import { VIRTUAL_DOC_TEMP_DIRECTORY, deleteDocument, quartoVdocDir } from "../vdoc/vdoc-tempfile"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; import { DisposableStore } from "core"; import { eventToPromise, filterEvent } from "./utils/event"; +import { Uri } from "vscode"; /** Create a diagnostics manager for tests, registered with the given disposable store. */ function createTestManager(disposables: DisposableStore, timeoutMs?: number) { @@ -19,6 +22,7 @@ function createTestManager(disposables: DisposableStore, timeoutMs?: number) { suite("Diagnostics", function () { const disposables = new DisposableStore(); + const toDelete: vscode.TextDocument[] = []; let client: LanguageClient; let manager: EmbeddedDiagnosticsManager; @@ -35,6 +39,8 @@ suite("Diagnostics", function () { }); teardown(async function () { + await Promise.all(toDelete.map(doc => deleteDocument(doc))); + disposables.clear(); await client.stop(); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); @@ -42,7 +48,8 @@ suite("Diagnostics", function () { }); test("receives diagnostics in the .qmd for embedded languages", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + toDelete.push(doc); assert.strictEqual(event.diagnostics.length, 1, "Expected one diagnostic"); assert.strictEqual( @@ -59,6 +66,7 @@ suite("Diagnostics", function () { test("updates diagnostics when .qmd edited", async function () { const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd"); + toDelete.push(doc); assert.strictEqual( event.diagnostics.length, @@ -84,9 +92,11 @@ suite("Diagnostics", function () { test("receives diagnostics for multiple languages independently", async function () { this.timeout(15000); - const uri = examplesUri("diagnostics-multilang.qmd"); + const doc = await openExampleTextDocument("diagnostics-multilang.qmd"); + toDelete.push(doc); + const uri = doc.uri; - // Subscribe before opening so we don't miss events fired during document open. + // Subscribe before showing so we don't miss events fired during document open. const events: DidUpdateDiagnosticsEvent[] = []; const gotBoth = new Promise((resolve) => { const listener = manager.onDidUpdateDiagnostics((e) => { @@ -100,8 +110,6 @@ suite("Diagnostics", function () { }); }); - // Open the document - should eventually get diagnostics for both languages. - const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); const result = await raceTimeout(gotBoth, 12000); @@ -118,7 +126,8 @@ suite("Diagnostics", function () { test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd"); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd"); + toDelete.push(doc); // Python diagnostics should be present despite Julia timing out. assert.ok( @@ -133,11 +142,11 @@ suite("Diagnostics", function () { test("cleans up vdoc after timeout when language server does not respond", async function () { const shortTimeoutManager = createTestManager(disposables, 200); - const uri = examplesUri("diagnostics-julia-only.qmd"); + const doc = await openExampleTextDocument("diagnostics-julia-only.qmd"); + toDelete.push(doc); const disposal = nextVdocDisposal(shortTimeoutManager, "timeout", "julia"); - await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); + await vscode.window.showTextDocument(doc); const result = await raceTimeout(disposal, 2000); assert.ok(result, "Expected Julia vdoc to be disposed via timeout"); @@ -148,6 +157,7 @@ suite("Diagnostics", function () { test("clears diagnostics when error is fixed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + toDelete.push(doc); const cleared = nextDiagnostics(manager, uri); const editor = await vscode.window.showTextDocument(doc); @@ -166,10 +176,10 @@ suite("Diagnostics", function () { }); test("cleans up vdoc after diagnostics are received", async function () { - const uri = examplesUri("diagnostics-python-undefined.qmd"); + const doc = await openExampleTextDocument("diagnostics-python-undefined.qmd"); + toDelete.push(doc); const disposal = nextVdocDisposal(manager, "diagnostics-received", "python"); - await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(uri); + await vscode.window.showTextDocument(doc); const result = await raceTimeout(disposal, 4000); assert.ok(result, "Expected Python vdoc to be disposed after diagnostics received"); @@ -181,10 +191,10 @@ suite("Diagnostics", function () { test("cleans up vdoc when document is closed", async function () { // Julia (no LS in tests) so the vdoc stays alive long enough to be // disposed by closing the document rather than by receiving diagnostics. - const uri = examplesUri("diagnostics-julia-only.qmd"); + const doc = await openExampleTextDocument("diagnostics-julia-only.qmd"); + toDelete.push(doc); const disposal = nextVdocDisposal(manager, "session-removed", "julia"); - const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); @@ -197,7 +207,8 @@ suite("Diagnostics", function () { }); test("reports diagnostics from multiple cells of the same language", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd"); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd"); + toDelete.push(doc); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 2, "Expected one diagnostic per cell"); @@ -211,7 +222,8 @@ suite("Diagnostics", function () { }); test("maps diagnostic line numbers correctly with content above cell", async function () { - const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); + toDelete.push(doc); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); @@ -224,6 +236,7 @@ suite("Diagnostics", function () { test("clears diagnostics when all executable cells are removed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); + toDelete.push(doc); // Remove the entire code cell, leaving only markdown. const cleared = nextDiagnostics(manager, uri); @@ -247,6 +260,7 @@ suite("Diagnostics", function () { test("clears diagnostics when document is closed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); + toDelete.push(doc); const cleared = nextDiagnostics(manager, uri); // We have to set the language to plaintext, since closing @@ -264,8 +278,47 @@ suite("Diagnostics", function () { "Diagnostics should be cleared after closing the document" ); }); + + suite("vdoc location", () => { + test("places typescript vdoc in local directory", async function () { + const { uri, event, doc } = await openAndAwaitVdocActivation(manager, "diagnostics-typescript.qmd", "ts"); + toDelete.push(doc); + const expectedDir = quartoVdocDir(uri.fsPath); + assert.ok( + event.uri.fsPath.startsWith(expectedDir), + `Expected TypeScript vdoc in local dir (${expectedDir}), got ${event.uri.fsPath}` + ); + }); + + test("places python vdoc in global temp directory", async function () { + const { uri, event, doc } = await openAndAwaitVdocActivation(manager, "diagnostics-python-undefined.qmd", "python"); + toDelete.push(doc); + assert.ok( + event.uri.fsPath.startsWith(VIRTUAL_DOC_TEMP_DIRECTORY), + `Expected Python vdoc in global temp dir (${VIRTUAL_DOC_TEMP_DIRECTORY}), got ${event.uri.fsPath}` + ); + }); + }); }); +/** + * Copy a fixture file to a unique URI and open it. + * + * VS Code keeps text documents in memory even after their editors are closed, + * so a fixture opened by one test remains in `workspace.textDocuments` for + * subsequent tests. When `EmbeddedDiagnosticsManager` is constructed it + * notifies the language server about all already-open documents. + * + * Copying to a fresh URI guarantees the document has never been seen before, + * and lets us delete it to fire onDidCloseTextDocument events. + */ +async function openExampleTextDocument(fixture: string): Promise { + const source = examplesUri(fixture); + const dest = Uri.joinPath(source, "..", `tmp-${randomUUID()}-${fixture}`); + await vscode.workspace.fs.copy(source, dest); + return await vscode.workspace.openTextDocument(dest); +} + function isUriEqual(a: vscode.Uri, b: vscode.Uri) { return a.toString() === b.toString(); } @@ -300,15 +353,43 @@ function nextVdocDisposal( ); } +/** + * Subscribe to the next vdoc activation event matching a language. + * Call before the triggering action. + */ +function nextVdocActivation( + manager: EmbeddedDiagnosticsManager, + documentUri: Uri, + language: string +) { + return eventToPromise( + filterEvent( + manager.onDidActivateVdoc, + (e) => isUriEqual(e.documentUri, documentUri) && + e.language === language + ) + ); +} + /** Open a .qmd fixture and wait for its first diagnostics event. */ async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string) { - const uri = examplesUri(fixture); - const diagnostics = nextDiagnostics(manager, uri); - const doc = await vscode.workspace.openTextDocument(uri); + const doc = await openExampleTextDocument(fixture); + const diagnostics = nextDiagnostics(manager, doc.uri); await vscode.window.showTextDocument(doc); const event = await raceTimeout(diagnostics, 4000); if (!event) { throw new Error(`Timed out waiting for diagnostics on ${fixture}`); } - return { uri, event, doc }; + return { uri: doc.uri, event, doc }; +} + +async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string) { + const doc = await openExampleTextDocument(fixture); + const activation = nextVdocActivation(manager, doc.uri, language); + await vscode.window.showTextDocument(doc); + const event = await raceTimeout(activation, 4000); + if (!event) { + throw new Error(`Timed out waiting for vdoc activation for ${language} in ${fixture}`); + } + return { uri: doc.uri, event, doc }; } diff --git a/apps/vscode/src/test/examples/.vscode/settings.json b/apps/vscode/src/test/examples/.vscode/settings.json index 02a3041c..daf7962c 100644 --- a/apps/vscode/src/test/examples/.vscode/settings.json +++ b/apps/vscode/src/test/examples/.vscode/settings.json @@ -1,3 +1,6 @@ { - "quarto.symbols.exportToWorkspace": "default" + "quarto.symbols.exportToWorkspace": "default", + // Disable the main diagnostics manager during tests, + // we test against a test-specific instance instead. + "quarto.cells.diagnostics.enabled": false } diff --git a/apps/vscode/src/test/fixtures/test-language-server.ts b/apps/vscode/src/test/fixtures/test-language-server.ts index eda7d3f4..4b4f91dd 100644 --- a/apps/vscode/src/test/fixtures/test-language-server.ts +++ b/apps/vscode/src/test/fixtures/test-language-server.ts @@ -48,7 +48,7 @@ function publishDiagnostics(document: TextDocument) { // Initialize the server. connection.onInitialize(() => { - console.log(`Initialized!`);; + console.log(`Initialized!`); return { capabilities: {}, }; diff --git a/apps/vscode/src/vdoc/vdoc-tempfile.ts b/apps/vscode/src/vdoc/vdoc-tempfile.ts index d1a75544..8742917c 100644 --- a/apps/vscode/src/vdoc/vdoc-tempfile.ts +++ b/apps/vscode/src/vdoc/vdoc-tempfile.ts @@ -78,7 +78,7 @@ export async function virtualDocUriFromTempFile( * * @param doc The `TextDocument` to delete */ -async function deleteDocument(doc: TextDocument) { +export async function deleteDocument(doc: TextDocument) { try { // First set the language to 'plaintext' so that the language client // closes the text document in the language server, which clears From 9d97b72b1ce69d4050f5b6aba18d4ff1f5bba73f Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 18:50:03 +0200 Subject: [PATCH 54/63] move toDelete into helpers --- apps/vscode/src/test/diagnostics.test.ts | 56 ++++++++++-------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 943f5813..48dcbe58 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -48,8 +48,7 @@ suite("Diagnostics", function () { }); test("receives diagnostics in the .qmd for embedded languages", async function () { - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); - toDelete.push(doc); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); assert.strictEqual(event.diagnostics.length, 1, "Expected one diagnostic"); assert.strictEqual( @@ -65,8 +64,7 @@ suite("Diagnostics", function () { }); test("updates diagnostics when .qmd edited", async function () { - const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd"); - toDelete.push(doc); + const { uri, event, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-none.qmd", toDelete); assert.strictEqual( event.diagnostics.length, @@ -92,8 +90,7 @@ suite("Diagnostics", function () { test("receives diagnostics for multiple languages independently", async function () { this.timeout(15000); - const doc = await openExampleTextDocument("diagnostics-multilang.qmd"); - toDelete.push(doc); + const doc = await openExampleTextDocument("diagnostics-multilang.qmd", toDelete); const uri = doc.uri; // Subscribe before showing so we don't miss events fired during document open. @@ -126,8 +123,7 @@ suite("Diagnostics", function () { test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd"); - toDelete.push(doc); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd", toDelete); // Python diagnostics should be present despite Julia timing out. assert.ok( @@ -142,8 +138,7 @@ suite("Diagnostics", function () { test("cleans up vdoc after timeout when language server does not respond", async function () { const shortTimeoutManager = createTestManager(disposables, 200); - const doc = await openExampleTextDocument("diagnostics-julia-only.qmd"); - toDelete.push(doc); + const doc = await openExampleTextDocument("diagnostics-julia-only.qmd", toDelete); const disposal = nextVdocDisposal(shortTimeoutManager, "timeout", "julia"); await vscode.window.showTextDocument(doc); @@ -156,8 +151,7 @@ suite("Diagnostics", function () { }); test("clears diagnostics when error is fixed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); - toDelete.push(doc); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); const cleared = nextDiagnostics(manager, uri); const editor = await vscode.window.showTextDocument(doc); @@ -176,8 +170,7 @@ suite("Diagnostics", function () { }); test("cleans up vdoc after diagnostics are received", async function () { - const doc = await openExampleTextDocument("diagnostics-python-undefined.qmd"); - toDelete.push(doc); + const doc = await openExampleTextDocument("diagnostics-python-undefined.qmd", toDelete); const disposal = nextVdocDisposal(manager, "diagnostics-received", "python"); await vscode.window.showTextDocument(doc); @@ -191,8 +184,7 @@ suite("Diagnostics", function () { test("cleans up vdoc when document is closed", async function () { // Julia (no LS in tests) so the vdoc stays alive long enough to be // disposed by closing the document rather than by receiving diagnostics. - const doc = await openExampleTextDocument("diagnostics-julia-only.qmd"); - toDelete.push(doc); + const doc = await openExampleTextDocument("diagnostics-julia-only.qmd", toDelete); const disposal = nextVdocDisposal(manager, "session-removed", "julia"); await vscode.window.showTextDocument(doc); @@ -207,8 +199,7 @@ suite("Diagnostics", function () { }); test("reports diagnostics from multiple cells of the same language", async function () { - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd"); - toDelete.push(doc); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd", toDelete); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 2, "Expected one diagnostic per cell"); @@ -222,8 +213,7 @@ suite("Diagnostics", function () { }); test("maps diagnostic line numbers correctly with content above cell", async function () { - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); - toDelete.push(doc); + const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); @@ -235,8 +225,7 @@ suite("Diagnostics", function () { }); test("clears diagnostics when all executable cells are removed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd"); - toDelete.push(doc); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); // Remove the entire code cell, leaving only markdown. const cleared = nextDiagnostics(manager, uri); @@ -259,8 +248,7 @@ suite("Diagnostics", function () { }); test("clears diagnostics when document is closed", async function () { - const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd"); - toDelete.push(doc); + const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); const cleared = nextDiagnostics(manager, uri); // We have to set the language to plaintext, since closing @@ -281,8 +269,7 @@ suite("Diagnostics", function () { suite("vdoc location", () => { test("places typescript vdoc in local directory", async function () { - const { uri, event, doc } = await openAndAwaitVdocActivation(manager, "diagnostics-typescript.qmd", "ts"); - toDelete.push(doc); + const { uri, event } = await openAndAwaitVdocActivation(manager, "diagnostics-typescript.qmd", "ts", toDelete); const expectedDir = quartoVdocDir(uri.fsPath); assert.ok( event.uri.fsPath.startsWith(expectedDir), @@ -291,8 +278,7 @@ suite("Diagnostics", function () { }); test("places python vdoc in global temp directory", async function () { - const { uri, event, doc } = await openAndAwaitVdocActivation(manager, "diagnostics-python-undefined.qmd", "python"); - toDelete.push(doc); + const { event } = await openAndAwaitVdocActivation(manager, "diagnostics-python-undefined.qmd", "python", toDelete); assert.ok( event.uri.fsPath.startsWith(VIRTUAL_DOC_TEMP_DIRECTORY), `Expected Python vdoc in global temp dir (${VIRTUAL_DOC_TEMP_DIRECTORY}), got ${event.uri.fsPath}` @@ -312,11 +298,13 @@ suite("Diagnostics", function () { * Copying to a fresh URI guarantees the document has never been seen before, * and lets us delete it to fire onDidCloseTextDocument events. */ -async function openExampleTextDocument(fixture: string): Promise { +async function openExampleTextDocument(fixture: string, toDelete: vscode.TextDocument[]): Promise { const source = examplesUri(fixture); const dest = Uri.joinPath(source, "..", `tmp-${randomUUID()}-${fixture}`); await vscode.workspace.fs.copy(source, dest); - return await vscode.workspace.openTextDocument(dest); + const doc = await vscode.workspace.openTextDocument(dest); + toDelete.push(doc); + return doc; } function isUriEqual(a: vscode.Uri, b: vscode.Uri) { @@ -372,8 +360,8 @@ function nextVdocActivation( } /** Open a .qmd fixture and wait for its first diagnostics event. */ -async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string) { - const doc = await openExampleTextDocument(fixture); +async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixture: string, toDelete: vscode.TextDocument[]) { + const doc = await openExampleTextDocument(fixture, toDelete); const diagnostics = nextDiagnostics(manager, doc.uri); await vscode.window.showTextDocument(doc); const event = await raceTimeout(diagnostics, 4000); @@ -383,8 +371,8 @@ async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixt return { uri: doc.uri, event, doc }; } -async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string) { - const doc = await openExampleTextDocument(fixture); +async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string, toDelete: vscode.TextDocument[]) { + const doc = await openExampleTextDocument(fixture, toDelete); const activation = nextVdocActivation(manager, doc.uri, language); await vscode.window.showTextDocument(doc); const event = await raceTimeout(activation, 4000); From c17832ca14e9b45a84e31cbcb950817b8cf8f692 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 18:55:36 +0200 Subject: [PATCH 55/63] more directly check for leaked vdocs --- apps/vscode/src/test/diagnostics.test.ts | 32 +++++++++---- apps/vscode/src/test/utils/vdoc.ts | 57 ------------------------ 2 files changed, 24 insertions(+), 65 deletions(-) delete mode 100644 apps/vscode/src/test/utils/vdoc.ts diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 48dcbe58..ab124e48 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -8,7 +8,6 @@ import { DidUpdateDiagnosticsEvent, EmbeddedDiagnosticsManager, VdocDisposeReaso import { VIRTUAL_DOC_TEMP_DIRECTORY, deleteDocument, quartoVdocDir } from "../vdoc/vdoc-tempfile"; import { MarkdownEngine } from "../markdown/engine"; import { TestLogOutputChannel } from "./fixtures/test-log-output-channel"; -import { assertNoLeakedVirtualDocs, deleteAllVirtualDocs } from "./utils/vdoc"; import { DisposableStore } from "core"; import { eventToPromise, filterEvent } from "./utils/event"; import { Uri } from "vscode"; @@ -22,29 +21,46 @@ function createTestManager(disposables: DisposableStore, timeoutMs?: number) { suite("Diagnostics", function () { const disposables = new DisposableStore(); + + /** Test docs to be deleted during teardown. See the note on {@link openExampleTextDocument} */ const toDelete: vscode.TextDocument[] = []; + /** All vdoc URIs created during tests, to check for leaks during teardown. */ + const vdocUris: vscode.Uri[] = []; let client: LanguageClient; let manager: EmbeddedDiagnosticsManager; setup(async function () { manager = createTestManager(disposables); + // Track vdoc URIs for the leak check during teardown. + disposables.add(manager.onDidActivateVdoc((e) => { + vdocUris.push(e.uri); + })); + // Start a test language server. client = testLanguageClient(); await client.start(); - - // Delete all vdocs before starting tests. - // We check for leaked vdocs in teardown. - await deleteAllVirtualDocs(); }); teardown(async function () { - await Promise.all(toDelete.map(doc => deleteDocument(doc))); - disposables.clear(); + await Promise.all(toDelete.map(doc => deleteDocument(doc))); await client.stop(); await vscode.commands.executeCommand("workbench.action.closeAllEditors"); - await assertNoLeakedVirtualDocs(); + + // Check for leaked vdocs. + const leaked = (await Promise.all( + vdocUris.map(async (uri) => { + const exists = await vscode.workspace.fs.stat(uri).then(() => true, () => false); + return exists ? uri : null; + }) + )).filter((uri): uri is vscode.Uri => uri !== null); + assert.strictEqual( + leaked.length, + 0, + `Leaked vdocs:\n${leaked.map(u => u.fsPath).join("\n")}` + ); + vdocUris.length = 0; }); test("receives diagnostics in the .qmd for embedded languages", async function () { diff --git a/apps/vscode/src/test/utils/vdoc.ts b/apps/vscode/src/test/utils/vdoc.ts deleted file mode 100644 index 1e9ba0f3..00000000 --- a/apps/vscode/src/test/utils/vdoc.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "assert"; -import { Uri, workspace } from "vscode"; -import { VIRTUAL_DOC_TEMP_DIRECTORY } from "../../vdoc/vdoc-tempfile"; - - -/** Delete all virtual documents from both the workspace and temp directory. */ -export async function deleteAllVirtualDocs() { - const [workspaceVdocs, tempDir] = await Promise.all([ - workspace.findFiles("**/.vdoc.*"), - workspace.fs.readDirectory(Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)), - ]); - - const deletes = workspaceVdocs.map((uri) => workspace.fs.delete(uri)); - for (const [name] of tempDir) { - if (name.startsWith(".vdoc.")) { - deletes.push(workspace.fs.delete(Uri.file(`${VIRTUAL_DOC_TEMP_DIRECTORY}/${name}`))); - } - } - - await Promise.all(deletes); -} - -/** - * Assert that there are no virtual documents leaked after tests. - */ -export async function assertNoLeakedVirtualDocs() { - await assertNoLocalVirtualDocs(); - await assertNoTempFileVirtualDocs(); -} - -/** - * Assert that there are no virtual documents leaked in the workspace. - */ -async function assertNoLocalVirtualDocs() { - const vdocFiles = await workspace.findFiles("**/.vdoc.*"); - assert.strictEqual( - vdocFiles.length, - 0, - `Expected no virtual doc files, but found ${vdocFiles.length}: ` + - vdocFiles.map((uri) => uri.fsPath).join(", ") - ); -} - -/** - * Assert that there are no virtual documents leaked in the temp folder. - */ -async function assertNoTempFileVirtualDocs() { - const tempDir = await workspace.fs.readDirectory(Uri.file(VIRTUAL_DOC_TEMP_DIRECTORY)); - const tempVdocFiles = tempDir.filter(([name]) => name.startsWith(".vdoc.")); - assert.strictEqual( - tempVdocFiles.length, - 0, - `Expected no virtual doc files in temp directory, ` + - `but found ${tempVdocFiles.length}: ` + - tempVdocFiles.map(([name]) => name).join(", ") - ); -} From 23e549f4f0fa651e3fb706894c6166b2c0f0db15 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 18:58:58 +0200 Subject: [PATCH 56/63] simplify test --- apps/vscode/src/test/diagnostics.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index ab124e48..45c2e835 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -139,7 +139,7 @@ suite("Diagnostics", function () { test("times out for unresponsive language servers without blocking others", async function () { // Julia has no language server registered in tests, so it will time out. // Python should still get its diagnostics independently. - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-timeout.qmd", toDelete); // Python diagnostics should be present despite Julia timing out. assert.ok( @@ -215,7 +215,7 @@ suite("Diagnostics", function () { }); test("reports diagnostics from multiple cells of the same language", async function () { - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-multicell.qmd", toDelete); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 2, "Expected one diagnostic per cell"); @@ -229,7 +229,7 @@ suite("Diagnostics", function () { }); test("maps diagnostic line numbers correctly with content above cell", async function () { - const { doc, event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); + const { event } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); const diagnostics = event.diagnostics; assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic"); @@ -267,12 +267,7 @@ suite("Diagnostics", function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-offset.qmd", toDelete); const cleared = nextDiagnostics(manager, uri); - // We have to set the language to plaintext, since closing - // documents/editors from an extension doesn't necessarily - // trigger onDidCloseTextDocument therefore doesn't notify - // the language server that textDocument/didClose. - await vscode.languages.setTextDocumentLanguage(doc, "plaintext"); - await vscode.commands.executeCommand("workbench.action.closeAllEditors"); + await deleteDocument(doc); const event = await raceTimeout(cleared, 4000); assert.ok(event, "Timed out waiting for diagnostics to clear on close"); @@ -387,6 +382,7 @@ async function openAndAwaitDiagnostics(manager: EmbeddedDiagnosticsManager, fixt return { uri: doc.uri, event, doc }; } +/** Open a .qmd fixture and wait for its virtual document to activate for a given language. */ async function openAndAwaitVdocActivation(manager: EmbeddedDiagnosticsManager, fixture: string, language: string, toDelete: vscode.TextDocument[]) { const doc = await openExampleTextDocument(fixture, toDelete); const activation = nextVdocActivation(manager, doc.uri, language); From 728a4d8e4959ee36692c6588d2041312e824e69f Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 19:13:45 +0200 Subject: [PATCH 57/63] fix diagnostics not clearing after timeout --- apps/vscode/src/providers/diagnostics.ts | 7 ++++++ apps/vscode/src/test/diagnostics.test.ts | 30 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 9c78c515..2ee7752f 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -311,6 +311,10 @@ export class EmbeddedDiagnosticsManager extends Disposable { `for ${workspace.asRelativePath(session.documentUri)}` ); await this.disposeActiveVdoc(session, 'timeout'); + // The happy path (handleDiagnosticsReceived) replaces old diagnostics + // with fresh ones. On timeout, no replacement is coming - clear explicitly. + session.diagnostics = []; + this.publishDiagnostics(session.documentUri); }, this.timeoutMs); session.activeVdoc = { @@ -340,6 +344,9 @@ export class EmbeddedDiagnosticsManager extends Disposable { `${session.language.ids[0]} in ${workspace.asRelativePath(session.documentUri)}: ` + JSON.stringify(error) ); + // Same as timeout - no replacement diagnostics are coming. + session.diagnostics = []; + this.publishDiagnostics(session.documentUri); } } diff --git a/apps/vscode/src/test/diagnostics.test.ts b/apps/vscode/src/test/diagnostics.test.ts index 45c2e835..844860b5 100644 --- a/apps/vscode/src/test/diagnostics.test.ts +++ b/apps/vscode/src/test/diagnostics.test.ts @@ -166,6 +166,36 @@ suite("Diagnostics", function () { assert.strictEqual(exists, false, "Expected vdoc file to be deleted after timeout"); }); + test("clears stale diagnostics after timeout", async function () { + const shortTimeoutManager = createTestManager(disposables, 200); + + const { uri, doc } = await openAndAwaitDiagnostics( + shortTimeoutManager, "diagnostics-timeout.qmd", toDelete + ); + assert.ok(vscode.languages.getDiagnostics(uri).length >= 1, "Should have Python diagnostics initially"); + + // Wait for the initial Julia timeout before editing, + // otherwise nextDiagnostics catches that event instead. + await raceTimeout(nextVdocDisposal(shortTimeoutManager, "timeout", "julia"), 2000); + + // Delete the Python cell, keeping only Julia (which will timeout). + const cleared = nextDiagnostics(shortTimeoutManager, uri); + const editor = await vscode.window.showTextDocument(doc); + await editor.edit((editBuilder) => { + editBuilder.delete( + new vscode.Range( + new vscode.Position(11, 0), + new vscode.Position(doc.lineCount, 0) + ) + ); + }); + + const event = await raceTimeout(cleared, 3000); + assert.ok(event, "Expected diagnostics update after timeout"); + assert.strictEqual(event.diagnostics.length, 0, + "Stale Python diagnostics should be cleared after Julia-only timeout"); + }); + test("clears diagnostics when error is fixed", async function () { const { uri, doc } = await openAndAwaitDiagnostics(manager, "diagnostics-python-undefined.qmd", toDelete); From aa5e085e4f559b9b38279a2cd21cdf8e8565acd6 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 19:19:09 +0200 Subject: [PATCH 58/63] dispose during deactivate --- apps/vscode/src/providers/diagnostics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 2ee7752f..19ec5779 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -512,6 +512,7 @@ export class EmbeddedDiagnosticsManager extends Disposable { * Resolves when all active vdocs have been disposed (or failed). */ async deactivate(): Promise { + this.dispose(); await this._disposePromise; } } From 84d77a94568ec439e598c547703a2dc5e5bd2ad5 Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 19:24:29 +0200 Subject: [PATCH 59/63] concurrently dispose vdocs --- apps/vscode/src/providers/diagnostics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/vscode/src/providers/diagnostics.ts b/apps/vscode/src/providers/diagnostics.ts index 19ec5779..457e50df 100644 --- a/apps/vscode/src/providers/diagnostics.ts +++ b/apps/vscode/src/providers/diagnostics.ts @@ -427,9 +427,9 @@ export class EmbeddedDiagnosticsManager extends Disposable { const langMap = this.sessionsByDocument.get(key); if (!langMap) { return; } - for (const session of langMap.values()) { - await this.disposeActiveVdoc(session, reason); - } + await Promise.allSettled( + [...langMap.values()].map(session => this.disposeActiveVdoc(session, reason)) + ); this.sessionsByDocument.delete(key); } From 6ef15baa03edc410fde581c0423606a7ce2ee88f Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 19:24:36 +0200 Subject: [PATCH 60/63] dont need this --- apps/vscode/src/test/utils/event.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/vscode/src/test/utils/event.ts b/apps/vscode/src/test/utils/event.ts index 5a1a42b1..e7273b54 100644 --- a/apps/vscode/src/test/utils/event.ts +++ b/apps/vscode/src/test/utils/event.ts @@ -1,19 +1,3 @@ -/* - * event.ts - * - * Copyright (C) 2026 by Posit Software, PBC - * Copyright (c) Microsoft Corporation. All rights reserved. - * - * Unless you have received this program directly from Posit Software pursuant - * to the terms of a commercial license agreement with Posit Software, then - * this program is licensed to you under the terms of version 3 of the - * GNU Affero General Public License. This program is distributed WITHOUT - * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, - * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the - * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. - * - */ - import { Event } from "vscode"; export function filterEvent( From 050d99962052b552bd51c9d5357976d760fd141a Mon Sep 17 00:00:00 2001 From: seem Date: Fri, 15 May 2026 19:24:43 +0200 Subject: [PATCH 61/63] build new test folders too --- apps/vscode/build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vscode/build.ts b/apps/vscode/build.ts index 82e2e1f8..61777686 100644 --- a/apps/vscode/build.ts +++ b/apps/vscode/build.ts @@ -19,7 +19,7 @@ import * as glob from "glob"; const args = process.argv; const dev = args[2] === "dev"; const test = args[2] === "test"; -const testFiles = glob.sync("src/test/*.ts"); +const testFiles = glob.sync("src/test/{*.ts,fixtures/*.ts,utils/*.ts}"); const testBuildOptions = { entryPoints: testFiles, From 233514efd3b5080f0e5b10d363bacf6cd404373f Mon Sep 17 00:00:00 2001 From: seem Date: Mon, 18 May 2026 15:14:11 +0200 Subject: [PATCH 62/63] convert test language server to plain JS The test language server is spawned as a child process by the LanguageClient, so it can't be part of the esbuild bundle. Converting it to plain JS means it never goes through the bundler and can be referenced directly from source. --- .../vscode/src/test/fixtures/test-language-client.ts | 5 ++++- ...st-language-server.ts => test-language-server.js} | 12 +++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) rename apps/vscode/src/test/fixtures/{test-language-server.ts => test-language-server.js} (89%) diff --git a/apps/vscode/src/test/fixtures/test-language-client.ts b/apps/vscode/src/test/fixtures/test-language-client.ts index 86cd736b..718ceae9 100644 --- a/apps/vscode/src/test/fixtures/test-language-client.ts +++ b/apps/vscode/src/test/fixtures/test-language-client.ts @@ -6,7 +6,10 @@ import { TestLogOutputChannel } from "./test-log-output-channel"; * A {@link LanguageClient} for testing, which connects to `test-language-server.js`. */ export function testLanguageClient(): LanguageClient { - const serverModule = path.join(__dirname, "test-language-server.js"); + // This code runs from bundled test files in test-out/, so __dirname is + // apps/vscode/test-out/. The server is a standalone .js file (not part of + // the bundle) because the LanguageClient spawns it as a child process. + const serverModule = path.join(__dirname, "..", "src", "test", "fixtures", "test-language-server.js"); const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, diff --git a/apps/vscode/src/test/fixtures/test-language-server.ts b/apps/vscode/src/test/fixtures/test-language-server.js similarity index 89% rename from apps/vscode/src/test/fixtures/test-language-server.ts rename to apps/vscode/src/test/fixtures/test-language-server.js index 4b4f91dd..46f574b3 100644 --- a/apps/vscode/src/test/fixtures/test-language-server.ts +++ b/apps/vscode/src/test/fixtures/test-language-server.js @@ -1,9 +1,11 @@ -import { +// @ts-check + +const { createConnection, DiagnosticSeverity, TextDocuments, -} from "vscode-languageserver/node"; -import { TextDocument } from "vscode-languageserver-textdocument"; +} = require("vscode-languageserver/node"); +const { TextDocument } = require("vscode-languageserver-textdocument"); /** * This module defines a language server for testing. @@ -17,8 +19,9 @@ const { console } = connection; /** * Publish diagnostics for a text document. + * @param {import("vscode-languageserver-textdocument").TextDocument} document */ -function publishDiagnostics(document: TextDocument) { +function publishDiagnostics(document) { // Get the document's lines. const allText = document.getText(); const lines = allText.split("\n"); @@ -75,6 +78,5 @@ documents.onDidClose(({ document }) => { // Connect the text document manager. documents.listen(connection); - // Listen on the connection. connection.listen(); From 53a28a64d0001c392e0d61b0e90a39a621467e61 Mon Sep 17 00:00:00 2001 From: seem Date: Mon, 18 May 2026 15:18:51 +0200 Subject: [PATCH 63/63] add missing typescript diagnostics test fixture --- apps/vscode/src/test/examples/diagnostics-typescript.qmd | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 apps/vscode/src/test/examples/diagnostics-typescript.qmd diff --git a/apps/vscode/src/test/examples/diagnostics-typescript.qmd b/apps/vscode/src/test/examples/diagnostics-typescript.qmd new file mode 100644 index 00000000..9959b579 --- /dev/null +++ b/apps/vscode/src/test/examples/diagnostics-typescript.qmd @@ -0,0 +1,8 @@ +--- +title: "TypeScript diagnostics" +format: html +--- + +```{typescript} +const x: number = undefined_var; +```