@hydranium/core 1.0.0-next.289 → 1.0.0-next.298

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 (38) hide show
  1. package/README.md +1 -0
  2. package/lib/browser/index.d.ts +10 -0
  3. package/lib/browser/index.d.ts.map +1 -0
  4. package/lib/browser/index.js +14 -0
  5. package/lib/browser/index.js.map +1 -0
  6. package/lib/browser/indexeddb-file-system-store.d.ts +82 -0
  7. package/lib/browser/indexeddb-file-system-store.d.ts.map +1 -0
  8. package/lib/browser/indexeddb-file-system-store.js +181 -0
  9. package/lib/browser/indexeddb-file-system-store.js.map +1 -0
  10. package/lib/langium/ast-extension/ast-extension-service.d.ts +39 -31
  11. package/lib/langium/ast-extension/ast-extension-service.d.ts.map +1 -1
  12. package/lib/langium/ast-extension/ast-extension-service.js +45 -19
  13. package/lib/langium/ast-extension/ast-extension-service.js.map +1 -1
  14. package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts +8 -8
  15. package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts.map +1 -1
  16. package/lib/langium/build-phase-pass/build-phase-pass-service.js +25 -16
  17. package/lib/langium/build-phase-pass/build-phase-pass-service.js.map +1 -1
  18. package/lib/langium/build-phase-pass/build-phase-pass.d.ts +19 -7
  19. package/lib/langium/build-phase-pass/build-phase-pass.d.ts.map +1 -1
  20. package/lib/langium/build-phase-pass/build-phase-pass.js +20 -0
  21. package/lib/langium/build-phase-pass/build-phase-pass.js.map +1 -1
  22. package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
  23. package/lib/langium/document-builder/build-pipeline-integration.js +11 -40
  24. package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
  25. package/lib/langium/residency/cst-residency-service.js +1 -1
  26. package/lib/langium/workspace/persistent-file-system-provider.d.ts +8 -0
  27. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  28. package/lib/langium/workspace/persistent-file-system-provider.js +11 -0
  29. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  30. package/package.json +14 -9
  31. package/src/browser/index.ts +14 -0
  32. package/src/browser/indexeddb-file-system-store.ts +192 -0
  33. package/src/langium/ast-extension/ast-extension-service.ts +74 -41
  34. package/src/langium/build-phase-pass/build-phase-pass-service.ts +28 -18
  35. package/src/langium/build-phase-pass/build-phase-pass.ts +36 -7
  36. package/src/langium/document-builder/build-pipeline-integration.ts +11 -42
  37. package/src/langium/residency/cst-residency-service.ts +1 -1
  38. package/src/langium/workspace/persistent-file-system-provider.ts +11 -0
