Skip to content

Commit d6087e8

Browse files
authored
feat: client-side support for incremental diagnostics (#752)
This PR adds client-side support for [lean4#13260](leanprover/lean4#13260). Currently, none of the consumers of diagnostics in the VS Code extension support incremental diagnostics, but adding client-side support for it already means that the server-side serialization overhead is reduced and we send less diagnostic data over the wire.
1 parent 79a7588 commit d6087e8

File tree

4 files changed

+226
-14
lines changed

4 files changed

+226
-14
lines changed

vscode-lean4/src/diagnostics.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { CancellationToken, DiagnosticCollection, Disposable, EventEmitter } from 'vscode'
2+
import { DocumentUri } from 'vscode-languageserver-protocol'
3+
import { CoalescingSyncQueue } from './utils/coalescingSyncQueue'
4+
import { LeanDiagnostic, LeanPublishDiagnosticsParams, p2cConverter } from './utils/converters'
5+
6+
export type DiagnosticChangeKind = 'replace' | 'append'
7+
8+
export class DiagnosticChangeEvent {
9+
readonly kind: DiagnosticChangeKind
10+
readonly params: LeanPublishDiagnosticsParams
11+
private readonly accumulated: LeanDiagnostic[]
12+
private readonly amountAccumulated: number
13+
14+
constructor(kind: DiagnosticChangeKind, params: LeanPublishDiagnosticsParams, accumulated: LeanDiagnostic[]) {
15+
this.kind = kind
16+
this.params = params
17+
this.accumulated = accumulated
18+
this.amountAccumulated = accumulated.length
19+
}
20+
21+
private accumulatedDiagnostics(): LeanDiagnostic[] {
22+
return this.accumulated.slice(0, this.amountAccumulated)
23+
}
24+
25+
accumulatedParams(): LeanPublishDiagnosticsParams {
26+
return { ...this.params, diagnostics: this.accumulatedDiagnostics() }
27+
}
28+
}
29+
30+
type SyncQueueEntry = {
31+
accumulatedParams: LeanPublishDiagnosticsParams
32+
pendingKind: DiagnosticChangeKind
33+
pendingBatch: LeanDiagnostic[]
34+
}
35+
36+
function combineEntries(existing: SyncQueueEntry, incoming: SyncQueueEntry): SyncQueueEntry {
37+
if (incoming.pendingKind === 'replace') {
38+
return incoming
39+
}
40+
incoming.pendingKind satisfies 'append'
41+
return {
42+
accumulatedParams: incoming.accumulatedParams,
43+
pendingKind: existing.pendingKind,
44+
pendingBatch: [...existing.pendingBatch, ...incoming.pendingBatch],
45+
}
46+
}
47+
48+
export class LeanClientDiagnosticCollection implements Disposable {
49+
readonly vsCodeCollection: DiagnosticCollection
50+
private diags: Map<DocumentUri, LeanPublishDiagnosticsParams> = new Map()
51+
52+
private diagnosticsChangedEmitter = new EventEmitter<DiagnosticChangeEvent>()
53+
onDidChangeDiagnostics = this.diagnosticsChangedEmitter.event
54+
55+
private syncQueue: CoalescingSyncQueue<SyncQueueEntry>
56+
57+
constructor(vsCodeCollection: DiagnosticCollection) {
58+
this.vsCodeCollection = vsCodeCollection
59+
this.syncQueue = new CoalescingSyncQueue(
60+
(uri: string, entry: SyncQueueEntry, token: CancellationToken) => this.syncToCollection(uri, entry, token),
61+
(existing, incoming) => combineEntries(existing, incoming)
62+
)
63+
}
64+
65+
private static determineChangeKind(
66+
prev: LeanPublishDiagnosticsParams | undefined,
67+
next: LeanPublishDiagnosticsParams,
68+
): DiagnosticChangeKind {
69+
if (prev === undefined) {
70+
return 'replace'
71+
}
72+
if (!next.isIncremental) {
73+
return 'replace'
74+
}
75+
return 'append'
76+
}
77+
78+
publishDiagnostics(params: LeanPublishDiagnosticsParams): void {
79+
const prev = this.diags.get(params.uri)
80+
const kind = LeanClientDiagnosticCollection.determineChangeKind(prev, params)
81+
82+
let accumulated: LeanDiagnostic[]
83+
if (kind === 'append') {
84+
accumulated = prev!.diagnostics
85+
accumulated.push(...params.diagnostics)
86+
} else {
87+
accumulated = [...params.diagnostics]
88+
}
89+
90+
const accumulatedParams = { ...params, diagnostics: accumulated }
91+
this.diags.set(accumulatedParams.uri, accumulatedParams)
92+
93+
const entry: SyncQueueEntry = {
94+
accumulatedParams,
95+
pendingKind: kind,
96+
pendingBatch: [...params.diagnostics],
97+
}
98+
99+
this.syncQueue.enqueue(accumulatedParams.uri, entry)
100+
}
101+
102+
private async syncToCollection(_uri: string, entry: SyncQueueEntry, token: CancellationToken): Promise<void> {
103+
const nonSilentDiagnostics = entry.accumulatedParams.diagnostics.filter(d => !d.isSilent)
104+
const vsUri = p2cConverter.asUri(entry.accumulatedParams.uri)
105+
const vsDiags = await p2cConverter.asDiagnostics(nonSilentDiagnostics, token)
106+
if (token.isCancellationRequested) {
107+
return
108+
}
109+
this.vsCodeCollection.set(vsUri, vsDiags)
110+
const collapsedParams = { ...entry.accumulatedParams, diagnostics: entry.pendingBatch }
111+
this.diagnosticsChangedEmitter.fire(
112+
new DiagnosticChangeEvent(entry.pendingKind, collapsedParams, entry.accumulatedParams.diagnostics),
113+
)
114+
}
115+
116+
dispose(): void {
117+
this.syncQueue.dispose()
118+
this.diagnosticsChangedEmitter.dispose()
119+
}
120+
}

vscode-lean4/src/leanclient.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Disposable,
55
EventEmitter,
66
FileSystemWatcher,
7+
languages,
78
OutputChannel,
89
Progress,
910
ProgressLocation,
@@ -32,7 +33,6 @@ import {
3233
} from 'vscode-languageclient/node'
3334

3435
import {
35-
LeanDiagnostic,
3636
LeanFileProgressParams,
3737
LeanFileProgressProcessingInfo,
3838
LeanServerCapabilities,
@@ -52,6 +52,7 @@ import { logger } from './utils/logger'
5252
import * as fs from 'fs'
5353
import { glob } from 'glob'
5454
import * as semver from 'semver'
55+
import { DiagnosticChangeEvent, LeanClientDiagnosticCollection } from './diagnostics'
5556
import { formatCommandExecutionOutput } from './utils/batch'
5657
import {
5758
c2pConverter,
@@ -98,11 +99,13 @@ function logConfig(): LogConfig | undefined {
9899
}
99100

100101
interface LeanClientCapabilties {
102+
incrementalDiagnosticSupport?: boolean
101103
silentDiagnosticSupport?: boolean
102104
rpcWireFormat?: RpcWireFormat
103105
}
104106

105107
const leanClientCapabilities: LeanClientCapabilties = {
108+
incrementalDiagnosticSupport: true,
106109
silentDiagnosticSupport: true,
107110
rpcWireFormat: 'v1'
108111
}
@@ -140,6 +143,8 @@ export class LeanClient implements Disposable {
140143
private didChangeEmitter = new EventEmitter<DidChangeTextDocumentParams>()
141144
didChange = this.didChangeEmitter.event
142145

146+
private diagnosticCollection: LeanClientDiagnosticCollection | undefined
147+
143148
private diagnosticsEmitter = new EventEmitter<LeanPublishDiagnosticsParams>()
144149
diagnostics = this.diagnosticsEmitter.event
145150

@@ -540,7 +545,18 @@ export class LeanClient implements Disposable {
540545
}
541546
}
542547
})
548+
this.diagnosticCollection?.dispose()
549+
const vsCodeCollection = languages.createDiagnosticCollection('lean4')
550+
this.diagnosticCollection = new LeanClientDiagnosticCollection(vsCodeCollection)
551+
this.diagnosticCollection.onDidChangeDiagnostics((e: DiagnosticChangeEvent) => {
552+
this.diagnosticsEmitter.fire(e.accumulatedParams())
553+
})
554+
this.client.onNotification('textDocument/publishDiagnostics', (params: LeanPublishDiagnosticsParams) => {
555+
this.diagnosticCollection?.publishDiagnostics(params)
556+
})
557+
543558
await this.client.start()
559+
544560
const rawVersion = this.client.initializeResult?.serverInfo?.version
545561
const version = rawVersion !== undefined ? semver.parse(rawVersion) : null
546562
if (version !== null && version.compare('0.2.0') < 0) {
@@ -709,6 +725,9 @@ export class LeanClient implements Disposable {
709725

710726
this.noPrompt = false
711727
this.progress = new Map()
728+
this.diagnosticCollection?.vsCodeCollection.dispose()
729+
this.diagnosticCollection?.dispose()
730+
this.diagnosticCollection = undefined
712731
this.client = undefined
713732
this.openServerDocuments = new Set()
714733
this.running = false
@@ -780,7 +799,7 @@ export class LeanClient implements Disposable {
780799
}
781800

782801
getDiagnostics(): DiagnosticCollection | undefined {
783-
return this.running ? this.client?.diagnostics : undefined
802+
return this.running ? this.diagnosticCollection?.vsCodeCollection : undefined
784803
}
785804

786805
get initializeResult(): InitializeResult | undefined {
@@ -874,18 +893,11 @@ export class LeanClient implements Disposable {
874893
}
875894
return next(type, param)
876895
},
877-
handleDiagnostics: (uri, diagnostics, next) => {
878-
const diagnosticsInVsCode = diagnostics.filter(d => !('isSilent' in d && d.isSilent))
879-
next(uri, diagnosticsInVsCode)
880-
const uri_ = c2pConverter.asUri(uri)
881-
const diagnostics_: LeanDiagnostic[] = []
882-
for (const d of diagnostics) {
883-
const d_: LeanDiagnostic = {
884-
...c2pConverter.asDiagnostic(d),
885-
}
886-
diagnostics_.push(d_)
887-
}
888-
this.diagnosticsEmitter.fire({ uri: uri_, diagnostics: diagnostics_ })
896+
handleDiagnostics: () => {
897+
// Suppress the default diagnostics handling by the LanguageClient.
898+
// We handle diagnostics ourselves via a custom notification handler
899+
// so that we can access the full LeanPublishDiagnosticsParams
900+
// (including version and isIncremental).
889901
},
890902

891903
didOpen: async (doc, next) => {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { CancellationToken, CancellationTokenSource, Disposable } from 'vscode'
2+
3+
/**
4+
* A queue that coalesces pending work by string key, processes one entry at a time,
5+
* and supports cancellation of in-flight work when the same key is re-enqueued.
6+
*
7+
* When a key is enqueued while its worker is already running, the in-flight
8+
* CancellationToken is cancelled. The worker should check the token and bail early.
9+
* If the worker completes without cancellation, the entry is removed from the queue.
10+
* If cancelled or errored, the entry stays so the next enqueue can update it.
11+
*/
12+
export class CoalescingSyncQueue<T> implements Disposable {
13+
private queue: Map<string, T> = new Map()
14+
private state: { kind: 'idle' } | { kind: 'busy'; key: string; tokenSource: CancellationTokenSource } = {
15+
kind: 'idle',
16+
}
17+
private disposed = false
18+
19+
constructor(
20+
private readonly worker: (key: string, value: T, token: CancellationToken) => Promise<void>,
21+
private readonly merge?: (existing: T, incoming: T) => T,
22+
) {}
23+
24+
enqueue(key: string, value: T): void {
25+
if (this.state.kind === 'busy' && this.state.key === key) {
26+
this.state.tokenSource.cancel()
27+
}
28+
29+
const existing = this.queue.get(key)
30+
if (this.merge !== undefined && existing !== undefined) {
31+
this.queue.set(key, this.merge(existing, value))
32+
} else {
33+
this.queue.set(key, value)
34+
}
35+
36+
if (this.state.kind === 'idle') {
37+
this.trigger()
38+
}
39+
}
40+
41+
private trigger(): void {
42+
queueMicrotask(() => void this.work())
43+
}
44+
45+
private async work(): Promise<void> {
46+
if (this.state.kind === 'busy' || this.disposed) {
47+
return
48+
}
49+
50+
const next = this.queue.entries().next()
51+
if (next.done) {
52+
return
53+
}
54+
const [key, value] = next.value
55+
56+
const tokenSource = new CancellationTokenSource()
57+
this.state = { kind: 'busy', key, tokenSource }
58+
try {
59+
await this.worker(key, value, tokenSource.token)
60+
if (!tokenSource.token.isCancellationRequested && !this.disposed) {
61+
this.queue.delete(key)
62+
}
63+
} finally {
64+
tokenSource.dispose()
65+
this.state = { kind: 'idle' }
66+
if (this.queue.size > 0 && !this.disposed) {
67+
this.trigger()
68+
}
69+
}
70+
}
71+
72+
dispose(): void {
73+
this.disposed = true
74+
if (this.state.kind === 'busy') {
75+
this.state.tokenSource.cancel()
76+
}
77+
this.queue.clear()
78+
}
79+
}

vscode-lean4/src/utils/converters.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface LeanDiagnostic extends ls.Diagnostic {
3232
export interface LeanPublishDiagnosticsParams {
3333
uri: ls.DocumentUri
3434
version?: ls.integer
35+
isIncremental?: boolean
3536
diagnostics: LeanDiagnostic[]
3637
}
3738

0 commit comments

Comments
 (0)