@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.
- package/lib/documents/ast-document-manager.d.ts +13 -25
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +0 -56
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-registry.d.ts +3 -2
- package/lib/documents/client-session-registry.d.ts.map +1 -1
- package/lib/documents/client-session-registry.js +3 -3
- package/lib/documents/client-session-registry.js.map +1 -1
- package/lib/documents/hydranium-text-documents.js +1 -1
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +9 -1
- package/lib/langium/model-service/client-session.d.ts.map +1 -1
- package/lib/langium/model-service/client-session.js +7 -0
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/index.d.ts +1 -0
- package/lib/langium/model-service/index.d.ts.map +1 -1
- package/lib/langium/model-service/index.js +1 -0
- package/lib/langium/model-service/index.js.map +1 -1
- package/lib/langium/model-service/model-events.d.ts +64 -0
- package/lib/langium/model-service/model-events.d.ts.map +1 -0
- package/lib/langium/model-service/model-events.js +10 -0
- package/lib/langium/model-service/model-events.js.map +1 -0
- package/lib/langium/model-service/model-service.d.ts +74 -38
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +137 -31
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/service-registry.d.ts +14 -0
- package/lib/langium/service-registry.d.ts.map +1 -1
- package/lib/langium/service-registry.js +21 -0
- package/lib/langium/service-registry.js.map +1 -1
- package/lib/langium/transfer/transfer-encoder.d.ts +2 -3
- package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
- package/lib/langium/transfer/transfer-encoder.js +2 -3
- package/lib/langium/transfer/transfer-encoder.js.map +1 -1
- package/lib/langium/workspace/document-uri-policy.d.ts +1 -2
- package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
- package/lib/langium/workspace/document-uri-policy.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +7 -51
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +0 -57
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-document-builder.d.ts +1 -1
- package/lib/testing/stub-document-builder.d.ts.map +1 -1
- package/lib/testing/stub-document-builder.js +19 -2
- package/lib/testing/stub-document-builder.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +2 -2
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/lib/testing/stub-service-registry.d.ts +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +14 -76
- package/src/documents/client-session-registry.ts +4 -3
- package/src/documents/hydranium-text-documents.ts +1 -1
- package/src/langium/model-service/client-session.ts +14 -1
- package/src/langium/model-service/index.ts +1 -0
- package/src/langium/model-service/model-events.ts +74 -0
- package/src/langium/model-service/model-service.ts +204 -42
- package/src/langium/service-registry.ts +22 -0
- package/src/langium/transfer/transfer-encoder.ts +2 -3
- package/src/langium/workspace/document-uri-policy.ts +1 -2
- package/src/testing/stub-ast-document-manager.ts +7 -126
- package/src/testing/stub-document-builder.ts +19 -3
- package/src/testing/stub-hydranium-text-documents.ts +2 -2
- 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,
|
|
27
|
-
import { AstDocument, type AstDocumentSavedEvent
|
|
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** (
|
|
161
|
-
*
|
|
162
|
-
* `
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1110
|
+
// Subscriptions
|
|
1056
1111
|
// ============================================================
|
|
1057
1112
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
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
|
-
|
|
1089
|
-
|
|
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
|
|
412
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
| '
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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`, `
|
|
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
|