From ecff8a1978ab71e1cc530acbc77ee275604b524c Mon Sep 17 00:00:00 2001 From: elliot Date: Wed, 29 Apr 2026 13:51:48 -0400 Subject: [PATCH 01/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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,