From c9e1236dc901be718624f6bbe77987f87b98e122 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 31 Jul 2026 20:12:51 -0700 Subject: [PATCH 1/6] Delay squiggles 300ms when idle, 1500ms during active typing. Squiggles for documents other than the one being edited refresh immediately. When the squiggle count drops to zero, refresh immediately. Note: Does not affect computation of squiggles - only display. --- .../language-service/diagnosticsPublisher.ts | 102 +++++++++ source/npm/qsharp/src/main.ts | 2 + .../qsharp/test/diagnosticsPublisher.test.mjs | 204 ++++++++++++++++++ .../src/language-service/diagnostics.ts | 40 +++- 4 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 source/npm/qsharp/src/language-service/diagnosticsPublisher.ts create mode 100644 source/npm/qsharp/test/diagnosticsPublisher.test.mjs diff --git a/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts new file mode 100644 index 00000000000..e52b6bf1c05 --- /dev/null +++ b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { VSDiagnostic } from "../../lib/web/qsc_wasm.js"; + +export interface DiagnosticsPublisherOptions { + publish: (uri: string, diagnostics: VSDiagnostic[]) => void; + /** Returns a function that cancels the scheduled callback. */ + schedule: (callback: () => void, delayMs: number) => () => void; + /** How long after the last keystroke to wait before publishing. */ + delayMs: number; + /** Upper bound on how long sustained typing can withhold a publish. */ + maxDelayMs: number; +} + +interface Pending { + uri: string; + diagnostics: VSDiagnostic[]; +} + +/** + * Withholds diagnostics for the document the user is currently typing in, so squiggles + * don't churn on every character of a half-written token. + * + * Only that one document is ever held, so there is at most one pending entry. Everything + * else - bulk reloads, background opens, other files in the same project - publishes as it + * arrives. The caller decides which document is "hot"; this type has no opinion on how + * that's determined, and takes its timer from the caller so it can be tested without one. + */ +export class DiagnosticsPublisher { + private hotUri: string | undefined; + private pending: Pending | undefined; + private cancelIdle: (() => void) | undefined; + private cancelCap: (() => void) | undefined; + + constructor(private readonly options: DiagnosticsPublisherOptions) {} + + /** Identifies the document being typed in. Anything else publishes immediately. */ + setHotUri(uri: string | undefined) { + if (this.hotUri === uri) { + return; + } + this.hotUri = uri; + + // A pending entry belongs to the document the user just left, which will get no further + // keystrokes to end its burst. + if (this.pending && this.pending.uri !== uri) { + this.flush(); + } + } + + receive(uri: string, diagnostics: VSDiagnostic[]) { + if (uri !== this.hotUri) { + this.options.publish(uri, diagnostics); + return; + } + + // Clearing the last error is the one result worth showing mid-token. + if (diagnostics.length === 0) { + this.reset(); + this.options.publish(uri, diagnostics); + return; + } + + this.pending = { uri, diagnostics }; + + this.cancelIdle?.(); + this.cancelIdle = this.options.schedule(() => { + this.cancelIdle = undefined; + this.flush(); + }, this.options.delayMs); + + // Deliberately not restarted per keystroke - that is what makes it a cap rather than a + // second debounce, so a long typing run still refreshes. + if (!this.cancelCap) { + this.cancelCap = this.options.schedule(() => { + this.cancelCap = undefined; + this.flush(); + }, this.options.maxDelayMs); + } + } + + flush() { + const pending = this.pending; + this.reset(); + if (pending) { + this.options.publish(pending.uri, pending.diagnostics); + } + } + + dispose() { + this.reset(); + } + + private reset() { + this.pending = undefined; + this.cancelIdle?.(); + this.cancelIdle = undefined; + this.cancelCap?.(); + this.cancelCap = undefined; + } +} diff --git a/source/npm/qsharp/src/main.ts b/source/npm/qsharp/src/main.ts index f6a0dd7e1fa..b1d527bf90e 100644 --- a/source/npm/qsharp/src/main.ts +++ b/source/npm/qsharp/src/main.ts @@ -213,6 +213,7 @@ export * as utils from "./utils.js"; export { log } from "./log.js"; export { QscEventTarget } from "./compiler/events.js"; export { QdkDiagnostics } from "./diagnostics.js"; +export { DiagnosticsPublisher } from "./language-service/diagnosticsPublisher.js"; export { default as samples } from "./samples.generated.js"; export { default as openqasm_samples } from "./openqasm-samples.generated.js"; export { @@ -233,6 +234,7 @@ export type { LanguageServiceEvent, LanguageServiceTestCallablesEvent, } from "./language-service/language-service.js"; +export type { DiagnosticsPublisherOptions } from "./language-service/diagnosticsPublisher.js"; export type { ProjectLoader } from "./project.js"; export type { CircuitGroup as CircuitData } from "./data-structures/circuit.js"; export type { LogLevel } from "./log.js"; diff --git a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs new file mode 100644 index 00000000000..e808180b519 --- /dev/null +++ b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @ts-check + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { DiagnosticsPublisher } from "../dist/language-service/diagnosticsPublisher.js"; + +/** + * @typedef {import("../dist/../lib/web/qsc_wasm.js").VSDiagnostic} VSDiagnostic + */ + +const idleDelayMs = 300; +const maxDelayMs = 1500; + +/** + * Only `code` is ever asserted on; the rest is filler to satisfy the shape. + * @param {string} code + * @returns {VSDiagnostic[]} + */ +function errors(code) { + return [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + message: code, + severity: "error", + code, + }, + ]; +} + +const anError = errors("Qsc.Parse"); + +/** + * Time never advances on its own here: `schedule` only records the callback, and tests + * fire it explicitly. That keeps every case free of real delays and races. + */ +function createHarness() { + /** @type {{ uri: string; diagnostics: VSDiagnostic[] }[]} */ + const published = []; + /** @type {{ callback: () => void; delayMs: number; cancelled: boolean; fired: boolean }[]} */ + const timers = []; + + const publisher = new DiagnosticsPublisher({ + publish: (uri, diagnostics) => published.push({ uri, diagnostics }), + schedule: (callback, delayMs) => { + const timer = { callback, delayMs, cancelled: false, fired: false }; + timers.push(timer); + return () => { + timer.cancelled = true; + }; + }, + delayMs: idleDelayMs, + maxDelayMs, + }); + + /** @param {number} delayMs */ + function live(delayMs) { + return timers.filter( + (t) => t.delayMs === delayMs && !t.cancelled && !t.fired, + ); + } + + /** @param {number} delayMs */ + function fire(delayMs) { + const pending = live(delayMs); + assert.equal(pending.length, 1, `expected one live ${delayMs}ms timer`); + pending[0].fired = true; + pending[0].callback(); + } + + return { publisher, published, timers, live, fire }; +} + +test("non-hot document publishes immediately", () => { + const { publisher, published, timers } = createHarness(); + publisher.setHotUri("file:///a.qs"); + + publisher.receive("file:///b.qs", anError); + + assert.deepEqual(published, [{ uri: "file:///b.qs", diagnostics: anError }]); + assert.equal(timers.length, 0); +}); + +test("a burst with no hot document publishes every uri immediately", () => { + const { publisher, published, timers } = createHarness(); + + publisher.receive("file:///a.qs", anError); + publisher.receive("file:///b.qs", anError); + publisher.receive("file:///c.qs", []); + + assert.deepEqual( + published.map((p) => p.uri), + ["file:///a.qs", "file:///b.qs", "file:///c.qs"], + ); + assert.equal(timers.length, 0); +}); + +test("hot document with errors is withheld", () => { + const { publisher, published, live } = createHarness(); + publisher.setHotUri("file:///a.qs"); + + publisher.receive("file:///a.qs", anError); + + assert.deepEqual(published, []); + assert.equal(live(idleDelayMs).length, 1); + assert.equal(live(maxDelayMs).length, 1); +}); + +test("only the latest diagnostics for the hot document are published", () => { + const { publisher, published, fire } = createHarness(); + publisher.setHotUri("file:///a.qs"); + + publisher.receive("file:///a.qs", errors("first")); + publisher.receive("file:///a.qs", errors("second")); + publisher.receive("file:///a.qs", errors("third")); + + fire(idleDelayMs); + + assert.deepEqual(published, [ + { uri: "file:///a.qs", diagnostics: errors("third") }, + ]); +}); + +test("clearing all errors publishes immediately and drops the pending entry", () => { + const { publisher, published, live, timers } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + + publisher.receive("file:///a.qs", []); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: [] }]); + assert.equal(live(idleDelayMs).length, 0); + assert.equal(live(maxDelayMs).length, 0); + assert.ok(timers.every((t) => t.cancelled)); +}); + +test("switching hot document flushes the pending entry", () => { + const { publisher, published, live } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + + publisher.setHotUri("file:///b.qs"); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); + assert.equal(live(idleDelayMs).length, 0); + assert.equal(live(maxDelayMs).length, 0); +}); + +test("the cap is scheduled once per burst, not per keystroke", () => { + const { publisher, timers } = createHarness(); + publisher.setHotUri("file:///a.qs"); + + publisher.receive("file:///a.qs", anError); + publisher.receive("file:///a.qs", anError); + publisher.receive("file:///a.qs", anError); + + assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 1); + assert.equal(timers.filter((t) => t.delayMs === idleDelayMs).length, 3); +}); + +test("the cap publishes and cancels the idle timer", () => { + const { publisher, published, live, fire } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + + fire(maxDelayMs); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); + assert.equal(live(idleDelayMs).length, 0); +}); + +test("a cap-driven publish starts a fresh cap for the next burst", () => { + const { publisher, published, timers, fire } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + fire(maxDelayMs); + + publisher.receive("file:///a.qs", errors("later")); + + assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 2); + + fire(maxDelayMs); + + assert.deepEqual(published, [ + { uri: "file:///a.qs", diagnostics: anError }, + { uri: "file:///a.qs", diagnostics: errors("later") }, + ]); +}); + +test("dispose cancels without publishing", () => { + const { publisher, published, timers } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + + publisher.dispose(); + + assert.deepEqual(published, []); + assert.ok(timers.every((t) => t.cancelled)); +}); diff --git a/source/vscode/src/language-service/diagnostics.ts b/source/vscode/src/language-service/diagnostics.ts index cbd0e9ac1d0..a8876d3ddbd 100644 --- a/source/vscode/src/language-service/diagnostics.ts +++ b/source/vscode/src/language-service/diagnostics.ts @@ -2,12 +2,19 @@ // Licensed under the MIT License. import { + DiagnosticsPublisher, ILanguageService, VSDiagnostic, qsharpLibraryUriScheme, } from "qsharp-lang"; import * as vscode from "vscode"; -import { qsharpLanguageId, toVsCodeDiagnostic } from "../common"; +import { isQdkDocument, qsharpLanguageId, toVsCodeDiagnostic } from "../common"; + +/** How long after the last keystroke to wait before refreshing squiggles. */ +const idleDelayMs = 300; + +/** Upper bound on how long sustained typing can withhold a refresh. */ +const maxDelayMs = 1500; export function startLanguageServiceDiagnostics( languageService: ILanguageService, @@ -15,6 +22,20 @@ export function startLanguageServiceDiagnostics( const diagCollection = vscode.languages.createDiagnosticCollection(qsharpLanguageId); + const publisher = new DiagnosticsPublisher({ + publish: (uri, diagnostics) => + diagCollection.set( + vscode.Uri.parse(uri), + diagnostics.map((d) => toVsCodeDiagnostic(d)), + ), + schedule: (callback, delayMs) => { + const handle = setTimeout(callback, delayMs); + return () => clearTimeout(handle); + }, + delayMs: idleDelayMs, + maxDelayMs, + }); + async function onDiagnostics(evt: { detail: { uri: string; @@ -30,20 +51,29 @@ export function startLanguageServiceDiagnostics( return; } - diagCollection.set( - uri, - diagnostics.diagnostics.map((d) => toVsCodeDiagnostic(d)), - ); + publisher.receive(diagnostics.uri, diagnostics.diagnostics); } languageService.addEventListener("diagnostics", onDiagnostics); + // A change event, rather than the active editor, is what marks a document as being typed in. + // Documents the language service never publishes for are ignored rather than clearing the hot + // document, since adopting one would silently disable the debounce until the user typed in a + // QDK file again. + const hotDocumentTracker = vscode.workspace.onDidChangeTextDocument((evt) => { + if (isQdkDocument(evt.document)) { + publisher.setHotUri(evt.document.uri.toString()); + } + }); + return [ { dispose: () => { languageService.removeEventListener("diagnostics", onDiagnostics); + publisher.dispose(); }, }, + hotDocumentTracker, diagCollection, ]; } From 306cfef083a44c5173031021adcc9e227689e0fb Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 31 Jul 2026 20:41:09 -0700 Subject: [PATCH 2/6] Handle non-qdk hot documents --- .../qsharp/test/diagnosticsPublisher.test.mjs | 21 +++++++++++++++++++ .../src/language-service/diagnostics.ts | 13 +++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs index e808180b519..3f60b8582e9 100644 --- a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs +++ b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs @@ -151,6 +151,27 @@ test("switching hot document flushes the pending entry", () => { assert.equal(live(maxDelayMs).length, 0); }); +test("clearing the hot document flushes and stops debouncing", () => { + const { publisher, published, live, timers } = createHarness(); + publisher.setHotUri("file:///a.qs"); + publisher.receive("file:///a.qs", anError); + + // Stands in for the user moving to a document the language service never publishes for. + publisher.setHotUri(undefined); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); + assert.equal(live(idleDelayMs).length, 0); + assert.equal(live(maxDelayMs).length, 0); + + publisher.receive("file:///a.qs", anError); + + assert.deepEqual(published, [ + { uri: "file:///a.qs", diagnostics: anError }, + { uri: "file:///a.qs", diagnostics: anError }, + ]); + assert.equal(timers.length, 2); +}); + test("the cap is scheduled once per burst, not per keystroke", () => { const { publisher, timers } = createHarness(); publisher.setHotUri("file:///a.qs"); diff --git a/source/vscode/src/language-service/diagnostics.ts b/source/vscode/src/language-service/diagnostics.ts index a8876d3ddbd..7545ef537a1 100644 --- a/source/vscode/src/language-service/diagnostics.ts +++ b/source/vscode/src/language-service/diagnostics.ts @@ -57,13 +57,16 @@ export function startLanguageServiceDiagnostics( languageService.addEventListener("diagnostics", onDiagnostics); // A change event, rather than the active editor, is what marks a document as being typed in. - // Documents the language service never publishes for are ignored rather than clearing the hot - // document, since adopting one would silently disable the debounce until the user typed in a - // QDK file again. + // Editing anything else clears the hot document, so only the file under the cursor is ever held. const hotDocumentTracker = vscode.workspace.onDidChangeTextDocument((evt) => { - if (isQdkDocument(evt.document)) { - publisher.setHotUri(evt.document.uri.toString()); + // Dirty-state and encoding changes raise this event too, with no edit behind them. + if (evt.contentChanges.length === 0) { + return; } + + publisher.setHotUri( + isQdkDocument(evt.document) ? evt.document.uri.toString() : undefined, + ); }); return [ From f1dbb4527fcfd6dc3a26984e919d584845c31ba1 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Fri, 31 Jul 2026 23:37:45 -0700 Subject: [PATCH 3/6] Base timing on edits rather than diagnostic events --- .../language-service/diagnosticsPublisher.ts | 77 +++++----- .../qsharp/test/diagnosticsPublisher.test.mjs | 132 ++++++++++++------ .../src/language-service/diagnostics.ts | 4 +- 3 files changed, 138 insertions(+), 75 deletions(-) diff --git a/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts index e52b6bf1c05..4404afaee05 100644 --- a/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts +++ b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts @@ -7,7 +7,7 @@ export interface DiagnosticsPublisherOptions { publish: (uri: string, diagnostics: VSDiagnostic[]) => void; /** Returns a function that cancels the scheduled callback. */ schedule: (callback: () => void, delayMs: number) => () => void; - /** How long after the last keystroke to wait before publishing. */ + /** How long after the last edit to wait before publishing. */ delayMs: number; /** Upper bound on how long sustained typing can withhold a publish. */ maxDelayMs: number; @@ -22,10 +22,13 @@ interface Pending { * Withholds diagnostics for the document the user is currently typing in, so squiggles * don't churn on every character of a half-written token. * - * Only that one document is ever held, so there is at most one pending entry. Everything - * else - bulk reloads, background opens, other files in the same project - publishes as it - * arrives. The caller decides which document is "hot"; this type has no opinion on how - * that's determined, and takes its timer from the caller so it can be tested without one. + * The wait runs from the last edit rather than the last result, so a pause the user has + * already taken counts against it and a slow compilation adds no delay of its own. + * + * Only the document being edited is ever held, so there is at most one pending entry. + * Everything else - bulk reloads, background opens, other files in the same project - + * publishes as it arrives. The caller reports the edits; this type has no opinion on how + * they're detected, and takes its timer from the caller so it can be tested without one. */ export class DiagnosticsPublisher { private hotUri: string | undefined; @@ -35,42 +38,28 @@ export class DiagnosticsPublisher { constructor(private readonly options: DiagnosticsPublisherOptions) {} - /** Identifies the document being typed in. Anything else publishes immediately. */ - setHotUri(uri: string | undefined) { - if (this.hotUri === uri) { - return; - } - this.hotUri = uri; - - // A pending entry belongs to the document the user just left, which will get no further - // keystrokes to end its burst. - if (this.pending && this.pending.uri !== uri) { - this.flush(); - } - } - - receive(uri: string, diagnostics: VSDiagnostic[]) { + /** Reports an edit. `uri` is undefined for documents we don't publish diagnostics for. */ + noteEdit(uri: string | undefined) { if (uri !== this.hotUri) { - this.options.publish(uri, diagnostics); - return; + // A pending entry belongs to the document the user just left, which will get no + // further edits to end its burst. + this.flush(); + this.endBurst(); + this.hotUri = uri; } - // Clearing the last error is the one result worth showing mid-token. - if (diagnostics.length === 0) { - this.reset(); - this.options.publish(uri, diagnostics); + if (uri === undefined) { return; } - this.pending = { uri, diagnostics }; - this.cancelIdle?.(); this.cancelIdle = this.options.schedule(() => { this.cancelIdle = undefined; + this.endBurst(); this.flush(); }, this.options.delayMs); - // Deliberately not restarted per keystroke - that is what makes it a cap rather than a + // Deliberately not restarted per edit - that is what makes it a cap rather than a // second debounce, so a long typing run still refreshes. if (!this.cancelCap) { this.cancelCap = this.options.schedule(() => { @@ -80,20 +69,42 @@ export class DiagnosticsPublisher { } } + receive(uri: string, diagnostics: VSDiagnostic[]) { + // A result that arrives once the typing has stopped is the one being waited on. + if (uri !== this.hotUri || !this.isBursting) { + this.options.publish(uri, diagnostics); + return; + } + + // Clearing the last error is the one result worth showing mid-token. + if (diagnostics.length === 0) { + this.pending = undefined; + this.options.publish(uri, diagnostics); + return; + } + + this.pending = { uri, diagnostics }; + } + flush() { const pending = this.pending; - this.reset(); + this.pending = undefined; if (pending) { this.options.publish(pending.uri, pending.diagnostics); } } dispose() { - this.reset(); + this.pending = undefined; + this.endBurst(); } - private reset() { - this.pending = undefined; + /** A burst runs from an edit until `delayMs` of quiet. */ + private get isBursting() { + return this.cancelIdle !== undefined; + } + + private endBurst() { this.cancelIdle?.(); this.cancelIdle = undefined; this.cancelCap?.(); diff --git a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs index 3f60b8582e9..a2f608f6bc0 100644 --- a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs +++ b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs @@ -76,17 +76,16 @@ function createHarness() { return { publisher, published, timers, live, fire }; } -test("non-hot document publishes immediately", () => { - const { publisher, published, timers } = createHarness(); - publisher.setHotUri("file:///a.qs"); +test("a document that is not being edited publishes immediately", () => { + const { publisher, published } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///b.qs", anError); assert.deepEqual(published, [{ uri: "file:///b.qs", diagnostics: anError }]); - assert.equal(timers.length, 0); }); -test("a burst with no hot document publishes every uri immediately", () => { +test("a burst with no edits publishes every uri immediately", () => { const { publisher, published, timers } = createHarness(); publisher.receive("file:///a.qs", anError); @@ -100,9 +99,9 @@ test("a burst with no hot document publishes every uri immediately", () => { assert.equal(timers.length, 0); }); -test("hot document with errors is withheld", () => { +test("errors for the document being edited are withheld", () => { const { publisher, published, live } = createHarness(); - publisher.setHotUri("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); @@ -111,9 +110,41 @@ test("hot document with errors is withheld", () => { assert.equal(live(maxDelayMs).length, 1); }); -test("only the latest diagnostics for the hot document are published", () => { +test("a result that arrives after the typing stopped publishes immediately", () => { const { publisher, published, fire } = createHarness(); - publisher.setHotUri("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); + + // Stands in for a compilation slower than the wait: the burst ends before it finishes. + fire(idleDelayMs); + publisher.receive("file:///a.qs", anError); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); +}); + +test("the wait restarts on each edit", () => { + const { publisher, timers, live } = createHarness(); + + publisher.noteEdit("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); + + assert.equal(timers.filter((t) => t.delayMs === idleDelayMs).length, 3); + assert.equal(live(idleDelayMs).length, 1); +}); + +test("the cap is scheduled once per burst, not per edit", () => { + const { publisher, timers } = createHarness(); + + publisher.noteEdit("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); + + assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 1); +}); + +test("only the latest diagnostics for the edited document are published", () => { + const { publisher, published, fire } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", errors("first")); publisher.receive("file:///a.qs", errors("second")); @@ -127,37 +158,47 @@ test("only the latest diagnostics for the hot document are published", () => { }); test("clearing all errors publishes immediately and drops the pending entry", () => { - const { publisher, published, live, timers } = createHarness(); - publisher.setHotUri("file:///a.qs"); + const { publisher, published, live, fire } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); publisher.receive("file:///a.qs", []); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: [] }]); - assert.equal(live(idleDelayMs).length, 0); - assert.equal(live(maxDelayMs).length, 0); - assert.ok(timers.every((t) => t.cancelled)); + + // The burst belongs to the typing, not to the pending entry, so it keeps running. + assert.equal(live(idleDelayMs).length, 1); + assert.equal(live(maxDelayMs).length, 1); + + fire(idleDelayMs); + + assert.equal(published.length, 1); }); -test("switching hot document flushes the pending entry", () => { - const { publisher, published, live } = createHarness(); - publisher.setHotUri("file:///a.qs"); +test("editing another document flushes the pending entry", () => { + const { publisher, published } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); - publisher.setHotUri("file:///b.qs"); + publisher.noteEdit("file:///b.qs"); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); - assert.equal(live(idleDelayMs).length, 0); - assert.equal(live(maxDelayMs).length, 0); + + // The first document is no longer the one being edited. + publisher.receive("file:///a.qs", errors("later")); + + assert.deepEqual(published, [ + { uri: "file:///a.qs", diagnostics: anError }, + { uri: "file:///a.qs", diagnostics: errors("later") }, + ]); }); -test("clearing the hot document flushes and stops debouncing", () => { - const { publisher, published, live, timers } = createHarness(); - publisher.setHotUri("file:///a.qs"); +test("editing a document we don't publish for flushes and stops debouncing", () => { + const { publisher, published, live } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); - // Stands in for the user moving to a document the language service never publishes for. - publisher.setHotUri(undefined); + publisher.noteEdit(undefined); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); assert.equal(live(idleDelayMs).length, 0); @@ -169,38 +210,49 @@ test("clearing the hot document flushes and stops debouncing", () => { { uri: "file:///a.qs", diagnostics: anError }, { uri: "file:///a.qs", diagnostics: anError }, ]); - assert.equal(timers.length, 2); }); -test("the cap is scheduled once per burst, not per keystroke", () => { - const { publisher, timers } = createHarness(); - publisher.setHotUri("file:///a.qs"); - - publisher.receive("file:///a.qs", anError); - publisher.receive("file:///a.qs", anError); +test("the idle timer ends the burst", () => { + const { publisher, published, live, fire } = createHarness(); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); - assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 1); - assert.equal(timers.filter((t) => t.delayMs === idleDelayMs).length, 3); + fire(idleDelayMs); + + assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); + assert.equal(live(maxDelayMs).length, 0); + + publisher.receive("file:///a.qs", errors("later")); + + assert.deepEqual(published, [ + { uri: "file:///a.qs", diagnostics: anError }, + { uri: "file:///a.qs", diagnostics: errors("later") }, + ]); }); -test("the cap publishes and cancels the idle timer", () => { +test("the cap publishes without ending the burst", () => { const { publisher, published, live, fire } = createHarness(); - publisher.setHotUri("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); fire(maxDelayMs); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); - assert.equal(live(idleDelayMs).length, 0); + + // Typing hasn't stopped, so the next result is still withheld. + assert.equal(live(idleDelayMs).length, 1); + publisher.receive("file:///a.qs", errors("later")); + + assert.equal(published.length, 1); }); -test("a cap-driven publish starts a fresh cap for the next burst", () => { +test("a cap-driven publish starts a fresh cap on the next edit", () => { const { publisher, published, timers, fire } = createHarness(); - publisher.setHotUri("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); fire(maxDelayMs); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", errors("later")); assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 2); @@ -215,7 +267,7 @@ test("a cap-driven publish starts a fresh cap for the next burst", () => { test("dispose cancels without publishing", () => { const { publisher, published, timers } = createHarness(); - publisher.setHotUri("file:///a.qs"); + publisher.noteEdit("file:///a.qs"); publisher.receive("file:///a.qs", anError); publisher.dispose(); diff --git a/source/vscode/src/language-service/diagnostics.ts b/source/vscode/src/language-service/diagnostics.ts index 7545ef537a1..195deaac504 100644 --- a/source/vscode/src/language-service/diagnostics.ts +++ b/source/vscode/src/language-service/diagnostics.ts @@ -10,7 +10,7 @@ import { import * as vscode from "vscode"; import { isQdkDocument, qsharpLanguageId, toVsCodeDiagnostic } from "../common"; -/** How long after the last keystroke to wait before refreshing squiggles. */ +/** How long after the last edit to wait before refreshing squiggles. */ const idleDelayMs = 300; /** Upper bound on how long sustained typing can withhold a refresh. */ @@ -64,7 +64,7 @@ export function startLanguageServiceDiagnostics( return; } - publisher.setHotUri( + publisher.noteEdit( isQdkDocument(evt.document) ? evt.document.uri.toString() : undefined, ); }); From 98f5d81d7576c2e9f2ceed6529319235b61786d2 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sun, 2 Aug 2026 11:30:14 -0700 Subject: [PATCH 4/6] Add clarifying comment --- source/language_service/src/protocol.rs | 2 +- source/npm/qsharp/src/language-service/language-service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/language_service/src/protocol.rs b/source/language_service/src/protocol.rs index 3b524425c9a..f3c80fe12de 100644 --- a/source/language_service/src/protocol.rs +++ b/source/language_service/src/protocol.rs @@ -63,7 +63,7 @@ pub struct DocumentStatusDiagnostic { #[derive(Debug)] pub struct DiagnosticUpdate { pub uri: String, - pub version: Option, + pub version: Option, // No version if not open pub errors: Vec, } diff --git a/source/npm/qsharp/src/language-service/language-service.ts b/source/npm/qsharp/src/language-service/language-service.ts index 802d2190273..2de87c0e194 100644 --- a/source/npm/qsharp/src/language-service/language-service.ts +++ b/source/npm/qsharp/src/language-service/language-service.ts @@ -290,7 +290,7 @@ export class QSharpLanguageService implements ILanguageService { Event; event.detail = { uri, - version: version ?? 0, + version: version ?? 0, // No version if not open diagnostics, }; this.eventHandler.dispatchEvent(event); From 091a6d73982e2724592c6e17a69f562e687df5db Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sun, 2 Aug 2026 11:48:02 -0700 Subject: [PATCH 5/6] Rename all the things --- .../language-service/diagnosticsPublisher.ts | 67 +++++----- source/npm/qsharp/src/main.ts | 2 +- .../qsharp/test/diagnosticsPublisher.test.mjs | 123 +++++++++--------- .../src/language-service/diagnostics.ts | 26 ++-- 4 files changed, 114 insertions(+), 104 deletions(-) diff --git a/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts index 4404afaee05..1236664a139 100644 --- a/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts +++ b/source/npm/qsharp/src/language-service/diagnosticsPublisher.ts @@ -3,9 +3,14 @@ import type { VSDiagnostic } from "../../lib/web/qsc_wasm.js"; -export interface DiagnosticsPublisherOptions { +/** + * Both configuration for DiagnosticsPublisher and hooks so that different editors + * (and tests) can implement key operations differently. + */ +export interface DiagnosticsPublisherImpl { + /** Called when diagnostics are ready to be displayed to the user. */ publish: (uri: string, diagnostics: VSDiagnostic[]) => void; - /** Returns a function that cancels the scheduled callback. */ + /** Called to schedule deferred work. */ schedule: (callback: () => void, delayMs: number) => () => void; /** How long after the last edit to wait before publishing. */ delayMs: number; @@ -13,7 +18,7 @@ export interface DiagnosticsPublisherOptions { maxDelayMs: number; } -interface Pending { +interface PendingDiagnostics { uri: string; diagnostics: VSDiagnostic[]; } @@ -22,75 +27,77 @@ interface Pending { * Withholds diagnostics for the document the user is currently typing in, so squiggles * don't churn on every character of a half-written token. * - * The wait runs from the last edit rather than the last result, so a pause the user has - * already taken counts against it and a slow compilation adds no delay of its own. + * The goal is to have a small delay when the user is idle but a cap on the maximum delay + * before squiggles are drawn. * - * Only the document being edited is ever held, so there is at most one pending entry. - * Everything else - bulk reloads, background opens, other files in the same project - - * publishes as it arrives. The caller reports the edits; this type has no opinion on how - * they're detected, and takes its timer from the caller so it can be tested without one. + * Only the active file is affected - everything else (e.g. closed files) still publishes + * ASAP. */ export class DiagnosticsPublisher { - private hotUri: string | undefined; - private pending: Pending | undefined; + // Active file (i.e. subject to delays) + private activeUri: string | undefined; + // Deferred diagnostics for `activeUri` + private pending: PendingDiagnostics | undefined; private cancelIdle: (() => void) | undefined; private cancelCap: (() => void) | undefined; - constructor(private readonly options: DiagnosticsPublisherOptions) {} + constructor(private readonly impl: DiagnosticsPublisherImpl) {} - /** Reports an edit. `uri` is undefined for documents we don't publish diagnostics for. */ - noteEdit(uri: string | undefined) { - if (uri !== this.hotUri) { + /// Callback for when a document is edited + onEdit(uri: string | undefined) { + if (uri !== this.activeUri) { // A pending entry belongs to the document the user just left, which will get no // further edits to end its burst. - this.flush(); + this.publishPending(); this.endBurst(); - this.hotUri = uri; + this.activeUri = uri; } + // Indicates a non-QDK document (which is still interesting, since it changes the activeUri) if (uri === undefined) { return; } this.cancelIdle?.(); - this.cancelIdle = this.options.schedule(() => { + this.cancelIdle = this.impl.schedule(() => { this.cancelIdle = undefined; this.endBurst(); - this.flush(); - }, this.options.delayMs); + this.publishPending(); + }, this.impl.delayMs); // Deliberately not restarted per edit - that is what makes it a cap rather than a // second debounce, so a long typing run still refreshes. if (!this.cancelCap) { - this.cancelCap = this.options.schedule(() => { + this.cancelCap = this.impl.schedule(() => { this.cancelCap = undefined; - this.flush(); - }, this.options.maxDelayMs); + this.publishPending(); + }, this.impl.maxDelayMs); } } - receive(uri: string, diagnostics: VSDiagnostic[]) { - // A result that arrives once the typing has stopped is the one being waited on. - if (uri !== this.hotUri || !this.isBursting) { - this.options.publish(uri, diagnostics); + onDiagnosticsUpdate(uri: string, diagnostics: VSDiagnostic[]) { + // Nothing is waiting on this result: either it's for another document, or the + // typing already stopped and the burst ended. + if (uri !== this.activeUri || !this.isBursting) { + this.impl.publish(uri, diagnostics); return; } // Clearing the last error is the one result worth showing mid-token. if (diagnostics.length === 0) { this.pending = undefined; - this.options.publish(uri, diagnostics); + this.impl.publish(uri, diagnostics); return; } this.pending = { uri, diagnostics }; } - flush() { + publishPending() { const pending = this.pending; this.pending = undefined; if (pending) { - this.options.publish(pending.uri, pending.diagnostics); + this.impl.publish(pending.uri, pending.diagnostics); } } diff --git a/source/npm/qsharp/src/main.ts b/source/npm/qsharp/src/main.ts index b1d527bf90e..8f67222683e 100644 --- a/source/npm/qsharp/src/main.ts +++ b/source/npm/qsharp/src/main.ts @@ -234,7 +234,7 @@ export type { LanguageServiceEvent, LanguageServiceTestCallablesEvent, } from "./language-service/language-service.js"; -export type { DiagnosticsPublisherOptions } from "./language-service/diagnosticsPublisher.js"; +export type { DiagnosticsPublisherImpl } from "./language-service/diagnosticsPublisher.js"; export type { ProjectLoader } from "./project.js"; export type { CircuitGroup as CircuitData } from "./data-structures/circuit.js"; export type { LogLevel } from "./log.js"; diff --git a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs index a2f608f6bc0..3c76629cdf8 100644 --- a/source/npm/qsharp/test/diagnosticsPublisher.test.mjs +++ b/source/npm/qsharp/test/diagnosticsPublisher.test.mjs @@ -59,7 +59,7 @@ function createHarness() { }); /** @param {number} delayMs */ - function live(delayMs) { + function getLiveTimersWithDelay(delayMs) { return timers.filter( (t) => t.delayMs === delayMs && !t.cancelled && !t.fired, ); @@ -67,20 +67,20 @@ function createHarness() { /** @param {number} delayMs */ function fire(delayMs) { - const pending = live(delayMs); + const pending = getLiveTimersWithDelay(delayMs); assert.equal(pending.length, 1, `expected one live ${delayMs}ms timer`); pending[0].fired = true; pending[0].callback(); } - return { publisher, published, timers, live, fire }; + return { publisher, published, timers, getLiveTimersWithDelay, fire }; } test("a document that is not being edited publishes immediately", () => { const { publisher, published } = createHarness(); - publisher.noteEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); - publisher.receive("file:///b.qs", anError); + publisher.onDiagnosticsUpdate("file:///b.qs", anError); assert.deepEqual(published, [{ uri: "file:///b.qs", diagnostics: anError }]); }); @@ -88,9 +88,9 @@ test("a document that is not being edited publishes immediately", () => { test("a burst with no edits publishes every uri immediately", () => { const { publisher, published, timers } = createHarness(); - publisher.receive("file:///a.qs", anError); - publisher.receive("file:///b.qs", anError); - publisher.receive("file:///c.qs", []); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); + publisher.onDiagnosticsUpdate("file:///b.qs", anError); + publisher.onDiagnosticsUpdate("file:///c.qs", []); assert.deepEqual( published.map((p) => p.uri), @@ -100,55 +100,55 @@ test("a burst with no edits publishes every uri immediately", () => { }); test("errors for the document being edited are withheld", () => { - const { publisher, published, live } = createHarness(); - publisher.noteEdit("file:///a.qs"); + const { publisher, published, getLiveTimersWithDelay } = createHarness(); + publisher.onEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); assert.deepEqual(published, []); - assert.equal(live(idleDelayMs).length, 1); - assert.equal(live(maxDelayMs).length, 1); + assert.equal(getLiveTimersWithDelay(idleDelayMs).length, 1); + assert.equal(getLiveTimersWithDelay(maxDelayMs).length, 1); }); test("a result that arrives after the typing stopped publishes immediately", () => { const { publisher, published, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); // Stands in for a compilation slower than the wait: the burst ends before it finishes. fire(idleDelayMs); - publisher.receive("file:///a.qs", anError); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); }); test("the wait restarts on each edit", () => { - const { publisher, timers, live } = createHarness(); + const { publisher, timers, getLiveTimersWithDelay } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.noteEdit("file:///a.qs"); - publisher.noteEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); assert.equal(timers.filter((t) => t.delayMs === idleDelayMs).length, 3); - assert.equal(live(idleDelayMs).length, 1); + assert.equal(getLiveTimersWithDelay(idleDelayMs).length, 1); }); test("the cap is scheduled once per burst, not per edit", () => { const { publisher, timers } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.noteEdit("file:///a.qs"); - publisher.noteEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 1); }); test("only the latest diagnostics for the edited document are published", () => { const { publisher, published, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); + publisher.onEdit("file:///a.qs"); - publisher.receive("file:///a.qs", errors("first")); - publisher.receive("file:///a.qs", errors("second")); - publisher.receive("file:///a.qs", errors("third")); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("first")); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("second")); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("third")); fire(idleDelayMs); @@ -158,17 +158,18 @@ test("only the latest diagnostics for the edited document are published", () => }); test("clearing all errors publishes immediately and drops the pending entry", () => { - const { publisher, published, live, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + const { publisher, published, getLiveTimersWithDelay, fire } = + createHarness(); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); - publisher.receive("file:///a.qs", []); + publisher.onDiagnosticsUpdate("file:///a.qs", []); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: [] }]); // The burst belongs to the typing, not to the pending entry, so it keeps running. - assert.equal(live(idleDelayMs).length, 1); - assert.equal(live(maxDelayMs).length, 1); + assert.equal(getLiveTimersWithDelay(idleDelayMs).length, 1); + assert.equal(getLiveTimersWithDelay(maxDelayMs).length, 1); fire(idleDelayMs); @@ -177,15 +178,15 @@ test("clearing all errors publishes immediately and drops the pending entry", () test("editing another document flushes the pending entry", () => { const { publisher, published } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); - publisher.noteEdit("file:///b.qs"); + publisher.onEdit("file:///b.qs"); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); // The first document is no longer the one being edited. - publisher.receive("file:///a.qs", errors("later")); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("later")); assert.deepEqual(published, [ { uri: "file:///a.qs", diagnostics: anError }, @@ -194,17 +195,17 @@ test("editing another document flushes the pending entry", () => { }); test("editing a document we don't publish for flushes and stops debouncing", () => { - const { publisher, published, live } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + const { publisher, published, getLiveTimersWithDelay } = createHarness(); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); - publisher.noteEdit(undefined); + publisher.onEdit(undefined); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); - assert.equal(live(idleDelayMs).length, 0); - assert.equal(live(maxDelayMs).length, 0); + assert.equal(getLiveTimersWithDelay(idleDelayMs).length, 0); + assert.equal(getLiveTimersWithDelay(maxDelayMs).length, 0); - publisher.receive("file:///a.qs", anError); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); assert.deepEqual(published, [ { uri: "file:///a.qs", diagnostics: anError }, @@ -213,16 +214,17 @@ test("editing a document we don't publish for flushes and stops debouncing", () }); test("the idle timer ends the burst", () => { - const { publisher, published, live, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + const { publisher, published, getLiveTimersWithDelay, fire } = + createHarness(); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); fire(idleDelayMs); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); - assert.equal(live(maxDelayMs).length, 0); + assert.equal(getLiveTimersWithDelay(maxDelayMs).length, 0); - publisher.receive("file:///a.qs", errors("later")); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("later")); assert.deepEqual(published, [ { uri: "file:///a.qs", diagnostics: anError }, @@ -231,29 +233,30 @@ test("the idle timer ends the burst", () => { }); test("the cap publishes without ending the burst", () => { - const { publisher, published, live, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + const { publisher, published, getLiveTimersWithDelay, fire } = + createHarness(); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); fire(maxDelayMs); assert.deepEqual(published, [{ uri: "file:///a.qs", diagnostics: anError }]); // Typing hasn't stopped, so the next result is still withheld. - assert.equal(live(idleDelayMs).length, 1); - publisher.receive("file:///a.qs", errors("later")); + assert.equal(getLiveTimersWithDelay(idleDelayMs).length, 1); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("later")); assert.equal(published.length, 1); }); test("a cap-driven publish starts a fresh cap on the next edit", () => { const { publisher, published, timers, fire } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); fire(maxDelayMs); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", errors("later")); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", errors("later")); assert.equal(timers.filter((t) => t.delayMs === maxDelayMs).length, 2); @@ -267,8 +270,8 @@ test("a cap-driven publish starts a fresh cap on the next edit", () => { test("dispose cancels without publishing", () => { const { publisher, published, timers } = createHarness(); - publisher.noteEdit("file:///a.qs"); - publisher.receive("file:///a.qs", anError); + publisher.onEdit("file:///a.qs"); + publisher.onDiagnosticsUpdate("file:///a.qs", anError); publisher.dispose(); diff --git a/source/vscode/src/language-service/diagnostics.ts b/source/vscode/src/language-service/diagnostics.ts index 195deaac504..8dccf156770 100644 --- a/source/vscode/src/language-service/diagnostics.ts +++ b/source/vscode/src/language-service/diagnostics.ts @@ -51,23 +51,23 @@ export function startLanguageServiceDiagnostics( return; } - publisher.receive(diagnostics.uri, diagnostics.diagnostics); + publisher.onDiagnosticsUpdate(diagnostics.uri, diagnostics.diagnostics); } languageService.addEventListener("diagnostics", onDiagnostics); - // A change event, rather than the active editor, is what marks a document as being typed in. - // Editing anything else clears the hot document, so only the file under the cursor is ever held. - const hotDocumentTracker = vscode.workspace.onDidChangeTextDocument((evt) => { - // Dirty-state and encoding changes raise this event too, with no edit behind them. - if (evt.contentChanges.length === 0) { - return; - } + // Feed edit events to the DiagnosticsPublisher + const diagnosticsPublisherEditTracker = + vscode.workspace.onDidChangeTextDocument((evt) => { + // Dirty-state and encoding changes raise this event too, with no edit behind them. + if (evt.contentChanges.length === 0) { + return; + } - publisher.noteEdit( - isQdkDocument(evt.document) ? evt.document.uri.toString() : undefined, - ); - }); + publisher.onEdit( + isQdkDocument(evt.document) ? evt.document.uri.toString() : undefined, + ); + }); return [ { @@ -76,7 +76,7 @@ export function startLanguageServiceDiagnostics( publisher.dispose(); }, }, - hotDocumentTracker, + diagnosticsPublisherEditTracker, diagCollection, ]; } From a395cf64653123dfcfc1ea634caa20e17c63b0c5 Mon Sep 17 00:00:00 2001 From: Andrew Casey Date: Sun, 2 Aug 2026 12:59:33 -0700 Subject: [PATCH 6/6] Drop output window edits --- source/vscode/src/language-service/diagnostics.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/source/vscode/src/language-service/diagnostics.ts b/source/vscode/src/language-service/diagnostics.ts index 8dccf156770..bc222bdeeea 100644 --- a/source/vscode/src/language-service/diagnostics.ts +++ b/source/vscode/src/language-service/diagnostics.ts @@ -59,13 +59,21 @@ export function startLanguageServiceDiagnostics( // Feed edit events to the DiagnosticsPublisher const diagnosticsPublisherEditTracker = vscode.workspace.onDidChangeTextDocument((evt) => { + const uri = evt.document.uri; + + if (uri.scheme === "output") { + // NB: this fires for output window changes, so it's very important not to cause + // output window changes in response (i.e. to avoid a cycle). + return; + } + // Dirty-state and encoding changes raise this event too, with no edit behind them. if (evt.contentChanges.length === 0) { return; } publisher.onEdit( - isQdkDocument(evt.document) ? evt.document.uri.toString() : undefined, + isQdkDocument(evt.document) ? uri.toString() : undefined, ); });