@hydranium/core 1.0.0-next.213 → 1.0.0-next.215

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.
@@ -9,13 +9,7 @@
9
9
 
10
10
  import { type CanonicalUri, type LanguageClientUri, textHash, type Tracer } from '@hydranium/protocol';
11
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';
12
+ import { Range, type TextDocumentsConfiguration, type TextEdit, uinteger } from 'vscode-languageserver';
19
13
  import { TextDocument, type TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument';
20
14
 
21
15
  /**
@@ -40,19 +34,6 @@ export interface LanguageClientDocumentState {
40
34
  * these per URI is the explicit correlation.
41
35
  */
42
36
  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, because a rejection or
51
- * {@link LanguageClientShadow.invalidateClientText} dropped what was
52
- * tracked. The echo is then reconstructed against the synced text, which
53
- * is sound only for a position-independent (full-text) change.
54
- */
55
- readonly before: string | undefined;
56
37
  /** {@link textHash} of the text this push moves the client to. */
57
38
  readonly afterHash: string;
58
39
  }
@@ -67,7 +48,11 @@ export interface PreparedLanguageClientPush {
67
48
  * buffer and is the caller's retry after a refusal.
68
49
  */
69
50
  readonly version: number | null;
70
- /** Record what the client answered. Only the first call counts; a refusal of an addressed push is logged. */
51
+ /**
52
+ * Record what the client answered. Only the first call counts; a refusal of
53
+ * an addressed push is logged; an answer that arrives after the client
54
+ * reopened the URI changes nothing.
55
+ */
71
56
  notifyOutcome(outcome: LanguageClientPushOutcome): void;
72
57
  }
73
58
 
@@ -85,13 +70,6 @@ export type LanguageClientChangeVerdict =
85
70
  * splice the wrong lines; `text` is what it now holds, and is authoritative.
86
71
  */
