@hydranium/core 1.0.0-next.95 → 1.0.0-next.97

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 (61) hide show
  1. package/lib/index.d.ts +1 -0
  2. package/lib/index.d.ts.map +1 -1
  3. package/lib/index.js +1 -0
  4. package/lib/index.js.map +1 -1
  5. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  6. package/lib/langium/integrity/integrity-service.js +8 -1
  7. package/lib/langium/integrity/integrity-service.js.map +1 -1
  8. package/lib/langium/language-module.d.ts +25 -0
  9. package/lib/langium/language-module.d.ts.map +1 -1
  10. package/lib/langium/language-module.js +14 -0
  11. package/lib/langium/language-module.js.map +1 -1
  12. package/lib/langium/model-service/model-service.d.ts +30 -18
  13. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  14. package/lib/langium/model-service/model-service.js +41 -1
  15. package/lib/langium/model-service/model-service.js.map +1 -1
  16. package/lib/langium/residency/cst-residency-service.d.ts +4 -6
  17. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  18. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  19. package/lib/langium/serialization/abstract-serializer.d.ts +11 -20
  20. package/lib/langium/serialization/abstract-serializer.d.ts.map +1 -1
  21. package/lib/langium/serialization/abstract-serializer.js +12 -21
  22. package/lib/langium/serialization/abstract-serializer.js.map +1 -1
  23. package/lib/langium/trivia/comment-preserver.d.ts +423 -0
  24. package/lib/langium/trivia/comment-preserver.d.ts.map +1 -0
  25. package/lib/langium/trivia/comment-preserver.js +906 -0
  26. package/lib/langium/trivia/comment-preserver.js.map +1 -0
  27. package/lib/langium/trivia/document-ending-preserver.d.ts +43 -0
  28. package/lib/langium/trivia/document-ending-preserver.d.ts.map +1 -0
  29. package/lib/langium/trivia/document-ending-preserver.js +48 -0
  30. package/lib/langium/trivia/document-ending-preserver.js.map +1 -0
  31. package/lib/langium/trivia/index.d.ts +14 -0
  32. package/lib/langium/trivia/index.d.ts.map +1 -0
  33. package/lib/langium/trivia/index.js +14 -0
  34. package/lib/langium/trivia/index.js.map +1 -0
  35. package/lib/langium/trivia/trivia-contribution.d.ts +37 -0
  36. package/lib/langium/trivia/trivia-contribution.d.ts.map +1 -0
  37. package/lib/langium/trivia/trivia-contribution.js +10 -0
  38. package/lib/langium/trivia/trivia-contribution.js.map +1 -0
  39. package/lib/langium/trivia/trivia-preserver.d.ts +50 -0
  40. package/lib/langium/trivia/trivia-preserver.d.ts.map +1 -0
  41. package/lib/langium/trivia/trivia-preserver.js +10 -0
  42. package/lib/langium/trivia/trivia-preserver.js.map +1 -0
  43. package/lib/langium/trivia/trivia-service.d.ts +70 -0
  44. package/lib/langium/trivia/trivia-service.d.ts.map +1 -0
  45. package/lib/langium/trivia/trivia-service.js +56 -0
  46. package/lib/langium/trivia/trivia-service.js.map +1 -0
  47. package/lib/testing/node/scratch-workspace.js +2 -2
  48. package/package.json +5 -5
  49. package/src/index.ts +1 -0
  50. package/src/langium/integrity/integrity-service.ts +9 -1
  51. package/src/langium/language-module.ts +38 -0
  52. package/src/langium/model-service/model-service.ts +58 -19
  53. package/src/langium/residency/cst-residency-service.ts +4 -6
  54. package/src/langium/serialization/abstract-serializer.ts +13 -32
  55. package/src/langium/trivia/comment-preserver.ts +1100 -0
  56. package/src/langium/trivia/document-ending-preserver.ts +55 -0
  57. package/src/langium/trivia/index.ts +14 -0
  58. package/src/langium/trivia/trivia-contribution.ts +39 -0
  59. package/src/langium/trivia/trivia-preserver.ts +54 -0
  60. package/src/langium/trivia/trivia-service.ts +99 -0
  61. package/src/testing/node/scratch-workspace.ts +2 -2