@@ -0,0 +1,192 @@
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 { FileSystemSeed } from '../langium/workspace/in-memory-file-system-provider.js';
11
+ import type { FileSystemStore } from '../langium/workspace/persistent-file-system-provider.js';
12
+
13
+ /** Out-of-line keys, so a key is the store path and the value is the content. */
14
+ const OBJECT_STORE_NAME = 'files';
15
+
16
+ /**
17
+ * The schema version. A bump needs a migration in `onupgradeneeded`, or every
18
+ * returning visitor's rows are left in a store the new code never reads.
19
+ */
20
+ const DATABASE_VERSION = 1;
21
+
22
+ /**
23
+ * A {@link FileSystemStore} in `IndexedDB`, so an edit survives a reload.
24
+ *
25
+ * # Why IndexedDB and not OPFS
26
+ *
27
+ * OPFS is the tempting answer, because `createSyncAccessHandle` gives
28
+ * SYNCHRONOUS file reads in a worker, which is what Langium's
29
+ * `FileSystemProvider` needs. It buys nothing here: directory enumeration
30
+ * (`FileSystemDirectoryHandle.entries()`) is async-only, so a directory index
31
+ * still has to live in memory, and once it does the file reads are being served
32
+ * from memory too. IndexedDB is the smaller dependency for the same shape.
33
+ *
34
+ * # It holds the DELTA, not the workspace
35
+ *
36
+ * Only what has been written lands here, and `PersistentFileSystemProvider` lays
37
+ * it over the host's seed. So a fixture edited in the host's bundle still
38
+ * reaches a returning visitor, and this database stays a few kilobytes rather
39
+ * than a copy of the workspace.
40
+ *
41
+ * # A failure is loud, deliberately
42
+ *
43
+ * Nothing here degrades to non-persistent operation. A refused open, a blocked
44
+ * upgrade or an aborted transaction, which is what a quota overrun looks like,
45
+ * rejects the call, which fails the worker's bootstrap or the save that asked.
46
+ * The alternative, carrying on without a store, would report a saved workspace
47
+ * and lose it on the next load. An origin EVICTED between visits is not a
48
+ * failure this class can see: its database opens empty, which is a first visit,
49
+ * and the provider is correct in that state. Evicted mid-session, the next
50
+ * transaction rejects like any other failure.
51
+ *
52
+ * # The database is origin-scoped
53
+ *
54
+ * Every page on the origin shares a database of the same name, and the user
55
+ * agent may evict it. The name is the one per-app value and is REQUIRED for
56
+ * that reason: a default would let two apps on one origin overwrite each
57
+ * other's edits with nothing reporting it.
58
+ */
59
+ export class IndexedDbFileSystemStore implements FileSystemStore {
60
+ /**
61
+ * Opened once and shared, and NOT kept when it fails: a refused or blocked
62
+ * open rejects the call that needed it and the next call opens again, so a
63
+ * cause that has cleared, such as the other tab closing, does not fail every
64
+ * later save with the first error.
65
+ */
66
+ protected connection?: Promise<IDBDatabase>;
67
+
68
+ constructor(protected readonly databaseName: string) {}
69
+
70
+ async load(): Promise<FileSystemSeed> {
71
+ const database = await this.open();
72
+ return new Promise<FileSystemSeed>((resolve, reject) => {
73
+ const transaction = database.transaction(OBJECT_STORE_NAME, 'readonly');
74
+ const files = transaction.objectStore(OBJECT_STORE_NAME);
75
+ const keyRequest = files.getAllKeys();
76
+ const valueRequest = files.getAll();
77
+ // Resolved on the TRANSACTION rather than on either request, which is
78
+ // what guarantees both have landed, and `getAllKeys` and `getAll`
79
+ // enumerate in the same key order, so they zip.
80
+ transaction.oncomplete = () => {
81
+ const keys: IDBValidKey[] = keyRequest.result;
82
+ const values: unknown[] = valueRequest.result;
83
+ const seed: Record<string, string> = {};
84
+ keys.forEach((key, index) => {
85
+ const value = values[index];
86
+ // Guarded rather than cast: this database outlives the code that
87
+ // wrote it, so a row from an older shape must be skipped instead
88
+ // of reaching the filesystem as non-string content.
89
+ if (typeof key === 'string' && typeof value === 'string') {
90
+ seed[key] = value;
91
+ }
92
+ });
93
+ resolve(seed);
94
+ };
95
+ transaction.onabort = () => reject(this.failure(transaction, 'read the stored workspace'));
96
+ });
97
+ }
98
+
99
+ async write(path: string, content: string): Promise<void> {
100
+ await this.mutate('store', files => files.put(content, path));
101
+ }
102
+
103
+ async remove(path: string): Promise<void> {
104
+ await this.mutate('drop', files => files.delete(path));
105
+ }
106
+
107
+ /**
108
+ * Drop everything, returning the next load to the seed.
109
+ *
110
+ * The escape hatch a persistent host needs and a seeded one does not: a saved
111
+ * document that no longer parses is otherwise restored on every load, and
112
+ * clearing site data is the only other way out. Not on {@link FileSystemStore},
113
+ * which the provider never clears.
114
+ */
115
+ async clear(): Promise<void> {
116
+ await this.mutate('clear', files => files.clear());
117
+ }
118
+
119
+ /**
120
+ * Run one write transaction and settle on its COMPLETION.
121
+ *
122
+ * Not on the request's `success`: a request can succeed inside a transaction
123
+ * that then aborts, which is exactly what a quota overrun looks like, so
124
+ * resolving early would report a save the database never kept.
125
+ */
126
+ protected async mutate(operation: string, act: (files: IDBObjectStore) => void): Promise<void> {
127
+ const database = await this.open();
128
+ return new Promise<void>((resolve, reject) => {
129
+ const transaction = database.transaction(OBJECT_STORE_NAME, 'readwrite');
130
+ transaction.oncomplete = () => resolve();
131
+ transaction.onabort = () => reject(this.failure(transaction, operation));
132
+ act(transaction.objectStore(OBJECT_STORE_NAME));
133
+ });
134
+ }
135
+
136
+ protected open(): Promise<IDBDatabase> {
137
+ this.connection ??= this.openDatabase().catch((error: unknown) => {
138
+ this.connection = undefined;
139
+ throw error;
140
+ });
141
+ return this.connection;
142
+ }
143
+
144
+ protected openDatabase(): Promise<IDBDatabase> {
145
+ return new Promise<IDBDatabase>((resolve, reject) => {
146
+ const request = indexedDB.open(this.databaseName, DATABASE_VERSION);
147
+ let blocked = false;
148
+ request.onupgradeneeded = () => {
149
+ // Runs on a first visit and on a version bump. `createObjectStore`
150
+ // throws if it already exists, so the guard is not defensive: a page
151
+ // opened twice with the store created once gets here only when the
152
+ // version moved.
153
+ if (!request.result.objectStoreNames.contains(OBJECT_STORE_NAME)) {
154
+ request.result.createObjectStore(OBJECT_STORE_NAME);
155
+ }
156
+ };
157
+ request.onsuccess = () => {
158
+ const database = request.result;
159
+ if (blocked) {
160
+ // Went through once the other tab closed, after this call had
161
+ // rejected. Nobody holds it, so it is closed rather than left
162
+ // open to block the NEXT upgrade.
163
+ database.close();
164
+ return;
165
+ }
166
+ // A newer version opening in another tab asks this one to let go,
167
+ // and holding on would block that upgrade for as long as this tab
168
+ // lives. Dropped, so every later call here rejects, which is loud.
169
+ database.onversionchange = () => {
170
+ database.close();
171
+ this.connection = undefined;
172
+ };
173
+ resolve(database);
174
+ };
175
+ request.onerror = () => reject(request.error ?? new Error(`Cannot open ${this.databaseName}`));
176
+ // A tab at an older version that lacks the close above holds the
177
+ // upgrade indefinitely. Reported rather than waited on: the host can say
178
+ // so, where a hung open looks like a worker that never started.
179
+ request.onblocked = () => {
180
+ blocked = true;
181
+ reject(new Error(`${this.databaseName} is open in another tab at an older version`));
182
+ };
183
+ });
184
+ }
185
+
186
+ protected failure(transaction: IDBTransaction, operation: string): Error {
187
+ // `transaction.error` names the real cause (`QuotaExceededError` above
188
+ // all); an abort with no error is a caller-side abort, which nothing here
189
+ // does.
190
+ return transaction.error ?? new Error(`Failed to ${operation} in ${this.databaseName}`);
191
+ }
192
+ }
@@ -15,6 +15,35 @@ import { type ServerLanguageServices } from '../language-module.js';
15
15
  import { Registry, type RegistryItem } from '../../util/registry.js';
