@hydranium/core 1.0.0-next.140 → 1.0.0-next.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  2. package/lib/documents/hydranium-text-documents.js +3 -13
  3. package/lib/documents/hydranium-text-documents.js.map +1 -1
  4. package/lib/langium/document-builder/document-builder.d.ts +140 -13
  5. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  6. package/lib/langium/document-builder/document-builder.js +218 -24
  7. package/lib/langium/document-builder/document-builder.js.map +1 -1
  8. package/lib/langium/model-service/model-service.d.ts +25 -28
  9. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  10. package/lib/langium/model-service/model-service.js +25 -30
  11. package/lib/langium/model-service/model-service.js.map +1 -1
  12. package/lib/langium/module.d.ts +5 -6
  13. package/lib/langium/module.d.ts.map +1 -1
  14. package/lib/langium/module.js.map +1 -1
  15. package/lib/langium/workspace/hydranium-workspace-lock.d.ts +13 -0
  16. package/lib/langium/workspace/hydranium-workspace-lock.d.ts.map +1 -1
  17. package/lib/langium/workspace/hydranium-workspace-lock.js +18 -0
  18. package/lib/langium/workspace/hydranium-workspace-lock.js.map +1 -1
  19. package/lib/lsp/hydranium-document-update-handler.d.ts +24 -21
  20. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  21. package/lib/lsp/hydranium-document-update-handler.js +27 -28
  22. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  23. package/lib/testing/make-test-services.d.ts.map +1 -1
  24. package/lib/testing/make-test-services.js +3 -2
  25. package/lib/testing/make-test-services.js.map +1 -1
  26. package/lib/testing/stub-document-builder.d.ts +12 -6
  27. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  28. package/lib/testing/stub-document-builder.js +9 -1
  29. package/lib/testing/stub-document-builder.js.map +1 -1
  30. package/package.json +5 -5
  31. package/src/documents/hydranium-text-documents.ts +3 -12
  32. package/src/langium/document-builder/document-builder.ts +253 -28
  33. package/src/langium/model-service/model-service.ts +34 -41
  34. package/src/langium/module.ts +5 -6
  35. package/src/langium/workspace/hydranium-workspace-lock.ts +21 -0
  36. package/src/lsp/hydranium-document-update-handler.ts +28 -31
  37. package/src/testing/make-test-services.ts +3 -2
  38. package/src/testing/stub-document-builder.ts +16 -6
@@ -58,17 +58,17 @@ const SAVE_SETTLE_TIMEOUT_MS = 10_000;
58
58
  */
