@hydranium/core 1.0.0-next.213 → 1.0.0-next.214
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/hydranium-text-documents.d.ts +9 -3
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +9 -7
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/language-client-shadow.d.ts +42 -51
- package/lib/documents/language-client-shadow.d.ts.map +1 -1
- package/lib/documents/language-client-shadow.js +44 -52
- package/lib/documents/language-client-shadow.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +11 -7
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/hydranium-text-documents.ts +9 -7
- package/src/documents/language-client-shadow.ts +66 -86
- package/src/langium/model-service/model-service.ts +11 -7
|
@@ -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
|
-
/**
|
|
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`,
|
|
115
|
-
* open of `key`:
|
|
116
|
-
*
|
|
117
|
-
* synced text
|
|
118
|
-
* open. A no-op when this URI
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
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
|
|
248
|
-
*
|
|
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
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
395
|
-
*
|
|
396
|
-
* does not reproduce `text` falls back to a full
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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
|
-
|
|
1088
|
-
|
|
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).
|
|
1096
|
-
//
|
|
1097
|
-
//
|
|
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
|
|
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
|
}
|