16
16
  import { type AstExtensionRegistry } from './ast-extension-contribution.js';
17
17
 
18
+ /**
19
+ * The document-build phases an {@link AstExtension} may fire at. `Changed` has
20
+ * no AST yet, and `IndexedContent` carries the same per-document information
21
+ * as `Parsed`, so neither is one.
22
+ */
23
+ export type AstExtensionPhase =
24
+ | DocumentState.Parsed
25
+ | DocumentState.ComputedScopes
26
+ | DocumentState.Linked
27
+ | DocumentState.IndexedReferences
28
+ | DocumentState.Validated;
29
+
30
+ export namespace AstExtensionPhase {
31
+ /**
32
+ * Every {@link AstExtensionPhase}, in build order. `BuildPipelineIntegration`
33
+ * wires one `onDocumentPhase` listener per entry and `register` refuses a
34
+ * phase outside it, so the two cannot drift apart. A fresh array each call.
35
+ */
36
+ export function all(): readonly AstExtensionPhase[] {
37
+ return [
38
+ DocumentState.Parsed,
39
+ DocumentState.ComputedScopes,
40
+ DocumentState.Linked,
41
+ DocumentState.IndexedReferences,
42
+ DocumentState.Validated
43
+ ];
44
+ }
45
+ }
46
+
18
47
  /**
19
48
  * A registration that derives data from an AST node and attaches it to the
20
49
  * node at a declared document-build phase.
@@ -29,13 +58,13 @@ import { type AstExtensionRegistry } from './ast-extension-contribution.js';
29
58
  * are stored as non-enumerable own properties. Otherwise symbol providers,
30
59
  * folding, semantic tokens, and validators will treat them as real children.
31
60
  *
32
- * **Phase semantics:** {@link state} declares the single phase at which
61
+ * **Phase semantics:** {@link phase} declares the single phase at which
33
62
  * {@link compute} fires. The framework wires one `onDocumentPhase` listener per
34
- * useful state unconditionally, rather than deriving the set from the states
35
- * registrations declare — so a registration's state must be one of those, and
36
- * adding one is a framework change, not a registration-time choice.
63
+ * {@link AstExtensionPhase} unconditionally, rather than deriving the set from
64
+ * the phases registrations declare — so `register` refuses any other phase,
65
+ * and adding one is a framework change, not a registration-time choice.
37
66
  *
38
- * **`.ref`-dependent derivations: declare `state: ComputedScopes`, not `Linked`.**
67
+ * **`.ref`-dependent derivations: declare `phase: ComputedScopes`, not `Linked`.**
39
68
  * Lazy reference resolution is available from `ComputedScopes` onward — the
40
69
  * global scope is built by `IndexedContent` (which runs before
41
70
  * `ComputedScopes`), so reading a reference's `.ref` inside a `ComputedScopes`
@@ -44,8 +73,8 @@ import { type AstExtensionRegistry } from './ast-extension-contribution.js';
44
73
  * {@link DocumentBuilderOptions.refreshCrossDocumentComputedScopes} is set, so
45
74
  * one that reads another document's node goes stale when that node changes.
46
75
  *
47
- * **DO NOT** stage a `state: ComputedScopes` placeholder hoping to refine it
48
- * via a separate `state: Linked` registration when a downstream consumer
76
+ * **DO NOT** stage a `phase: ComputedScopes` placeholder hoping to refine it
77
+ * via a separate `phase: Linked` registration when a downstream consumer
49
78
  * (typically a `ScopeExtension.addDescriptions` firing during link)
50
79
  * iterates the derived value to build a scope: `onDocumentPhase(Linked)` fires
51
80
  * AFTER Langium's `Linker.link()` has already walked the document, by which
@@ -63,32 +92,26 @@ export interface AstExtension<T extends AstNode = AstNode> extends RegistryItem
63
92
  /**
64
93
  * Per-document early-out. When set, the framework only invokes the
65
94
  * registration's callback for documents the filter accepts. If every
66
- * registration at a given state rejects the document, the framework skips
95
+ * registration at a given phase rejects the document, the framework skips
67
96
  * the `streamAllContents` walk entirely — important for large workspaces
68
97
  * where most documents carry none of the AST shapes a given extension
69
98
  * targets.
70
99
  *
71
- * Default: no filter (registration applies to every document at its state).
100
+ * Default: no filter (registration applies to every document at its phase).
72
101
  */