59
59
  export interface ModelServiceOptions extends LogNameOptions {
60
60
  /**
61
- * Serialise the facade's own build under the workspace WRITE lock, the way
62
- * Langium's `DefaultDocumentUpdateHandler` dispatches its build. Default
63
- * `true`.
61
+ * Serialise the facade's own build under the workspace WRITE lock, through
62
+ * `HydraniumDocumentBuilder.scheduleUpdate` as the LSP update handler
63
+ * builds. Default `true`.
64
64
  *
65
65
  * **Why it defaults on.** Unlocked, the facade's build races the LSP bridge's
66
66
  * build of the same URI — both are legitimate (the bridge exists only under a
67
67
  * `Connection`, so the facade stands in for it headless), but nothing
68
68
  * serialises them, so both run a full validation pass and Langium appends the
69
69
  * second onto the first. Every diagnostic is then duplicated, and the
70
- * duplication compounds per rebuild. Serialised, Langium elides the second
71
- * build entirely, so the redundant work goes too.
70
+ * duplication compounds per rebuild. Serialised, the second request shares
71
+ * the first build where it carries both, so the redundant work goes too.
72
72
  *
73
73
  * **What `false` costs.** The facade's build is then unserialised and can run
74
74
  * concurrently with the bridge's build of the same URI, so the affected
@@ -165,10 +165,10 @@ export interface ModelServiceOptions extends LogNameOptions {
165
165
  * implementing the URI gate at each callsite.
166
166
  *
167
167
  * **Two families, and the distinction matters more than the names suggest.**
168
- * `waitFor*` is a pure wait — it never triggers a build, so a caller waiting on
169
- * a document no build has touched waits until something else builds it.
170
- * `ensureDocumentState` and the phase shorthands over it *dispatch*: warm
171
- * documents are awaited, cold ones are built.
168
+ * `waitFor*` requests no build of its own: a wait on a URI with no document
169
+ * rejects, and one on a document no build will carry is re-queued by the
170
+ * builder's wait. `ensureDocumentState` and the phase shorthands over it
171
+ * *dispatch*: warm documents are awaited, cold ones are built.
172
172
  *
173
173
  * **Diagnostics are typed `never` below `Validated`.** Validation is the last
174
174
  * phase, so at any earlier landmark the array either is not yet computed or
@@ -397,8 +397,7 @@ export class DefaultModelService<
397
397
  /**
398
398
  * Wait for the document at `uri` to reach `state`. Pure wait — does
399
399
  * not trigger a build. If `uri` is not yet in the document registry
400
- * the call will hang until something else drives it through the
401
- * pipeline; for the cold-start case use {@link rebuild} instead.
400
+ * the call rejects; for the cold-start case use {@link rebuild} instead.
402
401
  *
403
402
  * Wrapped in a debug-level timing log via {@link Tracer.time} so
404
403
  * slow per-URI waits surface in build telemetry; the URI is
@@ -470,24 +469,23 @@ export class DefaultModelService<
470
469
  * Force a fresh build of the document at `uri` and wait for it to
471
470
  * reach `state` (or the integrity-settled landmark
472
471
  * {@link IntegrityService.SettledState} if omitted).
473
- * Always triggers `DocumentBuilder.update([uri], [])` regardless of
474
- * whether the document is already in the registry — call this when
475
- * you want to re-process from scratch.
472
+ * Always builds the document, whether or not it is already in the
473
+ * registry, in a build of its own or in one already scheduled that carries
474
+ * it — call this when you want to re-process from scratch.
476
475
  *
477
- * The facade is responsible for firing `DocumentBuilder.update`
478
- * directly because Langium's `DefaultDocumentUpdateHandler.didChangeContent`
476
+ * The facade is responsible for requesting the build itself because Langium's `DefaultDocumentUpdateHandler.didChangeContent`
479
477
  * (the standard text-document → builder bridge) only runs under an
480
478
  * LSP `Connection`. Running headless the bridge never fires; the
481
479
  * facade stands in for it on its own update path.
482
480
  *
483
481
  * Coexistence with an LSP head running on the same `DocumentBuilder` is
484
- * fine, but not because the builder merges the two: Langium does NOT
485
- * coalesce concurrent builds of the same URI. The LSP head fires `update`
486
- * from the LSP-driven event and the facade fires it from its own RPC-driven
487
- * event — both legitimate — so this method takes the workspace WRITE lock,
488
- * the same one Langium's own text-change bridge builds under, to serialise
489
- * them. Two unserialised builds of one URI each run a full validation pass
490
- * and Langium appends the second set onto the first, duplicating every
482
+ * fine: the LSP head builds from the LSP-driven event and the facade from
483
+ * its own RPC-driven event — both legitimate — and both go through
484
+ * `HydraniumDocumentBuilder.scheduleUpdate`, which serialises them under
485
+ * the workspace WRITE lock and lets the second share the first's build
486
+ * where that build carries it. Langium alone does not coalesce concurrent
487
+ * builds of one URI: two unserialised builds each run a full validation
488
+ * pass and Langium appends the second set onto the first, duplicating every
491
489
  * diagnostic. Opt out via
492
490
  * {@link ModelServiceOptions.serializeBuilds} — see there for the
493
491
  * non-reentrancy hazard that is the reason the opt-out exists.
@@ -498,9 +496,8 @@ export class DefaultModelService<
498
496
  * base's request start a build of its own, so the write is built and
499
497
  * delivered twice.
500
498
  *
501
- * Returns an empty `{ root, diagnostics }` envelope when the document
502
- * cannot be loaded; adopters that want to throw override this method
503
- * on their subclass.
499
+ * Rejects when the build leaves no document for `uri`, as for a URI with
500
+ * neither a file nor text: the wait after it has nothing to wait on.
504
501
  *
505
502
  * Consumers wanting "give me this doc at state X, building only if
506
503
  * needed" — use the per-state methods ({@link parsed} / {@link linked}
@@ -512,8 +509,8 @@ export class DefaultModelService<
512
509
 
513
510
  /**
514
511
  * Internal build core operating on an already-{@link CanonicalUri canonical}
515
- * URI — the build counterpart to {@link waitForDocumentStateCanonical}. Drives
516
- * `DocumentBuilder.update` then waits via the canonical wait core, so the
512
+ * URI — the build counterpart to {@link waitForDocumentStateCanonical}.
513
+ * Requests the build, then waits via the canonical wait core, so the
517
514
  * identity is resolved once at the public door and neither sink re-runs the
518
515
  * `realpath`. (`DocumentBuilder.update` still resolves each URI internally for
519
516
  * directory flattening — that is the build's own existence-aware resolution,
@@ -525,15 +522,15 @@ export class DefaultModelService<
525
522
  cancelToken?: CancellationToken
526
523
  ): Promise<AstDocument<TAst, TDiagnostic>> {
527
524
  const documentUri = UriUtils.toUri(uri);
528
- // Runs under the workspace WRITE lock, matching Langium's own
529
- // `DefaultDocumentUpdateHandler`, which dispatches its build as
530
- // `workspaceLock.write(token => documentBuilder.update(...))`.
525
+ // Runs under the workspace WRITE lock, through the builder's
526
+ // `scheduleUpdate` like the LSP update handler's build, so the build the
527
+ // store's change event already scheduled for this write carries this
528
+ // request too rather than being cancelled by it.
531
529
  //
532
530
  // Unlocked, this build races the LSP bridge's build of the same URI: both
533
531
  // are legitimate (the bridge only exists under a `Connection`, so the
534
- // facade stands in for it headless), and the coexistence note above relied
535
- // on Langium coalescing them by URI. It does not — nothing serialises the
536
- // two, so both reach `Validated`, and because each computes its missing
532
+ // facade stands in for it headless), and nothing serialises the two, so
533
+ // both reach `Validated`, and because each computes its missing
537
534
  // validation categories before the other has recorded its own, both run a
538
535
  // FULL pass and Langium appends the second onto the first (its append is
539
536
  // meant for category-partitioned passes). The user-visible result is every
@@ -556,13 +553,9 @@ export class DefaultModelService<
556
553
  if (isInsideWriteLock()) {
557
554
  throw new ReentrantWriteLockError(uri);
558
555
  }
559
- // The lock's OWN token, not the caller's: a later `write` cancels the
560
- // running one through that token, so substituting the caller's would
561
- // leave this build deaf to the lock's cancellation protocol. A caller
562
- // token still governs the phase wait below.
563
- await this.services.workspace.WorkspaceLock.write(lockToken =>
564
- this.services.workspace.DocumentBuilder.update([documentUri], [], lockToken)
565
- );
556
+ // The build runs on the lock's own token, which a later write cancels;
557
+ // the caller's token governs only the phase wait below.
558
+ await this.services.workspace.DocumentBuilder.scheduleUpdate([documentUri], []);
566
559
  } else {
567
560
  await this.services.workspace.DocumentBuilder.update([documentUri], [], cancelToken);
568
561
  }
@@ -160,12 +160,11 @@ export interface ServerAddedSharedServices<
160
160
  * the `awaitDocumentState` bug-fix, framework-wired phase logs,
161
161
  * and the `markNextReason` observability primitive without
162
162
  * binding the slot explicitly. The
163
- * `HydraniumDocumentUpdateHandler`'s `dispatch` calls this
164
- * slot's `markNextReason` to stage the LSP event that triggered
165
- * the build, for a subclass's build logging to tag its line with,
166
- * so narrowing here is a load-bearing type contract — adopters who
167
- * explicitly downgrade to Langium's default lose that call site's
168
- * compile-time target.
163
+ * `HydraniumDocumentUpdateHandler`'s `dispatch` and
164
+ * `ModelService.rebuild` build through this slot's
165
+ * `scheduleUpdate`, so narrowing here is a load-bearing contract:
166
+ * a builder without it fails every LSP-driven build, and every
167
+ * session write while `serializeBuilds` is on, at run time.
169
168
  */
170
169
  /* override */ DocumentBuilder: HydraniumDocumentBuilder;
171
170
  /**
@@ -29,7 +29,28 @@ import { runInWriteLockScope } from './write-lock-scope.js';
29
29
  * would turn ordinary read-then-write sequences into false positives.
30
30
  */
31
31
  export class HydraniumWorkspaceLock extends DefaultWorkspaceLock {
32
+ /** Backs {@link writeCancellations}. */
33
+ protected cancelledWriteCount = 0;
34
+
35
+ /**
36
+ * How many calls have cancelled the lock's latest write: every `write`,
37
+ * which cancels the write before it, and every `cancelWrite`. A caller that
38
+ * reads this right after queuing a write, and reads the same number later,
39
+ * holds a write nothing has cancelled, whether it is still queued or
40
+ * already running. Its token answers that only once the write runs, since
41
+ * a queued write has not been handed it yet.
42
+ */
43
+ get writeCancellations(): number {
44
+ return this.cancelledWriteCount;
45
+ }
46
+
32
47
  override write(action: (token: CancellationToken) => MaybePromise<void>): Promise<void> {
33
48
  return super.write(token => runInWriteLockScope(() => action(token)));
34
49
  }
50
+
51
+ /** Counted here rather than in {@link write}, because Langium's `write` cancels through this method too. */
52
+ override cancelWrite(): void {
53
+ this.cancelledWriteCount++;
54
+ super.cancelWrite();
55
+ }
35
56
  }
@@ -81,10 +81,11 @@ export interface HydraniumDocumentUpdateHandlerOptions {
81
81
  * 3. **Build-reason stamping** — the LSP event overrides
82
82
  * (`didOpenDocument`, `didChangeContent`, `didChangeWatchedFiles`)
83
83
  * populate {@link nextReason} with a canonical reason string.
84
- * {@link fireDocumentUpdate} captures the reason; {@link dispatch} stages
85
- * it on the builder via {@link HydraniumDocumentBuilder.markNextReason},
86
- * so a subclass that formats build logs can tag its line with the event
87
- * that caused the build without per-adopter wiring to capture it.
84
+ * {@link fireDocumentUpdate} captures the reason; {@link dispatch} hands
85
+ * it to {@link HydraniumDocumentBuilder.scheduleUpdate}, which stages it
86
+ * through {@link HydraniumDocumentBuilder.markNextReason} when the build
87
+ * runs, so a subclass that formats build logs can tag its line with the
88
+ * event that caused the build without per-adopter wiring to capture it.
88
89
  * 4. **Editor save gate** ({@link willSaveDocumentWaitUntil}) — the editor
89
90
  * writes the file itself, so the server's disk writes of that document
90
91
  * are ordered around it: the editor's save waits for the ones already
@@ -102,13 +103,12 @@ export interface HydraniumDocumentUpdateHandlerOptions {
102
103
  * Adopters with their own handler subclass should extend this class
103
104
  * (not Langium's `DefaultDocumentUpdateHandler`) so all of them are
104
105
  * preserved. The {@link dispatch} hook is the canonical override
105
- * point for adopters that need to stamp additional per-build metadata
106
- * inside the workspace-lock callback.
106
+ * point for adopters that need to stamp additional per-build metadata.
107
107
  */
108
108
  export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler {
109
109
  /**
110
110
  * Narrow Langium's `DefaultDocumentUpdateHandler.documentBuilder` typing to
111
- * the framework subclass: `markNextReason` is the only method this handler
111
+ * the framework subclass: `scheduleUpdate` is the only method this handler
112
112
  * reaches for beyond the Langium interface.
113
113
  */
114
114
  declare protected readonly documentBuilder: HydraniumDocumentBuilder;
@@ -140,9 +140,9 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
140
140
  protected nextReason?: string;
141
141
  /**
142
142
  * Build-reason captured at `fireDocumentUpdate` time, drained by
143
- * {@link dispatch} into `markNextReason`. Survives across debounced
144
- * batches (`?? pendingReason` in `fireDocumentUpdate` retains the
145
- * first reason in a coalesced burst).
143
+ * {@link dispatch} into the scheduled build. Survives across debounced
144
+ * batches: `fireDocumentUpdate` replaces it with each event's reason, so a
145
+ * coalesced burst carries its latest.
146
146
  */
147
147
  protected pendingReason?: string;
148
148
 
@@ -342,15 +342,13 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
342
342
  * flush immediately (`immediateFlush` or `debounceMs <= 0`) or restart
343
343
  * the trailing-edge timer. Subclasses that need to stamp per-build
344
344
  * metadata can override and inspect the merged sets — but the canonical
345
- * override point for the workspace-lock callback is {@link dispatch},
346
- * not this method.
345
+ * override point for the build is {@link dispatch}, not this method.
347
346
  */
348
347
  protected override fireDocumentUpdate(changed: URI[], deleted: URI[]): void {
349
348
  // Capture the reason set by the LSP event handler immediately before
350
- // we merge into the pending sets. `?? pendingReason` keeps the
351
- // first reason in a coalesced burst (LSP events arriving inside an
352
- // active debounce window all stamp the same `pendingReason` once,
353
- // because subsequent stamps see the field already non-undefined).
349
+ // we merge into the pending sets. Each event stamps its own reason, so
350
+ // a coalesced burst carries its latest; `?? pendingReason` keeps the
351
+ // earlier one only for a call no event stamped.
354
352
  this.pendingReason = this.nextReason ?? this.pendingReason;
355
353
  this.nextReason = undefined;
356
354
  for (const uri of changed) {
@@ -388,26 +386,25 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
388
386
  }
389
387
 
390
388
  /**
391
- * Workspace-lock + `documentBuilder.update` call for the merged sets.
392
- * Mirrors Langium's `super.fireDocumentUpdate` body plus
393
- * `markNextReason(pendingReason)` inside the write-lock callback, so the
394
- * build the lock is about to run carries the LSP event that triggered it.
395
- * Adopters override to stamp additional per-build metadata — the canonical
396
- * pattern is to read the reason via `this.pendingReason`, call
397
- * `super.dispatch(...)` or replicate the
398
- * `workspaceManager.ready -> workspaceLock.write` block, and add
399
- * adopter-specific behaviour inside the write callback.
389
+ * Build the merged sets once the workspace is ready, through
390
+ * {@link HydraniumDocumentBuilder.scheduleUpdate} with the pending reason,
391
+ * so the build carries the LSP event that triggered it. A session's write
392
+ * fires the store's change event before the session rebuilds the document
393
+ * itself, and the scheduled build is what the two share.
394
+ *
395
+ * Adopters override to stamp additional per-build metadata: read the reason
396
+ * via `this.pendingReason` and call `super.dispatch(...)`; the build may
397
+ * be shared with other requests, so metadata stamped for it describes
398
+ * theirs too. An override that needs code inside the write action takes
399
+ * `workspaceLock.write` itself, as Langium's `fireDocumentUpdate` does, and
400
+ * stages the reason through `markNextReason` there; its build then cancels
401
+ * a scheduled one it coincides with rather than sharing it.
400
402
  */
401
403
  protected dispatch(changed: URI[], deleted: URI[]): void {
402
404
  const reason = this.pendingReason;
403
405
  this.pendingReason = undefined;
404
406
  this.workspaceManager.ready
405
- .then(() =>
406
- this.workspaceLock.write(token => {
407
- this.documentBuilder.markNextReason(reason);
408
- return this.documentBuilder.update(changed, deleted, token);
409
- })
410
- )
407
+ .then(() => this.documentBuilder.scheduleUpdate(changed, deleted, reason))
411
408
  .catch(err => {
412
409
  // A deferred rebuild can finish after the LSP peer has gone away.
413
410
  // Unless the head was started through `startLanguageServer`, whose
@@ -312,7 +312,8 @@ export function makeTestServices<
312
312
  ): TestServicesBundle<TAst, TDiagnostic, TTransfer, TProject, TTransferDiagnostic> {
313
313
  const documents = makeStubLangiumDocuments<TAst, TDiagnostic>(options.seedDocuments);
314
314
  const textDocuments = makeStubHydraniumTextDocuments();
315
- const documentBuilder = makeStubDocumentBuilder();
315
+ const workspaceLock = new HydraniumWorkspaceLock();
316
+ const documentBuilder = makeStubDocumentBuilder(workspaceLock);
316
317
  const selfSaveRegistry = makeStubSelfSaveRegistry();
317
318
  const fileSystem = makeStubWritableFileSystem(selfSaveRegistry);
318
319
  const projectManager = makeStubProjectManager<TProject>(options.seedProjects);
@@ -366,7 +367,7 @@ export function makeTestServices<
366
367
  // Spread for the same reason as `ServiceRegistry` above — the slot must
367
368
  // be genuinely ABSENT, not present-and-undefined, when no index was seeded.
368
369
  ...(indexManager ? { IndexManager: indexManager } : {}),
369
- WorkspaceLock: new HydraniumWorkspaceLock(),
370
+ WorkspaceLock: workspaceLock,
370
371
  FileSystemTaskQueue: new DefaultFileSystemTaskQueue({ workspace: { DocumentUriPolicy: documentUriPolicy } })
371
372
  },
372
373
  model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject, TTransferDiagnostic>['model'],
@@ -14,7 +14,8 @@ import {
14
14
  type DocumentState,
15
15
  type DocumentUpdateListener,
16
16
  type LangiumDocument,
17
- type URI
17
+ type URI,
18
+ type WorkspaceLock
18
19
  } from '@hydranium/langium';
19
20
  import { type CancellationToken, Disposable } from 'vscode-languageserver';
20
21
  import { type HydraniumDocumentBuilder } from '../langium/document-builder/document-builder.js';
@@ -65,16 +66,17 @@ export interface StubWaitUntilGate {
65
66
  * Picks the methods the framework reads from {@link DocumentBuilder} on
66
67
  * the slot path — the compiler enforces those signatures stay aligned.
67
68
  * `update`, `onDocumentPhase`, `onUpdate`, and `waitUntil` carry stubbed
68
- * implementations; `build`, `onBuildPhase`, and `resetToState` are
69
- * implemented as loud-failing `notSupported` throwers so production code
70
- * that goes through the cast at the bind site (i.e. accesses methods the
69
+ * implementations, and `scheduleUpdate` calls `update` in one lock write per
70
+ * call (see {@link makeStubDocumentBuilder}); `build`, `onBuildPhase`, and
71
+ * `resetToState` are implemented as loud-failing `notSupported` throwers
72
+ * so production code that goes through the cast at the bind site (i.e. accesses methods the
71
73
  * stub claims but doesn't meaningfully implement) fails with a clear
72
74
  * message instead of "undefined is not a function".
73
75
  */
74
76
  export interface StubDocumentBuilder
75
77
  extends
76
78
  Pick<DocumentBuilder, 'update' | 'onDocumentPhase' | 'onUpdate' | 'build' | 'onBuildPhase' | 'resetToState' | 'updateBuildOptions'>,
77
- Pick<HydraniumDocumentBuilder, 'onDocumentPhaseDelivered'> {
79
+ Pick<HydraniumDocumentBuilder, 'onDocumentPhaseDelivered' | 'scheduleUpdate'> {
78
80
  /**
79
81
  * Single-overload stub of {@link DocumentBuilder.waitUntil}. Real has
80
82
  * two overloads (`(state, cancelToken?): Promise<void>` and
@@ -138,8 +140,13 @@ function reraise(result: unknown): void {
138
140
  * want to observe rebuilt state should `set` the new root on the document
139
141
  * registry and then call `firePhase` to trigger the framework's post-build
140
142
  * read path.
143
+ *
144
+ * `workspaceLock` is the lock `scheduleUpdate` writes under, without the real
145
+ * builder's sharing: pass the services tree's own, so a caller's lock ordering
146
+ * and reentrancy behave as against the real builder. Without one,
147
+ * `scheduleUpdate` records the update unlocked.
141
148
  */
142
- export function makeStubDocumentBuilder(): StubDocumentBuilder {
149
+ export function makeStubDocumentBuilder(workspaceLock?: WorkspaceLock): StubDocumentBuilder {
143
150
  const phaseListeners = new Map<DocumentState, DocumentPhaseListener[]>();
144
151
  const deliveredListeners = new Map<DocumentState, Array<(document: LangiumDocument, version: number) => void>>();
145
152
  const buildPhaseListeners = new Map<DocumentState, DocumentBuildListener[]>();
@@ -164,6 +171,9 @@ export function makeStubDocumentBuilder(): StubDocumentBuilder {
164
171
  async update(changed: URI[], deleted: URI[]) {
165
172
  updateCalls.push({ args: [changed, deleted] });
166
173
  },
174
+ scheduleUpdate(changed: URI[], deleted: URI[]) {
175
+ return workspaceLock ? workspaceLock.write(() => stub.update(changed, deleted)) : stub.update(changed, deleted);
176
+ },
167
177
  async waitUntil(state: DocumentState, uri?: URI) {
168
178
  waitUntilCalls.push({ args: [state, uri] });
169
179
  const gate = gates.shift();