@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.
Files changed (99) hide show
  1. package/lib/documents/ast-document-manager.js +4 -4
  2. package/lib/documents/ast-document-manager.js.map +1 -1
  3. package/lib/documents/client-ids.d.ts +1 -1
  4. package/lib/documents/client-ids.d.ts.map +1 -1
  5. package/lib/documents/client-ids.js +1 -1
  6. package/lib/documents/client-ids.js.map +1 -1
  7. package/lib/documents/client-session-registry.d.ts +24 -17
  8. package/lib/documents/client-session-registry.d.ts.map +1 -1
  9. package/lib/documents/client-session-registry.js +49 -29
  10. package/lib/documents/client-session-registry.js.map +1 -1
  11. package/lib/documents/dirty-state-tracker.d.ts +79 -0
  12. package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
  13. package/lib/documents/dirty-state-tracker.js +69 -0
  14. package/lib/documents/dirty-state-tracker.js.map +1 -0
  15. package/lib/documents/document-release-handler.d.ts +179 -0
  16. package/lib/documents/document-release-handler.d.ts.map +1 -0
  17. package/lib/documents/document-release-handler.js +283 -0
  18. package/lib/documents/document-release-handler.js.map +1 -0
  19. package/lib/documents/document-release-scheduler.d.ts +67 -0
  20. package/lib/documents/document-release-scheduler.d.ts.map +1 -0
  21. package/lib/documents/document-release-scheduler.js +80 -0
  22. package/lib/documents/document-release-scheduler.js.map +1 -0
  23. package/lib/documents/hydranium-text-documents.d.ts +137 -455
  24. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  25. package/lib/documents/hydranium-text-documents.js +373 -835
  26. package/lib/documents/hydranium-text-documents.js.map +1 -1
  27. package/lib/documents/index.d.ts +5 -1
  28. package/lib/documents/index.d.ts.map +1 -1
  29. package/lib/documents/index.js +5 -1
  30. package/lib/documents/index.js.map +1 -1
  31. package/lib/documents/language-client-shadow.d.ts +221 -0
  32. package/lib/documents/language-client-shadow.d.ts.map +1 -0
  33. package/lib/documents/language-client-shadow.js +288 -0
  34. package/lib/documents/language-client-shadow.js.map +1 -0
  35. package/lib/documents/text-ledger.d.ts +64 -0
  36. package/lib/documents/text-ledger.d.ts.map +1 -0
  37. package/lib/documents/text-ledger.js +63 -0
  38. package/lib/documents/text-ledger.js.map +1 -0
  39. package/lib/langium/document-builder/document-builder.d.ts +8 -7
  40. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  41. package/lib/langium/document-builder/document-builder.js +8 -7
  42. package/lib/langium/document-builder/document-builder.js.map +1 -1
  43. package/lib/langium/integrity/integrity-service.d.ts +1 -1
  44. package/lib/langium/integrity/integrity-service.js +4 -4
  45. package/lib/langium/integrity/integrity-service.js.map +1 -1
  46. package/lib/langium/model-service/client-session.d.ts +4 -4
  47. package/lib/langium/model-service/client-session.js +2 -2
  48. package/lib/langium/model-service/client-session.js.map +1 -1
  49. package/lib/langium/model-service/model-events.d.ts +8 -11
  50. package/lib/langium/model-service/model-events.d.ts.map +1 -1
  51. package/lib/langium/model-service/model-service.d.ts +18 -4
  52. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  53. package/lib/langium/model-service/model-service.js +4 -1
  54. package/lib/langium/model-service/model-service.js.map +1 -1
  55. package/lib/langium/module.d.ts +7 -0
  56. package/lib/langium/module.d.ts.map +1 -1
  57. package/lib/langium/module.js +3 -1
  58. package/lib/langium/module.js.map +1 -1
  59. package/lib/langium/workspace/file-not-found.d.ts +7 -1
  60. package/lib/langium/workspace/file-not-found.d.ts.map +1 -1
  61. package/lib/langium/workspace/file-not-found.js +14 -3
  62. package/lib/langium/workspace/file-not-found.js.map +1 -1
  63. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -6
  64. package/lib/lsp/hydranium-document-update-handler.js +10 -10
  65. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  66. package/lib/testing/make-test-services.d.ts +3 -0
  67. package/lib/testing/make-test-services.d.ts.map +1 -1
  68. package/lib/testing/make-test-services.js +5 -2
  69. package/lib/testing/make-test-services.js.map +1 -1
  70. package/lib/testing/stub-hydranium-text-documents.d.ts +9 -6
  71. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.js +25 -11
  73. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  74. package/package.json +5 -5
  75. package/src/documents/ast-document-manager.ts +4 -4
  76. package/src/documents/client-ids.ts +1 -1
  77. package/src/documents/client-session-registry.ts +55 -34
  78. package/src/documents/dirty-state-tracker.ts +130 -0
  79. package/src/documents/document-release-handler.ts +365 -0
  80. package/src/documents/document-release-scheduler.ts +121 -0
  81. package/src/documents/hydranium-text-documents.ts +407 -1033
  82. package/src/documents/index.ts +5 -1
  83. package/src/documents/language-client-shadow.ts +463 -0
  84. package/src/documents/text-ledger.ts +112 -0
  85. package/src/langium/document-builder/document-builder.ts +8 -7
  86. package/src/langium/integrity/integrity-service.ts +4 -4
  87. package/src/langium/model-service/client-session.ts +6 -6
  88. package/src/langium/model-service/model-events.ts +12 -11
  89. package/src/langium/model-service/model-service.ts +22 -4
  90. package/src/langium/module.ts +9 -1
  91. package/src/langium/workspace/file-not-found.ts +15 -3
  92. package/src/lsp/hydranium-document-update-handler.ts +10 -10
  93. package/src/testing/make-test-services.ts +7 -2
  94. package/src/testing/stub-hydranium-text-documents.ts +41 -21
  95. package/lib/documents/language-client-text-shadow.d.ts +0 -133
  96. package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
  97. package/lib/documents/language-client-text-shadow.js +0 -201
  98. package/lib/documents/language-client-text-shadow.js.map +0 -1
  99. package/src/documents/language-client-text-shadow.ts +0 -223
@@ -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-text-shadow.js';
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 `didClose` for the revert the text store runs
57
- * after a last close. Exposed so a subclass layering reasons of its own keeps
58
- * the names the framework emits.
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
- didClose: 'didClose'
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 text store for the revert that follows a last close.
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 last-close revert
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 revert on last close learns to remove it rather
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 revert
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 revert grace still holds its lost client's
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.isRevertPending(document.uri)) {
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.updateDiskBaseline(document.uri, repaired);
570
+ this.textDocuments.setDiskBaseline(document.uri, repaired);
571
571
  });
572
572
  }
573
573