73
102
  documentFilter?: (document: LangiumDocument) => boolean;
74
103
  /**
75
- * The single document-build state at which {@link compute} fires.
104
+ * The single document-build phase at which {@link compute} fires.
76
105
  *
77
- * Useful values:
78
106
  * - `Parsed` — derivations from AST shape only, no `.ref` reads.
79
- * - `ComputedScopes` — lazy `.ref` resolution available; the most common state.
107
+ * - `ComputedScopes` — lazy `.ref` resolution available; the most common.
80
108
  * - `Linked` — derivations needing fully resolved cross-references.
81
109
  * - `IndexedReferences` — cross-document reverse-lookup derivations.
82
110
  * - `Validated` — derivations depending on diagnostics (rare).
83
- *
84
- * Any other state type-checks but never fires: the framework wires a
85
- * listener only for the states above. `Changed` has no AST yet, and
86
- * `IndexedContent` carries the same per-document information as `Parsed`,
87
- * so neither is wired.
88
111
  */
89
- state: DocumentState;
112
+ phase: AstExtensionPhase;
90
113
  /**
91
- * Mutate `node` for this registration at {@link state}. The framework hands
114
+ * Mutate `node` for this registration at {@link phase}. The framework hands
92
115
  * `node` typed as {@link Mutable} so computed / synthetic property
93
116
  * assignments (`node._foo = ...`) compile without per-callback
94
117
  * `asMutable(...)` casts.
@@ -112,18 +135,18 @@ export interface AstExtension<T extends AstNode = AstNode> extends RegistryItem
112
135
  *
113
136
  * Adopter overrides go through {@link DefaultAstExtensionService}; the
114
137
  * interface keeps the public API stable while internals (the bucket-
115
- * by-state cache) stay `protected` on the default class.
138
+ * by-phase cache) stay `protected` on the default class.
116
139
  */
