@hydranium/core 1.0.0-next.23 → 1.0.0-next.24

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 (42) hide show
  1. package/lib/langium/document-builder/build-session.d.ts +93 -0
  2. package/lib/langium/document-builder/build-session.d.ts.map +1 -0
  3. package/lib/langium/document-builder/build-session.js +72 -0
  4. package/lib/langium/document-builder/build-session.js.map +1 -0
  5. package/lib/langium/document-builder/document-builder.d.ts +117 -6
  6. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  7. package/lib/langium/document-builder/document-builder.js +230 -9
  8. package/lib/langium/document-builder/document-builder.js.map +1 -1
  9. package/lib/langium/document-builder/index.d.ts +1 -0
  10. package/lib/langium/document-builder/index.d.ts.map +1 -1
  11. package/lib/langium/document-builder/index.js +1 -0
  12. package/lib/langium/document-builder/index.js.map +1 -1
  13. package/lib/langium/module.d.ts +7 -0
  14. package/lib/langium/module.d.ts.map +1 -1
  15. package/lib/langium/module.js +5 -0
  16. package/lib/langium/module.js.map +1 -1
  17. package/lib/langium/residency/cst-residency-service.d.ts +1 -1
  18. package/lib/langium/residency/cst-residency-service.js +1 -1
  19. package/lib/langium/shared-services.d.ts +2 -0
  20. package/lib/langium/shared-services.d.ts.map +1 -1
  21. package/lib/langium/shared-services.js.map +1 -1
  22. package/lib/langium/validation/document-validator.d.ts +17 -4
  23. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  24. package/lib/langium/validation/document-validator.js.map +1 -1
  25. package/lib/langium/workspace/document-uri-policy.d.ts +3 -4
  26. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  27. package/lib/langium/workspace/document-uri-policy.js +3 -4
  28. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  29. package/lib/langium/workspace/langium-documents.d.ts +44 -17
  30. package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
  31. package/lib/langium/workspace/langium-documents.js +45 -28
  32. package/lib/langium/workspace/langium-documents.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/langium/document-builder/build-session.ts +87 -0
  35. package/src/langium/document-builder/document-builder.ts +269 -9
  36. package/src/langium/document-builder/index.ts +1 -0
  37. package/src/langium/module.ts +11 -0
  38. package/src/langium/residency/cst-residency-service.ts +1 -1
  39. package/src/langium/shared-services.ts +6 -0
  40. package/src/langium/validation/document-validator.ts +17 -4
  41. package/src/langium/workspace/document-uri-policy.ts +3 -4
  42. 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 `notifyDocumentPhase`,
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 super.update(changedURIs, deletedURIs, cancelToken);
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
- /** Format the phase-reached log line. Override to customise wording. */
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 docInfo = documents.length === 1 ? this.formatUri(documents[0].uri) : `${documents.length} docs`;
639
- return `Reached phase '${DocumentState[state]}' [${docInfo}, ${elapsedMs}ms since previous phase]`;
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
- /** Dispatch a log line at the configured log level; a no-op when `logLevel === 'off'`. */
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
  }
@@ -8,5 +8,6 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './build-pipeline-integration.js';
11
+ export * from './build-session.js';
11
12
  export * from './document-builder.js';
12
13
  export * from './labeled-phase-listener.js';
@@ -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
- * `AbstractHydraniumLangiumDocuments.getOrCreateDocument`.
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 shape produced by {@link HydraniumDocumentValidator}: an LSP
30
- * {@link Diagnostic} extended with the protocol-level `element` path and
31
- * optional `property` name from {@link TransferDiagnostic}.
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: string;
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
- * `AbstractHydraniumLangiumDocuments.getOrCreateDocument` absorbs into its
110
- * empty-document fallback: under this policy that fallback, not this method,
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 Tracer } from '@hydranium/protocol';
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
- * Default `LangiumDocuments` for `@hydranium/core` consumers. Layers two
17
- * extensions on top of Langium's {@link DefaultLangiumDocuments}:
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. **`createEmptyDocument(uri)` synchronous** factory for a transient
28
- * document with no loadable on-disk content. `getOrCreateDocument` falls
29
- * back to it when the seam reports no loadable URI, or when `super` cannot
30
- * load it, so callers always receive a document. Abstract because the
31
- * parse-result root is grammar-specific (each grammar returns its own
32
- * root node type). The fallback document is deliberately
33
- * **not** registered: a transient placeholder must not mask the real
34
- * document once it appears.
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 abstract class AbstractHydraniumLangiumDocuments extends DefaultLangiumDocuments {
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 empty document for `uri`. Implemented by consumers
49
- * because the parse-result root is grammar-specific.
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
- abstract createEmptyDocument(uri: URI): LangiumDocument<AstNode>;
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
- // No concurrent document, so the load genuinely failed. The fallback
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
- // No loadable content (missing / synthetic / not yet written): a transient
91
- // empty placeholder keyed canonically not registered (see the class doc).
92
- return this.createEmptyDocument(resolved ?? uri);
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
  }