87
72
  | { 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
73
  /** The client's buffer is the synced text, so its ranges apply as sent. */
96
74
  | { readonly kind: 'direct' };
97
75
 
@@ -111,11 +89,12 @@ export type LanguageClientChangeVerdict =
111
89
  */
112
90
  export interface LanguageClientShadow {
113
91
  /**
114
- * The client opened `key` under `clientUri`. `firstOpen`, the store's first
115
- * open of `key`: its text becomes the diff baseline; otherwise an
116
- * equality-only baseline, since a buffer opened from disk can lag the
117
- * synced text. Either replaces what was tracked for `clientUri` before the
118
- * open. A no-op when this URI is already open.
92
+ * The client opened `key` under `clientUri` holding `text`. `firstOpen`,
93
+ * the store's first open of `key`: that text becomes the diff baseline;
94
+ * otherwise there is none, since a buffer opened from disk can lag the
95
+ * synced text, and a push of other text is a full replace. Either replaces
96
+ * what was tracked for `clientUri` before the open. A no-op when this URI
97
+ * is already open.
119
98
  */
120
99
  addOpen(key: CanonicalUri, clientUri: LanguageClientUri, version: number, text: string, firstOpen: boolean): void;
121
100
  /** The version the client last declared for `key` under `clientUri`; `undefined` when it never opened it there. */
@@ -123,7 +102,11 @@ export interface LanguageClientShadow {
123
102
  /**
124
103
  * Take `version` as the client's newest for `clientUri`, and classify its
125
104
  * change against `document`, the synced document. The caller has already
126
- * dropped a stale change.
105
+ * dropped a stale change. After a `direct` or `divergent` verdict the caller
106
+ * commits the change and reports the synced text through
107
+ * {@link LanguageClientShadow.setClientText}: a `direct` change advances what
108
+ * the client was last heard to hold only through that call, and without it
109
+ * the next change is rebuilt against a stale text.
127
110
  */
128
111
  acceptChange(
129
112
  key: CanonicalUri,
@@ -154,7 +137,12 @@ export interface LanguageClientShadow {
154
137
  * land while the push to another of its URIs is in flight.
155
138
  */
156
139
  preparePush(key: CanonicalUri, clientUri: LanguageClientUri, text: string): PreparedLanguageClientPush | undefined;
157
- /** Forget what the client holds under `clientUri`; its next push is a full replace. */
140
+ /**
141
+ * Drop the diff baseline and the pushes in flight under `clientUri`; its
142
+ * next push is a full replace, or nothing when the client was last heard to
143
+ * hold that text. What the client was last heard to hold stays, and its
144
+ * changes are still rebuilt against it.
145
+ */
158
146
  invalidateClientText(clientUri: LanguageClientUri): void;
159
147
  }
160
148
 
@@ -183,14 +171,17 @@ export function isFullReplace(edits: readonly TextEdit[]): boolean {
183
171
 
184
172
  export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument> implements LanguageClientShadow {
185
173
  protected readonly opens = new Map<CanonicalUri, Map<LanguageClientUri, LanguageClientDocumentState>>();
186
- /** The diff baseline: the text the client is believed to hold. */
174
+ /** The diff baseline: the text the client holds once every push in flight has landed. */
187
175
  protected readonly baselines = new Map<LanguageClientUri, string>();
188
176
  /**
189
- * The text the client declared at an open with no diff baseline, compared
190
- * for equality only. A line-keyed diff keyed to this snapshot splices a
191
- * buffer the client may have moved past.
177
+ * What the client was last heard to hold: the text of its open, advanced
178
+ * through every change since. Each change's ranges address the text the
179
+ * previous one left, so changes are rebuilt against this; the diff baseline
180
+ * moves ahead at a push and does not hold it. A push compares against it
181
+ * for equality only: a line-keyed diff keyed to it splices a buffer the
182
+ * client may have moved past.
192
183
  */
193
- protected readonly openedTexts = new Map<LanguageClientUri, string>();
184
+ protected readonly heardTexts = new Map<LanguageClientUri, string>();
194
185
  protected readonly pending = new Map<LanguageClientUri, PendingLanguageClientPush[]>();
195
186
 
196
187
  /**
@@ -218,10 +209,9 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
218
209
  // Dropped: a seed set before the open names no buffer, and a line diff
219
210
  // against it splices text the client does not hold.
220
211
  this.invalidateClientText(clientUri);
212
+ this.heardTexts.set(clientUri, text);
221
213
  if (firstOpen) {
222
214
  this.baselines.set(clientUri, text);
223
- } else {
224
- this.openedTexts.set(clientUri, text);
225
215
  }
226
216
  }
227
217
 
@@ -244,16 +234,14 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
244
234
  }
245
235
 
246
236
  /**
247
- * Reconstruct the client's resulting buffer against the text its ranges
248
- * address: the pre-push buffer of the oldest push still in flight, else
249
- * what the client is believed to hold.
237
+ * Reconstruct the client's resulting buffer against what it was last heard
238
+ * to hold, which its ranges address whatever was pushed meanwhile.
250
239
  *
251
- * The client applies pushes in order and echoes each against the buffer it
252
- * held before that push, so the first echo belongs to the oldest entry.
253
- * Matching against every pending hash, not only the oldest, recognises an
254
- * echo a newer push already superseded, and consumes everything older too.
255
- * A reconstruction matching none means the client's buffer went somewhere
256
- * we did not send it, and the queue drops.
240
+ * The client applies pushes in order, so the first echo belongs to the
241
+ * oldest entry. Matching against every pending hash, not only the oldest,
242
+ * recognises an echo a newer push already superseded, and consumes
243
+ * everything older too. A reconstruction matching none means the client's
244
+ * buffer went somewhere we did not send it, and the queue drops.
257
245
  *
258
246
  * Content equality is a sound echo proof because entries live only between
259
247
  * a push and its echo, and `didChange` arrives in mutation order: an undo
@@ -265,23 +253,20 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
265
253
  document: TextDocument,
266
254
  changes: TextDocumentContentChangeEvent[]
267
255
  ): LanguageClientChangeVerdict {
268
- const queued = this.pending.get(clientUri);
269
- const pending = queued?.length ? queued : undefined;
270
- const clientText = pending?.[0].before ?? this.clientText(clientUri);
271
- if (pending === undefined && (clientText === undefined || clientText === document.getText())) {
256
+ // The synced text stands in only for a URI with nothing recorded, which
257
+ // has nothing in flight either.
258
+ const heard = this.heardTexts.get(clientUri) ?? document.getText();
259
+ const pending = this.pending.get(clientUri);
260
+ if (!pending?.length && heard === document.getText()) {
261
+ // The ranges apply to the synced text as sent, and the caller's
262
+ // setClientText records the result. Were it rebuilt here too, every
263
+ // keystroke would copy the whole text a second time and rescan its lines.
272
264
  return { kind: 'direct' };
273
265
  }
274
- if (pending !== undefined && pending[0].before === undefined && changes.some(change => ContentChange.isIncremental(change))) {
275
- // A push sent to a buffer it did not know: the tracked text is now the
276
- // text that push moves the client to, the one text these ranges
277
- // provably do not address. Dropped, the next push is a full replace. A
278
- // full-text change reconstructs identically against any baseline.
279
- this.invalidateClientText(clientUri);
280
- return { kind: 'unreconstructable' };
281
- }
282
- const probe = this.configuration.create(clientUri, document.languageId, 0, clientText ?? document.getText());
266
+ const probe = this.configuration.create(clientUri, document.languageId, 0, heard);
283
267
  const reconstructed = this.configuration.update(probe, changes, 0).getText();
284
- if (pending !== undefined) {
268
+ this.heardTexts.set(clientUri, reconstructed);
269
+ if (pending?.length) {
285
270
  const matchIndex = pending.findIndex(push => push.afterHash === textHash(reconstructed));
286
271
  if (matchIndex >= 0) {
287
272
  pending.splice(0, matchIndex + 1);
@@ -289,11 +274,12 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
289
274
  }
290
275
  this.pending.delete(clientUri);
291
276
  }
292
- return { kind: 'divergent', text: reconstructed };
277
+ return heard === document.getText() ? { kind: 'direct' } : { kind: 'divergent', text: reconstructed };
293
278
  }
294
279
 
295
280
  setClientText(clientUri: LanguageClientUri, text: string): void {
296
281
  this.baselines.set(clientUri, text);
282
+ this.heardTexts.set(clientUri, text);
297
283
  this.pending.delete(clientUri);
298
284
  }
299
285
 
@@ -301,6 +287,7 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
301
287
  const uris = this.opens.get(key);
302
288
  if (uris?.delete(clientUri)) {
303
289
  this.invalidateClientText(clientUri);
290
+ this.heardTexts.delete(clientUri);
304
291
  if (uris.size === 0) {
305
292
  this.opens.delete(key);
306
293
  }
@@ -315,6 +302,7 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
315
302
  removeAllOpens(key: CanonicalUri): void {
316
303
  for (const clientUri of this.opens.get(key)?.keys() ?? []) {
317
304
  this.invalidateClientText(clientUri);
305
+ this.heardTexts.delete(clientUri);
318
306
  }
319
307
  this.opens.delete(key);
320
308
  }
@@ -329,14 +317,11 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
329
317
  if (state === undefined) {
330
318
  return undefined;
331
319
  }
332
- // Read before computing the edits, which moves the baseline: this is the
333
- // only text the push's echo can be reconstructed against.
334
- const before = this.clientText(clientUri);
335
320
  const edits = this.computeEdits(clientUri, text);
336
321
  if (edits.length === 0) {
337
322
  return undefined;
338
323
  }
339
- this.recordPendingPush(clientUri, before, text);
324
+ this.recordPendingPush(clientUri, text);
340
325
  // Gating a full replace turns a stale-by-one version into a refused
341
326
  // update for no safety gain, and refuses the retry after a rejection.
342
327
  const version = isFullReplace(edits) ? null : Math.max(state.declaredVersion, state.pushedVersion ?? state.declaredVersion);
@@ -355,7 +340,10 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
355
340
  `Language client refused applyEdit addressed at version ${version} (it last declared version ${state.declaredVersion})`
356
341
  );
357
342
  }
358
- this.settle(clientUri, version, state, outcome);
343
+ // A reopen while the push was in flight reset what is tracked for the new buffer.
344
+ if (this.opens.get(key)?.get(clientUri) === state) {
345
+ this.settle(clientUri, version, state, outcome);
346
+ }
359
347
  }
360
348
  }
361
349
  };
@@ -379,22 +367,16 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
379
367
 
380
368
  invalidateClientText(clientUri: LanguageClientUri): void {
381
369
  this.baselines.delete(clientUri);
382
- this.openedTexts.delete(clientUri);
383
370
  this.pending.delete(clientUri);
384
371
  }
385
372
 
386
- /** The text the client is believed to hold: the diff baseline, else what it declared at open. */
387
- protected clientText(clientUri: LanguageClientUri): string | undefined {
388
- return this.baselines.get(clientUri) ?? this.openedTexts.get(clientUri);
389
- }
390
-
391
373
  /**
392
374
  * The edits that bring the client from its baseline to `text`, moving the
393
375
  * baseline to `text`. A full replace without a baseline, none when the
394
- * client opened with exactly `text` (pushing it anyway dirties the buffer on
395
- * open), and otherwise a line diff, verified by applying it: a diff that
396
- * does not reproduce `text` falls back to a full replace rather than
397
- * corrupting the client.
376
+ * client was last heard to hold exactly `text` (pushing it anyway dirties
377
+ * the buffer and adds an undo step), and otherwise a line diff, verified by
378
+ * applying it: a diff that does not reproduce `text` falls back to a full
379
+ * replace rather than corrupting the client.
398
380
  */
399
381
  protected computeEdits(clientUri: LanguageClientUri, text: string): TextEdit[] {
400
382
  const old = this.baselines.get(clientUri);
@@ -404,9 +386,7 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
404
386
  this.baselines.set(clientUri, text);
405
387
  const fullReplace: TextEdit = { range: FULL_RANGE, newText: text };
406
388
  if (old === undefined) {
407
- const openedText = this.openedTexts.get(clientUri);
408
- this.openedTexts.delete(clientUri);
409
- return openedText === text ? [] : [fullReplace];
389
+ return this.heardTexts.get(clientUri) === text ? [] : [fullReplace];
410
390
  }
411
391
  const edits = diffToEdits(old, text);
412
392
  const probe = this.configuration.create(clientUri, 'plaintext', 0, old);
@@ -418,13 +398,13 @@ export class DefaultLanguageClientShadow<T extends TextDocument = TextDocument>
418
398
  }
419
399
 
420
400
  /** Bounded, so an echo that never arrives cannot grow the queue without limit. */
421
- protected recordPendingPush(clientUri: LanguageClientUri, before: string | undefined, text: string): void {
401
+ protected recordPendingPush(clientUri: LanguageClientUri, text: string): void {
422
402
  let queue = this.pending.get(clientUri);
423
403
  if (!queue) {
424
404
  queue = [];
425
405
  this.pending.set(clientUri, queue);
426
406
  }
427
- queue.push({ before, afterHash: textHash(text) });
407
+ queue.push({ afterHash: textHash(text) });
428
408
  if (queue.length > PENDING_ECHO_CAP) {
429
409
  queue.shift();
430
410
  this.tracer.with(clientUri).debug(`Pending-echo queue exceeded ${PENDING_ECHO_CAP} entries; dropped the oldest`);
@@ -1084,17 +1084,19 @@ export class DefaultModelService<
1084
1084
  const text = this.pendingSync.get(uri)!;
1085
1085
  this.pendingSync.delete(uri);
1086
1086
  try {
1087
- let result = await this.services.workspace.TextDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() });
1088
- if (result?.applied === false && !this.pendingSync.has(uri)) {
1087
+ const textDocuments = this.services.workspace.TextDocuments;
1088
+ let result = await textDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() });
1089
+ if (result?.applied === false && !this.pendingSync.has(uri) && textDocuments.get(uri)?.getText() === text) {
1089
1090
  // The push is addressed at the client's last known version, so a
1090
1091
  // rejection normally means the client's buffer moved while the
1091
1092
  // line-keyed diff was in flight — exactly the case where applying it
1092
1093
  // would splice the file. Dropping the push there would leave the
1093
1094
  // editor showing text the server has already superseded, with no
1094
1095
  // later settle guaranteed to correct it (a content-identical echo
1095
- // mints no rebuild). The rejection invalidated the shadow, so the
1096
- // retry is a full-range replace: position-independent, and therefore
1097
- // correct against whatever the client now holds.
1096
+ // mints no rebuild). After a rejection the retry is a full-range
1097
+ // replace: position-independent, and therefore correct against
1098
+ // whatever the client now holds. It sends nothing when the client
1099
+ // was last heard to hold the text already.
1098
1100
  //
1099
1101
  // Retried INLINE rather than re-enqueued, and exactly once. Inline
1100
1102
  // because a re-enqueue would have to out-order any settle that lands
@@ -1103,9 +1105,11 @@ export class DefaultModelService<
1103
1105
  // the workspace) must cost one extra RPC rather than spin. The
1104
1106
  // `pendingSync` check skips the retry when a newer settle has already
1105
1107
  // queued — best-effort, since a settle arriving later simply pushes
1106
- // after this and still wins.
1108
+ // after this and still wins. It is skipped too once the store holds
1109
+ // other text: the keystroke that made the client refuse replaced it,
1110
+ // and re-pushing would overwrite that keystroke in the editor.
1107
1111
  uriLogger.debug(`Re-pushing a full replace after the language client refused applyEdit`);
1108
- result = await this.services.workspace.TextDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() });
1112
+ result = await textDocuments.applyEditToLanguageClient(uri, text, { label: this.editLabel() });
1109
1113
  if (result?.applied === false) {
1110
1114
  uriLogger.warn(`Language client rejected the full-replace retry too — client content is stale`);
1111
1115
  }