117
140
  export interface AstExtensionService extends AstExtensionRegistry {
118
141
  /**
119
142
  * Run a single AST node through every registration declaring
120
- * `phase` as its state. Honours `documentFilter` and `nodeFilter`.
143
+ * `phase`. Honours `documentFilter` and `nodeFilter`.
121
144
  * Primarily for tests that need to invoke an extension callback without
122
145
  * driving Langium's full document-build pipeline.
123
146
  */
124
147
  extendNode(node: AstNode, document: LangiumDocument, phase: DocumentState): void;
125
148
  /**
126
- * Re-run all `compute` callbacks declaring `phase` as their state against
149
+ * Re-run all `compute` callbacks declaring `phase` against
127
150
  * every node in the document. Single-pass. Called by
128
151
  * `BuildPipelineIntegration` from an `onDocumentPhase` listener;
129
152
  * not for general adopter use.
@@ -148,18 +171,18 @@ export type AstExtensionServiceOptions = LogNameOptions;
148
171
  * owns the registry + per-node logic, not the listener lifecycle.
149
172
  *
150
173
  * **Performance:** `extendDocument` shares a single `streamAllContents` walk
151
- * per document per wired phase; cost is O(registrations-for-state × nodes),
152
- * and it early-outs when no registration targets the state or the document.
174
+ * per document per wired phase; cost is O(registrations-for-phase × nodes),
175
+ * and it early-outs when no registration targets the phase or the document.
153
176
  */
154
177
  export class DefaultAstExtensionService implements AstExtensionService {
155
178
  protected readonly extensions = new Registry<AstExtension>();
156
179
  /**
157
- * Per-state bucket of registrations targeting each state. Rebuilt lazily
180
+ * Per-phase bucket of registrations targeting each phase. Rebuilt lazily
158
181
  * on the first refresh after a registry mutation — staleness is detected
159
182
  * via reference identity of the registry's cached array (see {@link Registry.all}).
160
183
  */
161
- protected stateBuckets = new Map<DocumentState, readonly AstExtension[]>();
162
- protected stateBucketsFor: readonly AstExtension[] | undefined;
184
+ protected phaseBuckets = new Map<DocumentState, readonly AstExtension[]>();
185
+ protected phaseBucketsFor: readonly AstExtension[] | undefined;
163
186
 
164
187
  protected readonly tracer: Tracer;
165
188
 
@@ -189,6 +212,16 @@ export class DefaultAstExtensionService implements AstExtensionService {
189
212
  * which calls {@link extendDocument} for the wired phases.
190
213
  */
191
214
  register<T extends AstNode>(extension: AstExtension<T>): Disposable {
215
+ // A phase outside the wired set type-checks only past a cast, and a
216
+ // registration there is accepted and never fires.
217
+ if (!AstExtensionPhase.all().includes(extension.phase)) {
218
+ throw new Error(
219
+ `AST extension '${extension.id}' registered at phase ${DocumentState[extension.phase]} would never fire. ` +
220
+ `Allowed phases are ${AstExtensionPhase.all()
221
+ .map(phase => DocumentState[phase])
222
+ .join(', ')}.`
223
+ );
224
+ }
192
225
  // Cast: TypeScript can't see that `T extends AstNode` makes the extension
193
226
  // assignable to the default-parameter form. The runtime contract is the same.
194
227
  return this.extensions.register(extension as AstExtension);
@@ -196,10 +229,10 @@ export class DefaultAstExtensionService implements AstExtensionService {
196
229
 
197
230
  /**
198
231
  * Production code goes through {@link extendDocument} via the
199
- * `onDocumentPhase(state)` listener wired by `BuildPipelineIntegration`.
232
+ * `onDocumentPhase(phase)` listener wired by `BuildPipelineIntegration`.
200
233
  */
201
234
  extendNode(node: AstNode, document: LangiumDocument, phase: DocumentState): void {
202
- const bucket = this.getStateBucket(phase);
235
+ const bucket = this.getPhaseBucket(phase);
203
236
  for (const extension of bucket) {
204
237
  if (extension.documentFilter && !extension.documentFilter(document)) {
205
238
  continue;
@@ -228,7 +261,7 @@ export class DefaultAstExtensionService implements AstExtensionService {
228
261
  if (cancelToken?.isCancellationRequested) {
229
262
  return;
230
263
  }
231
- const bucket = this.getStateBucket(phase);
264
+ const bucket = this.getPhaseBucket(phase);
232
265
  if (bucket.length === 0) {
233
266
  return;
234
267
  }
@@ -261,33 +294,33 @@ export class DefaultAstExtensionService implements AstExtensionService {
261
294
  }
262
295
 
263
296
  /**
264
- * Bucket-by-state view of {@link extensions}, rebuilt lazily when the
297
+ * Bucket-by-phase view of {@link extensions}, rebuilt lazily when the
265
298
  * registry's cached `all()` reference changes. Replaces per-call allocation
266
299
  * on the per-node hot path — for a workspace with many documents at the
267
- * same state, the bucket is computed once and reused until the next
300
+ * same phase, the bucket is computed once and reused until the next
268
301
  * register / unregister.
269
302
  */
270
- protected getStateBucket(phase: DocumentState): readonly AstExtension[] {
303
+ protected getPhaseBucket(phase: DocumentState): readonly AstExtension[] {
271
304
  const current = this.extensions.all();
272
- if (this.stateBucketsFor !== current) {
305
+ if (this.phaseBucketsFor !== current) {
273
306
  const next = new Map<DocumentState, AstExtension[]>();
274
307
  for (const extension of current) {
275
- let bucket = next.get(extension.state);
308
+ let bucket = next.get(extension.phase);
276
309
  if (!bucket) {
277
310
  bucket = [];
278
- next.set(extension.state, bucket);
311
+ next.set(extension.phase, bucket);
279
312
  }
280
313
  bucket.push(extension);
281
314
  }
282
- this.stateBuckets = next;
283
- this.stateBucketsFor = current;
315
+ this.phaseBuckets = next;
316
+ this.phaseBucketsFor = current;
284
317
  }
285
- return this.stateBuckets.get(phase) ?? EMPTY_STATE_BUCKET;
318
+ return this.phaseBuckets.get(phase) ?? EMPTY_PHASE_BUCKET;
286
319
  }
287
320
  }
288
321
 
289
- /** Shared empty result so `getStateBucket` doesn't allocate per call on the cold path. */
290
- const EMPTY_STATE_BUCKET: readonly AstExtension[] = Object.freeze([]);
322
+ /** Shared empty result so `getPhaseBucket` doesn't allocate per call on the cold path. */
323
+ const EMPTY_PHASE_BUCKET: readonly AstExtension[] = Object.freeze([]);
291
324
 
292
325
  /**
293
326
  * Assign a non-enumerable own property — for synthetic children that must NOT
@@ -13,7 +13,7 @@ import { type CancellationToken, type Disposable } from 'vscode-languageserver';
13
13
  import { type LogNameOptions } from '../diagnostics/logger.js';
14
14
  import { type ServerSharedServices } from '../module.js';
15
15
  import { Registry } from '../../util/registry.js';
16
- import { type BuildPhasePass, type BuildPhasePassRegistry } from './build-phase-pass.js';
16
+ import { type BuildPhasePass, type BuildPhasePassRegistry, BuildPassPhase } from './build-phase-pass.js';
17
17
 
18
18
  /**
19
19
  * Public contract for the shared build-phase-pass runner. Extends the
@@ -31,7 +31,7 @@ import { type BuildPhasePass, type BuildPhasePassRegistry } from './build-phase-
31
31
  */
32
32
  export interface BuildPhasePassService extends BuildPhasePassRegistry {
33
33
  /**
34
- * Run every pass declaring `phase` as its state, in priority order,
34
+ * Run every pass declaring `phase`, in priority order,
35
35
  * sequentially — each pass is awaited before the next begins. The cancel
36
36
  * token is checked before each pass so a preempted build stops cleanly.
37
37
  * No-ops when no pass targets the phase. Called by
@@ -52,8 +52,8 @@ export type BuildPhasePassServiceOptions = LogNameOptions;
52
52
  * `BuildPipelineIntegration`,
53
53
  * which calls {@link runPasses} from its `onBuildPhase` listeners.
54
54
  *
55
- * **Performance:** {@link runPasses} shares the bucket-by-state cache pattern
56
- * with the AST-extension service — the per-state pass list is rebuilt lazily
55
+ * **Performance:** {@link runPasses} shares the bucket-by-phase cache pattern
56
+ * with the AST-extension service — the per-phase pass list is rebuilt lazily
57
57
  * only after a register / unregister, so steady-state dispatch allocates
58
58
  * nothing and early-outs when no pass targets the phase.
59
59
  *
@@ -64,9 +64,9 @@ export type BuildPhasePassServiceOptions = LogNameOptions;
64
64
  */
65
65
  export class DefaultBuildPhasePassService implements BuildPhasePassService {
66
66
  protected readonly passes = new Registry<BuildPhasePass>();
67
- /** Per-state bucket of passes targeting each state, rebuilt lazily on the first run after a registry mutation. */
68
- protected stateBuckets = new Map<DocumentState, readonly BuildPhasePass[]>();
69
- protected stateBucketsFor: readonly BuildPhasePass[] | undefined;
67
+ /** Per-phase bucket of passes targeting each phase, rebuilt lazily on the first run after a registry mutation. */
68
+ protected phaseBuckets = new Map<DocumentState, readonly BuildPhasePass[]>();
69
+ protected phaseBucketsFor: readonly BuildPhasePass[] | undefined;
70
70
 
71
71
  protected readonly tracer: Tracer;
72
72
 
@@ -84,11 +84,21 @@ export class DefaultBuildPhasePassService implements BuildPhasePassService {
84
84
  }
85
85
 
86
86
  register(pass: BuildPhasePass): Disposable {
87
+ // A phase outside the wired set type-checks only past a cast, and a pass
88
+ // there is accepted and never fires.
89
+ if (!BuildPassPhase.all().includes(pass.phase)) {
90
+ throw new Error(
91
+ `Build-phase pass '${pass.id}' registered at phase ${DocumentState[pass.phase]} would never fire. ` +
92
+ `Allowed phases are ${BuildPassPhase.all()
93
+ .map(phase => DocumentState[phase])
94
+ .join(', ')}.`
95
+ );
96
+ }
87
97
  return this.passes.register(pass);
88
98
  }
89
99
 
90
100
  async runPasses(documents: readonly LangiumDocument[], phase: DocumentState, cancelToken: CancellationToken): Promise<void> {
91
- const bucket = this.getStateBucket(phase);
101
+ const bucket = this.getPhaseBucket(phase);
92
102
  if (bucket.length === 0) {
93
103
  return;
94
104
  }
@@ -114,28 +124,28 @@ export class DefaultBuildPhasePassService implements BuildPhasePassService {
114
124
  }
115
125
 
116
126
  /**
117
- * Bucket-by-state view of {@link passes}, rebuilt lazily when the registry's
127
+ * Bucket-by-phase view of {@link passes}, rebuilt lazily when the registry's
118
128
  * cached `all()` reference changes. Each bucket preserves the registry's
119
129
  * priority order (`Registry.all()` is already priority-sorted).
120
130
  */
121
- protected getStateBucket(phase: DocumentState): readonly BuildPhasePass[] {
131
+ protected getPhaseBucket(phase: DocumentState): readonly BuildPhasePass[] {
122
132
  const current = this.passes.all();
123
- if (this.stateBucketsFor !== current) {
133
+ if (this.phaseBucketsFor !== current) {
124
134
  const next = new Map<DocumentState, BuildPhasePass[]>();
125
135
  for (const pass of current) {
126
- let bucket = next.get(pass.state);
136
+ let bucket = next.get(pass.phase);
127
137
  if (!bucket) {
128
138
  bucket = [];
129
- next.set(pass.state, bucket);
139
+ next.set(pass.phase, bucket);
130
140
  }
131
141
  bucket.push(pass);
132
142
  }
133
- this.stateBuckets = next;
134
- this.stateBucketsFor = current;
143
+ this.phaseBuckets = next;
144
+ this.phaseBucketsFor = current;
135
145
  }
136
- return this.stateBuckets.get(phase) ?? EMPTY_STATE_BUCKET;
146
+ return this.phaseBuckets.get(phase) ?? EMPTY_PHASE_BUCKET;
137
147
  }
138
148
  }
139
149
 
140
- /** Shared empty result so `getStateBucket` doesn't allocate per call on the cold path. */
141
- const EMPTY_STATE_BUCKET: readonly BuildPhasePass[] = Object.freeze([]);
150
+ /** Shared empty result so `getPhaseBucket` doesn't allocate per call on the cold path. */
151
+ const EMPTY_PHASE_BUCKET: readonly BuildPhasePass[] = Object.freeze([]);
@@ -7,10 +7,43 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { type DocumentState, type LangiumCoreServices, type LangiumDocument, type URI } from '@hydranium/langium';
10
+ import { DocumentState, type LangiumCoreServices, type LangiumDocument, type URI } from '@hydranium/langium';
11
11
  import { type CancellationToken, type Disposable } from 'vscode-languageserver';
12
12
  import { type RegistryItem } from '../../util/registry.js';
13
13
 
14
+ /**
15
+ * The build phases a {@link BuildPhasePass} may fire at: every phase after
16
+ * `Changed`, which has no AST yet. `IndexedContent` is one, unlike for an
17
+ * `AstExtension`: per document it carries nothing `Parsed` does, but per
18
+ * batch it is the only phase meaning the global index is whole again, which
19
+ * batch work keyed on a complete index has nowhere else to run.
20
+ */
21
+ export type BuildPassPhase =
22
+ | DocumentState.Parsed
23
+ | DocumentState.IndexedContent
24
+ | DocumentState.ComputedScopes
25
+ | DocumentState.Linked
26
+ | DocumentState.IndexedReferences
27
+ | DocumentState.Validated;
28
+
29
+ export namespace BuildPassPhase {
30
+ /**
31
+ * Every {@link BuildPassPhase}, in build order. `BuildPipelineIntegration`
32
+ * wires one `onBuildPhase` listener per entry and `register` refuses a phase
33
+ * outside it, so the two cannot drift apart. A fresh array each call.
34
+ */
35
+ export function all(): readonly BuildPassPhase[] {
36
+ return [
37
+ DocumentState.Parsed,
38
+ DocumentState.IndexedContent,
39
+ DocumentState.ComputedScopes,
40
+ DocumentState.Linked,
41
+ DocumentState.IndexedReferences,
42
+ DocumentState.Validated
43
+ ];
44
+ }
45
+ }
46
+
14
47
  /**
15
48
  * A unit of whole-batch work run once per build when the batch reaches a
16
49
  * declared build phase, ordered by {@link RegistryItem.priority}.
@@ -46,12 +79,8 @@ import { type RegistryItem } from '../../util/registry.js';
46
79
  * rather than running the remaining passes against a doomed build.
47
80
  */
48
81
  export interface BuildPhasePass extends RegistryItem {
49
- /**
50
- * The single build phase whose `onBuildPhase` fires this pass. The framework
51
- * wires a listener for the same phase set as AST extension; a pass declaring
52
- * any other state type-checks but never fires.
53
- */
54
- state: DocumentState;
82
+ /** The single build phase whose `onBuildPhase` fires this pass. */
83
+ phase: BuildPassPhase;
55
84
  /**
56
85
  * Run this pass against the whole built batch. May mutate and reparse
57
86
  * documents — the framework's integrity pass does — or derive cross-document