@hydranium/core 1.0.0-next.201 → 1.0.0-next.203

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 (63) hide show
  1. package/lib/documents/ast-document-manager.d.ts +13 -25
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +0 -56
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/client-session-registry.d.ts +3 -2
  6. package/lib/documents/client-session-registry.d.ts.map +1 -1
  7. package/lib/documents/client-session-registry.js +3 -3
  8. package/lib/documents/client-session-registry.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.js +1 -1
  10. package/lib/documents/hydranium-text-documents.js.map +1 -1
  11. package/lib/langium/model-service/client-session.d.ts +9 -1
  12. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  13. package/lib/langium/model-service/client-session.js +7 -0
  14. package/lib/langium/model-service/client-session.js.map +1 -1
  15. package/lib/langium/model-service/index.d.ts +1 -0
  16. package/lib/langium/model-service/index.d.ts.map +1 -1
  17. package/lib/langium/model-service/index.js +1 -0
  18. package/lib/langium/model-service/index.js.map +1 -1
  19. package/lib/langium/model-service/model-events.d.ts +64 -0
  20. package/lib/langium/model-service/model-events.d.ts.map +1 -0
  21. package/lib/langium/model-service/model-events.js +10 -0
  22. package/lib/langium/model-service/model-events.js.map +1 -0
  23. package/lib/langium/model-service/model-service.d.ts +74 -38
  24. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  25. package/lib/langium/model-service/model-service.js +137 -31
  26. package/lib/langium/model-service/model-service.js.map +1 -1
  27. package/lib/langium/service-registry.d.ts +14 -0
  28. package/lib/langium/service-registry.d.ts.map +1 -1
  29. package/lib/langium/service-registry.js +21 -0
  30. package/lib/langium/service-registry.js.map +1 -1
  31. package/lib/langium/transfer/transfer-encoder.d.ts +2 -3
  32. package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
  33. package/lib/langium/transfer/transfer-encoder.js +2 -3
  34. package/lib/langium/transfer/transfer-encoder.js.map +1 -1
  35. package/lib/langium/workspace/document-uri-policy.d.ts +1 -2
  36. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  37. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  38. package/lib/testing/stub-ast-document-manager.d.ts +7 -51
  39. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  40. package/lib/testing/stub-ast-document-manager.js +0 -57
  41. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  42. package/lib/testing/stub-document-builder.d.ts +1 -1
  43. package/lib/testing/stub-document-builder.d.ts.map +1 -1
  44. package/lib/testing/stub-document-builder.js +19 -2
  45. package/lib/testing/stub-document-builder.js.map +1 -1
  46. package/lib/testing/stub-hydranium-text-documents.js +2 -2
  47. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  48. package/lib/testing/stub-service-registry.d.ts +1 -1
  49. package/package.json +5 -5
  50. package/src/documents/ast-document-manager.ts +14 -76
  51. package/src/documents/client-session-registry.ts +4 -3
  52. package/src/documents/hydranium-text-documents.ts +1 -1
  53. package/src/langium/model-service/client-session.ts +14 -1
  54. package/src/langium/model-service/index.ts +1 -0
  55. package/src/langium/model-service/model-events.ts +74 -0
  56. package/src/langium/model-service/model-service.ts +204 -42
  57. package/src/langium/service-registry.ts +22 -0
  58. package/src/langium/transfer/transfer-encoder.ts +2 -3
  59. package/src/langium/workspace/document-uri-policy.ts +1 -2
  60. package/src/testing/stub-ast-document-manager.ts +7 -126
  61. package/src/testing/stub-document-builder.ts +19 -3
  62. package/src/testing/stub-hydranium-text-documents.ts +2 -2
  63. package/src/testing/stub-service-registry.ts +1 -1
