@hydranium/core 1.0.0-next.205 → 1.0.0-next.206
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 +174 -0
- package/lib/documents/document-release-handler.d.ts.map +1 -0
- package/lib/documents/document-release-handler.js +270 -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 +130 -455
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +362 -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 +12 -4
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +1 -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 +19 -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 +351 -0
- package/src/documents/document-release-scheduler.ts +121 -0
- package/src/documents/hydranium-text-documents.ts +395 -1034
- 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 +13 -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 +34 -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
|
@@ -1,201 +0,0 @@
|
|
|
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
|
-
import { diffLines } from 'diff';
|
|
10
|
-
import { Range, uinteger } from 'vscode-languageserver';
|
|
11
|
-
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
12
|
-
/** The range every full-document replace emitted here carries. */
|
|
13
|
-
const FULL_RANGE = Range.create(0, 0, uinteger.MAX_VALUE, uinteger.MAX_VALUE);
|
|
14
|
-
/**
|
|
15
|
-
* Whether `edits` is the single full-document replace {@link LanguageClientTextShadow.computeEdits}
|
|
16
|
-
* emits when it has no usable baseline.
|
|
17
|
-
*
|
|
18
|
-
* The distinction matters at the `workspace/applyEdit` egress: a full replace is
|
|
19
|
-
* position-INDEPENDENT and therefore lands correctly on any client buffer, while
|
|
20
|
-
* a line-keyed diff only lands correctly on the exact text it was diffed
|
|
21
|
-
* against. Only the latter needs a client-version gate.
|
|
22
|
-
*/
|
|
23
|
-
export function isFullReplace(edits) {
|
|
24
|
-
if (edits.length !== 1) {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
const { start, end } = edits[0].range;
|
|
28
|
-
return start.line === FULL_RANGE.start.line && start.character === FULL_RANGE.start.character && end.line === FULL_RANGE.end.line;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Tracks the text content the language client (typically Monaco) is believed to
|
|
32
|
-
* currently hold so outgoing `workspace/applyEdit` requests can ship minimal
|
|
33
|
-
* diffs instead of full-document replaces.
|
|
34
|
-
*
|
|
35
|
-
* Why it matters: a full-range replace on a moderately-sized YAML or DSL source
|
|
36
|
-
* forces Monaco to re-tokenise the entire document (observed multi-second hangs
|
|
37
|
-
* on large diagrams), whereas line-level diffs usually reduce that to a handful
|
|
38
|
-
* of small edits.
|
|
39
|
-
*
|
|
40
|
-
* Update points the caller is responsible for:
|
|
41
|
-
* - {@link computeEdits} after each outgoing server→client sync (updates the shadow optimistically).
|
|
42
|
-
* - {@link set} when the server receives a `didChange` from the language client (the client now
|
|
43
|
-
* holds that text; we shouldn't resend it).
|
|
44
|
-
* - {@link setOpenedText} when the client opens a document the server did not baseline for it —
|
|
45
|
-
* an equality-only baseline, since that text is a snapshot rather than a tracked one.
|
|
46
|
-
* - {@link invalidate} when the client rejects an edit (`applied=false`) or a doc is closed —
|
|
47
|
-
* the shadow is now stale and the next sync must fall back to a full replace.
|
|
48
|
-
*/
|
|
49
|
-
export class LanguageClientTextShadow {
|
|
50
|
-
onFallback;
|
|
51
|
-
documents;
|
|
52
|
-
shadow = new Map();
|
|
53
|
-
/**
|
|
54
|
-
* Per URI, the text the client declared it opened the document with, held
|
|
55
|
-
* only until a real baseline exists. Consulted by {@link computeEdits} when
|
|
56
|
-
* there is no shadow, and dropped on {@link invalidate}: a snapshot that
|
|
57
|
-
* outlived what the client holds would let the equality check skip an edit
|
|
58
|
-
* the client still needs.
|
|
59
|
-
*/
|
|
60
|
-
opened = new Map();
|
|
61
|
-
/**
|
|
62
|
-
* @param onFallback invoked when {@link computeEdits} falls back to a full-range replace due to
|
|
63
|
-
* apply-verify mismatch. Useful for logging regressions.
|
|
64
|
-
* @param documents the {@link ShadowDocumentSource} used to materialise the throwaway probe
|
|
65
|
-
* document in {@link computeEdits}. In production this is the owning
|
|
66
|
-
* `HydraniumTextDocuments` (whose `create` threads through its configured
|
|
67
|
-
* `TextDocumentsConfiguration`); tests pass a minimal stub. Routing
|
|
68
|
-
* through the manager rather than `TextDocument.create` directly avoids the
|
|
69
|
-
* `instanceof FullTextDocument` gate in the bare LSP `TextDocument.update`,
|
|
70
|
-
* keeping the verifier honest for adopters with a custom text-document type.
|
|
71
|
-
*/
|
|
72
|
-
constructor(onFallback, documents) {
|
|
73
|
-
this.onFallback = onFallback;
|
|
74
|
-
this.documents = documents;
|
|
75
|
-
}
|
|
76
|
-
/** Record the text the language client is known to hold (e.g. after a `didChange` echo). */
|
|
77
|
-
set(uri, text) {
|
|
78
|
-
this.shadow.set(uri, text);
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* Record the buffer the language client declared at `didOpen`, for a URI
|
|
82
|
-
* with no baseline yet.
|
|
83
|
-
*
|
|
84
|
-
* Deliberately NOT a {@link set}: this text is only ever compared for
|
|
85
|
-
* EQUALITY, never diffed against. A line-keyed diff is position-dependent,
|
|
86
|
-
* so keying one to a `didOpen` snapshot the client may have moved past
|
|
87
|
-
* splices its buffer — the failure {@link computeEdits}'s full-replace
|
|
88
|
-
* fallback exists to avoid.
|
|
89
|
-
*/
|
|
90
|
-
setOpenedText(uri, text) {
|
|
91
|
-
this.opened.set(uri, text);
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* The text the language client is believed to hold: the tracked text, else
|
|
95
|
-
* the buffer it declared at `didOpen`.
|
|
96
|
-
*
|
|
97
|
-
* Read at the `workspace/applyEdit` egress BEFORE {@link computeEdits},
|
|
98
|
-
* which overwrites the tracked text and drops the opened snapshot. A change
|
|
99
|
-
* the client sends carries ranges addressing this text and nothing else, so
|
|
100
|
-
* losing it leaves that change unreconstructable — and applying its ranges
|
|
101
|
-
* to the already-advanced synced text splices lines they never addressed.
|
|
102
|
-
*
|
|
103
|
-
* Deliberately wider than what {@link computeEdits} may diff from: an
|
|
104
|
-
* opened snapshot is a position-dependent key to a buffer the client may
|
|
105
|
-
* have moved past, so keying a diff to it splices that buffer.
|
|
106
|
-
*/
|
|
107
|
-
clientText(uri) {
|
|
108
|
-
return this.shadow.get(uri) ?? this.opened.get(uri);
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Whether a tracked text exists for `uri`, which is what decides whether a
|
|
112
|
-
* push can be diffed at all. Distinct from {@link clientText}, which also
|
|
113
|
-
* answers from the opened snapshot and so cannot settle this.
|
|
114
|
-
*/
|
|
115
|
-
isTracked(uri) {
|
|
116
|
-
return this.shadow.has(uri);
|
|
117
|
-
}
|
|
118
|
-
/** Forget the shadow for a URI; next {@link computeEdits} falls back to a full-document replace. */
|
|
119
|
-
invalidate(uri) {
|
|
120
|
-
this.shadow.delete(uri);
|
|
121
|
-
// Drops the opened snapshot too, and this is the only place that has to:
|
|
122
|
-
// `computeEdits` clears it whenever it consults it, so the sole way it can
|
|
123
|
-
// outlive what the client holds is surviving under a live shadow until that
|
|
124
|
-
// shadow is dropped here.
|
|
125
|
-
this.opened.delete(uri);
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Return the minimal LSP {@link TextEdit}s required to bring the language client's content from
|
|
129
|
-
* the tracked shadow to `newText`. Updates the shadow to `newText` so subsequent calls diff
|
|
130
|
-
* against it.
|
|
131
|
-
*
|
|
132
|
-
* - Empty array when the shadow already equals `newText` (caller should skip the RPC),
|
|
133
|
-
* or when there is no shadow and the client opened the document with exactly `newText`
|
|
134
|
-
* (see {@link setOpenedText}) — pushing it anyway dirties the client's buffer on open.
|
|
135
|
-
* - Full-range replace when there is no shadow (first sync or post-{@link invalidate}).
|
|
136
|
-
* - Line-level diff otherwise. Before returning, we locally reconstruct `newText` by applying
|
|
137
|
-
* the diff to `old`; if that doesn't match, we fall back to a full-range replace and notify
|
|
138
|
-
* the fallback callback. This catches any algorithmic regression in {@link diffToEdits}
|
|
139
|
-
* without risking client corruption.
|
|
140
|
-
*/
|
|
141
|
-
computeEdits(uri, newText) {
|
|
142
|
-
const old = this.shadow.get(uri);
|
|
143
|
-
if (old === newText) {
|
|
144
|
-
return [];
|
|
145
|
-
}
|
|
146
|
-
this.shadow.set(uri, newText);
|
|
147
|
-
const fullReplace = { range: FULL_RANGE, newText };
|
|
148
|
-
if (old === undefined) {
|
|
149
|
-
// The opened snapshot has served its purpose either way: the shadow above
|
|
150
|
-
// is now the baseline, so leaving it would only risk a later stale match.
|
|
151
|
-
const openedText = this.opened.get(uri);
|
|
152
|
-
this.opened.delete(uri);
|
|
153
|
-
return openedText === newText ? [] : [fullReplace];
|
|
154
|
-
}
|
|
155
|
-
const edits = diffToEdits(old, newText);
|
|
156
|
-
const verifyDoc = this.documents.create(uri, 'plaintext', 0, old);
|
|
157
|
-
if (TextDocument.applyEdits(verifyDoc, edits) !== newText) {
|
|
158
|
-
this.onFallback(uri, 'apply-verify-mismatch');
|
|
159
|
-
return [fullReplace];
|
|
160
|
-
}
|
|
161
|
-
return edits;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Convert a {@link diffLines} result to a list of LSP {@link TextEdit}s keyed on line positions
|
|
166
|
-
* in the *old* text. Contiguous added/removed hunks are coalesced into a single replace so the
|
|
167
|
-
* client sees one edit per changed region.
|
|
168
|
-
*/
|
|
169
|
-
export function diffToEdits(oldText, newText) {
|
|
170
|
-
const hunks = diffLines(oldText, newText);
|
|
171
|
-
const edits = [];
|
|
172
|
-
let oldLine = 0;
|
|
173
|
-
let i = 0;
|
|
174
|
-
while (i < hunks.length) {
|
|
175
|
-
const hunk = hunks[i];
|
|
176
|
-
if (!hunk.added && !hunk.removed) {
|
|
177
|
-
oldLine += hunk.count ?? 0;
|
|
178
|
-
i++;
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
// Coalesce a run of add/remove hunks into a single replace for this region.
|
|
182
|
-
let removedLines = 0;
|
|
183
|
-
let addedText = '';
|
|
184
|
-
while (i < hunks.length && (hunks[i].added || hunks[i].removed)) {
|
|
185
|
-
if (hunks[i].removed) {
|
|
186
|
-
removedLines += hunks[i].count ?? 0;
|
|
187
|
-
}
|
|
188
|
-
if (hunks[i].added) {
|
|
189
|
-
addedText += hunks[i].value;
|
|
190
|
-
}
|
|
191
|
-
i++;
|
|
192
|
-
}
|
|
193
|
-
edits.push({
|
|
194
|
-
range: Range.create(oldLine, 0, oldLine + removedLines, 0),
|
|
195
|
-
newText: addedText
|
|
196
|
-
});
|
|
197
|
-
oldLine += removedLines;
|
|
198
|
-
}
|
|
199
|
-
return edits;
|
|
200
|
-
}
|
|
201
|
-
//# sourceMappingURL=language-client-text-shadow.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"language-client-text-shadow.js","sourceRoot":"","sources":["../../src/documents/language-client-text-shadow.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,223 +0,0 @@
|
|
|
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 { diffLines } from 'diff';
|
|
11
|
-
import { Range, type TextEdit, uinteger } from 'vscode-languageserver';
|
|
12
|
-
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* The minimum surface the shadow needs from a `HydraniumTextDocuments`:
|
|
16
|
-
* a `create` method that materialises a fresh text document via the
|
|
17
|
-
* configured `TextDocumentsConfiguration`. Declared as a narrow
|
|
18
|
-
* structural interface so the manager satisfies it without an explicit
|
|
19
|
-
* import (sidestepping the circular `HydraniumTextDocuments → shadow → manager`
|
|
20
|
-
* dependency) and so tests can stub it with a minimal object literal.
|
|
21
|
-
*/
|
|
22
|
-
export interface ShadowDocumentSource {
|
|
23
|
-
create(uri: string, languageId: string, version: number, content: string): TextDocument;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** The range every full-document replace emitted here carries. */
|
|
27
|
-
const FULL_RANGE = Range.create(0, 0, uinteger.MAX_VALUE, uinteger.MAX_VALUE);
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Whether `edits` is the single full-document replace {@link LanguageClientTextShadow.computeEdits}
|
|
31
|
-
* emits when it has no usable baseline.
|
|
32
|
-
*
|
|
33
|
-
* The distinction matters at the `workspace/applyEdit` egress: a full replace is
|
|
34
|
-
* position-INDEPENDENT and therefore lands correctly on any client buffer, while
|
|
35
|
-
* a line-keyed diff only lands correctly on the exact text it was diffed
|
|
36
|
-
* against. Only the latter needs a client-version gate.
|
|
37
|
-
*/
|
|
38
|
-
export function isFullReplace(edits: readonly TextEdit[]): boolean {
|
|
39
|
-
if (edits.length !== 1) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
const { start, end } = edits[0].range;
|
|
43
|
-
return start.line === FULL_RANGE.start.line && start.character === FULL_RANGE.start.character && end.line === FULL_RANGE.end.line;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Tracks the text content the language client (typically Monaco) is believed to
|
|
48
|
-
* currently hold so outgoing `workspace/applyEdit` requests can ship minimal
|
|
49
|
-
* diffs instead of full-document replaces.
|
|
50
|
-
*
|
|
51
|
-
* Why it matters: a full-range replace on a moderately-sized YAML or DSL source
|
|
52
|
-
* forces Monaco to re-tokenise the entire document (observed multi-second hangs
|
|
53
|
-
* on large diagrams), whereas line-level diffs usually reduce that to a handful
|
|
54
|
-
* of small edits.
|
|
55
|
-
*
|
|
56
|
-
* Update points the caller is responsible for:
|
|
57
|
-
* - {@link computeEdits} after each outgoing server→client sync (updates the shadow optimistically).
|
|
58
|
-
* - {@link set} when the server receives a `didChange` from the language client (the client now
|
|
59
|
-
* holds that text; we shouldn't resend it).
|
|
60
|
-
* - {@link setOpenedText} when the client opens a document the server did not baseline for it —
|
|
61
|
-
* an equality-only baseline, since that text is a snapshot rather than a tracked one.
|
|
62
|
-
* - {@link invalidate} when the client rejects an edit (`applied=false`) or a doc is closed —
|
|
63
|
-
* the shadow is now stale and the next sync must fall back to a full replace.
|
|
64
|
-
*/
|
|
65
|
-
export class LanguageClientTextShadow {
|
|
66
|
-
protected shadow = new Map<string, string>();
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Per URI, the text the client declared it opened the document with, held
|
|
70
|
-
* only until a real baseline exists. Consulted by {@link computeEdits} when
|
|
71
|
-
* there is no shadow, and dropped on {@link invalidate}: a snapshot that
|
|
72
|
-
* outlived what the client holds would let the equality check skip an edit
|
|
73
|
-
* the client still needs.
|
|
74
|
-
*/
|
|
75
|
-
protected opened = new Map<string, string>();
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* @param onFallback invoked when {@link computeEdits} falls back to a full-range replace due to
|
|
79
|
-
* apply-verify mismatch. Useful for logging regressions.
|
|
80
|
-
* @param documents the {@link ShadowDocumentSource} used to materialise the throwaway probe
|
|
81
|
-
* document in {@link computeEdits}. In production this is the owning
|
|
82
|
-
* `HydraniumTextDocuments` (whose `create` threads through its configured
|
|
83
|
-
* `TextDocumentsConfiguration`); tests pass a minimal stub. Routing
|
|
84
|
-
* through the manager rather than `TextDocument.create` directly avoids the
|
|
85
|
-
* `instanceof FullTextDocument` gate in the bare LSP `TextDocument.update`,
|
|
86
|
-
* keeping the verifier honest for adopters with a custom text-document type.
|
|
87
|
-
*/
|
|
88
|
-
constructor(
|
|
89
|
-
protected onFallback: (uri: string, reason: string) => void,
|
|
90
|
-
protected documents: ShadowDocumentSource
|
|
91
|
-
) {}
|
|
92
|
-
|
|
93
|
-
/** Record the text the language client is known to hold (e.g. after a `didChange` echo). */
|
|
94
|
-
set(uri: string, text: string): void {
|
|
95
|
-
this.shadow.set(uri, text);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Record the buffer the language client declared at `didOpen`, for a URI
|
|
100
|
-
* with no baseline yet.
|
|
101
|
-
*
|
|
102
|
-
* Deliberately NOT a {@link set}: this text is only ever compared for
|
|
103
|
-
* EQUALITY, never diffed against. A line-keyed diff is position-dependent,
|
|
104
|
-
* so keying one to a `didOpen` snapshot the client may have moved past
|
|
105
|
-
* splices its buffer — the failure {@link computeEdits}'s full-replace
|
|
106
|
-
* fallback exists to avoid.
|
|
107
|
-
*/
|
|
108
|
-
setOpenedText(uri: string, text: string): void {
|
|
109
|
-
this.opened.set(uri, text);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The text the language client is believed to hold: the tracked text, else
|
|
114
|
-
* the buffer it declared at `didOpen`.
|
|
115
|
-
*
|
|
116
|
-
* Read at the `workspace/applyEdit` egress BEFORE {@link computeEdits},
|
|
117
|
-
* which overwrites the tracked text and drops the opened snapshot. A change
|
|
118
|
-
* the client sends carries ranges addressing this text and nothing else, so
|
|
119
|
-
* losing it leaves that change unreconstructable — and applying its ranges
|
|
120
|
-
* to the already-advanced synced text splices lines they never addressed.
|
|
121
|
-
*
|
|
122
|
-
* Deliberately wider than what {@link computeEdits} may diff from: an
|
|
123
|
-
* opened snapshot is a position-dependent key to a buffer the client may
|
|
124
|
-
* have moved past, so keying a diff to it splices that buffer.
|
|
125
|
-
*/
|
|
126
|
-
clientText(uri: string): string | undefined {
|
|
127
|
-
return this.shadow.get(uri) ?? this.opened.get(uri);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Whether a tracked text exists for `uri`, which is what decides whether a
|
|
132
|
-
* push can be diffed at all. Distinct from {@link clientText}, which also
|
|
133
|
-
* answers from the opened snapshot and so cannot settle this.
|
|
134
|
-
*/
|
|
135
|
-
isTracked(uri: string): boolean {
|
|
136
|
-
return this.shadow.has(uri);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Forget the shadow for a URI; next {@link computeEdits} falls back to a full-document replace. */
|
|
140
|
-
invalidate(uri: string): void {
|
|
141
|
-
this.shadow.delete(uri);
|
|
142
|
-
// Drops the opened snapshot too, and this is the only place that has to:
|
|
143
|
-
// `computeEdits` clears it whenever it consults it, so the sole way it can
|
|
144
|
-
// outlive what the client holds is surviving under a live shadow until that
|
|
145
|
-
// shadow is dropped here.
|
|
146
|
-
this.opened.delete(uri);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Return the minimal LSP {@link TextEdit}s required to bring the language client's content from
|
|
151
|
-
* the tracked shadow to `newText`. Updates the shadow to `newText` so subsequent calls diff
|
|
152
|
-
* against it.
|
|
153
|
-
*
|
|
154
|
-
* - Empty array when the shadow already equals `newText` (caller should skip the RPC),
|
|
155
|
-
* or when there is no shadow and the client opened the document with exactly `newText`
|
|
156
|
-
* (see {@link setOpenedText}) — pushing it anyway dirties the client's buffer on open.
|
|
157
|
-
* - Full-range replace when there is no shadow (first sync or post-{@link invalidate}).
|
|
158
|
-
* - Line-level diff otherwise. Before returning, we locally reconstruct `newText` by applying
|
|
159
|
-
* the diff to `old`; if that doesn't match, we fall back to a full-range replace and notify
|
|
160
|
-
* the fallback callback. This catches any algorithmic regression in {@link diffToEdits}
|
|
161
|
-
* without risking client corruption.
|
|
162
|
-
*/
|
|
163
|
-
computeEdits(uri: string, newText: string): TextEdit[] {
|
|
164
|
-
const old = this.shadow.get(uri);
|
|
165
|
-
if (old === newText) {
|
|
166
|
-
return [];
|
|
167
|
-
}
|
|
168
|
-
this.shadow.set(uri, newText);
|
|
169
|
-
const fullReplace: TextEdit = { range: FULL_RANGE, newText };
|
|
170
|
-
if (old === undefined) {
|
|
171
|
-
// The opened snapshot has served its purpose either way: the shadow above
|
|
172
|
-
// is now the baseline, so leaving it would only risk a later stale match.
|
|
173
|
-
const openedText = this.opened.get(uri);
|
|
174
|
-
this.opened.delete(uri);
|
|
175
|
-
return openedText === newText ? [] : [fullReplace];
|
|
176
|
-
}
|
|
177
|
-
const edits = diffToEdits(old, newText);
|
|
178
|
-
const verifyDoc = this.documents.create(uri, 'plaintext', 0, old);
|
|
179
|
-
if (TextDocument.applyEdits(verifyDoc, edits) !== newText) {
|
|
180
|
-
this.onFallback(uri, 'apply-verify-mismatch');
|
|
181
|
-
return [fullReplace];
|
|
182
|
-
}
|
|
183
|
-
return edits;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Convert a {@link diffLines} result to a list of LSP {@link TextEdit}s keyed on line positions
|
|
189
|
-
* in the *old* text. Contiguous added/removed hunks are coalesced into a single replace so the
|
|
190
|
-
* client sees one edit per changed region.
|
|
191
|
-
*/
|
|
192
|
-
export function diffToEdits(oldText: string, newText: string): TextEdit[] {
|
|
193
|
-
const hunks = diffLines(oldText, newText);
|
|
194
|
-
const edits: TextEdit[] = [];
|
|
195
|
-
let oldLine = 0;
|
|
196
|
-
let i = 0;
|
|
197
|
-
while (i < hunks.length) {
|
|
198
|
-
const hunk = hunks[i];
|
|
199
|
-
if (!hunk.added && !hunk.removed) {
|
|
200
|
-
oldLine += hunk.count ?? 0;
|
|
201
|
-
i++;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
// Coalesce a run of add/remove hunks into a single replace for this region.
|
|
205
|
-
let removedLines = 0;
|
|
206
|
-
let addedText = '';
|
|
207
|
-
while (i < hunks.length && (hunks[i].added || hunks[i].removed)) {
|
|
208
|
-
if (hunks[i].removed) {
|
|
209
|
-
removedLines += hunks[i].count ?? 0;
|
|
210
|
-
}
|
|
211
|
-
if (hunks[i].added) {
|
|
212
|
-
addedText += hunks[i].value;
|
|
213
|
-
}
|
|
214
|
-
i++;
|
|
215
|
-
}
|
|
216
|
-
edits.push({
|
|
217
|
-
range: Range.create(oldLine, 0, oldLine + removedLines, 0),
|
|
218
|
-
newText: addedText
|
|
219
|
-
});
|
|
220
|
-
oldLine += removedLines;
|
|
221
|
-
}
|
|
222
|
-
return edits;
|
|
223
|
-
}
|