@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.
- package/README.md +1 -0
- package/lib/browser/index.d.ts +10 -0
- package/lib/browser/index.d.ts.map +1 -0
- package/lib/browser/index.js +14 -0
- package/lib/browser/index.js.map +1 -0
- package/lib/browser/indexeddb-file-system-store.d.ts +82 -0
- package/lib/browser/indexeddb-file-system-store.d.ts.map +1 -0
- package/lib/browser/indexeddb-file-system-store.js +181 -0
- package/lib/browser/indexeddb-file-system-store.js.map +1 -0
- package/lib/langium/ast-extension/ast-extension-service.d.ts +39 -31
- package/lib/langium/ast-extension/ast-extension-service.d.ts.map +1 -1
- package/lib/langium/ast-extension/ast-extension-service.js +45 -19
- package/lib/langium/ast-extension/ast-extension-service.js.map +1 -1
- package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts +8 -8
- package/lib/langium/build-phase-pass/build-phase-pass-service.d.ts.map +1 -1
- package/lib/langium/build-phase-pass/build-phase-pass-service.js +25 -16
- package/lib/langium/build-phase-pass/build-phase-pass-service.js.map +1 -1
- package/lib/langium/build-phase-pass/build-phase-pass.d.ts +19 -7
- package/lib/langium/build-phase-pass/build-phase-pass.d.ts.map +1 -1
- package/lib/langium/build-phase-pass/build-phase-pass.js +20 -0
- package/lib/langium/build-phase-pass/build-phase-pass.js.map +1 -1
- package/lib/langium/document-builder/build-pipeline-integration.d.ts.map +1 -1
- package/lib/langium/document-builder/build-pipeline-integration.js +11 -40
- package/lib/langium/document-builder/build-pipeline-integration.js.map +1 -1
- package/lib/langium/residency/cst-residency-service.js +1 -1
- package/lib/langium/workspace/persistent-file-system-provider.d.ts +8 -0
- package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
- package/lib/langium/workspace/persistent-file-system-provider.js +11 -0
- package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
- package/package.json +14 -9
- package/src/browser/index.ts +14 -0
- package/src/browser/indexeddb-file-system-store.ts +192 -0
- package/src/langium/ast-extension/ast-extension-service.ts +74 -41
- package/src/langium/build-phase-pass/build-phase-pass-service.ts +28 -18
- package/src/langium/build-phase-pass/build-phase-pass.ts +36 -7
- package/src/langium/document-builder/build-pipeline-integration.ts +11 -42
- package/src/langium/residency/cst-residency-service.ts +1 -1
- 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
|
|
61
|
+
* **Phase semantics:** {@link phase} declares the single phase at which
|
|
33
62
|
* {@link compute} fires. The framework wires one `onDocumentPhase` listener per
|
|
34
|
-
*
|
|
35
|
-
* registrations declare — so
|
|
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 `
|
|
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 `
|
|
48
|
-
* via a separate `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
+
phase: AstExtensionPhase;
|
|
90
113
|
/**
|
|
91
|
-
* Mutate `node` for this registration at {@link
|
|
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-
|
|
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
|
|
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`
|
|
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-
|
|
152
|
-
* and it early-outs when no registration targets the
|
|
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-
|
|
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
|
|
162
|
-
protected
|
|
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(
|
|
232
|
+
* `onDocumentPhase(phase)` listener wired by `BuildPipelineIntegration`.
|
|
200
233
|
*/
|
|
201
234
|
extendNode(node: AstNode, document: LangiumDocument, phase: DocumentState): void {
|
|
202
|
-
const bucket = this.
|
|
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.
|
|
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-
|
|
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
|
|
300
|
+
* same phase, the bucket is computed once and reused until the next
|
|
268
301
|
* register / unregister.
|
|
269
302
|
*/
|
|
270
|
-
protected
|
|
303
|
+
protected getPhaseBucket(phase: DocumentState): readonly AstExtension[] {
|
|
271
304
|
const current = this.extensions.all();
|
|
272
|
-
if (this.
|
|
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.
|
|
308
|
+
let bucket = next.get(extension.phase);
|
|
276
309
|
if (!bucket) {
|
|
277
310
|
bucket = [];
|
|
278
|
-
next.set(extension.
|
|
311
|
+
next.set(extension.phase, bucket);
|
|
279
312
|
}
|
|
280
313
|
bucket.push(extension);
|
|
281
314
|
}
|
|
282
|
-
this.
|
|
283
|
-
this.
|
|
315
|
+
this.phaseBuckets = next;
|
|
316
|
+
this.phaseBucketsFor = current;
|
|
284
317
|
}
|
|
285
|
-
return this.
|
|
318
|
+
return this.phaseBuckets.get(phase) ?? EMPTY_PHASE_BUCKET;
|
|
286
319
|
}
|
|
287
320
|
}
|
|
288
321
|
|
|
289
|
-
/** Shared empty result so `
|
|
290
|
-
const
|
|
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
|
|
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-
|
|
56
|
-
* with the AST-extension service — the per-
|
|
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-
|
|
68
|
-
protected
|
|
69
|
-
protected
|
|
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.
|
|
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-
|
|
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
|
|
131
|
+
protected getPhaseBucket(phase: DocumentState): readonly BuildPhasePass[] {
|
|
122
132
|
const current = this.passes.all();
|
|
123
|
-
if (this.
|
|
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.
|
|
136
|
+
let bucket = next.get(pass.phase);
|
|
127
137
|
if (!bucket) {
|
|
128
138
|
bucket = [];
|
|
129
|
-
next.set(pass.
|
|
139
|
+
next.set(pass.phase, bucket);
|
|
130
140
|
}
|
|
131
141
|
bucket.push(pass);
|
|
132
142
|
}
|
|
133
|
-
this.
|
|
134
|
-
this.
|
|
143
|
+
this.phaseBuckets = next;
|
|
144
|
+
this.phaseBucketsFor = current;
|
|
135
145
|
}
|
|
136
|
-
return this.
|
|
146
|
+
return this.phaseBuckets.get(phase) ?? EMPTY_PHASE_BUCKET;
|
|
137
147
|
}
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
/** Shared empty result so `
|
|
141
|
-
const
|
|
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 {
|
|
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
|
-
|
|
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
|