@@ -23,8 +23,8 @@ import { type AstNode, DocumentState, type LangiumDocument, OperationCancelled,
23
23
  import { type AstDiagnostic } from '../validation/document-validator.js';
24
24
  import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
25
25
  import { ReentrantWriteLockError, isInsideWriteLock, isWriteLockScopeInstalled } from '../workspace/write-lock-scope.js';
26
- import { CancellationToken, type Disposable } from 'vscode-languageserver';
27
- import { AstDocument, type AstDocumentSavedEvent, type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
26
+ import { CancellationToken, Disposable } from 'vscode-languageserver';
27
+ import { AstDocument, type AstDocumentSavedEvent } from '../../documents/ast-document-manager.js';
28
28
  import { isConnectionGoneError } from '../../util/connection-liveness.js';
29
29
  import { type LogNameOptions } from '../diagnostics/logger.js';
30
30
  import { IntegrityService } from '../integrity/integrity-service.js';
@@ -33,6 +33,15 @@ import { LANGUAGE_CLIENT_ID } from '../../documents/client-ids.js';
33
33
  import { type OpenOptions } from '../../documents/client-session-registry.js';
34
34
  import { type ServerSharedServices } from '../module.js';
35
35
  import { type ClientSession } from './client-session.js';
36
+ import {
37
+ type ModelDeletedEvent,
38
+ type ModelDirtyChangedEvent,
39
+ type ModelEventFilter,
40
+ type ModelPhaseFilter,
41
+ type ModelReleasedEvent,
42
+ type ModelsBuiltEvent,
43
+ type ModelUpdatedEvent
44
+ } from './model-events.js';
36
45
 
37
46
  /**
38
47
  * The undo-stack entry for a server-authored write pushed to the editor.
@@ -88,6 +97,12 @@ export interface ModelServiceOptions extends LogNameOptions {
88
97
  readonly allowReentrantBuilds?: MaybeObservableValue<boolean>;
89
98
  }
90
99
 
100
+ /** One {@link ModelService.onModelUpdated} subscription; `uri` is canonical, absent for every document. */
101
+ export interface ModelUpdateSubscriber<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> {
102
+ readonly uri?: CanonicalUri;
103
+ readonly listener: (event: ModelUpdatedEvent<TAst, TDiagnostic>) => void;
104
+ }
105
+
91
106
  /** Options for a {@link DefaultModelService} wait on one document. */
92
107
  export interface SyncedWaitOptions {
93
108
  /**
@@ -157,12 +172,9 @@ export interface SyncedWaitOptions {
157
172
  * the injected `TransferEncoder` — symmetric with the
158
173
  * `getModelDocument` envelope path.
159
174
  *
160
- * **Subscriptions** ({@link onModelUpdated} / {@link onModelSaved} /
161
- * {@link onClientClosed}) are thin pass-throughs over
162
- * `DocumentBuilder.onDocumentPhase` and
163
- * `HydraniumTextDocuments.onDidSave` / `onDidClose`. Filtering
164
- * by URI happens here so consumers can subscribe per-document without
165
- * implementing the URI gate at each callsite.
175
+ * **Subscriptions** (`on*`) are how a head follows documents; it subscribes
176
+ * here rather than to the builder or the text store. Each takes a filter, and
177
+ * one without a `uri` hears every document.
166
178
  *
167
179
  * **Two families.** `waitFor*` only waits: it rejects for a URI with no
168
180
  * document, and for a root behind the store's text it waits for whatever
@@ -256,9 +268,28 @@ export interface ModelService<
256
268
  modelToText(uri: string, model: TTransfer | string, cancelToken?: CancellationToken): Promise<string>;
257
269
  getDocument(uri: string): LangiumDocument | undefined;
258
270
 
259
- onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
260
- onModelSaved(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void): Disposable;
261
- onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
271
+ /**
272
+ * Fires as a document reaches the filter's phase, `Validated` by default.
273
+ * Listeners run inside the build, so one that throws is logged and the
274
+ * others still run.
275
+ */
276
+ onModelUpdated(listener: (event: ModelUpdatedEvent<TAst, TDiagnostic>) => void, filter?: ModelPhaseFilter): Disposable;
277
+ /**
278
+ * Fires once per build that reaches the phase, with every document it
279
+ * carried there. A listener that throws is logged, as for {@link onModelUpdated}.
280
+ */
281
+ onModelsBuilt(listener: (event: ModelsBuiltEvent) => void, filter?: Pick<ModelPhaseFilter, 'phase'>): Disposable;
282
+ /**
283
+ * Fires for every save, whichever client made it, as the store announces
284
+ * it. A document saved before its first build arrives as an empty envelope
285
+ * at `UNRECORDED_VERSION`, whose `root` is `undefined` despite its type.
286
+ */
287
+ onModelSaved(listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void, filter?: ModelEventFilter): Disposable;
288
+ onModelDeleted(listener: (event: ModelDeletedEvent) => void, filter?: ModelEventFilter): Disposable;
289
+ onDirtyChanged(listener: (event: ModelDirtyChangedEvent) => void, filter?: ModelEventFilter): Disposable;
290
+ onModelReleased(listener: (event: ModelReleasedEvent) => void, filter?: ModelEventFilter): Disposable;
291
+ /** Fires when `filter.clientId` closes `filter.uri`, its session ending included. */
292
+ onClientClosed(listener: () => void, filter: { readonly uri: string; readonly clientId: string }): Disposable;
262
293
 
263
294
  /**
264
295
  * Start a client session, the only way to open and write documents through
@@ -269,6 +300,12 @@ export interface ModelService<
269
300
  * held by another live session or has documents open under it as a client
270
301
  * that is not a session.
271
302
  *
303
+ * A `resumeToken` matching the one the live session under `clientId` was
304
+ * started with ends that session as lost first, so the documents it was
305
+ * last to hold keep their unsaved text for the new one. The token guards
306
+ * against taking over a session by accident, not against a peer: anyone who
307
+ * knows it can end the session.
308
+ *
272
309
  * `TOpenOptions` types the options the session's `open` takes and its
273
310
  * `openOptions` returns. The narrowing is an unchecked cast, and it holds
274
311
  * only for the caller that started the session: a holder reached through
@@ -276,7 +313,8 @@ export interface ModelService<
276
313
  */
277
314
  createSession<TOpenOptions extends OpenOptions = OpenOptions>(
278
315
  label?: string,
279
- clientId?: string
316
+ clientId?: string,
317
+ options?: { readonly resumeToken?: string }
280
318
  ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
281
319
  /** The live session started under `clientId`, or `undefined` once it has ended or was never started. */
282
320
  getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined;
@@ -327,6 +365,13 @@ export class DefaultModelService<
327
365
  * this service.
328
366
  */
329
367
  protected sessionCloseListener?: Disposable;
368
+ /** The token each live session was started with, by client id, for a takeover by {@link createSession}. */
369
+ protected readonly resumeTokens = new Map<string, string>();
370
+ /**
371
+ * The {@link onModelUpdated} subscribers, by phase. One builder listener per
372
+ * phase serves them all, kept for the life of the service.
373
+ */
374
+ protected readonly updateSubscribers = new Map<DocumentState, Set<ModelUpdateSubscriber<TAst, TDiagnostic>>>();
330
375
 
331
376
  /**
332
377
  * Workspace-level readiness gate. Resolves when the framework has
@@ -837,18 +882,25 @@ export class DefaultModelService<
837
882
 
838
883
  createSession<TOpenOptions extends OpenOptions = OpenOptions>(
839
884
  label?: string,
840
- clientId?: string
885
+ clientId?: string,
886
+ options: { readonly resumeToken?: string } = {}
841
887
  ): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
842
888
  const sessionLabel = label ?? 'session';
843
889
  const id = clientId ?? `${sessionLabel}#${globalThis.crypto.randomUUID()}`;
844
890
  const textDocuments = this.services.workspace.TextDocuments;
891
+ if (options.resumeToken !== undefined && this.resumeTokens.get(id) === options.resumeToken) {
892
+ this.sessions.get(id)?.dispose('lost');
893
+ }
845
894
  textDocuments.registerSession(id);
846
895
  this.sessionCloseListener ??= textDocuments.onDidCloseSession(event => {
847
896
  // Also reached when the store ends a session directly; disposing the
848
897
  // handle makes its later calls fail rather than write under an id
849
- // this service no longer treats as a session.
850
- this.sessions.get(event.clientId)?.dispose();
898
+ // this service no longer treats as a session. Forgotten first: a
899
+ // dispose listener may start a replacement under the same id.
900
+ const ended = this.sessions.get(event.clientId);
851
901
  this.sessions.delete(event.clientId);
902
+ this.resumeTokens.delete(event.clientId);
903
+ ended?.dispose(event.cause);
852
904
  });
853
905
  let session: ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
854
906
  try {
@@ -866,6 +918,9 @@ export class DefaultModelService<
866
918
  throw err;
867
919
  }
868
920
  this.sessions.set(id, session);
921
+ if (options.resumeToken !== undefined) {
922
+ this.resumeTokens.set(id, options.resumeToken);
923
+ }
869
924
  return session;
870
925
  }
871
926
 
@@ -1052,41 +1107,148 @@ export class DefaultModelService<
1052
1107
  }
1053
1108
 
1054
1109
  // ============================================================
1055
- // Subscription pass-throughs
1110
+ // Subscriptions
1056
1111
  // ============================================================
1057
1112
 
1058
- /**
1059
- * Subscribe to AST-snapshot updates for `uri`. Fires after each
1060
- * rebuild that reaches the target phase. Delegates to the
1061
- * `HydraniumTextDocuments` — single listener registration shared with
1062
- * any direct `HydraniumTextDocuments.onUpdate` subscriber, so the same
1063
- * underlying `DocumentBuilder.onDocumentPhase` listener serves both
1064
- * call paths. The event's reason, source and cause are
1065
- * {@link AstDocumentManager.attributeUpdate}'s.
1066
- */
1067
- onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable {
1068
- return this.services.workspace.AstDocumentManager.onUpdate(uri, listener as never);
1113
+ onModelUpdated(listener: (event: ModelUpdatedEvent<TAst, TDiagnostic>) => void, filter: ModelPhaseFilter = {}): Disposable {
1114
+ const phase = filter.phase ?? DocumentState.Validated;
1115
+ let subscribers = this.updateSubscribers.get(phase);
1116
+ if (!subscribers) {
1117
+ const created = new Set<ModelUpdateSubscriber<TAst, TDiagnostic>>();
1118
+ this.updateSubscribers.set(phase, created);
1119
+ this.services.workspace.DocumentBuilder.onDocumentPhase(
1120
+ phase,
1121
+ labelPhaseListener(
1122
+ (document, cancelToken) => this.deliverUpdate(phase, created, document, cancelToken),
1123
+ 'ModelService.onModelUpdated'
1124
+ )
1125
+ );
1126
+ subscribers = created;
1127
+ }
1128
+ const subscriber = { uri: this.filterUri(filter), listener };
1129
+ subscribers.add(subscriber);
1130
+ return Disposable.create(() => subscribers.delete(subscriber));
1069
1131
  }
1070
1132
 
1071
- /**
1072
- * Subscribe to save events for `uri`. Fires on every persist through
1073
- * the multi-client text-document store — including saves originated
1074
- * by other heads (LSP editor `Ctrl+S`, the data-server `save`, etc.)
1075
- * so subscribers see one consistent stream regardless of who wrote
1076
- * the file. Delegates to the manager — single listener registration
1077
- * shared with any direct `HydraniumTextDocuments.onSave` subscriber.
1078
- */
1079
- onModelSaved(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void): Disposable {
1080
- return this.services.workspace.AstDocumentManager.onSave(uri, listener as never);
1133
+ onModelsBuilt(listener: (event: ModelsBuiltEvent) => void, filter: Pick<ModelPhaseFilter, 'phase'> = {}): Disposable {
1134
+ const phase = filter.phase ?? DocumentState.Validated;
1135
+ return this.services.workspace.DocumentBuilder.onBuildPhase(phase, (built, cancelToken) => {
1136
+ if (cancelToken.isCancellationRequested) {
1137
+ return;
1138
+ }
1139
+ try {
1140
+ listener(Object.freeze({ uris: built.map(document => this.uriPolicy.canonicalUri(document.uri.toString())), phase }));
1141
+ } catch (err: unknown) {
1142
+ this.tracer.error(`onModelsBuilt listener threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1143
+ }
1144
+ });
1145
+ }
1146
+
1147
+ onModelSaved(listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void, filter: ModelEventFilter = {}): Disposable {
1148
+ const target = this.filterUri(filter);
1149
+ return this.services.workspace.TextDocuments.onDidSave(event => {
1150
+ // LangiumDocuments keys by the canonical form, the saved event by the
1151
+ // client-facing one.
1152
+ const uri = this.uriPolicy.canonicalUri(event.document.uri);
1153
+ if (this.matches(target, uri)) {
1154
+ listener({ document: this.toAstDocument(UriUtils.toUri(uri)), sourceClientId: event.clientId });
1155
+ }
1156
+ });
1157
+ }
1158
+
1159
+ onModelDeleted(listener: (event: ModelDeletedEvent) => void, filter: ModelEventFilter = {}): Disposable {
1160
+ const target = this.filterUri(filter);
1161
+ return this.services.workspace.DocumentBuilder.onUpdate((_changed, deleted) => {
1162
+ for (const deletedUri of deleted) {
1163
+ const uri = this.uriPolicy.canonicalUri(deletedUri.toString());
1164
+ if (this.matches(target, uri)) {
1165
+ listener(Object.freeze({ uri }));
1166
+ }
1167
+ }
1168
+ });
1169
+ }
1170
+
1171
+ onDirtyChanged(listener: (event: ModelDirtyChangedEvent) => void, filter: ModelEventFilter = {}): Disposable {
1172
+ const target = this.filterUri(filter);
1173
+ return this.services.workspace.TextDocuments.onDidChangeDirty(event => {
1174
+ if (this.matches(target, event.uri)) {
1175
+ listener(event);
1176
+ }
1177
+ });
1178
+ }
1179
+
1180
+ onModelReleased(listener: (event: ModelReleasedEvent) => void, filter: ModelEventFilter = {}): Disposable {
1181
+ const target = this.filterUri(filter);
1182
+ return this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1183
+ if (this.matches(target, event.uri)) {
1184
+ listener(event);
1185
+ }
1186
+ });
1187
+ }
1188
+
1189
+ onClientClosed(listener: () => void, filter: { readonly uri: string; readonly clientId: string }): Disposable {
1190
+ const target = this.uriPolicy.canonicalUri(filter.uri);
1191
+ return this.services.workspace.TextDocuments.onDidClose(event => {
1192
+ if (event.clientId === filter.clientId && this.uriPolicy.canonicalUri(event.document.uri) === target) {
1193
+ listener();
1194
+ }
1195
+ });
1196
+ }
1197
+
1198
+ protected filterUri(filter: ModelEventFilter): CanonicalUri | undefined {
1199
+ return filter.uri === undefined ? undefined : this.uriPolicy.canonicalUri(filter.uri);
1200
+ }
1201
+
1202
+ protected matches(target: CanonicalUri | undefined, uri: CanonicalUri): boolean {
1203
+ return target === undefined || target === uri;
1081
1204
  }
1082
1205
 
1083
1206
  /**
1084
- * Subscribe to the `(uri, clientId)` close event. Delegates to the
1085
- * manager so the listener registry is shared with any direct
1086
- * `HydraniumTextDocuments.onClientClosed` subscriber.
1207
+ * Hand every subscriber of `phase` for `document` one event, built once, so
1208
+ * all of them see the same attribution. Built inside the build's listener
1209
+ * and handed out without an await: after one, another build can have reset
1210
+ * the document.
1087
1211
  */
1088
- onClientClosed(uri: string, clientId: string, listener: () => void): Disposable {
1089
- return this.services.workspace.AstDocumentManager.onClientClosed(uri, clientId, listener);
1212
+ protected deliverUpdate(
1213
+ phase: DocumentState,
1214
+ subscribers: ReadonlySet<ModelUpdateSubscriber<TAst, TDiagnostic>>,
1215
+ document: LangiumDocument,
1216
+ cancelToken: CancellationToken
1217
+ ): void {
1218
+ if (cancelToken.isCancellationRequested || subscribers.size === 0) {
1219
+ return;
1220
+ }
1221
+ const uri = this.uriPolicy.canonicalUri(document.uri.toString());
1222
+ const matching = [...subscribers].filter(subscriber => this.matches(subscriber.uri, uri));
1223
+ if (matching.length === 0) {
1224
+ return;
1225
+ }
1226
+ const manager = this.services.workspace.AstDocumentManager;
1227
+ const envelope = manager.toAstDocument(document) as AstDocument<TAst, TDiagnostic>;
1228
+ const event: ModelUpdatedEvent<TAst, TDiagnostic> = Object.freeze({
1229
+ // Copied: a validation run without a reset, of further categories,
1230
+ // appends to the live array.
1231
+ document:
1232
+ phase >= DocumentState.Validated
1233
+ ? { ...envelope, ...(envelope.diagnostics && { diagnostics: [...envelope.diagnostics] }) }
1234
+ : (this.withoutDiagnostics(envelope) as AstDocument<TAst, TDiagnostic>),
1235
+ ...manager.attributeUpdate(document),
1236
+ phase
1237
+ });
1238
+ for (const { listener } of matching) {
1239
+ // A write queued by an earlier listener cancels this build. Thrown,
1240
+ // so the builder leaves the version undelivered for those skipped.
1241
+ if (cancelToken.isCancellationRequested) {
1242
+ throw OperationCancelled;
1243
+ }
1244
+ try {
1245
+ listener(event);
1246
+ } catch (err: unknown) {
1247
+ this.tracer
1248
+ .with(uri)
1249
+ .error(`onModelUpdated listener threw: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1250
+ }
1251
+ }
1090
1252
  }
1091
1253
 
1092
1254
  // ============================================================
@@ -316,6 +316,28 @@ export class ExtendedServiceRegistry<
316
316
  return owners.length === 1 ? owners[0] : undefined;
317
317
  }
318
318
 
319
+ /**
320
+ * The language the strongest signal given names: a declared `languageId`,
321
+ * then `target`'s document, then the only registered language, then the sole
322
+ * producer of `type`. `undefined` when none answers, which leaves the
323
+ * fallback to the caller, since it differs per caller.
324
+ *
325
+ * A declared id comes first because every later signal is inferred, and an
326
+ * inference must not override a caller that said which grammar it means.
327
+ */
328
+ getLanguageFor(hint: { readonly languageId?: string; readonly target?: LanguageTarget; readonly type?: string }): TServices | undefined {
329
+ const declared = hint.languageId === undefined ? undefined : this.getServicesById(hint.languageId);
330
+ const located = declared ?? this.getServicesFor(hint.target);
331
+ if (located) {
332
+ return located;
333
+ }
334
+ const all = this.all;
335
+ if (all.length === 1) {
336
+ return all[0];
337
+ }
338
+ return hint.type === undefined ? undefined : this.soleServicesByType(hint.type);
339
+ }
340
+
319
341
  /**
320
342
  * Drop the memoised type index, so a language registered after the first
321
343
  * type lookup is accounted for. The reason this index belongs on the
@@ -408,9 +408,8 @@ export class DefaultTransferEncoder<
408
408
 
409
409
  /**
410
410
  * Build a wire {@link EncodedTransferDocument} envelope from a server-internal
411
- * {@link AstDocument} snapshot (the AST-typed envelope emitted by
412
- * `AstDocumentManager.onUpdate` / `onSave` and returned from
413
- * `ModelService.request` / `update` / `save`). Distinct from
411
+ * {@link AstDocument} snapshot (the AST-typed envelope `ModelService`'s
412
+ * events carry and its reads and session writes return). Distinct from
414
413
  * {@link toTransferDocument}(`LangiumDocument`) which encodes against
415
414
  * the live build artifact — adopter facade returns hold the snapshot
416
415
  * directly, and this helper bridges that to the wire shape without a
@@ -51,8 +51,7 @@ export function findRealpathDivergence(uri: URI | string, fs: RealpathCapableFil
51
51
  * - {@link canonicalUri} — TOTAL: maps any URI to the single canonical *string*
52
52
  * the framework keys a document by, regardless of whether the file exists.
53
53
  * This is the form that MUST match the adopter's `LangiumDocuments` keying so
54
- * the text store, AST-document event filters
55
- * (`AstDocumentManager.onUpdate` / `onSave` / `onClientClosed`), and
54
+ * the text store, `ModelService`'s event filters, and
56
55
  * `LangiumDocuments` lookups all agree on one identity for the same file. Use
57
56
  * for keying, comparison, and the text-store ↔ document-store bridges.
58
57
  *
@@ -10,12 +10,7 @@
10
10
  import { type AstNode, type LangiumDocument, UriUtils } from '@hydranium/langium';
11
11
  import { Disposable } from 'vscode-languageserver';
12
12
  import { type AstDiagnostic } from '../langium/validation/document-validator.js';
13
- import {
14
- AstDocument,
15
- type AstDocumentManager,
16
- type AstDocumentUpdatedEvent,
17
- type UpdateAttribution
18
- } from '../documents/ast-document-manager.js';
13
+ import { AstDocument, type AstDocumentManager, type UpdateAttribution } from '../documents/ast-document-manager.js';
19
14
  import { type WritableFileSystemProvider } from '../langium/workspace/file-system-provider.js';
20
15
  import { UNKNOWN_CLIENT_ID } from '../documents/client-ids.js';
21
16
  import { DefaultModelLedger, type ModelLedger } from '../documents/model-ledger.js';
@@ -24,16 +19,11 @@ import type { StubHydraniumTextDocuments } from './stub-hydranium-text-documents
24
19
  import type { StubLangiumDocuments } from './stub-langium-documents.js';
25
20
 
26
21
  /**
27
- * Minimal stub of {@link AstDocumentManager} for use in test
28
- * harnesses. Implements just the methods the model service and client
29
- * sessions call (`open` / `close` / `update` / `save` / `isOpen` /
30
- * `getDocument`);
31
- * `onSave` / `onClientClosed` forward to the underlying stub text store,
32
- * `onUpdate` is driven explicitly
33
- * through {@link StubAstDocumentManager.emitUpdate} because the stub tree runs
34
- * no build phases to emit from, and the remaining read helpers
35
- * (`getAuthor`) throw if invoked — tests that need them
36
- * should wire a richer stub or use the real {@link AstDocumentManager}.
22
+ * Minimal stub of {@link AstDocumentManager} for use in test harnesses,
23
+ * implementing what the model service and client sessions call. `getAuthor`
24
+ * throws if invoked, and `attributeUpdate` reports every update as its
25
+ * author's change; tests that need either should wire a richer stub or use
26
+ * the real {@link AstDocumentManager}.
37
27
  *
38
28
  * Open state is tracked per URI as the set of holding client ids, so a
39
29
  * document stays open until its last client closes. The stub forwards
@@ -51,61 +41,9 @@ import type { StubLangiumDocuments } from './stub-langium-documents.js';
51
41
  */
52
42
  export interface StubAstDocumentManager<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> extends Pick<
53
43
  AstDocumentManager<TAst, TDiagnostic>,
54
- | 'open'
55
- | 'close'
56
- | 'isOpen'
57
- | 'update'
58
- | 'save'
59
- | 'onUpdate'
60
- | 'onSave'
61
- | 'onClientClosed'
62
- | 'getAuthor'
63
- | 'getDocument'
64
- | 'toAstDocument'
65
- | 'attributeUpdate'
44
+ 'open' | 'close' | 'isOpen' | 'update' | 'save' | 'getAuthor' | 'getDocument' | 'toAstDocument' | 'attributeUpdate'
66
45
  > {
67
46
  readonly openClients: Map<string, Set<string>>;
68
-
69
- /**
70
- * Deliver an update event to every live {@link StubAstDocumentManager.onUpdate}
71
- * subscriber for `uri`. Returns how many listeners received it.
72
- *
73
- * **This exists because `onUpdate` cannot fire on its own, and that is a
74
- * trap without a driver.** The real manager emits from a
75
- * `DocumentBuilder.onDocumentPhase(Validated)` hook; a `makeTestServices`
76
- * tree has a stub builder that runs no phases, so a subscription made
77
- * through the stub would never be called. A test written as
78
- * `onUpdate(uri, …); await tick(); expect(events).toHaveLength(0)` therefore
79
- * passes for the wrong reason — the listener was never wired to anything —
80
- * and reads as coverage of a suppression path it never reached. Driving the
81
- * event explicitly is what lets such a test first prove delivery works, so
82
- * the negative assertion means something.
83
- *
84
- * **The return value is the point, not a convenience.** `0` says no
85
- * subscriber for that URI was registered, which is the state a negative
86
- * assertion cannot distinguish from correct suppression. Assert it is
87
- * non-zero before believing an empty event list.
88
- *
89
- * Two alternatives were rejected. Making `onUpdate` THROW would fence the
90
- * hazard at zero API cost, but this harness ships (`files: ["lib", "src"]`)
91
- * and an adopter subclass or test that subscribes and legitimately ignores
92
- * the event would start failing on a stub it never asked to fire. Wiring the
93
- * stub to the text-document store's change notifications — the way `onSave`
94
- * is wired — would make it fire on its own, which moves the pass reason of
95
- * every existing suite on a `makeTestServices` tree and asserts a phase
96
- * semantics (`Validated`, with the real manager's author lookup and
97
- * `changed`/`deleted`/`rebuilt` classification) that no stub tree has.
98
- */
99
- emitUpdate(uri: string, event: AstDocumentUpdatedEvent<TAst, TDiagnostic>): number;
100
-
101
- /**
102
- * How many live {@link StubAstDocumentManager.onUpdate} subscribers `uri`
103
- * has, or the total across every URI when called with no argument.
104
- *
105
- * Read from outside — it is what a test asserts to show a subscription was
106
- * actually established, and what makes {@link emitUpdate}'s `0` legible.
107
- */
108
- updateSubscriptions(uri?: string): number;
109
47
  }
110
48
 
111
49
  export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic>(
@@ -115,10 +53,6 @@ export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic ext
115
53
  ledger: ModelLedger = new DefaultModelLedger()
116
54
  ): StubAstDocumentManager<TAst, TDiagnostic> {
117
55
  const openClients = new Map<string, Set<string>>();
118
- // Keyed by the URI as given, not canonicalized: the stub has no URI policy,
119
- // and `getDocument` below looks documents up by their given URI for the same
120
- // reason, so both halves agree on identity.
121
- const updateListeners = new Map<string, Set<(event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void>>();
122
56
  const notSupported = (method: string): never => {
123
57
  throw new Error(`StubAstDocumentManager.${method} is not implemented; wire the real AstDocumentManager if your test needs it.`);
124
58
  };
@@ -162,59 +96,6 @@ export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic ext
162
96
  textDocuments.notifyDidSaveTextDocument({ textDocument: { uri }, text }, clientId);
163
97
  return version;
164
98
  },
165
- // Registers the listener but never calls it by itself — the real manager
166
- // emits from a `DocumentBuilder.onDocumentPhase(Validated)` hook and the
167
- // stub tree runs no phases. `emitUpdate` is the only thing that fires it;
168
- // see its declaration for why that is a driver rather than a throw or an
169
- // autonomous wiring.
170
- onUpdate(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable {
171
- const listeners = updateListeners.get(uri) ?? new Set<(event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void>();
172
- listeners.add(listener);
173
- updateListeners.set(uri, listeners);
174
- return Disposable.create(() => {
175
- listeners.delete(listener);
176
- if (listeners.size === 0) {
177
- updateListeners.delete(uri);
178
- }
179
- });
180
- },
181
- emitUpdate(uri: string, event: AstDocumentUpdatedEvent<TAst, TDiagnostic>): number {
182
- // Snapshot before iterating: a listener that disposes itself on first
183
- // delivery would otherwise mutate the set mid-iteration.
184
- const listeners = [...(updateListeners.get(uri) ?? [])];
185
- for (const listener of listeners) {
186
- listener(event);
187
- }
188
- return listeners.length;
189
- },
190
- updateSubscriptions(uri?: string): number {
191
- if (uri !== undefined) {
192
- return updateListeners.get(uri)?.size ?? 0;
193
- }
194
- let total = 0;
195
- for (const listeners of updateListeners.values()) {
196
- total += listeners.size;
197
- }
198
- return total;
199
- },
200
- onSave(uri: string, listener: (event: { document: AstDocument<TAst, TDiagnostic>; sourceClientId: string }) => void): Disposable {
201
- return textDocuments.onDidSave(event => {
202
- if (event.document.uri !== uri) {
203
- return;
204
- }
205
- listener({
206
- document: AstDocument.create<TAst, TDiagnostic>(uri, textDocuments.version(uri), undefined as unknown as TAst),
207
- sourceClientId: event.clientId
208
- });
209
- });
210
- },
211
- onClientClosed(uri: string, clientId: string, listener: () => void): Disposable {
212
- return textDocuments.onDidClose(event => {
213
- if (event.clientId === clientId && event.document.uri === uri) {
214
- listener();
215
- }
216
- });
217
- },
218
99
  getAuthor(): string {
219
100
  return notSupported('getAuthor');
220
101
  },
@@ -13,6 +13,7 @@ import {
13
13
  type DocumentPhaseListener,
14
14
  type DocumentState,
15
15
  type DocumentUpdateListener,
16
+ isOperationCancelled,
16
17
  type LangiumDocument,
17
18
  type URI,
18
19
  type WorkspaceLock
@@ -90,7 +91,7 @@ export interface StubDocumentBuilder
90
91
  readonly waitUntilCalls: ReadonlyArray<RecordedBuilderCall<[DocumentState, URI | undefined]>>;
91
92
  /**
92
93
  * Synchronously fire the phase listener(s) registered for `state` with
93
- * `document`, then, unless `cancelToken` is cancelled, the
94
+ * `document`, then, unless a listener was cancelled, the
94
95
  * `onDocumentPhaseDelivered` listeners. `cancelToken` defaults to a
95
96
  * non-cancelled token; pass a cancelled token to simulate a build preempted
96
97
  * by a concurrent write lock.
@@ -217,10 +218,25 @@ export function makeStubDocumentBuilder(workspaceLock?: WorkspaceLock): StubDocu
217
218
  // may stand in for a write that moves the version on. Read only when
218
219
  // someone is told, since many fakes carry no text document.
219
220
  const version = delivered.length > 0 ? document.textDocument.version : 0;
221
+ // As the real builder: a listener reached on a cancelled token, or
222
+ // throwing OperationCancelled, counts as cancelled and withholds the
223
+ // delivered listeners.
224
+ let cancelledListeners = 0;
220
225
  for (const listener of listeners) {
221
- reraise(listener(document, token));
226
+ if (token.isCancellationRequested) {
227
+ cancelledListeners++;
228
+ continue;
229
+ }
230
+ try {
231
+ reraise(listener(document, token));
232
+ } catch (err: unknown) {
233
+ if (!isOperationCancelled(err)) {
234
+ throw err;
235
+ }
236
+ cancelledListeners++;
237
+ }
222
238
  }
223
- if (!token.isCancellationRequested) {
239
+ if (cancelledListeners === 0) {
224
240
  for (const listener of delivered) {
225
241
  listener(document, version);
226
242
  }
@@ -283,7 +283,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
283
283
  registerSession(clientId) {
284
284
  sessions.register(clientId);
285
285
  },
286
- closeSession(clientId) {
286
+ closeSession(clientId, cause) {
287
287
  if (!sessions.isRegistered(clientId)) {
288
288
  return;
289
289
  }
@@ -292,7 +292,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
292
292
  stub.fireClose(uri, clientId);
293
293
  }
294
294
  } finally {
295
- sessions.unregister(clientId);
295
+ sessions.unregister(clientId, cause);
296
296
  }
297
297
  },
298
298
  get onDidCloseSession() {
@@ -29,7 +29,7 @@ export interface StubLanguageDescriptor {
29
29
  * AST `$type`s this language's grammar produces. Synthesised into a minimal
30
30
  * `Grammar` — one entry-less parser rule per type — which is exactly what
31
31
  * `collectProducibleTypes` reads, so type→language routing
32
- * (`buildLanguageTypeIndex`, `DataServer.resolveReferenceLanguage`) resolves
32
+ * (`buildLanguageTypeIndex`, `ExtendedServiceRegistry.getLanguageFor`) resolves
33
33
  * against this stub the same way it does against generated grammars.
34
34
  *
35
35
  * Entry-less on purpose: `reachableParserRules` falls back to every parser