@hydranium/core 1.0.0-next.205 → 1.0.0-next.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/documents/ast-document-manager.js +4 -4
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-ids.d.ts +1 -1
- package/lib/documents/client-ids.d.ts.map +1 -1
- package/lib/documents/client-ids.js +1 -1
- package/lib/documents/client-ids.js.map +1 -1
- package/lib/documents/client-session-registry.d.ts +24 -17
- package/lib/documents/client-session-registry.d.ts.map +1 -1
- package/lib/documents/client-session-registry.js +49 -29
- package/lib/documents/client-session-registry.js.map +1 -1
- package/lib/documents/dirty-state-tracker.d.ts +79 -0
- package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
- package/lib/documents/dirty-state-tracker.js +69 -0
- package/lib/documents/dirty-state-tracker.js.map +1 -0
- package/lib/documents/document-release-handler.d.ts +179 -0
- package/lib/documents/document-release-handler.d.ts.map +1 -0
- package/lib/documents/document-release-handler.js +283 -0
- package/lib/documents/document-release-handler.js.map +1 -0
- package/lib/documents/document-release-scheduler.d.ts +67 -0
- package/lib/documents/document-release-scheduler.d.ts.map +1 -0
- package/lib/documents/document-release-scheduler.js +80 -0
- package/lib/documents/document-release-scheduler.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +137 -455
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +373 -835
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +5 -1
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +5 -1
- package/lib/documents/index.js.map +1 -1
- package/lib/documents/language-client-shadow.d.ts +221 -0
- package/lib/documents/language-client-shadow.d.ts.map +1 -0
- package/lib/documents/language-client-shadow.js +288 -0
- package/lib/documents/language-client-shadow.js.map +1 -0
- package/lib/documents/text-ledger.d.ts +64 -0
- package/lib/documents/text-ledger.d.ts.map +1 -0
- package/lib/documents/text-ledger.js +63 -0
- package/lib/documents/text-ledger.js.map +1 -0
- package/lib/langium/document-builder/document-builder.d.ts +8 -7
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +8 -7
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +1 -1
- package/lib/langium/integrity/integrity-service.js +4 -4
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +4 -4
- package/lib/langium/model-service/client-session.js +2 -2
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-events.d.ts +8 -11
- package/lib/langium/model-service/model-events.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +18 -4
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +4 -1
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/module.d.ts +7 -0
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +3 -1
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/workspace/file-not-found.d.ts +7 -1
- package/lib/langium/workspace/file-not-found.d.ts.map +1 -1
- package/lib/langium/workspace/file-not-found.js +14 -3
- package/lib/langium/workspace/file-not-found.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +6 -6
- package/lib/lsp/hydranium-document-update-handler.js +10 -10
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/make-test-services.d.ts +3 -0
- package/lib/testing/make-test-services.d.ts.map +1 -1
- package/lib/testing/make-test-services.js +5 -2
- package/lib/testing/make-test-services.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +9 -6
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +25 -11
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +4 -4
- package/src/documents/client-ids.ts +1 -1
- package/src/documents/client-session-registry.ts +55 -34
- package/src/documents/dirty-state-tracker.ts +130 -0
- package/src/documents/document-release-handler.ts +365 -0
- package/src/documents/document-release-scheduler.ts +121 -0
- package/src/documents/hydranium-text-documents.ts +407 -1033
- package/src/documents/index.ts +5 -1
- package/src/documents/language-client-shadow.ts +463 -0
- package/src/documents/text-ledger.ts +112 -0
- package/src/langium/document-builder/document-builder.ts +8 -7
- package/src/langium/integrity/integrity-service.ts +4 -4
- package/src/langium/model-service/client-session.ts +6 -6
- package/src/langium/model-service/model-events.ts +12 -11
- package/src/langium/model-service/model-service.ts +22 -4
- package/src/langium/module.ts +9 -1
- package/src/langium/workspace/file-not-found.ts +15 -3
- package/src/lsp/hydranium-document-update-handler.ts +10 -10
- package/src/testing/make-test-services.ts +7 -2
- package/src/testing/stub-hydranium-text-documents.ts +41 -21
- package/lib/documents/language-client-text-shadow.d.ts +0 -133
- package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
- package/lib/documents/language-client-text-shadow.js +0 -201
- package/lib/documents/language-client-text-shadow.js.map +0 -1
- package/src/documents/language-client-text-shadow.ts +0 -223
package/src/documents/index.ts
CHANGED
|
@@ -10,9 +10,13 @@
|
|
|
10
10
|
export * from './client-ids.js';
|
|
11
11
|
export * from './client-session-errors.js';
|
|
12
12
|
export * from './client-session-registry.js';
|
|
13
|
-
export * from './language-client-
|
|
13
|
+
export * from './language-client-shadow.js';
|
|
14
14
|
export * from './hydranium-text-documents.js';
|
|
15
15
|
export * from './model-ledger.js';
|
|
16
|
+
export * from './text-ledger.js';
|
|
17
|
+
export * from './dirty-state-tracker.js';
|
|
18
|
+
export * from './document-release-handler.js';
|
|
19
|
+
export * from './document-release-scheduler.js';
|
|
16
20
|
export * from './version-sync-service.js';
|
|
17
21
|
export * from './ast-document-manager.js';
|
|
18
22
|
export * from './file-system-task-queue.js';
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 CrossBreeze, EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type CanonicalUri, type LanguageClientUri, textHash, type Tracer } from '@hydranium/protocol';
|
|
11
|
+
import { diffLines } from 'diff';
|
|
12
|
+
import {
|
|
13
|
+
Range,
|
|
14
|
+
TextDocumentContentChangeEvent as ContentChange,
|
|
15
|
+
type TextDocumentsConfiguration,
|
|
16
|
+
type TextEdit,
|
|
17
|
+
uinteger
|
|
18
|
+
} from 'vscode-languageserver';
|
|
19
|
+
import { TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The language client's open of a document under one URI, from its didOpen to
|
|
23
|
+
* its didClose. Each URI is its own editor buffer with its own version counter,
|
|
24
|
+
* so a file reached through a symlink and its real path has one of these each.
|
|
25
|
+
*/
|
|
26
|
+
export interface LanguageClientDocumentState {
|
|
27
|
+
/** The version the client last declared for this URI. */
|
|
28
|
+
declaredVersion: number;
|
|
29
|
+
/**
|
|
30
|
+
* The version an applied versioned push moved this URI to, ahead of its
|
|
31
|
+
* echo. Apart from `declaredVersion`, whose staleness guard would drop that
|
|
32
|
+
* echo and strand its pending push.
|
|
33
|
+
*/
|
|
34
|
+
pushedVersion?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One text pushed to the language client whose echo has not come back yet.
|
|
39
|
+
* Outbound pushes and inbound echoes are uncorrelated on the wire; a FIFO of
|
|
40
|
+
* these per URI is the explicit correlation.
|
|
41
|
+
*/
|
|
42
|
+
export interface PendingLanguageClientPush {
|
|
43
|
+
/**
|
|
44
|
+
* The text the client held BEFORE this push, and therefore the text its
|
|
45
|
+
* echo addresses with its ranges. A hash of the pushed text alone cannot
|
|
46
|
+
* reconstruct an incremental echo: its ranges address the previous buffer,
|
|
47
|
+
* and applied to the already-advanced synced text the line they insert
|
|
48
|
+
* lands twice.
|
|
49
|
+
*
|
|
50
|
+
* `undefined` only when that buffer is unknown — the client never declared
|
|
51
|
+
* one, or a rejection invalidated what was tracked. The echo is then
|
|
52
|
+
* reconstructed against the synced text, which is sound only for a
|
|
53
|
+
* position-independent (full-text) change.
|
|
54
|
+
*/
|
|
55
|
+
readonly before: string | undefined;
|
|
56
|
+
/** {@link textHash} of the text this push moves the client to. */
|
|
57
|
+
readonly afterHash: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A push {@link LanguageClientShadow.preparePush} has queued, for the store to send and settle. */
|
|
61
|
+
export interface PreparedLanguageClientPush {
|
|
62
|
+
readonly clientUri: LanguageClientUri;
|
|
63
|
+
readonly edits: TextEdit[];
|
|
64
|
+
/**
|
|
65
|
+
* The client version the edits are addressed at, so the client refuses a
|
|
66
|
+
* push its buffer has outrun; `null` for a full replace, which lands on any
|
|
67
|
+
* buffer and is the caller's retry after a refusal.
|
|
68
|
+
*/
|
|
69
|
+
readonly version: number | null;
|
|
70
|
+
/** Record what the client answered. Only the first call counts; a refusal of an addressed push is logged. */
|
|
71
|
+
notifyOutcome(outcome: LanguageClientPushOutcome): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the client answered a {@link PreparedLanguageClientPush}. */
|
|
75
|
+
export type LanguageClientPushOutcome = 'applied' | 'refused' | 'failed';
|
|
76
|
+
|
|
77
|
+
/** What an incoming language-client change is, as {@link LanguageClientShadow.acceptChange} finds it. */
|
|
78
|
+
export type LanguageClientChangeVerdict =
|
|
79
|
+
/** The client is reporting a text we pushed it. The synced document is already there. */
|
|
80
|
+
| { readonly kind: 'echo' }
|
|
81
|
+
/**
|
|
82
|
+
* The client's buffer holds a text we did not push it — a keystroke that
|
|
83
|
+
* raced a push, or an edit to a buffer the store has already been written
|
|
84
|
+
* past. Its ranges address that buffer, so applied to the synced text they
|
|
85
|
+
* splice the wrong lines; `text` is what it now holds, and is authoritative.
|
|
86
|
+
*/
|
|
87
|
+
| { readonly kind: 'divergent'; readonly text: string }
|
|
88
|
+
/**
|
|
89
|
+
* The change carries ranges and no known text addresses them. Adopting one
|
|
90
|
+
* anyway splices the document and stores an edit nobody made; dropping costs
|
|
91
|
+
* at most the one keystroke the client still holds and the next push
|
|
92
|
+
* contradicts.
|
|
93
|
+
*/
|
|
94
|
+
| { readonly kind: 'unreconstructable' }
|
|
95
|
+
/** The client's buffer is the synced text, so its ranges apply as sent. */
|
|
96
|
+
| { readonly kind: 'direct' };
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The store's model of what the LSP language client holds for each URI it
|
|
100
|
+
* opened or the store pushed to: the text, the version it declared and the version a push moved it
|
|
101
|
+
* to, and the pushes it has not echoed yet. The store speaks the protocol;
|
|
102
|
+
* this answers what an incoming change is and what an outgoing push sends.
|
|
103
|
+
*
|
|
104
|
+
* Keyed by the URI the client opened under, which differs from the store's
|
|
105
|
+
* canonical key when the path does not match its real path, so one document
|
|
106
|
+
* can have several.
|
|
107
|
+
*
|
|
108
|
+
* A push is a diff against the text the client holds rather than a full
|
|
109
|
+
* replace: a full-range replace on a large source makes Monaco re-tokenise the
|
|
110
|
+
* whole document, observed as multi-second hangs.
|
|
111
|
+
*/
|
|
112
|
+
export interface LanguageClientShadow {
|
|
113
|
+
/**
|
|
114
|
+
* The client opened `key` under `clientUri`. `firstOpen`, the store's first
|
|
115
|
+
* open of `key`: its text becomes the diff baseline, unless a push already
|
|
116
|
+
* waits for this URI; otherwise an equality-only baseline, since a buffer
|
|
117
|
+
* opened from disk can lag the synced text. A no-op when this URI is
|
|
118
|
+
* already open.
|
|
119
|
+
*/
|
|
120
|
+
addOpen(key: CanonicalUri, clientUri: LanguageClientUri, version: number, text: string, firstOpen: boolean): void;
|
|
121
|
+
/** The version the client last declared for `key` under `clientUri`; `undefined` when it never opened it there. */
|
|
122
|
+
declaredVersion(key: CanonicalUri, clientUri: LanguageClientUri): number | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* Take `version` as the client's newest for `clientUri`, and classify its
|
|
125
|
+
* change against `document`, the synced document. The caller has already
|
|
126
|
+
* dropped a stale change.
|
|
127
|
+
*/
|
|
128
|
+
acceptChange(
|
|
129
|
+
key: CanonicalUri,
|
|
130
|
+
clientUri: LanguageClientUri,
|
|
131
|
+
version: number,
|
|
132
|
+
document: TextDocument,
|
|
133
|
+
changes: TextDocumentContentChangeEvent[]
|
|
134
|
+
): LanguageClientChangeVerdict;
|
|
135
|
+
/** The client holds `text` with nothing in flight. */
|
|
136
|
+
setClientText(clientUri: LanguageClientUri, text: string): void;
|
|
137
|
+
/** The client closed `clientUri`; what it held there is forgotten. */
|
|
138
|
+
removeOpen(key: CanonicalUri, clientUri: LanguageClientUri): void;
|
|
139
|
+
/** Whether the client has `key` open: under `clientUri` when given, else under any URI. */
|
|
140
|
+
isOpen(key: CanonicalUri, clientUri?: LanguageClientUri): boolean;
|
|
141
|
+
/** Forget every URI the client holds `key` under. */
|
|
142
|
+
removeAllOpens(key: CanonicalUri): void;
|
|
143
|
+
/** The URIs a push of `key` goes to: each open of it, else `fallback`. */
|
|
144
|
+
pushTargets(key: CanonicalUri, fallback: LanguageClientUri): LanguageClientUri[];
|
|
145
|
+
/**
|
|
146
|
+
* Diff `text` against what the client holds under `clientUri` and queue the
|
|
147
|
+
* push for echo correlation, which has to precede the RPC because an echo
|
|
148
|
+
* can arrive before its response. `undefined` when there is nothing to send.
|
|
149
|
+
*/
|
|
150
|
+
preparePush(key: CanonicalUri, clientUri: LanguageClientUri, text: string): PreparedLanguageClientPush | undefined;
|
|
151
|
+
/** Forget what the client holds under `clientUri`; its next push is a full replace. */
|
|
152
|
+
invalidateClientText(clientUri: LanguageClientUri): void;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Upper bound on the pending pushes per URI. Echoes normally return within
|
|
157
|
+
* milliseconds; a queue this deep means the client stopped echoing.
|
|
158
|
+
*/
|
|
159
|
+
const PENDING_ECHO_CAP = 32;
|
|
160
|
+
|
|
161
|
+
/** The range every full-document replace emitted here carries. */
|
|
162
|
+
const FULL_RANGE = Range.create(0, 0, uinteger.MAX_VALUE, uinteger.MAX_VALUE);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whether `edits` is the single full-document replace a push emits when it has
|
|
166
|
+
* no usable baseline. A full replace is position-independent and lands on any
|
|
167
|
+
* client buffer; a line-keyed diff lands only on the text it was diffed
|
|
168
|
+
* against, so only the latter needs a client-version gate.
|
|
169
|
+
*/
|
|
170
|
+
export function isFullReplace(edits: readonly TextEdit[]): boolean {
|
|
171
|
+
if (edits.length !== 1) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
const { start, end } = edits[0].range;
|
|
175
|
+
return start.line === FULL_RANGE.start.line && start.character === FULL_RANGE.start.character && end.line === FULL_RANGE.end.line;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument> implements LanguageClientShadow {
|
|
179
|
+
protected readonly opens = new Map<CanonicalUri, Map<LanguageClientUri, LanguageClientDocumentState>>();
|
|
180
|
+
/** The diff baseline: the text the client is believed to hold. */
|
|
181
|
+
protected readonly baselines = new Map<LanguageClientUri, string>();
|
|
182
|
+
/**
|
|
183
|
+
* The text the client declared at an open with no diff baseline, compared
|
|
184
|
+
* for equality only. A line-keyed diff keyed to this snapshot splices a
|
|
185
|
+
* buffer the client may have moved past.
|
|
186
|
+
*/
|
|
187
|
+
protected readonly openedTexts = new Map<LanguageClientUri, string>();
|
|
188
|
+
protected readonly pending = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param configuration the store's text-document factories, the store's own
|
|
192
|
+
* `create` and `update`. Probes go through them rather than `TextDocument`
|
|
193
|
+
* directly, so an adopter's custom text-document type applies ranges here
|
|
194
|
+
* as it does on the synced document; the bare `TextDocument.update` also
|
|
195
|
+
* refuses a document it did not create.
|
|
196
|
+
*/
|
|
197
|
+
constructor(
|
|
198
|
+
protected readonly configuration: TextDocumentsConfiguration<T>,
|
|
199
|
+
protected readonly tracer: Tracer
|
|
200
|
+
) {}
|
|
201
|
+
|
|
202
|
+
addOpen(key: CanonicalUri, clientUri: LanguageClientUri, version: number, text: string, firstOpen: boolean): void {
|
|
203
|
+
let uris = this.opens.get(key);
|
|
204
|
+
if (uris?.has(clientUri)) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!uris) {
|
|
208
|
+
uris = new Map();
|
|
209
|
+
this.opens.set(key, uris);
|
|
210
|
+
}
|
|
211
|
+
uris.set(clientUri, { declaredVersion: version });
|
|
212
|
+
if (!firstOpen) {
|
|
213
|
+
this.openedTexts.set(clientUri, text);
|
|
214
|
+
} else if (!this.baselines.has(clientUri)) {
|
|
215
|
+
// A push to a closed file has the client open it from disk and apply
|
|
216
|
+
// afterwards, so a tracked text already names what it is about to hold.
|
|
217
|
+
this.baselines.set(clientUri, text);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
declaredVersion(key: CanonicalUri, clientUri: LanguageClientUri): number | undefined {
|
|
222
|
+
return this.opens.get(key)?.get(clientUri)?.declaredVersion;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
acceptChange(
|
|
226
|
+
key: CanonicalUri,
|
|
227
|
+
clientUri: LanguageClientUri,
|
|
228
|
+
version: number,
|
|
229
|
+
document: TextDocument,
|
|
230
|
+
changes: TextDocumentContentChangeEvent[]
|
|
231
|
+
): LanguageClientChangeVerdict {
|
|
232
|
+
const state = this.opens.get(key)?.get(clientUri);
|
|
233
|
+
if (state) {
|
|
234
|
+
state.declaredVersion = version;
|
|
235
|
+
}
|
|
236
|
+
return this.classify(clientUri, document, changes);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Reconstruct the client's resulting buffer against the text its ranges
|
|
241
|
+
* address: the pre-push buffer of the oldest push still in flight, else
|
|
242
|
+
* what the client is believed to hold.
|
|
243
|
+
*
|
|
244
|
+
* The client applies pushes in order and echoes each against the buffer it
|
|
245
|
+
* held before that push, so the first echo belongs to the oldest entry.
|
|
246
|
+
* Matching against every pending hash, not only the oldest, recognises an
|
|
247
|
+
* echo a newer push already superseded, and consumes everything older too.
|
|
248
|
+
* A reconstruction matching none means the client's buffer went somewhere
|
|
249
|
+
* we did not send it, and the queue drops.
|
|
250
|
+
*
|
|
251
|
+
* Content equality is a sound echo proof because entries live only between
|
|
252
|
+
* a push and its echo, and `didChange` arrives in mutation order: an undo
|
|
253
|
+
* back to a previously pushed text revisits a past state, while the queue
|
|
254
|
+
* holds only in-flight ones.
|
|
255
|
+
*/
|
|
256
|
+
protected classify(
|
|
257
|
+
clientUri: LanguageClientUri,
|
|
258
|
+
document: TextDocument,
|
|
259
|
+
changes: TextDocumentContentChangeEvent[]
|
|
260
|
+
): LanguageClientChangeVerdict {
|
|
261
|
+
const queued = this.pending.get(clientUri);
|
|
262
|
+
const pending = queued?.length ? queued : undefined;
|
|
263
|
+
const clientText = pending?.[0].before ?? this.clientText(clientUri);
|
|
264
|
+
if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
|
|
265
|
+
return { kind: 'direct' };
|
|
266
|
+
}
|
|
267
|
+
if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
|
|
268
|
+
// A push sent to a buffer it did not know: the tracked text is now the
|
|
269
|
+
// text that push moves the client to, the one text these ranges
|
|
270
|
+
// provably do not address. Dropped, the next push is a full replace. A
|
|
271
|
+
// full-text change reconstructs identically against any baseline.
|
|
272
|
+
this.invalidateClientText(clientUri);
|
|
273
|
+
return { kind: 'unreconstructable' };
|
|
274
|
+
}
|
|
275
|
+
const probe = this.configuration.create(clientUri, document.languageId, 0, clientText ?? document.getText());
|
|
276
|
+
const reconstructed = this.configuration.update(probe, changes, 0).getText();
|
|
277
|
+
if (pending !== undefined) {
|
|
278
|
+
const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
|
|
279
|
+
if (matchIndex >= 0) {
|
|
280
|
+
pending.splice(0, matchIndex + 1);
|
|
281
|
+
return { kind: 'echo' };
|
|
282
|
+
}
|
|
283
|
+
this.pending.delete(clientUri);
|
|
284
|
+
}
|
|
285
|
+
return { kind: 'divergent', text: reconstructed };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
setClientText(clientUri: LanguageClientUri, text: string): void {
|
|
289
|
+
this.baselines.set(clientUri, text);
|
|
290
|
+
this.pending.delete(clientUri);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
removeOpen(key: CanonicalUri, clientUri: LanguageClientUri): void {
|
|
294
|
+
const uris = this.opens.get(key);
|
|
295
|
+
if (uris?.delete(clientUri)) {
|
|
296
|
+
this.invalidateClientText(clientUri);
|
|
297
|
+
if (uris.size === 0) {
|
|
298
|
+
this.opens.delete(key);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
isOpen(key: CanonicalUri, clientUri?: LanguageClientUri): boolean {
|
|
304
|
+
const uris = this.opens.get(key);
|
|
305
|
+
return clientUri === undefined ? uris !== undefined : (uris?.has(clientUri) ?? false);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
removeAllOpens(key: CanonicalUri): void {
|
|
309
|
+
for (const clientUri of this.opens.get(key)?.keys() ?? []) {
|
|
310
|
+
this.invalidateClientText(clientUri);
|
|
311
|
+
}
|
|
312
|
+
this.opens.delete(key);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
pushTargets(key: CanonicalUri, fallback: LanguageClientUri): LanguageClientUri[] {
|
|
316
|
+
const uris = this.opens.get(key);
|
|
317
|
+
return uris && uris.size > 0 ? [...uris.keys()] : [fallback];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
preparePush(key: CanonicalUri, clientUri: LanguageClientUri, text: string): PreparedLanguageClientPush | undefined {
|
|
321
|
+
// Read before computing the edits, which moves the baseline: this is the
|
|
322
|
+
// only text the push's echo can be reconstructed against.
|
|
323
|
+
const before = this.clientText(clientUri);
|
|
324
|
+
const edits = this.computeEdits(clientUri, text);
|
|
325
|
+
if (edits.length === 0) {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
this.recordPendingPush(clientUri, before, text);
|
|
329
|
+
// Captured now: a reopen while the push is in flight replaces the state.
|
|
330
|
+
const state = this.opens.get(key)?.get(clientUri);
|
|
331
|
+
// Gating a full replace turns a stale-by-one version into a refused
|
|
332
|
+
// update for no safety gain, and refuses the retry after a rejection.
|
|
333
|
+
const version =
|
|
334
|
+
isFullReplace(edits) || state === undefined ? null : Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
|
|
335
|
+
let settled = false;
|
|
336
|
+
return {
|
|
337
|
+
clientUri,
|
|
338
|
+
edits,
|
|
339
|
+
version,
|
|
340
|
+
notifyOutcome: outcome => {
|
|
341
|
+
if (!settled) {
|
|
342
|
+
settled = true;
|
|
343
|
+
if (outcome === 'refused' && version !== null) {
|
|
344
|
+
this.tracer
|
|
345
|
+
.with(key)
|
|
346
|
+
.warn(
|
|
347
|
+
`Language client refused applyEdit addressed at version ${version} (it last declared version ${state?.declaredVersion})`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
this.settle(clientUri, version, state, outcome);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** What a prepared push's {@link PreparedLanguageClientPush.notifyOutcome} does. */
|
|
357
|
+
protected settle(
|
|
358
|
+
clientUri: LanguageClientUri,
|
|
359
|
+
version: number | null,
|
|
360
|
+
state: LanguageClientDocumentState | undefined,
|
|
361
|
+
outcome: LanguageClientPushOutcome
|
|
362
|
+
): void {
|
|
363
|
+
if (outcome !== 'applied') {
|
|
364
|
+
this.invalidateClientText(clientUri);
|
|
365
|
+
} else if (version !== null && state) {
|
|
366
|
+
// A client steps once per applied edit that changes its buffer. Left
|
|
367
|
+
// to the echo, a push sent next is refused at the old version.
|
|
368
|
+
state.pushedVersion = version + 1;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
invalidateClientText(clientUri: LanguageClientUri): void {
|
|
373
|
+
this.baselines.delete(clientUri);
|
|
374
|
+
this.openedTexts.delete(clientUri);
|
|
375
|
+
this.pending.delete(clientUri);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The text the client is believed to hold: the diff baseline, else what it declared at open. */
|
|
379
|
+
protected clientText(clientUri: LanguageClientUri): string | undefined {
|
|
380
|
+
return this.baselines.get(clientUri) ?? this.openedTexts.get(clientUri);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The edits that bring the client from its baseline to `text`, moving the
|
|
385
|
+
* baseline to `text`. A full replace without a baseline, none when the
|
|
386
|
+
* client opened with exactly `text` (pushing it anyway dirties the buffer on
|
|
387
|
+
* open), and otherwise a line diff, verified by applying it: a diff that
|
|
388
|
+
* does not reproduce `text` falls back to a full replace rather than
|
|
389
|
+
* corrupting the client.
|
|
390
|
+
*/
|
|
391
|
+
protected computeEdits(clientUri: LanguageClientUri, text: string): TextEdit[] {
|
|
392
|
+
const old = this.baselines.get(clientUri);
|
|
393
|
+
if (old === text) {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
this.baselines.set(clientUri, text);
|
|
397
|
+
const fullReplace: TextEdit = { range: FULL_RANGE, newText: text };
|
|
398
|
+
if (old === undefined) {
|
|
399
|
+
const openedText = this.openedTexts.get(clientUri);
|
|
400
|
+
this.openedTexts.delete(clientUri);
|
|
401
|
+
return openedText === text ? [] : [fullReplace];
|
|
402
|
+
}
|
|
403
|
+
const edits = diffToEdits(old, text);
|
|
404
|
+
const probe = this.configuration.create(clientUri, 'plaintext', 0, old);
|
|
405
|
+
if (TextDocument.applyEdits(probe, edits) !== text) {
|
|
406
|
+
this.tracer.with(clientUri).warn('Diff apply-verify fallback (apply-verify-mismatch) — using full-document replace');
|
|
407
|
+
return [fullReplace];
|
|
408
|
+
}
|
|
409
|
+
return edits;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Bounded, so an echo that never arrives cannot grow the queue without limit. */
|
|
413
|
+
protected recordPendingPush(clientUri: LanguageClientUri, before: string | undefined, text: string): void {
|
|
414
|
+
let queue = this.pending.get(clientUri);
|
|
415
|
+
if (!queue) {
|
|
416
|
+
queue = [];
|
|
417
|
+
this.pending.set(clientUri, queue);
|
|
418
|
+
}
|
|
419
|
+
queue.push({ before, afterHash: textHash(text) });
|
|
420
|
+
if (queue.length > PENDING_ECHO_CAP) {
|
|
421
|
+
queue.shift();
|
|
422
|
+
this.tracer.with(clientUri).debug(`Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Convert a {@link diffLines} result to a list of LSP {@link TextEdit}s keyed on line positions
|
|
429
|
+
* in the *old* text. Contiguous added/removed hunks are coalesced into a single replace so the
|
|
430
|
+
* client sees one edit per changed region.
|
|
431
|
+
*/
|
|
432
|
+
export function diffToEdits(oldText: string, newText: string): TextEdit[] {
|
|
433
|
+
const hunks = diffLines(oldText, newText);
|
|
434
|
+
const edits: TextEdit[] = [];
|
|
435
|
+
let oldLine = 0;
|
|
436
|
+
let i = 0;
|
|
437
|
+
while (i < hunks.length) {
|
|
438
|
+
const hunk = hunks[i];
|
|
439
|
+
if (!hunk.added && !hunk.removed) {
|
|
440
|
+
oldLine += hunk.count ?? 0;
|
|
441
|
+
i++;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// Coalesce a run of add/remove hunks into a single replace for this region.
|
|
445
|
+
let removedLines = 0;
|
|
446
|
+
let addedText = '';
|
|
447
|
+
while (i < hunks.length && (hunks[i].added || hunks[i].removed)) {
|
|
448
|
+
if (hunks[i].removed) {
|
|
449
|
+
removedLines += hunks[i].count ?? 0;
|
|
450
|
+
}
|
|
451
|
+
if (hunks[i].added) {
|
|
452
|
+
addedText += hunks[i].value;
|
|
453
|
+
}
|
|
454
|
+
i++;
|
|
455
|
+
}
|
|
456
|
+
edits.push({
|
|
457
|
+
range: Range.create(oldLine, 0, oldLine + removedLines, 0),
|
|
458
|
+
newText: addedText
|
|
459
|
+
});
|
|
460
|
+
oldLine += removedLines;
|
|
461
|
+
}
|
|
462
|
+
return edits;
|
|
463
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type CanonicalUri, type TextVersion, textHash } from '@hydranium/protocol';
|
|
11
|
+
import { type TextDocument } from 'vscode-languageserver-textdocument';
|
|
12
|
+
|
|
13
|
+
/** What {@link TextLedger} holds for a document: where its version sequence stands, kept across closes and reopens. */
|
|
14
|
+
export interface TextRecord {
|
|
15
|
+
readonly version: TextVersion;
|
|
16
|
+
/** {@link textHash} of the text at that version. */
|
|
17
|
+
readonly hash: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where each document's {@link TextVersion} sequence stands, and who authored
|
|
22
|
+
* each version; the text-side counterpart of `ModelLedger`.
|
|
23
|
+
*
|
|
24
|
+
* A record survives the document's release and is never pruned, not even when
|
|
25
|
+
* the file is deleted: a sequence that restarts lets a write based on the old
|
|
26
|
+
* text pass the base-version gate. Authors last only until the release.
|
|
27
|
+
*/
|
|
28
|
+
export interface TextLedger {
|
|
29
|
+
/**
|
|
30
|
+
* The version a first open of `key` with `text` starts at: the record's
|
|
31
|
+
* version when the text is unchanged, one step on when it changed;
|
|
32
|
+
* `undefined` without a record.
|
|
33
|
+
*/
|
|
34
|
+
openingVersion(key: CanonicalUri, text: string): TextVersion | undefined;
|
|
35
|
+
/** Keep where the sequence of the released `document` stands. */
|
|
36
|
+
record(key: CanonicalUri, document: TextDocument): void;
|
|
37
|
+
recordOf(key: CanonicalUri): TextRecord | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Text reached the closed document `key` outside the store's write paths.
|
|
40
|
+
* Steps the record iff `text` differs from it, and starts one at `0` when
|
|
41
|
+
* there is none.
|
|
42
|
+
*/
|
|
43
|
+
reconcile(key: CanonicalUri, text: string): TextVersion;
|
|
44
|
+
/** {@link textHash} of `document`'s text, taken once per version. */
|
|
45
|
+
hashOf(document: TextDocument): string;
|
|
46
|
+
setAuthor(key: CanonicalUri, version: TextVersion, author: string): void;
|
|
47
|
+
/** The author of `version`, or of the latest version when `version` is omitted. */
|
|
48
|
+
authorOf(key: CanonicalUri, version?: TextVersion): string | undefined;
|
|
49
|
+
clearAuthors(key: CanonicalUri): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class DefaultTextLedger implements TextLedger {
|
|
53
|
+
protected readonly records = new Map<CanonicalUri, TextRecord>();
|
|
54
|
+
protected readonly authors = new Map<CanonicalUri, string[]>();
|
|
55
|
+
protected readonly hashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
|
|
56
|
+
|
|
57
|
+
openingVersion(key: CanonicalUri, text: string): TextVersion | undefined {
|
|
58
|
+
const recorded = this.records.get(key);
|
|
59
|
+
if (recorded === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return recorded.hash === textHash(text) ? recorded.version : recorded.version + 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
record(key: CanonicalUri, document: TextDocument): void {
|
|
66
|
+
this.records.set(key, { version: document.version, hash: this.hashOf(document) });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
recordOf(key: CanonicalUri): TextRecord | undefined {
|
|
70
|
+
return this.records.get(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
reconcile(key: CanonicalUri, text: string): TextVersion {
|
|
74
|
+
const hash = textHash(text);
|
|
75
|
+
const recorded = this.records.get(key);
|
|
76
|
+
if (recorded?.hash === hash) {
|
|
77
|
+
return recorded.version;
|
|
78
|
+
}
|
|
79
|
+
const version = recorded === undefined ? 0 : recorded.version + 1;
|
|
80
|
+
this.records.set(key, { version, hash });
|
|
81
|
+
return version;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
hashOf(document: TextDocument): string {
|
|
85
|
+
const taken = this.hashes.get(document);
|
|
86
|
+
if (taken?.version === document.version) {
|
|
87
|
+
return taken.hash;
|
|
88
|
+
}
|
|
89
|
+
const hash = textHash(document.getText());
|
|
90
|
+
this.hashes.set(document, { version: document.version, hash });
|
|
91
|
+
return hash;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setAuthor(key: CanonicalUri, version: TextVersion, author: string): void {
|
|
95
|
+
let history = this.authors.get(key);
|
|
96
|
+
if (!history) {
|
|
97
|
+
history = [];
|
|
98
|
+
this.authors.set(key, history);
|
|
99
|
+
}
|
|
100
|
+
history[version] = author;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
authorOf(key: CanonicalUri, version?: TextVersion): string | undefined {
|
|
104
|
+
const history = this.authors.get(key);
|
|
105
|
+
// `!== undefined` so version 0 is not read as "latest".
|
|
106
|
+
return version !== undefined ? history?.[version] : history?.at(-1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
clearAuthors(key: CanonicalUri): void {
|
|
110
|
+
this.authors.delete(key);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -53,15 +53,16 @@ export const DEFAULT_LOGGED_PHASES: DocumentState[] = [
|
|
|
53
53
|
/**
|
|
54
54
|
* The build reasons the framework stages through
|
|
55
55
|
* {@link HydraniumDocumentBuilder.markNextReason}: the LSP events the update
|
|
56
|
-
* handler dispatches on, and `
|
|
57
|
-
*
|
|
58
|
-
* the names the
|
|
56
|
+
* handler dispatches on, and `didRelease` for the builds the
|
|
57
|
+
* `DocumentReleaseHandler` runs once the text store has released a document.
|
|
58
|
+
* Exposed so a subclass layering reasons of its own keeps the names the
|
|
59
|
+
* framework emits.
|
|
59
60
|
*/
|
|
60
61
|
export const HYDRANIUM_BUILD_REASONS = Object.freeze({
|
|
61
62
|
didOpen: 'didOpen',
|
|
62
63
|
didChangeContent: 'didChangeContent',
|
|
63
64
|
didChangeWatchedFiles: 'didChangeWatchedFiles',
|
|
64
|
-
|
|
65
|
+
didRelease: 'didRelease'
|
|
65
66
|
} as const);
|
|
66
67
|
|
|
67
68
|
/**
|
|
@@ -294,7 +295,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
294
295
|
/**
|
|
295
296
|
* Stage an LSP event name for the next `update()` call. Adopters call before
|
|
296
297
|
* the update fires; `HydraniumDocumentUpdateHandler` does it for the LSP
|
|
297
|
-
* events, and the
|
|
298
|
+
* events, and the release handler for the builds that follow a release.
|
|
298
299
|
*
|
|
299
300
|
* The framework stages the value and never reads it back. The consumer is a
|
|
300
301
|
* subclass overriding the build logging, which takes
|
|
@@ -793,7 +794,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
793
794
|
* `drained` set, from a {@link WorkspaceLock} read: it runs once no write
|
|
794
795
|
* runs or is queued, so no locked build is left that could still carry a
|
|
795
796
|
* waited-on document. A cancelled build is usually followed by its
|
|
796
|
-
* canceller's, but a write that builds nothing, such as a
|
|
797
|
+
* canceller's, but a write that builds nothing, such as a release build
|
|
797
798
|
* that finds its document reopened, leaves a wait armed during the build
|
|
798
799
|
* with nothing to resolve it, and so does a build that fails. One read
|
|
799
800
|
* serves every build that throws before it runs.
|
|
@@ -1068,7 +1069,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
1068
1069
|
* both hold it by. A document created and not yet saved has text but no
|
|
1069
1070
|
* file, and a policy that checks the disk reports it absent. A registered
|
|
1070
1071
|
* document whose file has gone is kept too: its rebuild then fails on the
|
|
1071
|
-
* read, which is how the
|
|
1072
|
+
* read, which is how the release handler learns to remove it rather
|
|
1072
1073
|
* than leave it holding its last client's text.
|
|
1073
1074
|
*/
|
|
1074
1075
|
protected flattenAndAdaptURI(uri: URI): URI[] {
|
|
@@ -461,7 +461,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
461
461
|
* mutation rode the current build, so `ModelService.syncToLanguageClient`
|
|
462
462
|
* (the persistent integrity-settled listener) mirrors it to the client by
|
|
463
463
|
* *content* (shadow diff), and the editor shows it as an unsaved change.
|
|
464
|
-
* - **Held only through the data or GLSP head**, or waiting out the
|
|
464
|
+
* - **Held only through the data or GLSP head**, or waiting out the release
|
|
465
465
|
* grace after its last client's connection was lost → the store is the
|
|
466
466
|
* authority, and those heads read the correction from the settled build.
|
|
467
467
|
* In silent mode it is also written to disk when the text it was computed
|
|
@@ -499,9 +499,9 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
499
499
|
return;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
// A document waiting out the
|
|
502
|
+
// A document waiting out the release grace still holds its lost client's
|
|
503
503
|
// unsaved text, so writing it as a closed file would persist that text.
|
|
504
|
-
if (this.textDocuments.isOpenInAnyClient(document.uri) || this.textDocuments.
|
|
504
|
+
if (this.textDocuments.isOpenInAnyClient(document.uri) || this.textDocuments.isReleaseDeferred(document.uri)) {
|
|
505
505
|
if (this.syncMode === 'silent' && parsedFrom !== undefined) {
|
|
506
506
|
await this.persistIfDiskMatches(document, parsedFrom);
|
|
507
507
|
}
|
|
@@ -567,7 +567,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
|
|
|
567
567
|
return;
|
|
568
568
|
}
|
|
569
569
|
await this.fileSystemProvider.writeFile(uri, repaired);
|
|
570
|
-
this.textDocuments.
|
|
570
|
+
this.textDocuments.setDiskBaseline(document.uri, repaired);
|
|
571
571
|
});
|
|
572
572
|
}
|
|
573
573
|
|