@hydranium/core 1.0.0-next.384 → 1.0.0-next.386

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 (31) hide show
  1. package/lib/documents/model-ledger.d.ts +1 -1
  2. package/lib/documents/model-ledger.d.ts.map +1 -1
  3. package/lib/langium/document-builder/document-builder.d.ts +22 -1
  4. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  5. package/lib/langium/document-builder/document-builder.js +45 -3
  6. package/lib/langium/document-builder/document-builder.js.map +1 -1
  7. package/lib/langium/module.js +3 -3
  8. package/lib/langium/module.js.map +1 -1
  9. package/lib/langium/residency/cst-residency-service.d.ts +13 -10
  10. package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
  11. package/lib/langium/residency/cst-residency-service.js +19 -19
  12. package/lib/langium/residency/cst-residency-service.js.map +1 -1
  13. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +44 -4
  14. package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
  15. package/lib/langium/workspace/hydranium-langium-document-factory.js +115 -9
  16. package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
  17. package/lib/node/event-loop-monitor.d.ts.map +1 -1
  18. package/lib/node/event-loop-monitor.js +1 -1
  19. package/lib/node/event-loop-monitor.js.map +1 -1
  20. package/lib/node/server-state-snapshot.d.ts +5 -3
  21. package/lib/node/server-state-snapshot.d.ts.map +1 -1
  22. package/lib/node/server-state-snapshot.js +16 -13
  23. package/lib/node/server-state-snapshot.js.map +1 -1
  24. package/package.json +5 -5
  25. package/src/documents/model-ledger.ts +1 -1
  26. package/src/langium/document-builder/document-builder.ts +50 -3
  27. package/src/langium/module.ts +3 -3
  28. package/src/langium/residency/cst-residency-service.ts +20 -21
  29. package/src/langium/workspace/hydranium-langium-document-factory.ts +136 -8
  30. package/src/node/event-loop-monitor.ts +1 -3
  31. package/src/node/server-state-snapshot.ts +22 -13
@@ -23,6 +23,7 @@ import {
23
23
  URI
24
24
  } from '@hydranium/langium';
25
25
  import { type HydraniumTextDocuments } from '../../documents/hydranium-text-documents.js';
26
+ import { type ModelLedger } from '../../documents/model-ledger.js';
26
27
  import { type LogNameOptions } from '../diagnostics/logger.js';
27
28
  import { type ServerSharedServicesMinimal } from '../shared-services.js';
28
29
 
