@hydranium/core 1.0.0-next.23 → 1.0.0-next.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/langium/document-builder/build-session.d.ts +93 -0
- package/lib/langium/document-builder/build-session.d.ts.map +1 -0
- package/lib/langium/document-builder/build-session.js +72 -0
- package/lib/langium/document-builder/build-session.js.map +1 -0
- package/lib/langium/document-builder/document-builder.d.ts +117 -6
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +230 -9
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/document-builder/index.d.ts +1 -0
- package/lib/langium/document-builder/index.d.ts.map +1 -1
- package/lib/langium/document-builder/index.js +1 -0
- package/lib/langium/document-builder/index.js.map +1 -1
- package/lib/langium/module.d.ts +7 -0
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +5 -0
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/residency/cst-residency-service.d.ts +1 -1
- package/lib/langium/residency/cst-residency-service.js +1 -1
- package/lib/langium/shared-services.d.ts +2 -0
- package/lib/langium/shared-services.d.ts.map +1 -1
- package/lib/langium/shared-services.js.map +1 -1
- package/lib/langium/validation/document-validator.d.ts +17 -4
- package/lib/langium/validation/document-validator.d.ts.map +1 -1
- package/lib/langium/validation/document-validator.js.map +1 -1
- package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
- package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
- package/lib/langium/workspace/document-uri-policy.js +3 -4
- package/lib/langium/workspace/document-uri-policy.js.map +1 -1
- package/lib/langium/workspace/langium-documents.d.ts +44 -17
- package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
- package/lib/langium/workspace/langium-documents.js +45 -28
- package/lib/langium/workspace/langium-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/langium/document-builder/build-session.ts +87 -0
- package/src/langium/document-builder/document-builder.ts +269 -9
- package/src/langium/document-builder/index.ts +1 -0
- package/src/langium/module.ts +11 -0
- package/src/langium/residency/cst-residency-service.ts +1 -1
- package/src/langium/shared-services.ts +6 -0
- package/src/langium/validation/document-validator.ts +17 -4
- package/src/langium/workspace/document-uri-policy.ts +3 -4
- package/src/langium/workspace/langium-documents.ts +55 -33
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { type Clock, type LogThreshold, type MaybeObservableValue, ObservableValue, type Tracer } from '@hydranium/protocol';
|
|
11
11
|
import {
|
|
12
|
+
type AstNode,
|
|
12
13
|
type BuildOptions,
|
|
13
14
|
DefaultDocumentBuilder,
|
|
14
15
|
type DocumentPhaseListener,
|
|
@@ -26,6 +27,7 @@ import { CST_REHYDRATION_RESET_STATE, isCstShed } from '../residency/cst-residen
|
|
|
26
27
|
import { type ExtendedServiceRegistry } from '../service-registry.js';
|
|
27
28
|
import { type ServerSharedServicesMinimal } from '../shared-services.js';
|
|
28
29
|
import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
|
|
30
|
+
import { BuildSession, type BuildSessionContext } from './build-session.js';
|
|
29
31
|
import { type LabeledPhaseListener, labelPhaseListener } from './labeled-phase-listener.js';
|
|
30
32
|
|
|
31
33
|
/** Document states a phase-reached line is emitted for by default — every built phase. */
|
|
@@ -78,6 +80,18 @@ export interface DocumentBuilderOptions extends LogNameOptions {
|
|
|
78
80
|
* logged. Default: `25`. Read per phase; accepts a {@link MaybeObservableValue}.
|
|
79
81
|
*/
|
|
80
82
|
readonly slowBuildMs?: MaybeObservableValue<number>;
|
|
83
|
+
/**
|
|
84
|
+
* Build duration at or above which a build's phase-reached and slow-listener
|
|
85
|
+
* lines are emitted; they are held for the duration of the build and dropped
|
|
86
|
+
* when it finishes faster. Default `0` — no buffering, every line emitted as
|
|
87
|
+
* it is produced, which is the only setting that keeps lines interleaved with
|
|
88
|
+
* the rest of the log in real time.
|
|
89
|
+
*
|
|
90
|
+
* Set it to make a fast rebuild log nothing but its one build line. The
|
|
91
|
+
* decision needs the build's TOTAL duration, so it cannot be made by any
|
|
92
|
+
* per-line hook. Read once per build; accepts a {@link MaybeObservableValue}.
|
|
93
|
+
*/
|
|
94
|
+
readonly phaseDetailMs?: MaybeObservableValue<number>;
|
|
81
95
|
/**
|
|
82
96
|
* Refresh cross-document `ComputedScopes` derivations when a referencing
|
|
83
97
|
* document is cascade-rebuilt (see
|
|
@@ -106,14 +120,21 @@ export interface DocumentBuilderOptions extends LogNameOptions {
|
|
|
106
120
|
* {@link reparseAndRelink} — for a build-phase listener that mutated a
|
|
107
121
|
* document's AST and must reconcile it within the same build.
|
|
108
122
|
* - **Diagnostic dedupe** at `Validated` ({@link dedupeDiagnostics}).
|
|
123
|
+
* - **Build sessions** ({@link BuildSession}): each `update` / `build` call is
|
|
124
|
+
* one correlated unit carrying an id, a trigger label, a start time and
|
|
125
|
+
* cancellation lineage, so every line of a rebuild reads as belonging to it
|
|
126
|
+
* and a preempted build is distinguishable from the winner.
|
|
109
127
|
* - **Logging instrumentation** (default on, opt-out via `logLevel: 'off'`):
|
|
110
|
-
* phase-reached lines, slow-listener breakdowns on
|
|
111
|
-
* and slow-build-phase totals on `notifyBuildPhase`.
|
|
128
|
+
* a per-build line, phase-reached lines, slow-listener breakdowns on
|
|
129
|
+
* `notifyDocumentPhase`, and slow-build-phase totals on `notifyBuildPhase`.
|
|
112
130
|
*
|
|
113
131
|
* Adopters extend this class — the configuration knobs cover what most
|
|
114
132
|
* adopters need; the `format*Line` methods, `formatUri`, and
|
|
115
133
|
* `collectDeletedURIs` are protected so subclasses can customise wording or
|
|
116
|
-
* domain-aware cascades without re-implementing surrounding logic.
|
|
134
|
+
* domain-aware cascades without re-implementing surrounding logic. An adopter
|
|
135
|
+
* with build-scoped state of its own subclasses {@link BuildSession} and
|
|
136
|
+
* overrides {@link createBuildSession}, which puts that state under the same
|
|
137
|
+
* preemption-correct teardown rather than a reimplementation of it.
|
|
117
138
|
*/
|
|
118
139
|
export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
119
140
|
protected readonly tracer: Tracer;
|
|
@@ -127,6 +148,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
127
148
|
protected readonly slowListenerMs: ObservableValue<number>;
|
|
128
149
|
/** Live slow-build-phase-total threshold; read `.value` per phase. */
|
|
129
150
|
protected readonly slowBuildMs: ObservableValue<number>;
|
|
151
|
+
/** Live phase-detail buffering threshold; read `.value` once per build, onto the session. */
|
|
152
|
+
protected readonly phaseDetailMs: ObservableValue<number>;
|
|
130
153
|
protected readonly uriPolicy: DocumentUriPolicy;
|
|
131
154
|
protected readonly clock: Clock;
|
|
132
155
|
/** Narrower handle on the same registry as the inherited `serviceRegistry`, for {@link ExtendedServiceRegistry.registrations}. */
|
|
@@ -139,6 +162,22 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
139
162
|
protected readonly refreshCrossDocumentComputedScopes: boolean;
|
|
140
163
|
/** LSP event name (e.g. `'didChangeWatchedFiles'`) staged for the next `update()` call. */
|
|
141
164
|
protected pendingUpdateReason?: string;
|
|
165
|
+
/**
|
|
166
|
+
* The build currently in progress, or `undefined` between builds.
|
|
167
|
+
*
|
|
168
|
+
* A subclass carrying its own build-scoped state returns a {@link BuildSession}
|
|
169
|
+
* subclass from {@link createBuildSession} and narrows this with a typeguard
|
|
170
|
+
* where it reads that state — rather than redeclaring the field, whose
|
|
171
|
+
* initialiser would run after `super()` and clear a session opened during
|
|
172
|
+
* construction.
|
|
173
|
+
*/
|
|
174
|
+
protected activeSession?: BuildSession;
|
|
175
|
+
/**
|
|
176
|
+
* `traceId` of the last build that ended in cancellation, for the successor's
|
|
177
|
+
* "cancels #N" tag. Held here rather than on a session because the session
|
|
178
|
+
* that carries it is already gone by the time its successor is opened.
|
|
179
|
+
*/
|
|
180
|
+
protected lastCancelledTraceId?: number;
|
|
142
181
|
|
|
143
182
|
constructor(services: ServerSharedServicesMinimal, options: DocumentBuilderOptions = {}) {
|
|
144
183
|
super(services);
|
|
@@ -151,6 +190,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
151
190
|
this.slowPhaseMs = ObservableValue.from(options.slowPhaseMs ?? 25);
|
|
152
191
|
this.slowListenerMs = ObservableValue.from(options.slowListenerMs ?? 5);
|
|
153
192
|
this.slowBuildMs = ObservableValue.from(options.slowBuildMs ?? 25);
|
|
193
|
+
this.phaseDetailMs = ObservableValue.from(options.phaseDetailMs ?? 0);
|
|
154
194
|
this.refreshCrossDocumentComputedScopes = options.refreshCrossDocumentComputedScopes ?? false;
|
|
155
195
|
if (this.logLevel !== 'off') {
|
|
156
196
|
this.registerPhaseListeners();
|
|
@@ -193,7 +233,16 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
193
233
|
const doc = this.langiumDocuments.getDocument(UriUtils.toUri(this.uriPolicy.canonicalUri(uri)));
|
|
194
234
|
const docState = doc ? DocumentState[doc.state] : 'unknown (document not loaded)';
|
|
195
235
|
const lastPhase = this.lastPhaseMs > 0 ? `${Math.round(performance.now() - this.lastPhaseMs)}ms ago` : 'no phase observed';
|
|
196
|
-
return `current state: '${docState}', last phase: ${lastPhase}`;
|
|
236
|
+
return `current state: '${docState}', last phase: ${lastPhase}, active build: ${this.formatSession(this.activeSession)}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Render a session for a status line. `undefined` — no build in progress — reads as `none`. */
|
|
240
|
+
protected formatSession(session: BuildSession | undefined): string {
|
|
241
|
+
if (!session) {
|
|
242
|
+
return 'none';
|
|
243
|
+
}
|
|
244
|
+
const id = session.traceId !== undefined ? `#${session.traceId}` : 'untimed';
|
|
245
|
+
return `${id} (${session.trigger}, ${Math.round(performance.now() - session.startMs)}ms in)`;
|
|
197
246
|
}
|
|
198
247
|
|
|
199
248
|
// ============================================================
|
|
@@ -409,7 +458,35 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
409
458
|
this.ensureLanguageFileExtensions();
|
|
410
459
|
const changedURIs = changed.flatMap(uri => this.flattenAndAdaptURI(uri));
|
|
411
460
|
const deletedURIs = deleted.flatMap(uri => this.collectDeletedURIs(uri));
|
|
412
|
-
return
|
|
461
|
+
return this.runInSession(
|
|
462
|
+
{
|
|
463
|
+
kind: 'update',
|
|
464
|
+
trigger: this.buildTriggerLabel(changedURIs, deletedURIs),
|
|
465
|
+
triggerCountsDocs: changedURIs.length + deletedURIs.length !== 1,
|
|
466
|
+
changed: changedURIs,
|
|
467
|
+
deleted: deletedURIs
|
|
468
|
+
},
|
|
469
|
+
this.rebuildLabel(changedURIs, deletedURIs),
|
|
470
|
+
() => super.update(changedURIs, deletedURIs, cancelToken)
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The workspace-initialization entry point, bracketed by a session like
|
|
476
|
+
* {@link update}. Langium's `update` reaches `buildDocuments` directly rather
|
|
477
|
+
* than through here, so the two never nest.
|
|
478
|
+
*/
|
|
479
|
+
override build<T extends AstNode>(
|
|
480
|
+
documents: Array<LangiumDocument<T>>,
|
|
481
|
+
options?: BuildOptions,
|
|
482
|
+
cancelToken?: CancellationToken
|
|
483
|
+
): Promise<void> {
|
|
484
|
+
const uris = documents.map(document => document.uri);
|
|
485
|
+
return this.runInSession(
|
|
486
|
+
{ kind: 'build', trigger: `${documents.length} docs`, triggerCountsDocs: true, changed: uris, deleted: [] },
|
|
487
|
+
`Build documents (${documents.length} docs)`,
|
|
488
|
+
() => super.build(documents, options, cancelToken)
|
|
489
|
+
);
|
|
413
490
|
}
|
|
414
491
|
|
|
415
492
|
/**
|
|
@@ -615,6 +692,149 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
615
692
|
];
|
|
616
693
|
}
|
|
617
694
|
|
|
695
|
+
// ============================================================
|
|
696
|
+
// Build sessions — one rebuild as a correlated unit
|
|
697
|
+
// ============================================================
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Open a session, run `body` inside it, and close it — the bracket every
|
|
701
|
+
* line of a rebuild is emitted within.
|
|
702
|
+
*
|
|
703
|
+
* The session is installed **synchronously**, before the timed body runs, so
|
|
704
|
+
* that state a subclass computed in {@link createBuildSession} is already
|
|
705
|
+
* readable by the time Langium's `update` consults `shouldRelink`.
|
|
706
|
+
*
|
|
707
|
+
* Teardown is preemption-correct, which is the reason this is framework code
|
|
708
|
+
* rather than a recipe. Langium's write mutex cancels an in-flight build when
|
|
709
|
+
* a later one arrives, so two sessions overlap: the successor installs itself
|
|
710
|
+
* as {@link activeSession} while the predecessor is still unwinding, and the
|
|
711
|
+
* predecessor's `finally` runs LAST. Clearing unconditionally there would
|
|
712
|
+
* discard the winner's state mid-build. Only the session that is still
|
|
713
|
+
* current clears — and the check is reference equality on the session object,
|
|
714
|
+
* not on {@link BuildSession.traceId}, which is `undefined` for every build
|
|
715
|
+
* whenever the timing level is suppressed and would compare equal to itself
|
|
716
|
+
* across two different builds.
|
|
717
|
+
*
|
|
718
|
+
* Re-entrancy is not hypothetical even without an adopter: {@link
|
|
719
|
+
* requeueOrphaned} calls `update` from inside a wait, while a build may be
|
|
720
|
+
* running.
|
|
721
|
+
*/
|
|
722
|
+
protected runInSession(context: BuildSessionContext, label: string, body: () => Promise<void>): Promise<void> {
|
|
723
|
+
// Read before installing the new session: the id being superseded belongs
|
|
724
|
+
// to the OUTGOING build, or — when the previous one already finished
|
|
725
|
+
// cancelled — to the id it parked for its successor.
|
|
726
|
+
const supersededId = this.activeSession?.traceId ?? this.lastCancelledTraceId;
|
|
727
|
+
this.lastCancelledTraceId = undefined;
|
|
728
|
+
const reason = this.pendingUpdateReason;
|
|
729
|
+
this.pendingUpdateReason = undefined;
|
|
730
|
+
const tags: string[] = [];
|
|
731
|
+
if (reason) {
|
|
732
|
+
tags.push(`event: ${reason}`);
|
|
733
|
+
}
|
|
734
|
+
if (supersededId !== undefined) {
|
|
735
|
+
tags.push(`cancels #${supersededId}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const session = this.createBuildSession(context);
|
|
739
|
+
this.activeSession = session;
|
|
740
|
+
// A phase's "since previous phase" must measure from the build's start,
|
|
741
|
+
// not from whenever the last build's final phase happened to land.
|
|
742
|
+
this.lastPhaseMs = session.startMs;
|
|
743
|
+
return this.tracer.time(
|
|
744
|
+
label,
|
|
745
|
+
async () => {
|
|
746
|
+
try {
|
|
747
|
+
await body();
|
|
748
|
+
} catch (err: unknown) {
|
|
749
|
+
if (isOperationCancelled(err)) {
|
|
750
|
+
session.cancelled = true;
|
|
751
|
+
}
|
|
752
|
+
throw err;
|
|
753
|
+
} finally {
|
|
754
|
+
this.endSession(session);
|
|
755
|
+
}
|
|
756
|
+
},
|
|
757
|
+
this.logLevel,
|
|
758
|
+
{
|
|
759
|
+
logAfterMs: 0,
|
|
760
|
+
forceMemoryAboveMs: session.buffers ? session.detailThresholdMs : undefined,
|
|
761
|
+
captureId: id => {
|
|
762
|
+
session.traceId = id;
|
|
763
|
+
},
|
|
764
|
+
tags
|
|
765
|
+
}
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Construct the session for one build. Override to return a
|
|
771
|
+
* {@link BuildSession} subclass carrying adopter build-scoped state — it is
|
|
772
|
+
* called before the build body, so anything derived here is readable
|
|
773
|
+
* throughout it.
|
|
774
|
+
*/
|
|
775
|
+
protected createBuildSession(context: BuildSessionContext): BuildSession {
|
|
776
|
+
return new BuildSession(performance.now(), context.trigger, context.triggerCountsDocs, this.phaseDetailMs.value);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Close `session`: flush what it buffered, then release it if it is still the
|
|
781
|
+
* current one (see {@link runInSession} on why that check is conditional).
|
|
782
|
+
* The flush is unconditional — a preempted build's lines still describe work
|
|
783
|
+
* that happened.
|
|
784
|
+
*/
|
|
785
|
+
protected endSession(session: BuildSession): void {
|
|
786
|
+
this.flushSession(session);
|
|
787
|
+
if (this.activeSession === session) {
|
|
788
|
+
this.activeSession = undefined;
|
|
789
|
+
if (session.cancelled) {
|
|
790
|
+
this.lastCancelledTraceId = session.traceId;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Emit the lines `session` held back, if it ran long enough to be worth the
|
|
797
|
+
* detail; drop them otherwise. Emits through {@link emit} rather than
|
|
798
|
+
* {@link log}, which would route them straight back into the buffer.
|
|
799
|
+
*/
|
|
800
|
+
protected flushSession(session: BuildSession): void {
|
|
801
|
+
const elapsedMs = performance.now() - session.startMs;
|
|
802
|
+
if (elapsedMs >= session.detailThresholdMs) {
|
|
803
|
+
for (const line of session.bufferedLines) {
|
|
804
|
+
this.emit(line);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
session.bufferedLines.length = 0;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** Label for the build's own log line. Override to customise wording. */
|
|
811
|
+
protected rebuildLabel(changed: URI[], deleted: URI[]): string {
|
|
812
|
+
if (changed.length === 0 && deleted.length === 0) {
|
|
813
|
+
return 'Rebuild documents (nothing to do)';
|
|
814
|
+
}
|
|
815
|
+
if (changed.length === 1 && deleted.length === 0) {
|
|
816
|
+
return `Rebuild document: ${this.formatUri(changed[0])}`;
|
|
817
|
+
}
|
|
818
|
+
if (changed.length === 0 && deleted.length === 1) {
|
|
819
|
+
return `Rebuild after delete: ${this.formatUri(deleted[0])}`;
|
|
820
|
+
}
|
|
821
|
+
return `Rebuild documents (${changed.length} changed, ${deleted.length} deleted)`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** Short description of what triggered the build, repeated on every phase line. Override to customise wording. */
|
|
825
|
+
protected buildTriggerLabel(changed: URI[], deleted: URI[]): string {
|
|
826
|
+
if (changed.length === 0 && deleted.length === 0) {
|
|
827
|
+
return 'nothing';
|
|
828
|
+
}
|
|
829
|
+
if (changed.length === 1 && deleted.length === 0) {
|
|
830
|
+
return this.formatUri(changed[0]);
|
|
831
|
+
}
|
|
832
|
+
if (changed.length === 0 && deleted.length === 1) {
|
|
833
|
+
return `deleted ${this.formatUri(deleted[0])}`;
|
|
834
|
+
}
|
|
835
|
+
return `${changed.length} changed, ${deleted.length} deleted`;
|
|
836
|
+
}
|
|
837
|
+
|
|
618
838
|
// ============================================================
|
|
619
839
|
// Logging — phase-reached listeners
|
|
620
840
|
// ============================================================
|
|
@@ -630,13 +850,35 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
630
850
|
const now = performance.now();
|
|
631
851
|
const elapsedMs = Math.round(now - this.lastPhaseMs);
|
|
632
852
|
this.lastPhaseMs = now;
|
|
853
|
+
// Counted before the line is formatted, so the formatter stays a pure
|
|
854
|
+
// function of state a caller can also set up in a test.
|
|
855
|
+
if (this.activeSession) {
|
|
856
|
+
this.activeSession.phasesLogged++;
|
|
857
|
+
}
|
|
633
858
|
this.log(this.phaseReachedLine(state, documents, elapsedMs));
|
|
634
859
|
}
|
|
635
860
|
|
|
636
|
-
/**
|
|
861
|
+
/**
|
|
862
|
+
* Format the phase-reached log line. Override to customise wording.
|
|
863
|
+
*
|
|
864
|
+
* Within a session the line names what triggered the build, so a phase read
|
|
865
|
+
* in isolation still says which rebuild it belongs to. `elapsedMs` is ignored
|
|
866
|
+
* for the FIRST phase of a session: it measures from the previous build's
|
|
867
|
+
* last phase, an idle gap that says nothing about this build.
|
|
868
|
+
*/
|
|
637
869
|
protected phaseReachedLine(state: DocumentState, documents: LangiumDocument[], elapsedMs: number): string {
|
|
638
|
-
const
|
|
639
|
-
|
|
870
|
+
const session = this.activeSession;
|
|
871
|
+
let docInfo: string;
|
|
872
|
+
if (session) {
|
|
873
|
+
docInfo = session.triggerCountsDocs ? `building ${session.trigger}` : `building ${session.trigger}, ${documents.length} docs`;
|
|
874
|
+
} else {
|
|
875
|
+
docInfo = documents.length === 1 ? this.formatUri(documents[0].uri) : `${documents.length} docs`;
|
|
876
|
+
}
|
|
877
|
+
const elapsedInfo =
|
|
878
|
+
session && session.phasesLogged <= 1
|
|
879
|
+
? `${Math.round(performance.now() - session.startMs)}ms since build start`
|
|
880
|
+
: `${elapsedMs}ms since previous phase`;
|
|
881
|
+
return `Reached phase '${DocumentState[state]}' [${docInfo}, ${elapsedInfo}]`;
|
|
640
882
|
}
|
|
641
883
|
|
|
642
884
|
// ============================================================
|
|
@@ -832,8 +1074,26 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
832
1074
|
// Internal helpers
|
|
833
1075
|
// ============================================================
|
|
834
1076
|
|
|
835
|
-
/**
|
|
1077
|
+
/**
|
|
1078
|
+
* Dispatch a log line at the configured log level; a no-op when `logLevel ===
|
|
1079
|
+
* 'off'`.
|
|
1080
|
+
*
|
|
1081
|
+
* Held on the active session when it buffers, so the "was this build worth a
|
|
1082
|
+
* per-phase breakdown" decision — which needs the build's total duration, and
|
|
1083
|
+
* so cannot be taken by anything that runs while the lines are produced — is
|
|
1084
|
+
* deferred to {@link flushSession}.
|
|
1085
|
+
*/
|
|
836
1086
|
protected log(message: string): void {
|
|
1087
|
+
const session = this.activeSession;
|
|
1088
|
+
if (session?.buffers) {
|
|
1089
|
+
session.bufferedLines.push(message);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
this.emit(message);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** Write a line out, bypassing session buffering. The single sink every framework log line reaches. */
|
|
1096
|
+
protected emit(message: string): void {
|
|
837
1097
|
this.tracer.logAt(this.logLevel, message);
|
|
838
1098
|
}
|
|
839
1099
|
}
|
package/src/langium/module.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { HydraniumIndexManager } from './workspace/index-manager.js';
|
|
|
26
26
|
import { HydraniumWorkspaceManager } from './workspace/hydranium-workspace-manager.js';
|
|
27
27
|
import { HydraniumWorkspaceLock } from './workspace/hydranium-workspace-lock.js';
|
|
28
28
|
import { HydraniumLangiumDocumentFactory } from './workspace/hydranium-langium-document-factory.js';
|
|
29
|
+
import { type HydraniumDocumentRegistry, HydraniumLangiumDocuments } from './workspace/langium-documents.js';
|
|
29
30
|
import { type AdditionalDocumentContribution } from './workspace/additional-document-contribution.js';
|
|
30
31
|
import { DefaultDocumentUriPolicy, type DocumentUriPolicy } from './workspace/document-uri-policy.js';
|
|
31
32
|
import { HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
|
|
@@ -113,6 +114,12 @@ export interface ServerAddedSharedServices<TProject extends Project = Project> {
|
|
|
113
114
|
workspace: {
|
|
114
115
|
/* override */ TextDocuments: HydraniumTextDocuments<TextDocument>;
|
|
115
116
|
/* override */ WorkspaceManager: HydraniumWorkspaceManager;
|
|
117
|
+
/**
|
|
118
|
+
* Narrow Langium's registry slot to the framework surface, which adds
|
|
119
|
+
* `createEmptyDocument` — reachable only through this narrowing, and
|
|
120
|
+
* wanted by a scope provider querying a URI before the file exists.
|
|
121
|
+
*/
|
|
122
|
+
/* override */ LangiumDocuments: HydraniumDocumentRegistry;
|
|
116
123
|
/**
|
|
117
124
|
* Narrow Langium's `IndexManager` slot to the framework subclass, which
|
|
118
125
|
* layers an `elementsByName` map over Langium's index and exposes
|
|
@@ -392,6 +399,10 @@ export function createServerSharedModule(
|
|
|
392
399
|
// document retains serialized text via the per-language Serializer,
|
|
393
400
|
// making it re-read-safe through the virtual-aware FileSystemProvider.
|
|
394
401
|
LangiumDocumentFactory: services => new HydraniumLangiumDocumentFactory(services),
|
|
402
|
+
// Langium's default routes through no identity seam and treats every
|
|
403
|
+
// failed load alike, so leaving this unbound opts a server out of both
|
|
404
|
+
// with nothing to signal it.
|
|
405
|
+
LangiumDocuments: services => new HydraniumLangiumDocuments(services),
|
|
395
406
|
DocumentBuilder: services => new HydraniumDocumentBuilder(services),
|
|
396
407
|
// The write-lock scope this marks is inert unless a host installs a
|
|
397
408
|
// scope tracker — `@hydranium/core/node` does.
|
|
@@ -225,7 +225,7 @@ function rehydrateCst(document: LangiumDocument, factory: LangiumDocumentFactory
|
|
|
225
225
|
* demand by the identity-preserving graft of {@link rehydrateNode} /
|
|
226
226
|
* {@link rehydrate}, driven transparently from the framework
|
|
227
227
|
* `NameProvider.getNameNode` chokepoint, the comment provider, and
|
|
228
|
-
* `
|
|
228
|
+
* `HydraniumLangiumDocuments.getOrCreateDocument`.
|
|
229
229
|
*/
|
|
230
230
|
export class CstResidencyService {
|
|
231
231
|
protected readonly strategy: CstResidencyStrategy;
|
|
@@ -11,6 +11,7 @@ import type { Clock, Logger, Project, Tracer } from '@hydranium/protocol';
|
|
|
11
11
|
import type { LangiumSharedCoreServices } from '@hydranium/langium';
|
|
12
12
|
import type { SelfSaveRegistry } from '../documents/self-save-registry.js';
|
|
13
13
|
import type { WritableFileSystemProvider } from '../documents/ast-document-manager.js';
|
|
14
|
+
import type { HydraniumDocumentRegistry } from './workspace/langium-documents.js';
|
|
14
15
|
import type { BuildPhasePassService } from './build-phase-pass/build-phase-pass-service.js';
|
|
15
16
|
import type { CstResidencyService } from './residency/cst-residency-service.js';
|
|
16
17
|
import type { BuildPipelineIntegration } from './document-builder/build-pipeline-integration.js';
|
|
@@ -75,6 +76,11 @@ export interface ServerSharedServicesMinimal<TProject extends Project = Project>
|
|
|
75
76
|
// framework always binds, so `wsRelativePath` and the folder-walk are
|
|
76
77
|
// reachable from the minimal surface without a cast.
|
|
77
78
|
WorkspaceManager: HydraniumWorkspaceManager;
|
|
79
|
+
// Same, for the registry: the framework always binds
|
|
80
|
+
// `HydraniumLangiumDocuments`, and `createEmptyDocument` is reachable
|
|
81
|
+
// only through this narrowing — a scope provider querying a URI before
|
|
82
|
+
// the file exists is the caller that needs it.
|
|
83
|
+
/* override */ LangiumDocuments: HydraniumDocumentRegistry;
|
|
78
84
|
ProjectManager: ProjectManager<TProject>;
|
|
79
85
|
SelfSaveRegistry: SelfSaveRegistry;
|
|
80
86
|
BuildPipelineIntegration: BuildPipelineIntegration;
|
|
@@ -26,12 +26,25 @@ import { isSyntheticNode } from '../workspace/synthetic.js';
|
|
|
26
26
|
import { isVirtualUri } from '../workspace/virtual-document.js';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Diagnostic
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Diagnostic read off a `LangiumDocument`: an LSP {@link Diagnostic} that MAY
|
|
30
|
+
* carry the protocol-level `element` path and `property` name from
|
|
31
|
+
* {@link TransferDiagnostic}.
|
|
32
|
+
*
|
|
33
|
+
* `element` is optional because a document's diagnostics do not all come from
|
|
34
|
+
* this validator. {@link HydraniumDocumentValidator.toDiagnostic} always sets
|
|
35
|
+
* one, but Langium pushes lexer and parser errors straight onto the document
|
|
36
|
+
* without routing them through it, so a document that fails to parse carries
|
|
37
|
+
* diagnostics with no path at all. Treat it as absent, not empty.
|
|
38
|
+
*
|
|
39
|
+
* Narrowing `LangiumDocument.diagnostics` to this type instead is unavailable:
|
|
40
|
+
* declaration merging may add a member but not retype one, and augmenting the
|
|
41
|
+
* LSP `Diagnostic` reaches only one of the two declaration files its package
|
|
42
|
+
* ships, since the `exports` map splits `import` from `default` with no `types`
|
|
43
|
+
* condition. Consumers therefore cast at the read, and the cast is sound only
|
|
44
|
+
* because this field is optional.
|
|
32
45
|
*/
|
|
33
46
|
export interface TransferLspDiagnostic extends Diagnostic {
|
|
34
|
-
element
|
|
47
|
+
element?: string;
|
|
35
48
|
property?: string;
|
|
36
49
|
}
|
|
37
50
|
|
|
@@ -105,10 +105,9 @@ export class DefaultDocumentUriPolicy implements DocumentUriPolicy {
|
|
|
105
105
|
/**
|
|
106
106
|
* No filesystem access, so existence cannot be checked — every URI is
|
|
107
107
|
* treated as loadable and returned unchanged. A later load that misses
|
|
108
|
-
* therefore reaches the filesystem and throws, which
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* is what makes a missing file a placeholder rather than an error.
|
|
108
|
+
* therefore reaches the filesystem and throws, which reports the miss with
|
|
109
|
+
* the reason the read failed. Under a policy that CAN check, the miss is
|
|
110
|
+
* caught earlier and reported without one.
|
|
112
111
|
*/
|
|
113
112
|
loadUri(uri: URI | string): URI | undefined {
|
|
114
113
|
return UriUtils.toUri(uri);
|
|
@@ -7,14 +7,24 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
|
-
import { type
|
|
11
|
-
import { type AstNode, DefaultLangiumDocuments, type LangiumDocument, type URI } from '@hydranium/langium';
|
|
10
|
+
import { type AstNode, DefaultLangiumDocuments, type LangiumDocument, type LangiumDocuments, type URI } from '@hydranium/langium';
|
|
12
11
|
import { type ServerSharedServicesMinimal } from '../shared-services.js';
|
|
13
12
|
import { type DocumentUriPolicy } from './document-uri-policy.js';
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* The registry surface the framework adds on top of Langium's
|
|
16
|
+
* {@link LangiumDocuments}. Declared separately from the implementing class so
|
|
17
|
+
* the shared-services slot can narrow to it: the class takes the services tree
|
|
18
|
+
* as its constructor parameter, so naming the CLASS there would make that type
|
|
19
|
+
* depend on itself.
|
|
20
|
+
*/
|
|
21
|
+
export interface HydraniumDocumentRegistry extends LangiumDocuments {
|
|
22
|
+
createEmptyDocument(uri: URI): LangiumDocument<AstNode>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default `LangiumDocuments` for `@hydranium/core` consumers, extending
|
|
27
|
+
* Langium's {@link DefaultLangiumDocuments} with:
|
|
18
28
|
*
|
|
19
29
|
* 1. **Identity via the {@link DocumentUriPolicy} seam.**
|
|
20
30
|
* `getOrCreateDocument` resolves the requested URI through the seam — the
|
|
@@ -24,31 +34,52 @@ import { type DocumentUriPolicy } from './document-uri-policy.js';
|
|
|
24
34
|
* `LangiumDocuments` override. The default resolves to the URI unchanged
|
|
25
35
|
* (`DefaultDocumentUriPolicy`), matching Langium's own keying.
|
|
26
36
|
*
|
|
27
|
-
* 2.
|
|
28
|
-
* document
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
37
|
+
* 2. **A load that finds nothing rejects**, rather than answering with a
|
|
38
|
+
* transient empty document. An empty AST validates clean, so a placeholder
|
|
39
|
+
* hands the caller something that silently is not the file, and a caller
|
|
40
|
+
* that wants a document for a URI with no content on disk cannot use one
|
|
41
|
+
* anyway: it is unregistered, so no build, index or save can see it, and
|
|
42
|
+
* `LangiumDocumentFactory.update` reads from disk. Content that legitimately
|
|
43
|
+
* lives outside the filesystem belongs in a virtual document, which carries
|
|
44
|
+
* real source text.
|
|
35
45
|
*/
|
|
36
|
-
export
|
|
46
|
+
export class HydraniumLangiumDocuments extends DefaultLangiumDocuments implements HydraniumDocumentRegistry {
|
|
37
47
|
/** Document-identity seam, shared with the text store, event filters, and builder. */
|
|
38
48
|
protected readonly uriPolicy: DocumentUriPolicy;
|
|
39
|
-
protected readonly tracer: Tracer;
|
|
40
49
|
|
|
41
50
|
constructor(protected override readonly services: ServerSharedServicesMinimal) {
|
|
42
51
|
super(services);
|
|
43
52
|
this.uriPolicy = services.workspace.DocumentUriPolicy;
|
|
44
|
-
this.tracer = services.Tracer.for('LangiumDocuments');
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
|
-
* Build a transient
|
|
49
|
-
*
|
|
56
|
+
* Build a transient document for `uri` by parsing empty text with the
|
|
57
|
+
* grammar `uri` routes to, so the root is that language's entry type with
|
|
58
|
+
* every containment list initialised.
|
|
59
|
+
*
|
|
60
|
+
* For callers that need a document at a URI with no content on disk — the
|
|
61
|
+
* scope provider querying before a file exists is the case it was added for.
|
|
62
|
+
* `getOrCreateDocument` deliberately does NOT fall back to this: fabricating
|
|
63
|
+
* a document behind a caller that asked to LOAD one hands back something
|
|
64
|
+
* that silently is not the file, whereas calling this is a caller saying it
|
|
65
|
+
* wants a stand-in.
|
|
66
|
+
*
|
|
67
|
+
* The result is not registered, so nothing downstream can see it, and
|
|
68
|
+
* `LangiumDocumentFactory.update` would read from disk. It is a probe, not a
|
|
69
|
+
* document under construction; content that must survive belongs in a
|
|
70
|
+
* virtual document, which carries real source text.
|
|
71
|
+
*
|
|
72
|
+
* Building the root by hand instead needs the entry type name and a cast
|
|
73
|
+
* past the generated types, and leaves those lists `undefined`. A grammar
|
|
74
|
+
* whose entry rule opens with mandatory syntax yields a parse error here,
|
|
75
|
+
* and it is kept: `AstReflection.isComplete` is `false` for such a root
|
|
76
|
+
* however it is built, so the error states the same thing.
|
|
50
77
|
*/
|
|
51
|
-
|
|
78
|
+
createEmptyDocument(uri: URI): LangiumDocument<AstNode> {
|
|
79
|
+
// The two-argument overload is synchronous; passing a cancellation token
|
|
80
|
+
// selects the promise-returning one, which this contract cannot await.
|
|
81
|
+
return this.langiumDocumentFactory.fromString('', uri);
|
|
82
|
+
}
|
|
52
83
|
|
|
53
84
|
override async getOrCreateDocument(uri: URI): Promise<LangiumDocument<AstNode>> {
|
|
54
85
|
const resolved = this.uriPolicy.loadUri(uri);
|
|
@@ -68,27 +99,18 @@ export abstract class AbstractHydraniumLangiumDocuments extends DefaultLangiumDo
|
|
|
68
99
|
return await super.getOrCreateDocument(resolved);
|
|
69
100
|
} catch (error: unknown) {
|
|
70
101
|
// Load lost a race with a concurrent create — return that document.
|
|
102
|
+
// Checked before propagating, so a race is not reported as a
|
|
103
|
+
// missing file; cancellation carries no document and falls through.
|
|
71
104
|
const reentrant = this.getDocument(resolved);
|
|
72
105
|
if (reentrant) {
|
|
73
106
|
this.services.workspace.CstResidencyService.rehydrate(reentrant);
|
|
74
107
|
return reentrant;
|
|
75
108
|
}
|
|
76
|
-
|
|
77
|
-
// below still runs, because the file-not-found case is the ordinary
|
|
78
|
-
// one here and no provider-independent way to recognise it exists:
|
|
79
|
-
// Node's provider throws `ENOENT`, the in-memory one a bare `Error`,
|
|
80
|
-
// and the provider is a seam an adopter rebinds. Discriminating on
|
|
81
|
-
// message text across that seam would be a worse defect than the
|
|
82
|
-
// one it fixes. So the failure is TRACED rather than propagated —
|
|
83
|
-
// an unreadable-but-present file still degrades to an empty
|
|
84
|
-
// document, but it stops doing so silently.
|
|
85
|
-
this.tracer
|
|
86
|
-
.with(resolved.toString())
|
|
87
|
-
.debug(`Load failed, falling back to an empty document: ${error instanceof Error ? error.message : String(error)}`);
|
|
109
|
+
throw error;
|
|
88
110
|
}
|
|
89
111
|
}
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
112
|
+
// The seam reports no loadable content, so there is nothing to read and
|
|
113
|
+
// no error from a read to carry.
|
|
114
|
+
throw new Error(`No loadable content for ${uri.toString()}`);
|
|
93
115
|
}
|
|
94
116
|
}
|