@hydranium/core 1.0.0-next.114 → 1.0.0-next.117
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/README.md +3 -1
- package/lib/documents/ast-document-manager.d.ts +52 -2
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +67 -5
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/client-session-errors.d.ts +44 -0
- package/lib/documents/client-session-errors.d.ts.map +1 -0
- package/lib/documents/client-session-errors.js +57 -0
- package/lib/documents/client-session-errors.js.map +1 -0
- package/lib/documents/client-session-registry.d.ts +118 -0
- package/lib/documents/client-session-registry.d.ts.map +1 -0
- package/lib/documents/client-session-registry.js +171 -0
- package/lib/documents/client-session-registry.js.map +1 -0
- package/lib/documents/hydranium-text-documents.d.ts +77 -25
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +143 -52
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/documents/index.d.ts +2 -0
- package/lib/documents/index.d.ts.map +1 -1
- package/lib/documents/index.js +2 -0
- package/lib/documents/index.js.map +1 -1
- package/lib/langium/integrity/integrity-rule.d.ts +13 -5
- package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-rule.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts +37 -6
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +81 -17
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +120 -0
- package/lib/langium/model-service/client-session.d.ts.map +1 -0
- package/lib/langium/model-service/client-session.js +175 -0
- package/lib/langium/model-service/client-session.js.map +1 -0
- 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-service.d.ts +76 -8
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +148 -21
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +20 -3
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +4 -3
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +3 -0
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +45 -14
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +107 -6
- package/src/documents/client-session-errors.ts +54 -0
- package/src/documents/client-session-registry.ts +211 -0
- package/src/documents/hydranium-text-documents.ts +158 -55
- package/src/documents/index.ts +2 -0
- package/src/langium/integrity/integrity-rule.ts +13 -5
- package/src/langium/integrity/integrity-service.ts +84 -17
- package/src/langium/model-service/client-session.ts +257 -0
- package/src/langium/model-service/index.ts +1 -0
- package/src/langium/model-service/model-service.ts +176 -21
- package/src/lsp/hydranium-document-update-handler.ts +22 -4
- package/src/testing/stub-ast-document-manager.ts +7 -2
- package/src/testing/stub-hydranium-text-documents.ts +62 -18
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
********************************************************************************/
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
+
type BasedOn,
|
|
11
12
|
type CanonicalUri,
|
|
12
13
|
type CloseModelArgs,
|
|
13
14
|
ConflictError,
|
|
@@ -34,7 +35,10 @@ import { type LogNameOptions } from '../diagnostics/logger.js';
|
|
|
34
35
|
import { IntegrityService } from '../integrity/integrity-service.js';
|
|
35
36
|
import { labelPhaseListener } from '../document-builder/labeled-phase-listener.js';
|
|
36
37
|
import { LANGUAGE_CLIENT_ID } from '../../documents/client-ids.js';
|
|
38
|
+
import { DocumentNotOpenError } from '../../documents/client-session-errors.js';
|
|
39
|
+
import { type OpenOptions } from '../../documents/client-session-registry.js';
|
|
37
40
|
import { type ServerSharedServices } from '../module.js';
|
|
41
|
+
import { type ClientSession, DefaultClientSession } from './client-session.js';
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* The undo-stack entry for a server-authored write pushed to the editor.
|
|
@@ -274,6 +278,21 @@ export interface ModelService<
|
|
|
274
278
|
onModelUpdated(uri: string, listener: (event: AstDocumentUpdatedEvent<TAst, TDiagnostic>) => void): Disposable;
|
|
275
279
|
onModelSaved(uri: string, listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void): Disposable;
|
|
276
280
|
onClientClosed(uri: string, clientId: string, listener: () => void): Disposable;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Start a client session. The id defaults to `label#` plus a random UUID;
|
|
284
|
+
* a fixed `clientId` is taken as given. Throws `DuplicateClientIdError` when
|
|
285
|
+
* the id is reserved by the framework, held by another live session, or has
|
|
286
|
+
* documents open under it as a client that is not a session.
|
|
287
|
+
*
|
|
288
|
+
* `TOpenOptions` types the options the session's `open` takes.
|
|
289
|
+
*/
|
|
290
|
+
createSession<TOpenOptions extends OpenOptions = OpenOptions>(
|
|
291
|
+
label?: string,
|
|
292
|
+
clientId?: string
|
|
293
|
+
): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
|
|
294
|
+
/** The live session started under `clientId`, or `undefined` once it has ended or was never started. */
|
|
295
|
+
getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined;
|
|
277
296
|
}
|
|
278
297
|
|
|
279
298
|
export class DefaultModelService<
|
|
@@ -318,6 +337,20 @@ export class DefaultModelService<
|
|
|
318
337
|
protected readonly syncChains = new Map<string, Promise<void>>();
|
|
319
338
|
protected readonly pendingSync = new Map<string, string>();
|
|
320
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The live sessions this service started, by client id. {@link update}
|
|
342
|
+
* reads it to tell a session's write, which must find its document open,
|
|
343
|
+
* from a write under a plain client id, which opens it.
|
|
344
|
+
*/
|
|
345
|
+
protected readonly sessions = new Map<string, ClientSession<TAst, TDiagnostic, TTransfer>>();
|
|
346
|
+
/**
|
|
347
|
+
* Drops an ended session from {@link sessions}. Subscribed by the first
|
|
348
|
+
* {@link createSession} rather than at construction, so a services tree with
|
|
349
|
+
* no text store, or one that knows nothing of sessions, still constructs
|
|
350
|
+
* this service.
|
|
351
|
+
*/
|
|
352
|
+
protected sessionCloseListener?: Disposable;
|
|
353
|
+
|
|
321
354
|
/**
|
|
322
355
|
* Workspace-level readiness gate. Resolves when the framework has
|
|
323
356
|
* finished its first workspace build cycle and the `ModelService`
|
|
@@ -681,8 +714,19 @@ export class DefaultModelService<
|
|
|
681
714
|
* with vN's specific settled snapshot, intermediate-phase
|
|
682
715
|
* observability, slow-warn / hard-timeout behaviour) override this
|
|
683
716
|
* method.
|
|
717
|
+
*
|
|
718
|
+
* **Upsert, except for a session.** Under a plain client id the write opens
|
|
719
|
+
* the document for that client first, creating it from the payload when no
|
|
720
|
+
* file exists. Under the id of a live session from {@link createSession} it
|
|
721
|
+
* opens nothing, and fails with `DocumentNotOpenError` unless the session
|
|
722
|
+
* has the URI open at the moment the text is applied.
|
|
684
723
|
*/
|
|
685
724
|
async update(args: TransferUpdateArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
725
|
+
// Read before the first await, while the call is still the caller's: a
|
|
726
|
+
// session that ends during the serialise would otherwise read as a plain
|
|
727
|
+
// client id here, and the write would open the document for an id no
|
|
728
|
+
// session owns any more and land there.
|
|
729
|
+
const bySession = this.sessions.has(args.clientId);
|
|
686
730
|
const stopwatch = this.slowUpdateWarn !== undefined ? this.services.Clock.stopwatch() : undefined;
|
|
687
731
|
// Per-stage self-time breakdown of the update/reconcile chain (serialise →
|
|
688
732
|
// open → apply → rebuild), opt-in at debug — the default path skips the
|
|
@@ -731,16 +775,34 @@ export class DefaultModelService<
|
|
|
731
775
|
// reason to close a document it never asked to open. Serialisation is
|
|
732
776
|
// held back too; it is the adopter's code and need not be side-effect
|
|
733
777
|
// free.
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
throw new ConflictError(uri, args.basedOn, currentVersion);
|
|
740
|
-
}
|
|
778
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
779
|
+
this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
|
|
780
|
+
// Whether this call's own open is the one that creates the document, read
|
|
781
|
+
// in the same step as the check above so no other open can land between.
|
|
782
|
+
const opensCold = !bySession && textDocuments.get(uri) === undefined;
|
|
741
783
|
const text = await run('serialize', () => this.modelToText(uri, args.model, cancelToken));
|
|
742
|
-
|
|
743
|
-
|
|
784
|
+
if (!bySession) {
|
|
785
|
+
await run('open', () => this.open({ uri, clientId: args.clientId, text }));
|
|
786
|
+
}
|
|
787
|
+
const appliedVersion = await run('apply', () => {
|
|
788
|
+
// A session's write never opens its document. The check sits in the
|
|
789
|
+
// same synchronous step as the apply: made before the serialize await
|
|
790
|
+
// instead, it passes for a write whose session closes the document
|
|
791
|
+
// during that await, and the write then lands on a document the
|
|
792
|
+
// session no longer has open.
|
|
793
|
+
if (bySession && !textDocuments.isOpenInClient(uri, args.clientId)) {
|
|
794
|
+
throw new DocumentNotOpenError(uri, args.clientId);
|
|
795
|
+
}
|
|
796
|
+
// The gate again, in the same step as the apply: two writes based on
|
|
797
|
+
// one version both pass the check at the door while they serialise,
|
|
798
|
+
// and without this both apply. The version a cold open assigned from
|
|
799
|
+
// this write's own text is not a conflict; the store holding exactly
|
|
800
|
+
// that text is how it shows.
|
|
801
|
+
if (!(opensCold && textDocuments.get(uri)?.getText() === text)) {
|
|
802
|
+
this.checkBasedOn(uri, args.basedOn, textDocuments.version(uri));
|
|
803
|
+
}
|
|
804
|
+
return this.services.workspace.AstDocumentManager.update(uri, text, args.clientId);
|
|
805
|
+
});
|
|
744
806
|
// Dispatch through the public `rebuild` (which re-canonicalizes the already-
|
|
745
807
|
// canonical `uri` once, idempotently) rather than `rebuildCanonical`, so an
|
|
746
808
|
// adopter `rebuild` override stays in the update path. The redundant call is
|
|
@@ -765,6 +827,23 @@ export class DefaultModelService<
|
|
|
765
827
|
return doc;
|
|
766
828
|
}
|
|
767
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Throw {@link ConflictError} when `basedOn` names a version other than
|
|
832
|
+
* `currentVersion`. {@link update} calls it at the door and again in the
|
|
833
|
+
* synchronous step that applies the text, and has to call it synchronously
|
|
834
|
+
* there: a check separated from the apply by an await lets two writes based
|
|
835
|
+
* on one version both apply.
|
|
836
|
+
*/
|
|
837
|
+
protected checkBasedOn(uri: string, basedOn: BasedOn, currentVersion: number): void {
|
|
838
|
+
if (isSnapshotVersion(basedOn) && currentVersion !== basedOn) {
|
|
839
|
+
// Distinct from the post-build "superseded" debug line in `update`: this
|
|
840
|
+
// is a based-on-stale rejection (the write never applies), not two
|
|
841
|
+
// writes racing.
|
|
842
|
+
this.tracer.debug(`Conflict on ${uri}: based-on v${basedOn} stale, server at v${currentVersion}`);
|
|
843
|
+
throw new ConflictError(uri, basedOn, currentVersion);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
768
847
|
/**
|
|
769
848
|
* Persist `uri` to disk. Same content-change + settled-phase flow as
|
|
770
849
|
* {@link update}, then writes via the
|
|
@@ -773,8 +852,16 @@ export class DefaultModelService<
|
|
|
773
852
|
* the `onDidSave` event regardless of who originated the persist).
|
|
774
853
|
*
|
|
775
854
|
* Returns the post-save AST snapshot.
|
|
855
|
+
*
|
|
856
|
+
* Under the id of a live session the save also fails with
|
|
857
|
+
* `DocumentNotOpenError` when the session closes the URI while the text is
|
|
858
|
+
* being built, and writes nothing. Once the manager has taken the text, the
|
|
859
|
+
* write completes whatever the session does next.
|
|
776
860
|
*/
|
|
777
861
|
async save(args: TransferSaveArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
|
|
862
|
+
// Read before the first await, as in `update`: a session that ends during
|
|
863
|
+
// the rebuild would otherwise read as a plain client id below.
|
|
864
|
+
const bySession = this.sessions.has(args.clientId);
|
|
778
865
|
// Dispatch through `update` (not its internals) so an adopter `update`
|
|
779
866
|
// override — version-matched resolution, etc. — applies to saves too.
|
|
780
867
|
const doc = await this.update(args, cancelToken);
|
|
@@ -782,6 +869,12 @@ export class DefaultModelService<
|
|
|
782
869
|
// the canonical (real) path follows any symlink to the same file, and the
|
|
783
870
|
// `onDidSave` keys the one canonical registration.
|
|
784
871
|
const uri = this.uriPolicy.canonicalUri(args.uri);
|
|
872
|
+
// In the same synchronous step as the manager taking the text: checked
|
|
873
|
+
// any earlier, a session that closes the URI during the rebuild still has
|
|
874
|
+
// the shared text, other clients' edits included, written in its name.
|
|
875
|
+
if (bySession && !this.services.workspace.TextDocuments.isOpenInClient(uri, args.clientId)) {
|
|
876
|
+
throw new DocumentNotOpenError(uri, args.clientId);
|
|
877
|
+
}
|
|
785
878
|
await this.services.workspace.AstDocumentManager.save(uri, args.clientId);
|
|
786
879
|
return doc;
|
|
787
880
|
}
|
|
@@ -906,6 +999,55 @@ export class DefaultModelService<
|
|
|
906
999
|
return this.services.workspace.AstDocumentManager.getDocument(uri);
|
|
907
1000
|
}
|
|
908
1001
|
|
|
1002
|
+
// ============================================================
|
|
1003
|
+
// Client sessions
|
|
1004
|
+
// ============================================================
|
|
1005
|
+
|
|
1006
|
+
createSession<TOpenOptions extends OpenOptions = OpenOptions>(
|
|
1007
|
+
label?: string,
|
|
1008
|
+
clientId?: string
|
|
1009
|
+
): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
|
|
1010
|
+
const sessionLabel = label ?? 'session';
|
|
1011
|
+
const id = clientId ?? `${sessionLabel}#${globalThis.crypto.randomUUID()}`;
|
|
1012
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
1013
|
+
textDocuments.registerSession(id);
|
|
1014
|
+
this.sessionCloseListener ??= textDocuments.onDidCloseSession(event => {
|
|
1015
|
+
// Also reached when the store ends a session directly; disposing the
|
|
1016
|
+
// handle makes its later calls fail rather than write under an id
|
|
1017
|
+
// this service no longer treats as a session.
|
|
1018
|
+
this.sessions.get(event.clientId)?.dispose();
|
|
1019
|
+
this.sessions.delete(event.clientId);
|
|
1020
|
+
});
|
|
1021
|
+
let session: ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>;
|
|
1022
|
+
try {
|
|
1023
|
+
session = this.newSession<TOpenOptions>(id, sessionLabel);
|
|
1024
|
+
} catch (err: unknown) {
|
|
1025
|
+
textDocuments.closeSession(id);
|
|
1026
|
+
throw err;
|
|
1027
|
+
}
|
|
1028
|
+
this.sessions.set(id, session);
|
|
1029
|
+
return session;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
getSession(clientId: string): ClientSession<TAst, TDiagnostic, TTransfer> | undefined {
|
|
1033
|
+
return this.sessions.get(clientId);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Build the handle for a session {@link createSession} has already
|
|
1038
|
+
* registered. The override point for a custom session class; narrow the
|
|
1039
|
+
* return type to it.
|
|
1040
|
+
*
|
|
1041
|
+
* Registration and the uniqueness check have already happened when this
|
|
1042
|
+
* runs, so an override cannot skip them.
|
|
1043
|
+
*/
|
|
1044
|
+
protected newSession<TOpenOptions extends OpenOptions = OpenOptions>(
|
|
1045
|
+
clientId: string,
|
|
1046
|
+
label: string
|
|
1047
|
+
): ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
|
|
1048
|
+
return new DefaultClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions>(this, this.services, clientId, label);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
909
1051
|
// ============================================================
|
|
910
1052
|
// LSP-client sync (mirror non-LSP-client changes back to Monaco)
|
|
911
1053
|
// ============================================================
|
|
@@ -917,15 +1059,20 @@ export class DefaultModelService<
|
|
|
917
1059
|
* `HydraniumTextDocuments.applyEditToLanguageClient` and
|
|
918
1060
|
* `HydraniumTextDocuments.stagePendingContent`.
|
|
919
1061
|
*
|
|
920
|
-
* Routes
|
|
921
|
-
*
|
|
1062
|
+
* Routes by client registration, because an open and a closed document
|
|
1063
|
+
* answer different questions:
|
|
922
1064
|
*
|
|
923
1065
|
* - **Open in the language client** → {@link syncOpenDocument}: mirror the
|
|
924
1066
|
* settled text via a coalesced `applyEditToLanguageClient`, routed purely by
|
|
925
1067
|
* **content**.
|
|
926
|
-
* - **
|
|
927
|
-
*
|
|
928
|
-
*
|
|
1068
|
+
* - **Open only in another client** (the data or GLSP head) → nothing. A
|
|
1069
|
+
* language client opening it joins the existing entry and is refreshed from
|
|
1070
|
+
* the store, and only a FIRST open reads a stage, so staging here would
|
|
1071
|
+
* leave text nothing reads.
|
|
1072
|
+
* - **Closed in every client** → {@link stageClosedDocument}, gated by
|
|
1073
|
+
* **provenance** ({@link isNonLanguageClientEdit}). With the default text
|
|
1074
|
+
* store this never stages; see {@link stageClosedDocument} for why, and
|
|
1075
|
+
* for the store it serves.
|
|
929
1076
|
*
|
|
930
1077
|
* The decision is **re-derived from the current settled state every time** (it
|
|
931
1078
|
* is not a one-shot enrolment), which is what makes it self-healing: a doc
|
|
@@ -935,9 +1082,9 @@ export class DefaultModelService<
|
|
|
935
1082
|
*/
|
|
936
1083
|
protected syncToLanguageClient(document: LangiumDocument): void {
|
|
937
1084
|
// `document.textDocument.uri` is the server-identity (canonical) URI off the
|
|
938
|
-
// build. Route by
|
|
939
|
-
//
|
|
940
|
-
//
|
|
1085
|
+
// build. Route by client registration (presence questions — both
|
|
1086
|
+
// predicates canonicalize internally, so a divergent open path still
|
|
1087
|
+
// resolves to the same record), but key the outbound sync by this
|
|
941
1088
|
// CANONICAL URI — the same key `settleSave` looks the chain up under —
|
|
942
1089
|
// so a save-settle can drain the in-flight applyEdit. The canonical→client-URI
|
|
943
1090
|
// translation (a symlinked path the client opened, or a dual-open fan-out)
|
|
@@ -945,9 +1092,10 @@ export class DefaultModelService<
|
|
|
945
1092
|
// the canonical key to the recorded language-client URI(s); driving the chain
|
|
946
1093
|
// in client space here would key it under a URI `settleSave` never computes, so
|
|
947
1094
|
// the drain would miss for a divergent open path.
|
|
948
|
-
|
|
1095
|
+
const textDocuments = this.services.workspace.TextDocuments;
|
|
1096
|
+
if (textDocuments.isOpenInLanguageClient(document.textDocument.uri)) {
|
|
949
1097
|
this.syncOpenDocument(document.textDocument.uri, document.textDocument.getText());
|
|
950
|
-
} else {
|
|
1098
|
+
} else if (!textDocuments.isOpenInAnyClient(document.textDocument.uri)) {
|
|
951
1099
|
this.stageClosedDocument(document);
|
|
952
1100
|
}
|
|
953
1101
|
}
|
|
@@ -968,11 +1116,18 @@ export class DefaultModelService<
|
|
|
968
1116
|
}
|
|
969
1117
|
|
|
970
1118
|
/**
|
|
971
|
-
* Stage the settled text of a document
|
|
972
|
-
*
|
|
1119
|
+
* Stage the settled text of a document no client holds so the eventual
|
|
1120
|
+
* first `didOpen` sees this in-memory text instead of stale disk —
|
|
973
1121
|
* but only for a {@link isNonLanguageClientEdit genuine non-language-client edit}.
|
|
974
1122
|
* A document rebuilt by an internal build is skipped, leaving disk authoritative
|
|
975
1123
|
* on the next open.
|
|
1124
|
+
*
|
|
1125
|
+
* The default `HydraniumTextDocuments` never gets this far. Its own paths
|
|
1126
|
+
* record a version's author only while a client holds the document, and the
|
|
1127
|
+
* last close drops that history with the tracking record, so a document no
|
|
1128
|
+
* client holds reports no author and the provenance gate refuses. The stage
|
|
1129
|
+
* is reachable for a text store that keeps authorship past the last close,
|
|
1130
|
+
* or a subclass overriding {@link isNonLanguageClientEdit}.
|
|
976
1131
|
*/
|
|
977
1132
|
protected stageClosedDocument(document: LangiumDocument): void {
|
|
978
1133
|
if (this.isNonLanguageClientEdit(document)) {
|
|
@@ -133,7 +133,10 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
|
|
|
133
133
|
*/
|
|
134
134
|
protected pendingReason?: string;
|
|
135
135
|
|
|
136
|
-
constructor(
|
|
136
|
+
constructor(
|
|
137
|
+
protected readonly services: ServerSharedServices,
|
|
138
|
+
options: HydraniumDocumentUpdateHandlerOptions = {}
|
|
139
|
+
) {
|
|
137
140
|
super(services);
|
|
138
141
|
this.logger = services.Logger;
|
|
139
142
|
this.selfSaveRegistry = services.workspace.SelfSaveRegistry;
|
|
@@ -201,6 +204,11 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
|
|
|
201
204
|
*
|
|
202
205
|
* Per-client closes (where other clients still hold the URI) are
|
|
203
206
|
* suppressed via {@link HydraniumTextDocuments.isOpenInAnyClient}.
|
|
207
|
+
*
|
|
208
|
+
* The dispatch waits for the URI's disk queue to drain, because the rebuild
|
|
209
|
+
* re-reads the file: dispatched while a save is still queued, it reads the
|
|
210
|
+
* text from before that save, and the document reverts past it. A client
|
|
211
|
+
* that opens the URI meanwhile holds the text, so no revert follows.
|
|
204
212
|
*/
|
|
205
213
|
didCloseDocument(change: TextDocumentChangeEvent<TextDocument>): void {
|
|
206
214
|
if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
|
|
@@ -210,9 +218,19 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
|
|
|
210
218
|
if (uri.scheme !== 'file') {
|
|
211
219
|
return;
|
|
212
220
|
}
|
|
213
|
-
this.
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
this.services.workspace.AstDocumentManager.queueDiskTask(change.document.uri, async () => undefined)
|
|
222
|
+
.then(() => {
|
|
223
|
+
if (this.textDocuments.isOpenInAnyClient(change.document.uri)) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.nextReason = HYDRANIUM_BUILD_REASONS.didClose;
|
|
227
|
+
this.immediateFlush = true;
|
|
228
|
+
this.fireDocumentUpdate([uri], []);
|
|
229
|
+
})
|
|
230
|
+
.catch((err: unknown) => {
|
|
231
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
232
|
+
this.logger.error(`Revert on last close dropped for ${change.document.uri}. ${detail}`);
|
|
233
|
+
});
|
|
216
234
|
}
|
|
217
235
|
|
|
218
236
|
override didChangeWatchedFiles(params: DidChangeWatchedFilesParams): void {
|
|
@@ -24,8 +24,9 @@ import type { StubLangiumDocuments } from './stub-langium-documents.js';
|
|
|
24
24
|
* Minimal stub of {@link AstDocumentManager} for use in test
|
|
25
25
|
* harnesses. Implements just the methods the framework's
|
|
26
26
|
* `ModelService` calls (`open` / `close` / `update` / `save` /
|
|
27
|
-
* `isOpen` / `getDocument`)
|
|
28
|
-
* underlying stub text
|
|
27
|
+
* `isOpen` / `getDocument`) and a disk queue that runs each task at once;
|
|
28
|
+
* `onSave` / `onClientClosed` forward to the underlying stub text store,
|
|
29
|
+
* `onUpdate` is driven explicitly
|
|
29
30
|
* through {@link StubAstDocumentManager.emitUpdate} because the stub tree runs
|
|
30
31
|
* no build phases to emit from, and the remaining read helpers
|
|
31
32
|
* (`isTriggeringEdit`, `getAuthor`) throw if invoked — tests that need them
|
|
@@ -52,6 +53,7 @@ export interface StubAstDocumentManager<TAst extends AstNode, TDiagnostic extend
|
|
|
52
53
|
| 'isOpen'
|
|
53
54
|
| 'update'
|
|
54
55
|
| 'save'
|
|
56
|
+
| 'queueDiskTask'
|
|
55
57
|
| 'onUpdate'
|
|
56
58
|
| 'onSave'
|
|
57
59
|
| 'onClientClosed'
|
|
@@ -151,6 +153,9 @@ export function makeStubAstDocumentManager<TAst extends AstNode, TDiagnostic ext
|
|
|
151
153
|
await fileSystem.writeFile(UriUtils.toUri(uri), text);
|
|
152
154
|
textDocuments.notifyDidSaveTextDocument({ textDocument: { uri }, text }, clientId);
|
|
153
155
|
},
|
|
156
|
+
queueDiskTask<T>(_uri: string, task: () => Promise<T>): Promise<T> {
|
|
157
|
+
return task();
|
|
158
|
+
},
|
|
154
159
|
// Registers the listener but never calls it by itself — the real manager
|
|
155
160
|
// emits from a `DocumentBuilder.onDocumentPhase(Validated)` hook and the
|
|
156
161
|
// stub tree runs no phases. `emitUpdate` is the only thing that fires it;
|
|
@@ -12,6 +12,7 @@ import type { ApplyWorkspaceEditResult } from 'vscode-languageserver';
|
|
|
12
12
|
import { Disposable } from 'vscode-languageserver';
|
|
13
13
|
import type { TextDocument } from 'vscode-languageserver-textdocument';
|
|
14
14
|
import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
|
|
15
|
+
import { ClientSessionRegistry } from '../documents/client-session-registry.js';
|
|
15
16
|
import type { ClientTextDocumentChangeEvent, HydraniumTextDocuments } from '../documents/hydranium-text-documents.js';
|
|
16
17
|
|
|
17
18
|
/** Snapshot of an open document tracked by the stub. */
|
|
@@ -27,9 +28,11 @@ export interface StubTextDocumentEntry {
|
|
|
27
28
|
* reads from on test paths — the content channel
|
|
28
29
|
* (`notifyDidChangeTextDocument` / `applyContentChange` / `version` /
|
|
29
30
|
* `getAuthor`), the open-state probes (`isOpenInLanguageClient` /
|
|
30
|
-
* `isOpenInAnyClient` / `openDocuments`), the push channel
|
|
31
|
-
* client (`applyEditToLanguageClient` / `stagePendingContent`)
|
|
32
|
-
* save / close notifications
|
|
31
|
+
* `isOpenInAnyClient` / `isOpenInClient` / `openDocuments`), the push channel
|
|
32
|
+
* to the language client (`applyEditToLanguageClient` / `stagePendingContent`),
|
|
33
|
+
* the save / close notifications, and the client-session table
|
|
34
|
+
* (`registerSession` / `closeSession` / `onDidCloseSession` and the open
|
|
35
|
+
* options), which is a real `ClientSessionRegistry` — plus test-only helpers:
|
|
33
36
|
*
|
|
34
37
|
* - {@link seedOpen} — pre-populate an open document without firing change
|
|
35
38
|
* events. Use to set up a baseline version before exercising an `update`.
|
|
@@ -69,6 +72,12 @@ export interface StubHydraniumTextDocuments extends Pick<
|
|
|
69
72
|
| 'applyEditToLanguageClient'
|
|
70
73
|
| 'stagePendingContent'
|
|
71
74
|
| 'openDocuments'
|
|
75
|
+
| 'isOpenInClient'
|
|
76
|
+
| 'registerSession'
|
|
77
|
+
| 'closeSession'
|
|
78
|
+
| 'onDidCloseSession'
|
|
79
|
+
| 'openOptions'
|
|
80
|
+
| 'setOpenOptions'
|
|
72
81
|
> {
|
|
73
82
|
/**
|
|
74
83
|
* Stub-tailored read accessor. Returns just the surface the framework's
|
|
@@ -93,7 +102,13 @@ export interface StubHydraniumTextDocuments extends Pick<
|
|
|
93
102
|
seedOpenInLanguageClient(uri: string): void;
|
|
94
103
|
/** Synchronously deliver `onDidClose` to subscribers. */
|
|
95
104
|
fireClose(uri: string, clientId: string): void;
|
|
96
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Drop recorded state. Useful for `beforeEach` resets.
|
|
107
|
+
*
|
|
108
|
+
* Sessions end without announcing it, so a model service built on this
|
|
109
|
+
* stub still holds the handles it started before the reset. Build a fresh
|
|
110
|
+
* service after a reset rather than reusing one across it.
|
|
111
|
+
*/
|
|
97
112
|
reset(): void;
|
|
98
113
|
readonly changes: readonly StubTextDocumentEntry[];
|
|
99
114
|
readonly saves: readonly { uri: string; clientId: string }[];
|
|
@@ -125,8 +140,13 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
|
|
|
125
140
|
let applyEditHandler: (uri: string, text: string) => ApplyWorkspaceEditResult | undefined = () => ({ applied: true });
|
|
126
141
|
const saveListeners: Array<(event: { document: { uri: string }; clientId: string }) => void> = [];
|
|
127
142
|
const closeListeners: Array<(event: ClientTextDocumentChangeEvent<TextDocument>) => void> = [];
|
|
143
|
+
// The real registry, so the session table, the opens, the closing state and
|
|
144
|
+
// the open options behave as the store's do. The stub's plain-string keys
|
|
145
|
+
// stand in for canonical URIs (one stub-boundary cast, like `fireClose`).
|
|
146
|
+
const sessions = new ClientSessionRegistry();
|
|
147
|
+
const key = (uri: string): CanonicalUri => uri as CanonicalUri;
|
|
128
148
|
|
|
129
|
-
|
|
149
|
+
const stub: StubHydraniumTextDocuments = {
|
|
130
150
|
get changes() {
|
|
131
151
|
return changes;
|
|
132
152
|
},
|
|
@@ -207,21 +227,37 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
|
|
|
207
227
|
isOpenInAnyClient(uri) {
|
|
208
228
|
return docs.has(uri) || languageClientOpen.has(uri);
|
|
209
229
|
},
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
230
|
+
isOpenInClient(uri, clientId) {
|
|
231
|
+
return sessions.isOpenIn(key(uri), clientId);
|
|
232
|
+
},
|
|
233
|
+
registerSession(clientId) {
|
|
234
|
+
sessions.register(clientId);
|
|
235
|
+
},
|
|
236
|
+
closeSession(clientId) {
|
|
237
|
+
if (!sessions.isRegistered(clientId)) {
|
|
238
|
+
return;
|
|
216
239
|
}
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
240
|
+
try {
|
|
241
|
+
for (const uri of sessions.beginClose(clientId)) {
|
|
242
|
+
stub.fireClose(uri, clientId);
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
sessions.unregister(clientId);
|
|
221
246
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
return
|
|
247
|
+
},
|
|
248
|
+
get onDidCloseSession() {
|
|
249
|
+
return sessions.onDidCloseSession;
|
|
250
|
+
},
|
|
251
|
+
openOptions(uri, clientId) {
|
|
252
|
+
return sessions.openOptions(key(uri), clientId);
|
|
253
|
+
},
|
|
254
|
+
setOpenOptions(uri, clientId, options) {
|
|
255
|
+
sessions.setOpenOptions(key(uri), clientId, options);
|
|
256
|
+
},
|
|
257
|
+
openDocuments() {
|
|
258
|
+
// From the registry that also answers `isOpenInClient`, which both
|
|
259
|
+
// seeding channels feed, so the two never disagree about an open.
|
|
260
|
+
return sessions.openDocuments();
|
|
225
261
|
},
|
|
226
262
|
setApplyEditHandler(handler) {
|
|
227
263
|
applyEditHandler = handler;
|
|
@@ -234,9 +270,11 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
|
|
|
234
270
|
staged.push({ uri, text });
|
|
235
271
|
},
|
|
236
272
|
seedOpen(uri, text, clientId) {
|
|
273
|
+
sessions.addOpen(key(uri), clientId);
|
|
237
274
|
docs.set(uri, { uri, version: 1, text, clientId });
|
|
238
275
|
},
|
|
239
276
|
seedOpenInLanguageClient(uri) {
|
|
277
|
+
sessions.addOpen(key(uri), LANGUAGE_CLIENT_ID);
|
|
240
278
|
languageClientOpen.add(uri);
|
|
241
279
|
},
|
|
242
280
|
fireClose(uri, clientId) {
|
|
@@ -247,6 +285,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
|
|
|
247
285
|
if (holder && holder.clientId === clientId) {
|
|
248
286
|
docs.delete(uri);
|
|
249
287
|
}
|
|
288
|
+
sessions.removeOpen(key(uri), clientId);
|
|
250
289
|
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
251
290
|
languageClientOpen.delete(uri);
|
|
252
291
|
}
|
|
@@ -265,6 +304,11 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
|
|
|
265
304
|
saveListeners.length = 0;
|
|
266
305
|
closeListeners.length = 0;
|
|
267
306
|
applyEditHandler = () => ({ applied: true });
|
|
307
|
+
// Cleared in place rather than replaced: a model service built on the
|
|
308
|
+
// stub subscribed to `onDidCloseSession` once, and a new registry would
|
|
309
|
+
// leave that subscription on one nothing fires.
|
|
310
|
+
sessions.clear();
|
|
268
311
|
}
|
|
269
312
|
};
|
|
313
|
+
return stub;
|
|
270
314
|
}
|