@@ -90,19 +91,17 @@ export interface CstResidencyOptions extends LogNameOptions {
90
91
  export const CST_REHYDRATION_RESET_STATE: DocumentState = DocumentState.Changed;
91
92
 
92
93
  /**
93
- * Whether `document`'s CST has been shed by a residency policy: the AST root is
94
- * resident but its `$cstNode` was nulled. The built-state and present-root guards
95
- * exclude the `fromModel` INVALID placeholder and not-yet-parsed documents, which
96
- * legitimately have no CST. The single definition of "shed", shared by
97
- * {@link CstResidencyService} and the document builder's rehydrate-on-re-entry
98
- * seam so the two cannot drift.
94
+ * Whether `document` has a resident AST without a CST, and `ledger` holds the
95
+ * text to graft one from. That text is the one a residency policy recorded when
96
+ * it shed the CST, or the serialized text of a `fromModel` document. A
97
+ * not-yet-parsed document has no CST to graft, and nor has a `fromModel`
98
+ * document without a serializer. The single definition of "shed", shared by the
99
+ * document builder's rehydrate-on-re-entry seam and the server-state snapshot
100
+ * so the two cannot drift.
99
101
  */
100
- export function isCstShed(document: LangiumDocument): boolean {
101
- return (
102
- document.state >= DocumentState.Parsed &&
103
- document.parseResult?.value !== undefined &&
104
- document.parseResult.value.$cstNode === undefined
105
- );
102
+ export function isCstShed(document: LangiumDocument, ledger: ModelLedger): boolean {
103
+ const root = document.parseResult?.value;
104
+ return document.state >= DocumentState.Parsed && root !== undefined && root.$cstNode === undefined && ledger.textOf(root) !== undefined;
106
105
  }
107
106
 
108
107
  /**
@@ -485,8 +484,9 @@ export class DefaultCstResidencyService implements CstResidencyService {
485
484
  * references. Only a `Validated` document qualifies: the idle timer can fire
486
485
  * while a build waits between documents, and a build that indexes a
487
486
  * document's references without their CST drops them from the reference
488
- * index. Its root needs a recorded version, because `shed` then records no
489
- * text and the graft refuses it. A document whose graft diverged on its
487
+ * index. Its root needs a recorded version, since without one the ledger
488
+ * keeps no text to graft the CST back from, and `shed` refuses it for a
489
+ * caller that skips this check. A document whose graft diverged on its
490
490
  * current text would cost a parse on every re-entry. Checked before
491
491
  * {@link isSheddable}, so an override of that policy keeps these guards.
492
492
  */
@@ -515,22 +515,21 @@ export class DefaultCstResidencyService implements CstResidencyService {
515
515
  /**
516
516
  * Shed one document's CST: null every AST node's `$cstNode` and every
517
517
  * reference's `$refNode`. Returns the number of `$cstNode`s cleared (0 if the
518
- * document is unparsed or already shed).
518
+ * document is unparsed, already shed, or has no recorded version).
519
519
  */
520
520
  protected shed(document: LangiumDocument): number {
521
521
  const root = document.parseResult?.value;
522
- if (root === undefined || root.$cstNode === undefined) {
522
+ const ledger = this.services.workspace.ModelLedger;
523
+ // Without a version the ledger keeps no text, and without the text no
524
+ // build can graft the CST back or tell the document is shed.
525
+ if (root === undefined || root.$cstNode === undefined || ledger.versionOf(root) === UNRECORDED_VERSION) {
523
526
  return 0;
524
527
  }
525
528
  // Readers of the parsed text, the `'text-diagnostics'` fingerprint among
526
529
  // them, otherwise see none once the CST is gone. Recorded in the ledger
527
530
  // directly: no root is produced, and reconciling it with the store would
528
531
  // step a closed document's sequence for a root behind its text.
529
- const ledger = this.services.workspace.ModelLedger;
530
- const version = ledger.versionOf(root);
531
- if (version !== UNRECORDED_VERSION) {
532
- ledger.record(root, version, ledger.textOf(root));
533
- }
532
+ ledger.record(root, ledger.versionOf(root), ledger.textOf(root));
534
533
  let cleared = 0;
535
534
  for (const node of AstUtils.streamAst(root)) {
536
535
  if (node.$cstNode) {
@@ -9,11 +9,19 @@
9
9
 
10
10
  import {
11
11
  type AstNode,
12
+ type AstReflection,
12
13
  AstUtils,
13
14
  DefaultLangiumDocumentFactory,
15
+ DocumentState,
16
+ isAstNode,
17
+ isMultiReference,
14
18
  type LangiumDocument,
19
+ type Linker,
20
+ type MultiReference,
15
21
  type Mutable,
16
22
  type ParseResult,
23
+ type Reference,
24
+ type ReferenceInfo,
17
25
  type URI
18
26
  } from '@hydranium/langium';
19
27
  import { type CancellationToken } from 'vscode-languageserver-protocol';
@@ -44,10 +52,18 @@ interface WithSerializer {
44
52
  * scope descriptions pointing at them, stay valid. A `fromString` document
45
53
  * already carries its source, so only `fromModel` needs help.
46
54
  *
55
+ * The first build grafts the CST of that text onto the model, as for a shed
56
+ * document, which gives every reference the `$refNode` the reference index
57
+ * needs. A model that does not pair node for node with its serialized text is
58
+ * re-parsed instead, and its nodes are replaced: from its file, or from the
59
+ * text it holds when no file is at its URI, since reading the URI would throw.
60
+ * While the document is open, the store's text wins.
61
+ *
47
62
  * Degrades to Langium's text-less `fromModel` when no serializer is bound for
48
- * the URI's language (the serializer throws or is absent) — the document still
49
- * works; only a re-read of it would yield empty text, which never happens for a
50
- * build-once virtual document.
63
+ * the URI's language (the serializer throws or is absent). Its first build
64
+ * re-reads it from the store or its file, replacing its nodes, as Langium
65
+ * does. With neither, it keeps its nodes but gets no CST, so its references
66
+ * stay out of the reference index and it is not relinked.
51
67
  *
52
68
  * Also links the model's container properties (`$container` /
53
69
  * `$containerProperty` / `$containerIndex`), which Langium's parser sets via
@@ -71,9 +87,13 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
71
87
  protected readonly modelLedger: () => ModelLedger;
72
88
  /** A thunk: the residency service resolves `LangiumDocuments`, which is built with this factory. */
73
89
  protected readonly cstResidency: () => CstResidencyService;
90
+ /** The code-built documents, which are re-read from the text they hold when no file is at their URI. */
91
+ protected readonly codeBuiltDocuments = new WeakSet<LangiumDocument>();
92
+ protected readonly astReflection: AstReflection;
74
93
 
75
94
  constructor(services: ServerSharedServicesMinimal) {
76
95
  super(services);
96
+ this.astReflection = services.AstReflection;
77
97
  this.versionSyncService = () => services.workspace.VersionSyncService;
78
98
  this.modelLedger = () => services.workspace.ModelLedger;
79
99
  this.cstResidency = () => services.workspace.CstResidencyService;
@@ -109,12 +129,67 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
109
129
  // Make the code-built AST as structurally complete as a parsed one — the
110
130
  // parser links containers, `fromModel` does not. In place (identity kept).
111
131
  AstUtils.linkContentToContainer(model, { deep: true });
132
+ // The parser fills in the defaults a model built in code may leave out.
133
+ // Filled before serializing, since a serializer may read them, and
134
+ // without them the model does not pair with the parse of its text.
135
+ for (const node of AstUtils.streamAst(model)) {
136
+ AstUtils.assignMandatoryProperties(this.astReflection, node);
137
+ }
112
138
  const text = this.serializeModel(model, uri);
113
139
  if (text === undefined) {
114
140
  return super.fromModel(model, uri);
115
141
  }
116
142
  const parseResult: ParseResult<T> = { value: model, parserErrors: [], lexerErrors: [] };
117
- return this.createLangiumDocument<T>(parseResult, uri, undefined, text);
143
+ const document = this.createLangiumDocument<T>(parseResult, uri, undefined, text);
144
+ this.codeBuiltDocuments.add(document);
145
+ const linker = this.serviceRegistry.getServices(uri).references.Linker;
146
+ document.references = AstUtils.streamAst(model)
147
+ .flatMap(node => AstUtils.streamReferences(node))
148
+ .toArray()
149
+ .flatMap(info => this.rebuildReference(linker, info) ?? []);
150
+ return document;
151
+ }
152
+
153
+ /**
154
+ * Replace a code-set reference with one the linker built, holding the same
155
+ * target. A relink resets and the reference index reads only such
156
+ * references, and only those listed in `document.references`. The first
157
+ * build unlinks them before its link phase, which resolves and indexes them.
158
+ * The target is kept in the field Langium's references hold it in, so a
159
+ * linker whose references have no such field resolves it on first read.
160
+ * `undefined` when the reference has no slot to go back in.
161
+ */
162
+ protected rebuildReference(
163
+ linker: Linker,
164
+ { reference, container, property, index }: ReferenceInfo
165
+ ): Reference | MultiReference | undefined {
166
+ const owner: unknown = Reflect.get(container, property);
167
+ if (index !== undefined && !Array.isArray(owner)) {
168
+ return undefined;
169
+ }
170
+ let rebuilt: Reference | MultiReference;
171
+ if (isMultiReference(reference)) {
172
+ // Read through the field, as `items` would resolve a linker-built reference.
173
+ const items = '_items' in reference ? reference._items : reference.items;
174
+ const multi = linker.buildMultiReference(container, property, reference.$refNode, reference.$refText);
175
+ if ('_items' in multi) {
176
+ multi._items = Array.isArray(items) ? [...items] : undefined;
177
+ }
178
+ rebuilt = multi;
179
+ } else {
180
+ const target = '_ref' in reference ? reference._ref : reference.ref;
181
+ const single = linker.buildReference(container, property, reference.$refNode, reference.$refText);
182
+ if ('_ref' in single) {
183
+ single._ref = isAstNode(target) ? target : undefined;
184
+ }
185
+ rebuilt = single;
186
+ }
187
+ if (Array.isArray(owner) && index !== undefined) {
188
+ owner[index] = rebuilt;
189
+ } else {
190
+ Reflect.set(container, property, rebuilt);
191
+ }
192
+ return rebuilt;
118
193
  }
119
194
 
120
195
  protected override createLangiumDocument<T extends AstNode = AstNode>(
@@ -127,7 +202,10 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
127
202
  // Without `textDocument` Langium creates one at version 0 on first read,
128
203
  // which needs a language for `uri` that a folder URI does not have.
129
204
  const parsed = parseResult.value.$cstNode?.root.fullText ?? text ?? textDocument?.getText();
130
- this.versionSyncService().modelProduced(document, { version: this.versionOfParsedText(uri, parsed, textDocument?.version ?? 0) });
205
+ const version = this.versionOfParsedText(uri, parsed, textDocument?.version ?? 0);
206
+ // A `fromModel` root has no CST to hold its serialized text. Recorded, it
207
+ // is the text a build grafts a CST from, keeping the model's nodes.
208
+ this.versionSyncService().modelProduced(document, { version, text: parseResult.value.$cstNode ? undefined : text });
131
209
  return document;
132
210
  }
133
211
 
@@ -171,8 +249,12 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
171
249
  // documents, and a reset now would drop their build state.
172
250
  const root = document.parseResult.value;
173
251
  const parsedFrom = root.$cstNode === undefined ? this.modelLedger().textOf(root) : undefined;
252
+ const codeBuilt = await this.codeBuiltSource(document);
174
253
  if (parsedFrom !== undefined) {
175
- const text = this.textDocuments?.get(document.uri.toString())?.getText() ?? (await this.fileSystemProvider.readFile(document.uri));
254
+ const text =
255
+ codeBuilt?.text ??
256
+ this.textDocuments?.get(document.uri.toString())?.getText() ??
257
+ (await this.fileSystemProvider.readFile(document.uri));
176
258
  if (text === parsedFrom) {
177
259
  this.cstResidency().rehydrate(document);
178
260
  if (root.$cstNode === undefined) {
@@ -180,8 +262,11 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
180
262
  }
181
263
  }
182
264
  }
183
- const stored = this.textDocuments?.get(document.uri.toString())?.version;
184
- const updated = await super.update(document, cancellationToken);
265
+ const stored = codeBuilt === undefined ? this.textDocuments?.get(document.uri.toString())?.version : codeBuilt.version;
266
+ const updated =
267
+ codeBuilt === undefined
268
+ ? await super.update(document, cancellationToken)
269
+ : await this.reparse(document, codeBuilt.text, cancellationToken);
185
270
  const version =
186
271
  stored ??
187
272
  this.versionOfParsedText(
@@ -193,6 +278,49 @@ export class HydraniumLangiumDocumentFactory extends DefaultLangiumDocumentFacto
193
278
  return updated;
194
279
  }
195
280
 
281
+ /**
282
+ * The text a code-built document is read from when no file is at its URI:
283
+ * the store's while it is open, else the text it holds, serialized at
284
+ * creation or last read, as `VirtualFileSystemProvider` serves it.
285
+ * `undefined` for any other document, which is read as Langium reads it.
286
+ * The store's version is read with its text, since an edit can land
287
+ * before the caller resumes. Held text has no version.
288
+ */
289
+ protected async codeBuiltSource(document: LangiumDocument): Promise<{ text: string; version: number | undefined } | undefined> {
290
+ if (!this.codeBuiltDocuments.has(document) || (await this.fileSystemProvider.exists(document.uri))) {
291
+ return undefined;
292
+ }
293
+ const open = this.textDocuments?.get(document.uri.toString());
294
+ return open === undefined
295
+ ? { text: document.textDocument.getText(), version: undefined }
296
+ : { text: open.getText(), version: open.version };
297
+ }
298
+
299
+ /**
300
+ * Langium's `update` with `text` in place of the text it reads from the
301
+ * store or the URI. Mirrors `DefaultLangiumDocumentFactory.update` of
302
+ * Langium 4.3.1, so a Langium upgrade has to compare the two. From
303
+ * Langium 4.4, an override of `getUpdatedText` replaces it.
304
+ */
305
+ protected async reparse<T extends AstNode = AstNode>(
306
+ document: Mutable<LangiumDocument<T>>,
307
+ text: string,
308
+ cancellationToken: CancellationToken
309
+ ): Promise<LangiumDocument<T>> {
310
+ const textDocument = this.textDocuments?.get(document.uri.toString());
311
+ Object.defineProperty(
312
+ document,
313
+ 'textDocument',
314
+ textDocument ? { value: textDocument } : { get: this.createTextDocumentGetter(document.uri, text) }
315
+ );
316
+ if (document.parseResult.value.$cstNode?.root.fullText !== text) {
317
+ document.parseResult = await this.parseAsync<T>(document.uri, text, cancellationToken);
318
+ (document.parseResult.value as Mutable<AstNode>).$document = document;
319
+ }
320
+ document.state = DocumentState.Parsed;
321
+ return document;
322
+ }
323
+
196
324
  /**
197
325
  * `version`, or {@link STALE_VERSION} when the store holds `uri` open with
198
326
  * other text than `parsed`: text read from the file while an open landed.
@@ -52,9 +52,7 @@ export function startEventLoopMonitor(shared: ServerSharedServicesMinimal, optio
52
52
  const onStallSnapshot =
53
53
  options.onStallSnapshot ??
54
54
  ((maxMs: number) =>
55
- shared.Logger.for('Process').info(
56
- formatServerState(shared.workspace.LangiumDocuments, `auto: event-loop stall ${Math.round(maxMs)}ms`)
57
- ));
55
+ shared.Logger.for('Process').info(formatServerState(shared.workspace, `auto: event-loop stall ${Math.round(maxMs)}ms`)));
58
56
  const histogram = monitorEventLoopDelay({ resolution: 10 });
59
57
  histogram.enable();
60
58
  const interval = setInterval(() => {
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { Format, type Stopwatch, SystemClock } from '@hydranium/protocol';
11
11
  import { DocumentState, type LangiumDocuments } from '@hydranium/langium';
12
+ import { type ModelLedger } from '../documents/model-ledger.js';
13
+ import { type ServerSharedServicesMinimal } from '../langium/shared-services.js';
12
14
  import { isCstShed } from '../langium/residency/cst-residency-service.js';
13
15
  import { performance } from 'node:perf_hooks';
14
16
  import * as v8 from 'node:v8';
@@ -58,9 +60,15 @@ export interface ServerStateSnapshotOptions {
58
60
  * logs the result, e.g. `tracer.info(formatServerState(...))`), matching
59
61
  * `formatProcessMemory` / `formatPodMemory`. Tracks deltas across calls so
60
62
  * subsequent snapshots show "since last snapshot" rates. Optional sections are
61
- * requested via `options` ({@link ServerStateSnapshotOptions}).
63
+ * requested via `options` ({@link ServerStateSnapshotOptions}). `workspace`
64
+ * adds the document counts. Its ledger tells a shed document from one that
65
+ * never had a CST.
62
66
  */
63
- export function formatServerState(documents?: LangiumDocuments, label?: string, options?: ServerStateSnapshotOptions): string {
67
+ export function formatServerState(
68
+ workspace?: Pick<ServerSharedServicesMinimal['workspace'], 'LangiumDocuments' | 'ModelLedger'>,
69
+ label?: string,
70
+ options?: ServerStateSnapshotOptions
71
+ ): string {
64
72
  const sinceSample = new SystemClock().stopwatch();
65
73
  const mem = process.memoryUsage();
66
74
  const heap = v8.getHeapStatistics();
@@ -110,7 +118,8 @@ export function formatServerState(documents?: LangiumDocuments, label?: string,
110
118
  const eluPct = eluTotal > 0 ? Math.round((elu.active / eluTotal) * 100) : 0;
111
119
  lines.push(` loop utilization ${eluPct}% (cumulative since process start)`);
112
120
  }
113
- if (documents) {
121
+ if (workspace) {
122
+ const { LangiumDocuments: documents, ModelLedger: ledger } = workspace;
114
123
  const counts = countDocumentsByState(documents);
115
124
  const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
116
125
  const breakdown = Object.entries(counts)
@@ -118,9 +127,9 @@ export function formatServerState(documents?: LangiumDocuments, label?: string,
118
127
  .map(([state, n]) => `${state}: ${n}`)
119
128
  .join(', ');
120
129
  lines.push(` documents ${total} tracked${breakdown ? ` (${breakdown})` : ''}`);
121
- // CST-residency breakdown — only meaningful (and only emitted) once a residency
122
- // policy has shed at least one document; silent under the always-keep default.
123
- const residency = countCstResidency(documents);
130
+ // CST-residency breakdown, emitted once a document waits for a CST graft:
131
+ // shed by a residency policy, or built from code and not built yet.
132
+ const residency = countCstResidency(documents, ledger);
124
133
  if (residency.shed > 0) {
125
134
  lines.push(` cst ${residency.resident} resident, ${residency.shed} shed`);
126
135
  }
@@ -143,17 +152,17 @@ export function formatServerState(documents?: LangiumDocuments, label?: string,
143
152
 
144
153
  /**
145
154
  * Count documents whose CST is resident vs. shed by a residency policy
146
- * ({@link isCstShed}). A document can also be in neither bucket — not-yet-parsed
147
- * or a synthetic placeholder with no `$cstNode` at all (nothing to be "resident"
148
- * or "shed") — which is intentionally counted as neither; `resident + shed` is
149
- * therefore ≤ total tracked, with the remainder explained by the state breakdown
150
- * on the preceding `documents` line.
155
+ * ({@link isCstShed}). A document can also be in neither bucket: a
156
+ * not-yet-parsed document, a synthetic placeholder, or a `fromModel` document
157
+ * without a serializer has no CST and no text to graft one from. So
158
+ * `resident + shed` is at most the total tracked, with the remainder explained
159
+ * by the state breakdown on the preceding `documents` line.
151
160
  */
152
- function countCstResidency(documents: LangiumDocuments): { resident: number; shed: number } {
161
+ function countCstResidency(documents: LangiumDocuments, ledger: ModelLedger): { resident: number; shed: number } {
153
162
  let resident = 0;
154
163
  let shed = 0;
155
164
  for (const doc of documents.all) {
156
- if (isCstShed(doc)) {
165
+ if (isCstShed(doc, ledger)) {
157
166
  shed++;
158
167
  } else if (doc.parseResult?.value?.$cstNode !== undefined) {
159
168
  resident++;