@@ -0,0 +1,1100 @@
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 Tracer } from '@hydranium/protocol';
11
+ import {
12
+ type AstNode,
13
+ AstUtils,
14
+ type CompositeCstNode,
15
+ type CstNode,
16
+ GrammarAST,
17
+ GrammarUtils,
18
+ isAstNode,
19
+ type LangiumDocument,
20
+ type URI
21
+ } from '@hydranium/langium';
22
+ import { type LogNameOptions } from '../diagnostics/logger.js';
23
+ import { type HydraniumLanguageServices } from '../language-module.js';
24
+ import { type TriviaContribution, type TriviaRegistry } from './trivia-contribution.js';
25
+ import { type TriviaPreserver } from './trivia-preserver.js';
26
+
27
+ /**
28
+ * Where a comment sat relative to the node it belongs to.
29
+ *
30
+ * The CST records source order but not ownership, so getting this wrong
31
+ * relocates a comment rather than losing it.
32
+ *
33
+ * - `atContainerStart` — before everything the container holds.
34
+ * - `leading` — on its own line(s) above the node that follows it.
35
+ * - `trailing` — on the same line the previous node ended on.
36
+ * - `trailingOnContainer` — on the container's own header line, or amongst its
37
+ * syntax with more of the container still to come.
38
+ * - `afterNode` — on its own line after the last node, still inside the
39
+ * container.
40
+ * - `atContainerEnd` — after everything the container holds.
41
+ */
42
+ export type CommentPlacement = 'atContainerStart' | 'leading' | 'trailing' | 'trailingOnContainer' | 'afterNode' | 'atContainerEnd';
43
+
44
+ /** One comment, bound to the AST node it hangs off rather than to an offset. */
45
+ export interface DocumentComment {
46
+ /** The comment's source text, delimiters included. */
47
+ readonly text: string;
48
+ /**
49
+ * The node the comment hangs off, held as the AST OBJECT rather than a path
50
+ * or an offset, so a write that mutates the tree IN PLACE — an integrity
51
+ * repair, a diagram gesture — is reflected without re-extracting.
52
+ *
53
+ * A transfer-model write supplies a separate object graph. A unique rename
54
+ * can still be matched when its type, parent slot and other properties agree;
55
+ * ambiguous matches are dropped.
56
+ */
57
+ readonly anchor: AstNode;
58
+ readonly placement: CommentPlacement;
59
+ /**
60
+ * Blank lines between the comment and what it leads, for the placements that
61
+ * emit it BEFORE its anchor.
62
+ *
63
+ * Captured for the placements that emit it after as well, where nothing reads
64
+ * it: there the spacing an author means is the gap above the comment, which
65
+ * {@link blankLinesBefore} carries.
66
+ */
67
+ readonly blankLinesAfter: number;
68
+ /**
69
+ * Blank lines between the comment and what PRECEDES it, for the placements
70
+ * that emit it after their anchor. A comment separated from the closing brace
71
+ * above it by a blank line reads as a new thought; re-emitting it flush
72
+ * against that brace silently rewrites the author's spacing.
73
+ */
74
+ readonly blankLinesBefore: number;
75
+ /**
76
+ * The indentation the comment sat at in the source, when it owned its line —
77
+ * `undefined` for one sharing a line with code, which is never reindented.
78
+ *
79
+ * A MULTI-LINE comment needs it to shift its continuation lines, which carry
80
+ * their original indentation inside {@link text}: emitting the block at a new
81
+ * indentation without shifting them leaves it hanging off its old column.
82
+ *
83
+ * A single-line one needs it whenever its anchor turns out to share a line,
84
+ * because the indentation read off a mid-line offset is the code's and not
85
+ * the comment's.
86
+ */
87
+ readonly sourceIndent?: string;
88
+ }
89
+
90
+ /** What {@link CommentPreserver} takes off a document. */
91
+ export interface CommentTrivia {
92
+ readonly comments: readonly DocumentComment[];
93
+ /** Original tree, retained to detect ambiguous or removed anchors at apply time. */
94
+ readonly sourceRoot: AstNode;
95
+ }
96
+
97
+ /** Construction options for {@link CommentPreserver}. */
98
+ export interface CommentPreserverOptions extends LogNameOptions {
99
+ /**
100
+ * Registry id, defaulting to `comments`. Set it only to run a second comment
101
+ * preserver beside the framework's; a subclass that REPLACES it keeps the
102
+ * default so the contribution sub-key and the id stay in step.
103
+ */
104
+ readonly id?: string;
105
+ /**
106
+ * How many comments spliced into shared lines are worth blaming one at a
107
+ * time when the result will not parse. Default {@link DEFAULT_ISOLATED_EDITS}.
108
+ *
109
+ * Isolating means re-splicing and re-parsing the whole document once per
110
+ * candidate, so the search costs the document's length times their number.
111
+ * Past this many the write drops them together instead — the same text it
112
+ * would reach anyway if every one of them were at fault, without spending
113
+ * that search on a build's write lock.
114
+ */
115
+ readonly maxIsolatedEdits?: number;
116
+ }
117
+
118
+ function isComposite(node: CstNode): node is CompositeCstNode {
119
+ return 'content' in node;
120
+ }
121
+
122
+ function tokenNameOf(node: CstNode): string | undefined {
123
+ return 'tokenType' in node ? (node as CstNode & { tokenType: { name: string } }).tokenType.name : undefined;
124
+ }
125
+
126
+ /**
127
+ * Where a node begins and ends in the text it was located in, and which node
128
+ * that is.
129
+ *
130
+ * Exported because {@link CommentPreserver.editFor} takes one, and an override
131
+ * that cannot name its own parameter type has to restate the shape — which then
132
+ * stops compiling the moment a field is added here.
133
+ */
134
+ export interface AnchorSpan {
135
+ readonly offset: number;
136
+ readonly end: number;
137
+ readonly owner: AstNode;
138
+ }
139
+
140
+ /**
141
+ * One splice, with what it takes to check the risky kind afterwards.
142
+ *
143
+ * `inline` is set only for an edit that opens a line inside syntax the
144
+ * serializer emitted whole; it carries what the comment must look like when the
145
+ * result is read back, since that edit is the one that can hand a comment to
146
+ * the wrong declaration.
147
+ *
148
+ * Exported for the same reason {@link AnchorSpan} is: the members that take one
149
+ * are protected, so an override has to be able to name it.
150
+ */
151
+ export interface InlineAwareEdit {
152
+ readonly at: number;
153
+ readonly text: string;
154
+ /** Capture order, so several comments on one anchor keep the order written. */
155
+ readonly order: number;
156
+ readonly inline?: { readonly text: string; readonly key: string };
157
+ }
158
+
159
+ /**
160
+ * Carries a document's comments across a write that re-serializes it from the
161
+ * AST: extract against the node each comment hangs off, then splice into the
162
+ * serializer's output, located by re-parsing it.
163
+ *
164
+ * **Emitting comments from inside a serializer instead drops them silently for
165
+ * most nodes.** A hand-written concrete-syntax serializer reaches its children
166
+ * through its own per-`$type` emitters, not through
167
+ * `AbstractSerializer.serializeNode`, so a hook on that seam is reached by some
168
+ * nodes and bypassed by the rest — and the ones it misses fail as absence,
169
+ * which no test notices unless it asserts on exact text.
170
+ */
171
+ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
172
+ /**
173
+ * **Registering a second preserver under this id throws.** A subclass added
174
+ * ALONGSIDE the framework's — rather than replacing it by rebinding the
175
+ * `comments` contribution sub-key — collides here, and the throw surfaces
176
+ * from the first write rather than from server start, because the service is
177
+ * constructed lazily. Pass an `id` to run both.
178
+ */
179
+ readonly id: string;
180
+ readonly label = 'Comments';
181
+
182
+ /** Default {@link CommentPreserverOptions.maxIsolatedEdits}. */
183
+ static readonly DEFAULT_ISOLATED_EDITS = 24;
184
+
185
+ protected readonly tracer: Tracer;
186
+ protected readonly maxIsolatedEdits: number;
187
+ protected commentTokenNames?: readonly string[];
188
+ /** Set for the duration of one {@link apply}; see {@link cachedAnchorKey}. */
189
+ protected keyMemo?: Map<AstNode, string | undefined>;
190
+ /** Set for the duration of one {@link apply}; see {@link ambiguousRetainedKey}. */
191
+ protected listVerdicts?: Map<AstNode, Map<string, boolean>>;
192
+
193
+ constructor(
194
+ protected readonly services: HydraniumLanguageServices,
195
+ options: CommentPreserverOptions = {}
196
+ ) {
197
+ this.id = options.id ?? 'comments';
198
+ this.maxIsolatedEdits = options.maxIsolatedEdits ?? CommentPreserver.DEFAULT_ISOLATED_EDITS;
199
+ this.tracer = services.shared.Tracer.for(options.logName ?? 'CommentPreserver');
200
+ }
201
+
202
+ /**
203
+ * Every comment terminal the grammar declares, by token name.
204
+ *
205
+ * Deliberately NOT `GrammarConfig.multilineCommentRules`, which Langium
206
+ * populates only with terminals whose regex spans lines — so a grammar that
207
+ * comments with `//` answers an empty list there and every one of its
208
+ * comments would be invisible to a capture keyed on it.
209
+ */
210
+ protected getCommentTokenNames(): readonly string[] {
211
+ if (!this.commentTokenNames) {
212
+ this.commentTokenNames = this.services.Grammar.rules
213
+ .filter(GrammarAST.isTerminalRule)
214
+ .filter(rule => GrammarUtils.isCommentTerminal(rule))
215
+ .map(rule => rule.name);
216
+ }
217
+ return this.commentTokenNames;
218
+ }
219
+
220
+ /**
221
+ * Stable identity for one AST node, used to find it again in the
222
+ * re-serialized text — or `undefined` when no stable identity exists.
223
+ *
224
+ * **Read through the `NameProvider`, so the anchor is what the grammar treats
225
+ * as IDENTITY rather than whatever sits on `name`.** A grammar whose `name`
226
+ * is a display label, carrying its identifier on another property, would
227
+ * otherwise anchor comments to a value users retitle freely. A simple
228
+ * retitle can be matched, but the real identifier remains the safer key.
229
+ * `NameProviderOptions.nameProperties` names that property.
230
+ *
231
+ * **Whatever an override returns must survive a sibling being inserted into
232
+ * the same list.** A positional key does not: every comment after the
233
+ * insertion point is then written against the following declaration — a move,
234
+ * which reads as text the author wrote there and which nothing downstream can
235
+ * detect. That is why an unidentified node answers `undefined` here rather
236
+ * than falling back to a container index, and why this cannot delegate to
237
+ * `ElementKeyProvider`, whose name-based strategy makes exactly that fallback:
238
+ * its keys are derived and consumed within one snapshot, where no position can
239
+ * have shifted underneath them.
240
+ *
241
+ * Overriding is the supported route for a grammar that identifies some nodes
242
+ * outside the naming surface altogether — by a cross-reference that is unique
243
+ * among its siblings, say. Capture and lookup both read this one definition,
244
+ * so an override cannot make those two disagree.
245
+ *
246
+ * **Matching a rename does not read it.** That comparison asks the
247
+ * `NameProvider` what changed, so a node identified only by an override is
248
+ * carried across a write that leaves its identity alone and dropped by one
249
+ * that rewrites it — never misplaced, but never followed either.
250
+ */
251
+ protected anchorKey(node: AstNode): string | undefined {
252
+ const nameProvider = this.services.references.NameProvider;
253
+ const segments: string[] = [];
254
+ let current: AstNode | undefined = node;
255
+ while (current?.$container) {
256
+ const name = nameProvider.getOwnName(current);
257
+ if (name === undefined || name.length === 0) {
258
+ return undefined;
259
+ }
260
+ segments.unshift(`${current.$type}#${this.escapeKeySegment(name)}`);
261
+ current = current.$container;
262
+ }
263
+ return segments.length === 0 ? `@root:${node.$type}` : segments.join('/');
264
+ }
265
+
266
+ /**
267
+ * Escape the characters {@link anchorKey} builds its keys out of, so a name
268
+ * containing one cannot spell a key another node also answers to — which
269
+ * would make two distinct nodes indistinguishable to the lookup.
270
+ */
271
+ protected escapeKeySegment(name: string): string {
272
+ return name.replace(/[\\#/]/g, character => `\\${character}`);
273
+ }
274
+
275
+ /**
276
+ * Collect every comment in `document`, each bound to the node it hangs off.
277
+ *
278
+ * Call this BEFORE the mutation that prompts the write. The anchors are AST
279
+ * objects, so a rule that renames one in place is reflected automatically;
280
+ * capturing afterwards would work too, but capturing first is what keeps the
281
+ * CST and the comments describing the same state.
282
+ */
283
+ extract(document: LangiumDocument): CommentTrivia {
284
+ // The capture reads the CST, which a residency policy may have shed.
285
+ // Restored rather than skipped: skipping would lose the whole file's
286
+ // comments for a document that had gone idle, silently and only under a
287
+ // shedding strategy.
288
+ this.services.shared.workspace.CstResidencyService.rehydrate(document);
289
+ const root = document.parseResult.value.$cstNode;
290
+ const commentTokens = this.getCommentTokenNames();
291
+ if (!root || commentTokens.length === 0) {
292
+ return { comments: [], sourceRoot: document.parseResult.value };
293
+ }
294
+ const source = document.textDocument.getText();
295
+ const comments: DocumentComment[] = [];
296
+ this.captureFrom(root, source, commentTokens, comments);
297
+ return { comments, sourceRoot: document.parseResult.value };
298
+ }
299
+
300
+ /** Recursive half of {@link extract}: one composite node's content, then its children. */
301
+ protected captureFrom(node: CstNode, source: string, commentTokens: readonly string[], into: DocumentComment[]): void {
302
+ if (!isComposite(node)) {
303
+ return;
304
+ }
305
+ const content = node.content;
306
+ content.forEach((child, index) => {
307
+ const tokenName = tokenNameOf(child);
308
+ if (child.hidden && tokenName !== undefined && commentTokens.includes(tokenName)) {
309
+ into.push(this.classify(child, index, node, source));
310
+ }
311
+ this.captureFrom(child, source, commentTokens, into);
312
+ });
313
+ }
314
+
315
+ /**
316
+ * Decide which node a comment belongs to, and how it sat against it.
317
+ *
318
+ * A comment on the same line the previous node ENDED on is that node's
319
+ * trailing comment — checked first, because by source order it also precedes
320
+ * whatever comes next, and reading it as the NEXT node's leading comment is
321
+ * what relocates an end-of-line note onto the following declaration.
322
+ */
323
+ protected classify(comment: CstNode, index: number, container: CompositeCstNode, source: string): DocumentComment {
324
+ const content = container.content;
325
+ const ownsOwnNode = (candidate: CstNode | undefined): boolean =>
326
+ candidate?.astNode !== undefined && candidate.astNode !== container.astNode;
327
+ const realSibling = (from: number, step: number): CstNode | undefined => {
328
+ for (let scan = from; scan >= 0 && scan < content.length; scan += step) {
329
+ if (!content[scan].hidden) {
330
+ return content[scan];
331
+ }
332
+ }
333
+ return undefined;
334
+ };
335
+
336
+ const previous = realSibling(index - 1, -1);
337
+ const next = realSibling(index + 1, 1);
338
+
339
+ // Measured to whatever comes NEXT IN SOURCE, comment or not — never to the
340
+ // next real node, which would skip the rest of a comment block and make
341
+ // every line of it re-emit the whole gap that follows the block.
342
+ const following = content[index + 1];
343
+ const blankLinesAfter = following === undefined ? 0 : this.countBlankLines(source.slice(comment.end, following.offset));
344
+ const preceding = index > 0 ? content[index - 1] : undefined;
345
+ const blankLinesBefore = preceding === undefined ? 0 : this.countBlankLines(source.slice(preceding.end, comment.offset));
346
+
347
+ // Only a comment that OWNS its line can be reindented — one sharing a line
348
+ // with code keeps whatever spacing that line gives it.
349
+ const lineStart = source.lastIndexOf('\n', comment.offset - 1) + 1;
350
+ const beforeOnLine = source.slice(lineStart, comment.offset);
351
+ const sourceIndent = beforeOnLine.trim().length === 0 ? beforeOnLine : undefined;
352
+
353
+ const text = this.normalizeLineEndings(comment.text);
354
+ const sharesLineWithPrevious = previous !== undefined && !source.slice(previous.end, comment.offset).includes('\n');
355
+
356
+ if (sharesLineWithPrevious) {
357
+ // On the same line as whatever precedes it, so it annotates that line.
358
+ // When the preceding sibling is the CONTAINER's own syntax — an opening
359
+ // brace, a name token, a keyword — the line is the container's header
360
+ // and the comment belongs to the container, NOT to the first member
361
+ // that happens to follow. Reading it as that member's leading comment
362
+ // moves a note about the declaration onto its first child.
363
+ return ownsOwnNode(previous)
364
+ ? { text, anchor: previous.astNode!, placement: 'trailing', blankLinesAfter: 0, blankLinesBefore: 0 }
365
+ : { text, anchor: container.astNode!, placement: 'trailingOnContainer', blankLinesAfter: 0, blankLinesBefore: 0 };
366
+ }
367
+ if (ownsOwnNode(next)) {
368
+ return { text, anchor: next!.astNode!, placement: 'leading', blankLinesAfter, blankLinesBefore: 0, sourceIndent };
369
+ }
370
+ if (ownsOwnNode(previous)) {
371
+ return { text, anchor: previous!.astNode!, placement: 'afterNode', blankLinesAfter: 0, blankLinesBefore, sourceIndent };
372
+ }
373
+ if (previous === undefined) {
374
+ return { text, anchor: container.astNode!, placement: 'atContainerStart', blankLinesAfter, blankLinesBefore: 0, sourceIndent };
375
+ }
376
+ // Surrounded by the container's own syntax on both sides. A further
377
+ // sibling after it means the comment sits INSIDE the construct — between
378
+ // the braces of an empty body, or partway through a header — so it stays
379
+ // with the container rather than being pushed past its closing syntax,
380
+ // which would move it out to the enclosing scope. Only a comment with
381
+ // nothing after it at all actually trails the container.
382
+ const placement: CommentPlacement = next === undefined ? 'atContainerEnd' : 'trailingOnContainer';
383
+ return { text, anchor: container.astNode!, placement, blankLinesAfter, blankLinesBefore, sourceIndent };
384
+ }
385
+
386
+ /**
387
+ * Comment text with CRLF reduced to LF.
388
+ *
389
+ * Serializers emit LF, so splicing a comment captured from a CRLF document
390
+ * verbatim puts lone CR bytes in the middle of LF-terminated lines.
391
+ */
392
+ protected normalizeLineEndings(text: string): string {
393
+ return text.includes('\r') ? text.replace(/\r\n/g, '\n') : text;
394
+ }
395
+
396
+ /** Blank lines in a run of whitespace — one fewer than its newlines. */
397
+ protected countBlankLines(gap: string): number {
398
+ return Math.max(0, (gap.match(/\n/g)?.length ?? 0) - 1);
399
+ }
400
+
401
+ /**
402
+ * Splice the captured comments back into `serialized`.
403
+ *
404
+ * A comment whose anchor the write deleted, or whose anchor has no stable
405
+ * key, is dropped — counted at `debug`, because a silent drop is the failure
406
+ * this preserver exists to remove and an unexplained one just moves it.
407
+ */
408
+ apply(serialized: string, trivia: CommentTrivia, uri: URI): string {
409
+ if (trivia.comments.length === 0) {
410
+ return serialized;
411
+ }
412
+ // Both memos are scoped to this call and not to the instance, because an
413
+ // in-place write changes what a node's key IS: a repair that renames a
414
+ // node between two writes would otherwise be answered from the first.
415
+ this.keyMemo = new Map();
416
+ this.listVerdicts = new Map();
417
+ try {
418
+ return this.applyComments(serialized, trivia, uri);
419
+ } finally {
420
+ this.keyMemo = undefined;
421
+ this.listVerdicts = undefined;
422
+ }
423
+ }
424
+
425
+ /** {@link apply}'s body, inside the per-write memos it sets up. */
426
+ protected applyComments(serialized: string, trivia: CommentTrivia, uri: URI): string {
427
+ const spliced = this.spliceComments(serialized, trivia, uri);
428
+ // **Never hand back text the grammar cannot read.** A splice edits syntax
429
+ // it did not produce, and a line comment in particular ends whatever
430
+ // shares its line — so a placement that is merely wrong becomes a file
431
+ // that no longer parses, written to disk by an integrity repair with no
432
+ // user gesture. Dropping the comments is recoverable; corrupting the
433
+ // document is not.
434
+ if (spliced !== serialized && this.parse(spliced, uri) === undefined) {
435
+ this.tracer.withUri(uri.toString()).warn('Reattaching comments produced text that does not parse; writing without them');
436
+ return serialized;
437
+ }
438
+ return spliced;
439
+ }
440
+
441
+ /**
442
+ * {@link anchorKey}, answered once per node per write.
443
+ *
444
+ * The key walks `$container` to the root building a segment each step, and
445
+ * every stage of a write asks about the same nodes — locating spans, matching
446
+ * a rename, judging a key the output kept. Recomputing makes each of those
447
+ * stages cost nodes × depth, and the stages that scan a sibling list do it
448
+ * once per comment.
449
+ */
450
+ protected cachedAnchorKey(node: AstNode): string | undefined {
451
+ const memo = this.keyMemo;
452
+ if (memo === undefined) {
453
+ return this.anchorKey(node);
454
+ }
455
+ if (!memo.has(node)) {
456
+ memo.set(node, this.anchorKey(node));
457
+ }
458
+ return memo.get(node);
459
+ }
460
+
461
+ /**
462
+ * Parse `text` as a throwaway document, or `undefined` when it is not
463
+ * readable — by reported error, or by a throw from a URI that routes to no
464
+ * services. Does NOT register the result, so `LangiumDocuments` is untouched.
465
+ */
466
+ protected parse(text: string, uri: URI): LangiumDocument | undefined {
467
+ let document: LangiumDocument;
468
+ try {
469
+ document = this.services.shared.workspace.LangiumDocumentFactory.fromString(text, uri);
470
+ } catch {
471
+ return undefined;
472
+ }
473
+ return document.parseResult.parserErrors.length > 0 || document.parseResult.lexerErrors.length > 0 ? undefined : document;
474
+ }
475
+
476
+ /** The splice itself, run before {@link apply}'s parse check. */
477
+ protected spliceComments(serialized: string, trivia: CommentTrivia, uri: URI): string {
478
+ const located = this.locate(serialized, uri);
479
+ if (located === undefined) {
480
+ return serialized;
481
+ }
482
+
483
+ const sourceOwners = new Map<string, AstNode>();
484
+ const sourceCollisions = new Set<string>();
485
+ const liveAnchors = new Set<AstNode>();
486
+ for (const node of [trivia.sourceRoot, ...AstUtils.streamAllContents(trivia.sourceRoot)]) {
487
+ liveAnchors.add(node);
488
+ const key = this.cachedAnchorKey(node);
489
+ if (key === undefined) {
490
+ continue;
491
+ }
492
+ const owner = sourceOwners.get(key);
493
+ if (owner !== undefined && owner !== node) {
494
+ sourceCollisions.add(key);
495
+ } else {
496
+ sourceOwners.set(key, node);
497
+ }
498
+ }
499
+ const needsRename = trivia.comments.some(comment => {
500
+ const key = this.cachedAnchorKey(comment.anchor);
501
+ return key !== undefined && !located.has(key);
502
+ });
503
+ const renamed = needsRename
504
+ ? this.matchRenamedAnchors(trivia.sourceRoot, serialized, uri, located, sourceOwners, sourceCollisions)
505
+ : new Map<AstNode, string>();
506
+
507
+ const edits: InlineAwareEdit[] = [];
508
+ let dropped = 0;
509
+ trivia.comments.forEach((comment, order) => {
510
+ const key = this.cachedAnchorKey(comment.anchor);
511
+ const destination = key === undefined ? undefined : located.has(key) ? key : this.renamedKey(comment.anchor, key, renamed);
512
+ const retained = destination === key ? located.get(key!) : undefined;
513
+ const span =
514
+ key === undefined ||
515
+ !liveAnchors.has(comment.anchor) ||
516
+ sourceCollisions.has(key) ||
517
+ destination === undefined ||
518
+ (retained !== undefined && this.ambiguousRetainedKey(comment.anchor, retained.owner))
519
+ ? undefined
520
+ : located.get(destination);
521
+ if (span === undefined) {
522
+ dropped++;
523
+ return;
524
+ }
525
+ const edit = this.editFor(comment, span, serialized);
526
+ edits.push({
527
+ ...edit,
528
+ order,
529
+ inline: this.needsReadBack(comment, span, serialized) ? { text: comment.text, key: destination! } : undefined
530
+ });
531
+ });
532
+
533
+ if (dropped > 0) {
534
+ this.tracer.withUri(uri.toString()).debug(`Dropped ${dropped} comment(s): anchor deleted by the write, or carrying no stable key`);
535
+ }
536
+
537
+ // Stable by offset, then by capture order, so several comments landing on
538
+ // one anchor keep the order the author wrote them in.
539
+ edits.sort((left, right) => left.at - right.at || left.order - right.order);
540
+ // **This is what licenses the mid-line split** `editFor` makes for a
541
+ // `leading` comment whose anchor does not start its line. Parsing alone
542
+ // cannot license it: text can parse perfectly well having handed the
543
+ // comment to the following declaration instead. Re-extracting and
544
+ // requiring the same anchor back is the only evidence the comment still
545
+ // belongs where its author put it.
546
+ //
547
+ // A failed edit is dropped and the rest reassembled, since removing one
548
+ // changes what the others land in; two rounds, then the inline edits go
549
+ // as a set rather than iterating towards a text nothing has verified.
550
+ let remaining: readonly InlineAwareEdit[] = edits;
551
+ let result = this.assembleComments(serialized, remaining);
552
+ for (let attempt = 0; attempt < 2; attempt++) {
553
+ const failed = this.unverifiedInlineEdits(result, remaining, uri, serialized);
554
+ if (failed.size === 0) {
555
+ return result;
556
+ }
557
+ remaining = remaining.filter(edit => !failed.has(edit));
558
+ result = this.assembleComments(serialized, remaining);
559
+ }
560
+ return this.assembleComments(
561
+ serialized,
562
+ remaining.filter(edit => edit.inline === undefined)
563
+ );
564
+ }
565
+
566
+ /**
567
+ * Whether this comment's edit has to be read back before it can be trusted.
568
+ *
569
+ * Both cases are the serializer having put the anchor on a line it shares.
570
+ * A comment that wants the line above has to open one mid-construct; a
571
+ * comment that wants the end of the anchor's line gets the end of a line that
572
+ * may belong to a later sibling, and a second such comment lands inside the
573
+ * first one's text and stops being a comment at all. Neither can be judged
574
+ * from the offsets alone — only from what the text says once re-read.
575
+ */
576
+ protected needsReadBack(comment: DocumentComment, span: AnchorSpan, serialized: string): boolean {
577
+ if (comment.placement === 'leading' || comment.placement === 'atContainerStart') {
578
+ return !this.startOfLineIsBlank(serialized, span.offset);
579
+ }
580
+ return comment.placement === 'trailing' && this.endOfLineAt(serialized, span.end) !== span.end;
581
+ }
582
+
583
+ /**
584
+ * Splice `edits` into `serialized`, in the order given.
585
+ *
586
+ * Two inline edits landing on one offset collapse into a single opened line,
587
+ * so a stack of comments above one inline member comes back as a stack rather
588
+ * than with a blank line between each pair.
589
+ */
590
+ protected assembleComments(serialized: string, edits: readonly InlineAwareEdit[]): string {
591
+ let result = '';
592
+ let cursor = 0;
593
+ let previousInlineAt: number | undefined;
594
+ for (const edit of edits) {
595
+ const text = edit.inline !== undefined && edit.at === previousInlineAt ? edit.text.replace(/^\n[ \t]*/, '') : edit.text;
596
+ result += serialized.slice(cursor, edit.at) + text;
597
+ cursor = edit.at;
598
+ previousInlineAt = edit.inline !== undefined ? edit.at : undefined;
599
+ }
600
+ return result + serialized.slice(cursor);
601
+ }
602
+
603
+ /**
604
+ * Whether the output node now answering to `source`'s key is too likely to be
605
+ * a DIFFERENT node for the comment to follow it.
606
+ *
607
+ * A key surviving the write normally means the node did. It can also mean a
608
+ * sibling took the name over, which reads as the author having written the
609
+ * comment about a declaration they never saw.
610
+ *
611
+ * **What is detectable is a list whose members changed places; what is not is
612
+ * a payload that renames a node and gives its old name to another.** Those
613
+ * two writes produce the same text and the same keys, and the node carrying
614
+ * the old name afterwards is byte-identical under both readings, so no rule
615
+ * over this evidence separates them. A caller that knows which node it
616
+ * renamed is the only thing that could, and the transfer model carries no
617
+ * such record.
618
+ *
619
+ * **The evidence is the ORDER of the keys the two lists share.** Adding or
620
+ * removing siblings shifts the rest but leaves them in the same relative
621
+ * order; only names changing places can put two shared keys out of order. A
622
+ * list whose shared keys invert is therefore read as exchanged identities and
623
+ * those siblings lose their comments — which costs a deliberate reorder its
624
+ * comments, the conservative half of a trade whose other half is that a bare
625
+ * swap cannot move one.
626
+ *
627
+ * **Comparing counts or key SETS instead misses cases each way.** A swap
628
+ * alongside an insertion leaves the counts differing and the key set whole,
629
+ * so neither test sees it; a deletion alongside an insertion leaves the counts
630
+ * equal while every surviving sibling is still itself, so a count test drops
631
+ * comments that were never in doubt.
632
+ */
633
+ protected ambiguousRetainedKey(source: AstNode, output: AstNode): boolean {
634
+ const sourceParent = source.$container;
635
+ const outputParent = output.$container;
636
+ if (!sourceParent || !outputParent) {
637
+ return false;
638
+ }
639
+ const parentKey = this.cachedAnchorKey(sourceParent);
640
+ if (parentKey !== this.cachedAnchorKey(outputParent) || source.$containerProperty !== output.$containerProperty) {
641
+ return true;
642
+ }
643
+ if (source.$containerIndex === output.$containerIndex) {
644
+ return false;
645
+ }
646
+ const property = source.$containerProperty;
647
+ if (property === undefined) {
648
+ return true;
649
+ }
650
+ // Answered once per list rather than once per comment. One insertion moves
651
+ // every later sibling, so each of their comments asks the same question of
652
+ // the same two lists — and scanning both per comment costs the list's
653
+ // length squared.
654
+ //
655
+ // Memoised against the parent NODE rather than its key, because two
656
+ // same-named parents answer to one key and would otherwise share a verdict
657
+ // about lists that have nothing to do with each other.
658
+ const cached = this.listVerdicts?.get(sourceParent)?.get(property);
659
+ if (cached !== undefined) {
660
+ return cached;
661
+ }
662
+ const verdict = this.reidentifiedList(sourceParent, outputParent, property);
663
+ if (this.listVerdicts !== undefined) {
664
+ const byProperty = this.listVerdicts.get(sourceParent) ?? new Map<string, boolean>();
665
+ byProperty.set(property, verdict);
666
+ this.listVerdicts.set(sourceParent, byProperty);
667
+ }
668
+ return verdict;
669
+ }
670
+
671
+ /**
672
+ * Whether the two lists differ in a way a plain insertion or deletion cannot
673
+ * explain — the expensive half of {@link ambiguousRetainedKey}, split out so
674
+ * it can be answered once per list.
675
+ *
676
+ * What says the members may have swapped names rather than moved:
677
+ *
678
+ * - **The lists are the same length.** Every rename chain over a fixed set of
679
+ * slots looks exactly like the shift a deletion plus an insertion produces,
680
+ * and for members carrying nothing but a name the properties match under
681
+ * both readings. Neither this nor anything downstream can separate them.
682
+ * - **Keys present on both sides have changed places.** Adding or removing
683
+ * siblings shifts the rest but never reorders them, so an inversion is
684
+ * evidence no insertion or deletion can account for — and it is evidence
685
+ * the lengths alone miss, since a swap alongside an insertion leaves the
686
+ * counts differing and the key set whole.
687
+ */
688
+ protected reidentifiedList(sourceParent: AstNode, outputParent: AstNode, property: string): boolean {
689
+ const before = (sourceParent as unknown as Record<string, unknown>)[property];
690
+ const after = (outputParent as unknown as Record<string, unknown>)[property];
691
+ if (!Array.isArray(before) || !Array.isArray(after)) {
692
+ return true;
693
+ }
694
+ if (before.length === after.length) {
695
+ return true;
696
+ }
697
+ const positions = new Map<string, number>();
698
+ after.filter(isAstNode).forEach((node: AstNode, index: number) => {
699
+ const key = this.cachedAnchorKey(node);
700
+ if (key !== undefined && !positions.has(key)) {
701
+ positions.set(key, index);
702
+ }
703
+ });
704
+ let furthest = -1;
705
+ for (const node of before.filter(isAstNode) as AstNode[]) {
706
+ const key = this.cachedAnchorKey(node);
707
+ const position = key === undefined ? undefined : positions.get(key);
708
+ if (position === undefined) {
709
+ continue;
710
+ }
711
+ if (position < furthest) {
712
+ return true;
713
+ }
714
+ furthest = position;
715
+ }
716
+ return false;
717
+ }
718
+
719
+ /**
720
+ * The inline edits `candidate` did not round-trip: each one whose comment came
721
+ * back on a different anchor, and — when `candidate` does not parse at all —
722
+ * each one that cannot be put back beside the others without breaking it
723
+ * again. `serialized` is the unspliced text those trials are rebuilt from,
724
+ * since finding the single edit at fault means assembling the rest without it.
725
+ *
726
+ * Costs a parse and an extract, so it answers empty without either when
727
+ * nothing was spliced inline — which is every write into a document the
728
+ * serializer lays out one declaration per line.
729
+ */
730
+ protected unverifiedInlineEdits(
731
+ candidate: string,
732
+ edits: readonly InlineAwareEdit[],
733
+ uri: URI,
734
+ serialized: string
735
+ ): Set<InlineAwareEdit> {
736
+ const inlineEdits = edits.filter(edit => edit.inline !== undefined);
737
+ if (inlineEdits.length === 0) {
738
+ return new Set();
739
+ }
740
+ const reparsed = this.parse(candidate, uri);
741
+ if (reparsed === undefined) {
742
+ if (inlineEdits.length > this.maxIsolatedEdits) {
743
+ this.tracer
744
+ .withUri(uri.toString())
745
+ .debug(`Spliced text did not parse; dropping ${inlineEdits.length} shared-line comments without isolating`);
746
+ return new Set(inlineEdits);
747
+ }
748
+ const retained = edits.filter(edit => edit.inline === undefined);
749
+ const failed = new Set<InlineAwareEdit>();
750
+ for (const edit of inlineEdits) {
751
+ const trial = [...retained, edit].sort((left, right) => left.at - right.at || left.order - right.order);
752
+ const text = this.assembleComments(serialized, trial);
753
+ if (this.parse(text, uri) !== undefined && this.unverifiedInlineEdits(text, trial, uri, serialized).size === 0) {
754
+ retained.push(edit);
755
+ } else {
756
+ failed.add(edit);
757
+ }
758
+ }
759
+ return failed;
760
+ }
761
+ // Counted rather than scanned per edit: several comments can share an
762
+ // anchor and a text, and each edit must consume one of them.
763
+ const captured = new Map<string, number>();
764
+ for (const comment of this.extract(reparsed).comments) {
765
+ const seen = `${this.cachedAnchorKey(comment.anchor) ?? ''}\u0000${comment.text}`;
766
+ captured.set(seen, (captured.get(seen) ?? 0) + 1);
767
+ }
768
+ const failed = new Set<InlineAwareEdit>();
769
+ for (const edit of inlineEdits) {
770
+ const expected = edit.inline!;
771
+ const wanted = `${expected.key}\u0000${expected.text}`;
772
+ const remaining = captured.get(wanted) ?? 0;
773
+ if (remaining === 0) {
774
+ failed.add(edit);
775
+ } else {
776
+ captured.set(wanted, remaining - 1);
777
+ }
778
+ }
779
+ return failed;
780
+ }
781
+
782
+ protected renamedKey(anchor: AstNode, oldKey: string, renamed: Map<AstNode, string>): string | undefined {
783
+ let current: AstNode | undefined = anchor;
784
+ while (current !== undefined) {
785
+ const newPrefix = renamed.get(current);
786
+ const oldPrefix = this.cachedAnchorKey(current);
787
+ if (newPrefix !== undefined && oldPrefix !== undefined && (oldKey === oldPrefix || oldKey.startsWith(`${oldPrefix}/`))) {
788
+ return newPrefix + oldKey.slice(oldPrefix.length);
789
+ }
790
+ current = current.$container;
791
+ }
792
+ return undefined;
793
+ }
794
+
795
+ protected matchRenamedAnchors(
796
+ sourceRoot: AstNode,
797
+ serialized: string,
798
+ uri: URI,
799
+ located: Map<string, AnchorSpan>,
800
+ sourceOwners: Map<string, AstNode>,
801
+ sourceCollisions: Set<string>
802
+ ): Map<AstNode, string> {
803
+ const matches = new Map<AstNode, string>();
804
+ const output = this.parse(serialized, uri);
805
+ if (output === undefined) {
806
+ return matches;
807
+ }
808
+ const outputNodes = [output.parseResult.value, ...AstUtils.streamAllContents(output.parseResult.value)];
809
+ const unmatchedBySlot = new Map<string, AstNode[]>();
810
+ for (const node of outputNodes) {
811
+ const key = this.cachedAnchorKey(node);
812
+ const parent = node.$container && this.cachedAnchorKey(node.$container);
813
+ if (key === undefined || !located.has(key) || sourceOwners.has(key) || parent === undefined) {
814
+ continue;
815
+ }
816
+ const slot = JSON.stringify([parent, node.$containerProperty, node.$containerIndex, node.$type]);
817
+ const candidates = unmatchedBySlot.get(slot) ?? [];
818
+ candidates.push(node);
819
+ unmatchedBySlot.set(slot, candidates);
820
+ }
821
+ const claimed = new Map<AstNode, AstNode>();
822
+ const ambiguous = new Set<AstNode>();
823
+ for (const source of [sourceRoot, ...AstUtils.streamAllContents(sourceRoot)]) {
824
+ const key = this.cachedAnchorKey(source);
825
+ if (key === undefined || sourceCollisions.has(key) || located.has(key) || !source.$container) {
826
+ continue;
827
+ }
828
+ const parentKey = this.cachedAnchorKey(source.$container);
829
+ if (parentKey === undefined) {
830
+ continue;
831
+ }
832
+ const slot = JSON.stringify([parentKey, source.$containerProperty, source.$containerIndex, source.$type]);
833
+ const candidates = (unmatchedBySlot.get(slot) ?? []).filter(candidate => this.sameAsideFromName(source, candidate));
834
+ if (candidates.length === 1) {
835
+ const candidate = candidates[0];
836
+ if (ambiguous.has(candidate)) {
837
+ continue;
838
+ }
839
+ if (claimed.has(candidate)) {
840
+ matches.delete(claimed.get(candidate)!);
841
+ ambiguous.add(candidate);
842
+ } else {
843
+ matches.set(source, this.cachedAnchorKey(candidate)!);
844
+ }
845
+ claimed.set(candidate, source);
846
+ }
847
+ }
848
+ return matches;
849
+ }
850
+
851
+ protected sameAsideFromName(source: AstNode, target: AstNode): boolean {
852
+ const nameProvider = this.services.references.NameProvider;
853
+ const before = nameProvider.getOwnName(source);
854
+ const after = nameProvider.getOwnName(target);
855
+ if (before === undefined || after === undefined || before === after) {
856
+ return false;
857
+ }
858
+ let renamed = false;
859
+ for (const key of this.comparableProperties(source.$type)) {
860
+ const oldValue = (source as unknown as Record<string, unknown>)[key];
861
+ const newValue = (target as unknown as Record<string, unknown>)[key];
862
+ if (oldValue === before && newValue === after) {
863
+ renamed = true;
864
+ } else if (!this.sameValue(oldValue, newValue)) {
865
+ return false;
866
+ }
867
+ }
868
+ return renamed;
869
+ }
870
+
871
+ /**
872
+ * The properties `type` declares in the grammar — everything a rename match
873
+ * may compare, and nothing else.
874
+ *
875
+ * **Comparing own keys instead compares derived state, and matches nothing.**
876
+ * A built document carries whatever `ast.extensions` computed onto its nodes;
877
+ * the re-parsed serializer output is never built and carries none of it. Every
878
+ * candidate then differs on a property the grammar never mentioned, so no
879
+ * rename is ever matched — on the real write path only, because a document a
880
+ * test parses from a string has no computed state to disagree about.
881
+ */
882
+ protected comparableProperties(type: string): readonly string[] {
883
+ return Object.keys(this.services.shared.AstReflection.getTypeMetaData(type).properties);
884
+ }
885
+
886
+ protected sameValue(left: unknown, right: unknown): boolean {
887
+ if (left === right) {
888
+ return true;
889
+ }
890
+ if (Array.isArray(left) && Array.isArray(right)) {
891
+ return left.length === right.length && left.every((value, index) => this.sameValue(value, right[index]));
892
+ }
893
+ if (isAstNode(left) && isAstNode(right)) {
894
+ if (left.$type !== right.$type) {
895
+ return false;
896
+ }
897
+ return this.comparableProperties(left.$type).every(key =>
898
+ this.sameValue((left as unknown as Record<string, unknown>)[key], (right as unknown as Record<string, unknown>)[key])
899
+ );
900
+ }
901
+ if (left !== null && right !== null && typeof left === 'object' && typeof right === 'object') {
902
+ if ('$refText' in left && '$refText' in right) {
903
+ return left.$refText === right.$refText;
904
+ }
905
+ }
906
+ return false;
907
+ }
908
+
909
+ /** The insertion this comment's placement implies, against its anchor's span. */
910
+ protected editFor(comment: DocumentComment, span: AnchorSpan, serialized: string): { at: number; text: string } {
911
+ const blanks = '\n'.repeat(comment.blankLinesAfter);
912
+ const blanksBefore = '\n'.repeat(comment.blankLinesBefore);
913
+ switch (comment.placement) {
914
+ case 'trailing':
915
+ return { at: this.endOfLineAt(serialized, span.end), text: ` ${comment.text}` };
916
+ // Appended to the container's FIRST line, which is its header. The
917
+ // comment may have sat further along that line in the source — mid
918
+ // header, or between braces the serializer has since collapsed to `{}`
919
+ // — and the exact column is not recoverable from a re-emitted
920
+ // document. The line is, and that is what keeps it on its own
921
+ // declaration instead of on a neighbour.
922
+ case 'trailingOnContainer':
923
+ return { at: this.endOfLineAt(serialized, span.offset), text: ` ${comment.text}` };
924
+ // Both of these open a NEW line after the anchor, which is only safe
925
+ // while nothing else shares the anchor's line. When the serializer put
926
+ // the anchor and its container's closing syntax together — an inline
927
+ // enum body, say — splitting there pushes that syntax onto the comment's
928
+ // line, and a line comment then ends the construct. Appending to the
929
+ // line instead keeps the comment on the same declaration and the
930
+ // document readable.
931
+ case 'afterNode': {
932
+ if (!this.restOfLineIsBlank(serialized, span.end)) {
933
+ return { at: this.endOfLineAt(serialized, span.end), text: ` ${comment.text}` };
934
+ }
935
+ const indent = this.indentAt(serialized, span.offset);
936
+ return { at: span.end, text: `\n${blanksBefore}${indent}${this.reindent(comment, indent)}` };
937
+ }
938
+ case 'atContainerEnd':
939
+ return this.restOfLineIsBlank(serialized, span.end)
940
+ ? { at: span.end, text: `\n${blanksBefore}${comment.text}` }
941
+ : { at: this.endOfLineAt(serialized, span.end), text: ` ${comment.text}` };
942
+ case 'atContainerStart':
943
+ case 'leading':
944
+ default: {
945
+ // `leading` means "on the line above", and the anchor may sit
946
+ // mid-line — a member of a body the serializer emitted inline.
947
+ // **Opening a line there is sound only because the caller reads the
948
+ // result back and requires this comment on this same anchor**,
949
+ // dropping the edit when it is not. Unguarded, the split strands the
950
+ // rest of the construct at column zero and lands somewhere different
951
+ // again on the next write.
952
+ if (!this.startOfLineIsBlank(serialized, span.offset)) {
953
+ const indent = comment.sourceIndent ?? this.indentAt(serialized, span.offset);
954
+ return { at: span.offset, text: `\n${indent}${this.reindent(comment, indent)}\n${blanks}${indent}` };
955
+ }
956
+ const indent = this.indentAt(serialized, span.offset);
957
+ return { at: span.offset, text: `${this.reindent(comment, indent)}\n${blanks}${indent}` };
958
+ }
959
+ }
960
+ }
961
+
962
+ /**
963
+ * A multi-line comment's continuation lines, shifted by the same delta its
964
+ * first line moves — so a block emitted at a new indentation stays square
965
+ * instead of trailing its original column.
966
+ *
967
+ * **Only the leading whitespace run is touched, and an outdent removes at
968
+ * most what is there** — shifting further would eat the comment's own text.
969
+ *
970
+ * Single-line comments and ones sharing a line with code are returned
971
+ * unchanged.
972
+ */
973
+ protected reindent(comment: DocumentComment, targetIndent: string): string {
974
+ if (comment.sourceIndent === undefined || !comment.text.includes('\n')) {
975
+ return comment.text;
976
+ }
977
+ // Indentation is compared as characters, which only means anything while
978
+ // both sides use the SAME whitespace character. A tab-indented source
979
+ // re-emitted with spaces has no meaningful delta, and shifting by one
980
+ // anyway prepends spaces in front of tabs.
981
+ const sourceUnit = /^\t*$/.test(comment.sourceIndent) ? '\t' : ' ';
982
+ const targetUnit = /^\t*$/.test(targetIndent) ? '\t' : ' ';
983
+ const delta = targetIndent.length - comment.sourceIndent.length;
984
+ if (delta === 0 || sourceUnit !== targetUnit) {
985
+ return comment.text;
986
+ }
987
+ const [first, ...rest] = comment.text.split('\n');
988
+ const shifted = rest.map(line => {
989
+ if (delta > 0) {
990
+ return targetUnit.repeat(delta) + line;
991
+ }
992
+ const removable = /^[ \t]*/.exec(line)?.[0].length ?? 0;
993
+ return line.slice(Math.min(-delta, removable));
994
+ });
995
+ return [first, ...shifted].join('\n');
996
+ }
997
+
998
+ /** Whether everything from `offset` to the end of its line is whitespace. */
999
+ protected restOfLineIsBlank(text: string, offset: number): boolean {
1000
+ return text.slice(offset, this.endOfLineAt(text, offset)).trim().length === 0;
1001
+ }
1002
+
1003
+ /** Whether `offset` is preceded on its own line by whitespace alone. */
1004
+ protected startOfLineIsBlank(text: string, offset: number): boolean {
1005
+ return text.slice(text.lastIndexOf('\n', offset - 1) + 1, offset).trim().length === 0;
1006
+ }
1007
+
1008
+ /**
1009
+ * End of the line `offset` sits on, as an insertion point for a trailing
1010
+ * comment. Stops before a CR so an insertion into CRLF text lands inside the
1011
+ * line rather than between its two terminator bytes.
1012
+ */
1013
+ protected endOfLineAt(text: string, offset: number): number {
1014
+ const newline = text.indexOf('\n', offset);
1015
+ const lineEnd = newline < 0 ? text.length : newline;
1016
+ return lineEnd > 0 && text[lineEnd - 1] === '\r' ? lineEnd - 1 : lineEnd;
1017
+ }
1018
+
1019
+ /** Leading whitespace of the line `offset` sits on, so an insertion lines up with it. */
1020
+ protected indentAt(text: string, offset: number): string {
1021
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
1022
+ return /^\s*/.exec(text.slice(lineStart, offset))?.[0] ?? '';
1023
+ }
1024
+
1025
+ /**
1026
+ * Where every node landed in the serializer's output, keyed by
1027
+ * {@link anchorKey}.
1028
+ *
1029
+ * Re-parses `serialized` through the document factory, which does NOT
1030
+ * register the result, so this leaves `LangiumDocuments` alone. Output the
1031
+ * grammar cannot read, by reported error or by a throw from a URI that
1032
+ * routes to no services, yields `undefined`: the caller then writes the
1033
+ * serializer's text unchanged rather than splicing into text already wrong.
1034
+ *
1035
+ * **A key claimed by two DIFFERENT nodes is removed, not merged.** A repeated
1036
+ * identity is exactly what the integrity tier exists to repair, so collisions
1037
+ * reach this method routinely; merging their spans puts every one of their
1038
+ * comments on whichever came first. Removing the key drops those instead,
1039
+ * which is the only outcome here that keeps a comment off a declaration its
1040
+ * author did not write it on.
1041
+ */
1042
+ protected locate(serialized: string, uri: URI): Map<string, AnchorSpan> | undefined {
1043
+ const document = this.parse(serialized, uri);
1044
+ if (document === undefined) {
1045
+ this.tracer.withUri(uri.toString()).warn('Serialized output did not re-parse; writing it without comments');
1046
+ return undefined;
1047
+ }
1048
+ const root = document.parseResult.value.$cstNode;
1049
+ if (!root) {
1050
+ return undefined;
1051
+ }
1052
+ const found = new Map<string, AnchorSpan>();
1053
+ // One AST node owns many CST nodes — its composite plus every token under
1054
+ // it — so a repeated key is only a collision when a DIFFERENT node claims
1055
+ // it. Tracking the owner is what separates the two.
1056
+ const owners = new Map<string, AstNode>();
1057
+ const collided = new Set<string>();
1058
+ // One AST node owns every CST node beneath it, so this asks for the same
1059
+ // key once per token; `cachedAnchorKey` is what keeps the pass from
1060
+ // costing nodes x depth.
1061
+ const visit = (node: CstNode): void => {
1062
+ const key = node.astNode !== undefined && !node.hidden ? this.cachedAnchorKey(node.astNode) : undefined;
1063
+ if (key !== undefined) {
1064
+ const owner = owners.get(key);
1065
+ if (owner === undefined) {
1066
+ owners.set(key, node.astNode!);
1067
+ found.set(key, { offset: node.offset, end: node.end, owner: node.astNode! });
1068
+ } else if (owner === node.astNode) {
1069
+ // Widest span per node: its first CST node gives the start, a
1070
+ // later token contributing to the same node extends the end.
1071
+ const existing = found.get(key)!;
1072
+ found.set(key, {
1073
+ offset: Math.min(existing.offset, node.offset),
1074
+ end: Math.max(existing.end, node.end),
1075
+ owner: existing.owner
1076
+ });
1077
+ } else {
1078
+ collided.add(key);
1079
+ }
1080
+ }
1081
+ if (isComposite(node)) {
1082
+ node.content.forEach(visit);
1083
+ }
1084
+ };
1085
+ visit(root);
1086
+ for (const key of collided) {
1087
+ found.delete(key);
1088
+ }
1089
+ return found;
1090
+ }
1091
+ }
1092
+
1093
+ /** Registers {@link CommentPreserver}; bound at `trivia.preservers.comments`. */
1094
+ export class CommentPreserverContribution implements TriviaContribution {
1095
+ constructor(protected readonly services: HydraniumLanguageServices) {}
1096
+
1097
+ registerTriviaPreservers(registry: TriviaRegistry): void {
1098
+ registry.register(new CommentPreserver(this.services));
1099
+ }
1100
+ }