@hydranium/core 1.0.0-next.185 → 1.0.0-next.188
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 +26 -44
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +20 -50
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/documents/hydranium-text-documents.d.ts +87 -30
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +201 -37
- 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/documents/model-ledger.d.ts +47 -0
- package/lib/documents/model-ledger.d.ts.map +1 -0
- package/lib/documents/model-ledger.js +30 -0
- package/lib/documents/model-ledger.js.map +1 -0
- package/lib/documents/version-sync-service.d.ts +134 -0
- package/lib/documents/version-sync-service.d.ts.map +1 -0
- package/lib/documents/version-sync-service.js +171 -0
- package/lib/documents/version-sync-service.js.map +1 -0
- package/lib/langium/bootstrap.d.ts.map +1 -1
- package/lib/langium/bootstrap.js +16 -0
- package/lib/langium/bootstrap.js.map +1 -1
- package/lib/langium/document-builder/document-builder.d.ts +31 -25
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +43 -39
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
- package/lib/langium/integrity/integrity-service.js +6 -15
- package/lib/langium/integrity/integrity-service.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +14 -10
- package/lib/langium/model-service/client-session.d.ts.map +1 -1
- package/lib/langium/model-service/client-session.js +18 -15
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +91 -41
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +127 -50
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/module.d.ts +6 -0
- package/lib/langium/module.d.ts.map +1 -1
- package/lib/langium/module.js +5 -1
- package/lib/langium/module.js.map +1 -1
- package/lib/langium/residency/cst-residency-service.d.ts.map +1 -1
- package/lib/langium/residency/cst-residency-service.js +10 -1
- package/lib/langium/residency/cst-residency-service.js.map +1 -1
- package/lib/langium/shared-services.d.ts +6 -2
- package/lib/langium/shared-services.d.ts.map +1 -1
- package/lib/langium/shared-services.js.map +1 -1
- package/lib/langium/transfer/transfer-encoder.d.ts +19 -10
- package/lib/langium/transfer/transfer-encoder.d.ts.map +1 -1
- package/lib/langium/transfer/transfer-encoder.js +8 -7
- package/lib/langium/transfer/transfer-encoder.js.map +1 -1
- package/lib/langium/workspace/hydranium-langium-document-factory.d.ts +31 -1
- package/lib/langium/workspace/hydranium-langium-document-factory.d.ts.map +1 -1
- package/lib/langium/workspace/hydranium-langium-document-factory.js +54 -0
- package/lib/langium/workspace/hydranium-langium-document-factory.js.map +1 -1
- package/lib/langium/workspace/langium-documents.d.ts +7 -0
- package/lib/langium/workspace/langium-documents.d.ts.map +1 -1
- package/lib/langium/workspace/langium-documents.js +10 -0
- package/lib/langium/workspace/langium-documents.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +1 -0
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/testing/make-test-services.d.ts +7 -0
- package/lib/testing/make-test-services.d.ts.map +1 -1
- package/lib/testing/make-test-services.js +10 -2
- package/lib/testing/make-test-services.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +3 -2
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +7 -1
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.d.ts +2 -2
- package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
- package/lib/testing/stub-hydranium-text-documents.js +6 -1
- package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +45 -77
- package/src/documents/hydranium-text-documents.ts +225 -46
- package/src/documents/index.ts +2 -0
- package/src/documents/model-ledger.ts +66 -0
- package/src/documents/version-sync-service.ts +267 -0
- package/src/langium/bootstrap.ts +16 -0
- package/src/langium/document-builder/document-builder.ts +76 -37
- package/src/langium/integrity/integrity-service.ts +6 -15
- package/src/langium/model-service/client-session.ts +29 -21
- package/src/langium/model-service/model-service.ts +182 -64
- package/src/langium/module.ts +9 -1
- package/src/langium/residency/cst-residency-service.ts +10 -1
- package/src/langium/shared-services.ts +6 -2
- package/src/langium/transfer/transfer-encoder.ts +25 -18
- package/src/langium/workspace/hydranium-langium-document-factory.ts +83 -0
- package/src/langium/workspace/langium-documents.ts +11 -0
- package/src/lsp/hydranium-document-update-handler.ts +3 -0
- package/src/testing/make-test-services.ts +15 -2
- package/src/testing/stub-ast-document-manager.ts +10 -2
- package/src/testing/stub-hydranium-text-documents.ts +8 -3
|
@@ -52,8 +52,12 @@ import {
|
|
|
52
52
|
asLanguageClientUri,
|
|
53
53
|
DisposableCollection,
|
|
54
54
|
type Stopwatch,
|
|
55
|
+
type TextState,
|
|
56
|
+
type TextVersion,
|
|
55
57
|
textHash,
|
|
56
|
-
type Tracer
|
|
58
|
+
type Tracer,
|
|
59
|
+
STALE_VERSION,
|
|
60
|
+
UNRECORDED_VERSION
|
|
57
61
|
} from '@hydranium/protocol';
|
|
58
62
|
import { type LogNameOptions } from '../langium/diagnostics/logger.js';
|
|
59
63
|
import { HYDRANIUM_BUILD_REASONS } from '../langium/document-builder/document-builder.js';
|
|
@@ -125,8 +129,13 @@ export interface LastOpenClosedEvent {
|
|
|
125
129
|
/** Delivered by {@link HydraniumTextDocuments.onDidChangeDirty}. */
|
|
126
130
|
export interface DocumentDirtyChangedEvent {
|
|
127
131
|
readonly uri: CanonicalUri;
|
|
128
|
-
/**
|
|
129
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The text the new answer of {@link HydraniumTextDocuments.isDirty} was
|
|
134
|
+
* decided on. The answer changes with the text, before any build, so it can
|
|
135
|
+
* name text whose model has not been sent yet. Absent when the document no
|
|
136
|
+
* longer exists, or when the build that follows its release failed.
|
|
137
|
+
*/
|
|
138
|
+
readonly text?: TextState;
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
/**
|
|
@@ -242,16 +251,17 @@ export type RepairCommit<T extends TextDocument> =
|
|
|
242
251
|
| { readonly status: 'not-open' };
|
|
243
252
|
|
|
244
253
|
/**
|
|
245
|
-
* Where a URI's shared version sequence left off
|
|
246
|
-
*
|
|
247
|
-
*
|
|
254
|
+
* Where a URI's shared version sequence left off while no client holds it —
|
|
255
|
+
* started by its first build, stepped by builds of changed text, written at
|
|
256
|
+
* last-client close, and consulted at the next open so the sequence CONTINUES
|
|
257
|
+
* instead of restarting at whatever version id the opening client declares. One entry
|
|
248
258
|
* of {@link HydraniumTextDocuments.__versionSequences} — a `protected` field, so
|
|
249
259
|
* a subclass reading the map has to name what it holds.
|
|
250
260
|
*/
|
|
251
261
|
export interface VersionSequence {
|
|
252
|
-
/** The shared version
|
|
262
|
+
/** The shared version of the text last closed or built. */
|
|
253
263
|
readonly version: number;
|
|
254
|
-
/** {@link textHash} of
|
|
264
|
+
/** {@link textHash} of that text. */
|
|
255
265
|
readonly contentHash: string;
|
|
256
266
|
}
|
|
257
267
|
|
|
@@ -339,10 +349,12 @@ function isFileNotFound(err: unknown, target: URI): boolean {
|
|
|
339
349
|
* - A SERVER-OWNED shared version sequence: per-URI, monotonic across
|
|
340
350
|
* close/reopen cycles, advancing exactly when the synced content changes.
|
|
341
351
|
* Client-declared version ids (Monaco's buffer numbering) feed only a
|
|
342
|
-
* per-client staleness guard and never leak into
|
|
352
|
+
* per-client staleness guard and never leak into a running sequence —
|
|
343
353
|
* the two are different things (an editor's edit-operation counter vs the
|
|
344
354
|
* document's content-revision number), and splicing them lets versions drift
|
|
345
|
-
* silently past
|
|
355
|
+
* silently past base-version gate holders. A URI with no sequence, and no
|
|
356
|
+
* root that records a version, starts at its opener's declared id: no version
|
|
357
|
+
* was handed out for it.
|
|
346
358
|
* - Version-author history so each edit is attributable to its originating client.
|
|
347
359
|
* - The revert to disk once no client has a document open, for every head
|
|
348
360
|
* ({@link revertToDisk}), deferred by
|
|
@@ -374,21 +386,36 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
374
386
|
|
|
375
387
|
/**
|
|
376
388
|
* Per-URI shared-version continuity across close/reopen cycles
|
|
377
|
-
* ({@link VersionSequence}),
|
|
378
|
-
* the next first-client open.
|
|
379
|
-
*
|
|
389
|
+
* ({@link VersionSequence}), kept for every document a build or a client
|
|
390
|
+
* gave the store, and consulted by the next first-client open. A URI
|
|
391
|
+
* with none, and no root that records a version, starts at its opener's
|
|
392
|
+
* declared version.
|
|
393
|
+
* DELIBERATELY outside {@link DocumentTrackingRecord}: that record is deleted on last close, while the version sequence must
|
|
380
394
|
* survive it — the shared version is a server-owned, monotonic,
|
|
381
395
|
* advances-iff-content-changes counter that never resets while the server
|
|
382
|
-
* lives. That invariant is what makes an optimistic
|
|
396
|
+
* lives. That invariant is what makes an optimistic base-version gate
|
|
383
397
|
* sound: "version unchanged ⇔ content unchanged", with no false conflicts
|
|
384
398
|
* from close/reopen version resets and no false passes from a reopened
|
|
385
399
|
* sequence coincidentally landing on a stale writer's number.
|
|
386
400
|
*
|
|
387
|
-
* Never pruned
|
|
388
|
-
*
|
|
401
|
+
* Never pruned, not even when the file is deleted: a recreated file
|
|
402
|
+
* restarting at `0` would let a write based on the deleted text pass. Two
|
|
403
|
+
* small values per URI ever built, however often it changes.
|
|
389
404
|
*/
|
|
390
405
|
protected readonly __versionSequences = new Map<CanonicalUri, VersionSequence>();
|
|
391
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Released documents last announced dirty. Their clean flip waits for the
|
|
409
|
+
* revert, so it carries the reverted text's version; a first open before the
|
|
410
|
+
* revert takes the entry over, and its own dirty answer decides the flip.
|
|
411
|
+
* Each release enters a token of its own, so the revert of an earlier
|
|
412
|
+
* release cannot announce a later one clean before that one's revert.
|
|
413
|
+
*/
|
|
414
|
+
protected readonly __releasedDirty = new Map<CanonicalUri, object>();
|
|
415
|
+
|
|
416
|
+
/** Per held document, the {@link textHash} of its text at the version it was taken. */
|
|
417
|
+
protected readonly __textHashes = new WeakMap<TextDocument, { readonly version: number; readonly hash: string }>();
|
|
418
|
+
|
|
392
419
|
/**
|
|
393
420
|
* Texts pushed to the LSP textual language client via
|
|
394
421
|
* {@link applyEditToLanguageClient} whose echoes have not come back yet
|
|
@@ -634,7 +661,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
634
661
|
}
|
|
635
662
|
|
|
636
663
|
// The SHARED version advances iff the content actually changes — the
|
|
637
|
-
// invariant optimistic
|
|
664
|
+
// invariant optimistic base-version gates rely on. The new text is
|
|
638
665
|
// only known after applying the (possibly incremental) changes, so
|
|
639
666
|
// apply at a tentative +1 and roll the version back on an identical
|
|
640
667
|
// result (an empty-changes update only re-stamps the version).
|
|
@@ -689,12 +716,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
689
716
|
* A content-identical write still fires the change event (rebuild): the
|
|
690
717
|
* authored write's server-side rebuild is correctness-bearing, it just
|
|
691
718
|
* mints no new version — nothing observable changed, so watchers'
|
|
692
|
-
*
|
|
719
|
+
* base versions stay valid.
|
|
693
720
|
*
|
|
694
721
|
* Returns the resulting shared version. Throws when the document is not
|
|
695
722
|
* open — callers (`AstDocumentManager.update`) open first.
|
|
696
723
|
*/
|
|
697
|
-
applyContentChange(uri: DocumentUri, text: string, clientId: string):
|
|
724
|
+
applyContentChange(uri: DocumentUri, text: string, clientId: string): TextVersion {
|
|
698
725
|
const key = this.documentKey(uri);
|
|
699
726
|
let document = this.__syncedDocuments.get(key);
|
|
700
727
|
if (document === undefined) {
|
|
@@ -833,16 +860,15 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
833
860
|
// here at release, not on every change.
|
|
834
861
|
this.__versionSequences.set(uri, {
|
|
835
862
|
version: syncedDocument.version,
|
|
836
|
-
contentHash:
|
|
863
|
+
contentHash: this.heldTextHash(syncedDocument)
|
|
837
864
|
});
|
|
838
|
-
|
|
865
|
+
if (this.__documents.get(uri)?.dirty === true) {
|
|
866
|
+
this.__releasedDirty.set(uri, {});
|
|
867
|
+
}
|
|
839
868
|
this.__syncedDocuments.delete(uri);
|
|
840
869
|
// One delete clears every per-URI axis (version history + any staged
|
|
841
870
|
// pending content) so a future open with the same URI starts fresh.
|
|
842
871
|
this.__documents.delete(uri);
|
|
843
|
-
if (wasDirty) {
|
|
844
|
-
this.dirtyChangedEmitter.fire(Object.freeze({ uri, dirty: false }));
|
|
845
|
-
}
|
|
846
872
|
this.lastOpenClosedEmitter.fire(Object.freeze({ uri }));
|
|
847
873
|
}
|
|
848
874
|
|
|
@@ -867,21 +893,71 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
867
893
|
* document meanwhile would have its text rebuilt over, or the document
|
|
868
894
|
* removed. A document some client has open again, or that waits out a new
|
|
869
895
|
* grace, is left to that client.
|
|
896
|
+
*
|
|
897
|
+
* A document released dirty is announced clean, so a watcher is never left
|
|
898
|
+
* holding it dirty: once the revert has parsed the file, even if it is then
|
|
899
|
+
* cancelled, or else once the build it requests in its place has parsed it,
|
|
900
|
+
* as the announcement names the store's text. One the revert or that build
|
|
901
|
+
* removed, or that build failed, is announced without text. A reopen before
|
|
902
|
+
* that takes the announcement over.
|
|
870
903
|
*/
|
|
871
904
|
protected async revertToDisk(uri: CanonicalUri): Promise<void> {
|
|
872
905
|
const workspace = this.services.workspace;
|
|
873
906
|
const target = UriUtils.toUri(uri);
|
|
874
907
|
const reopened = (): boolean => this.isOpenInAnyClient(uri) || this.__syncedDocuments.has(uri);
|
|
908
|
+
const releasedDirty = this.__releasedDirty.get(uri);
|
|
909
|
+
const owesFlip = (): boolean => releasedDirty !== undefined && this.__releasedDirty.get(uri) === releasedDirty;
|
|
910
|
+
const announceClean = (withText = true): void => {
|
|
911
|
+
if (owesFlip()) {
|
|
912
|
+
this.__releasedDirty.delete(uri);
|
|
913
|
+
// The sequence of a removed document still names the discarded text.
|
|
914
|
+
const text = !withText || workspace.LangiumDocuments.getDocument(target) === undefined ? undefined : this.textState(uri);
|
|
915
|
+
this.dirtyChangedEmitter.fire(Object.freeze(text ? { uri, text } : { uri }));
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
let parsedFromFile = false;
|
|
919
|
+
let parses: Disposable | undefined;
|
|
920
|
+
let onDisk: boolean | undefined;
|
|
921
|
+
let cancelled = false;
|
|
922
|
+
let stopWaiting: (() => void) | undefined;
|
|
923
|
+
// Without it, a revert stopped short leaves the root on the released text.
|
|
924
|
+
const buildInstead = (): void => {
|
|
925
|
+
void workspace.VersionSyncService.requestRecoveryBuild(target, {
|
|
926
|
+
deleted: onDisk === false,
|
|
927
|
+
reason: HYDRANIUM_BUILD_REASONS.didClose,
|
|
928
|
+
// The update handler dropped the change it held back at the release.
|
|
929
|
+
ignoreDeferred: true,
|
|
930
|
+
stillNeeded: () => !reopened()
|
|
931
|
+
}).then(built => {
|
|
932
|
+
if (!built) {
|
|
933
|
+
this.tracer.with(uri).error('Build after a revert that stopped short failed; the store keeps the released text');
|
|
934
|
+
// No parse or removal is coming.
|
|
935
|
+
stopWaiting?.();
|
|
936
|
+
announceClean(false);
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
};
|
|
875
940
|
try {
|
|
876
941
|
// Read before the lock: every build and read waits while the lock is
|
|
877
942
|
// held, and this read waits on the file's save I/O.
|
|
878
|
-
|
|
943
|
+
onDisk = await workspace.FileSystemTaskQueue.enqueue(uri, () => workspace.FileSystemProvider.exists(target));
|
|
879
944
|
await workspace.WorkspaceManager?.ready;
|
|
945
|
+
// Queuing the write cancels the running build, even when it then reverts nothing.
|
|
946
|
+
if (reopened()) {
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
880
949
|
await workspace.WorkspaceLock.write(async token => {
|
|
881
950
|
if (reopened()) {
|
|
882
951
|
return;
|
|
883
952
|
}
|
|
953
|
+
// Observed rather than awaited: a build cancelled after its parse
|
|
954
|
+
// returns early, and the build it yields to does not parse again.
|
|
955
|
+
parses = workspace.VersionSyncService.onDidRecordModel(document => {
|
|
956
|
+
parsedFromFile ||= this.documentKey(document.uri.toString()) === uri;
|
|
957
|
+
});
|
|
884
958
|
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
959
|
+
// Cleared once the build ends: a cancelled one throws, and the lock resolves.
|
|
960
|
+
cancelled = true;
|
|
885
961
|
try {
|
|
886
962
|
await (onDisk
|
|
887
963
|
? workspace.DocumentBuilder.update([target], [], token)
|
|
@@ -895,7 +971,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
895
971
|
workspace.DocumentBuilder.markNextReason(HYDRANIUM_BUILD_REASONS.didClose);
|
|
896
972
|
await workspace.DocumentBuilder.update([], [target], token);
|
|
897
973
|
}
|
|
974
|
+
cancelled = false;
|
|
898
975
|
});
|
|
976
|
+
// Cancelled before its parse: the write that cancelled it may build nothing.
|
|
977
|
+
if (cancelled && !parsedFromFile && !reopened() && workspace.LangiumDocuments.getDocument(target) !== undefined) {
|
|
978
|
+
buildInstead();
|
|
979
|
+
}
|
|
899
980
|
} catch (err: unknown) {
|
|
900
981
|
// A revert that finishes after the LSP peer went away fails its
|
|
901
982
|
// diagnostics publish, and one that runs after its workspace was torn
|
|
@@ -906,6 +987,38 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
906
987
|
}
|
|
907
988
|
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
908
989
|
this.tracer.with(uri).error(`Revert on last close dropped. ${detail}`);
|
|
990
|
+
// The update handler drops a change still debounced at release, so
|
|
991
|
+
// without this build the root stays behind the store's version. A
|
|
992
|
+
// document known to have no file is removed, as the revert would have.
|
|
993
|
+
if (!reopened()) {
|
|
994
|
+
buildInstead();
|
|
995
|
+
}
|
|
996
|
+
} finally {
|
|
997
|
+
parses?.dispose();
|
|
998
|
+
if (parsedFromFile || workspace.LangiumDocuments.getDocument(target) === undefined) {
|
|
999
|
+
announceClean();
|
|
1000
|
+
} else if (owesFlip()) {
|
|
1001
|
+
// Cancelled before its parse or failed, the revert left the released
|
|
1002
|
+
// text in the store: announced now, the clean flip would name it.
|
|
1003
|
+
stopWaiting = (): void => {
|
|
1004
|
+
parsed.dispose();
|
|
1005
|
+
deleted.dispose();
|
|
1006
|
+
};
|
|
1007
|
+
const done = (): void => {
|
|
1008
|
+
stopWaiting?.();
|
|
1009
|
+
announceClean();
|
|
1010
|
+
};
|
|
1011
|
+
const parsed = workspace.VersionSyncService.onDidRecordModel(document => {
|
|
1012
|
+
if (this.documentKey(document.uri.toString()) === uri) {
|
|
1013
|
+
done();
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
const deleted = workspace.DocumentBuilder.onUpdate((_changed, deletedUris) => {
|
|
1017
|
+
if (deletedUris.some(deletedUri => this.documentKey(deletedUri.toString()) === uri)) {
|
|
1018
|
+
done();
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
909
1022
|
}
|
|
910
1023
|
}
|
|
911
1024
|
|
|
@@ -1041,13 +1154,16 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1041
1154
|
const source = pendingText ? ', source=pending' : '';
|
|
1042
1155
|
// The SHARED version is server-assigned: continue the persisted
|
|
1043
1156
|
// sequence — same version when the content is unchanged since the
|
|
1044
|
-
// last close (so watchers'
|
|
1157
|
+
// last close (so watchers' base versions stay valid), one
|
|
1045
1158
|
// step when it changed (so no stale pointer can coincidentally pass
|
|
1046
|
-
// the optimistic gate).
|
|
1047
|
-
// declared id as the sequence seed.
|
|
1159
|
+
// the optimistic gate). An open with no sequence yet seeds it from the built root.
|
|
1048
1160
|
const sequence = this.__versionSequences.get(uri);
|
|
1049
1161
|
const version =
|
|
1050
|
-
sequence === undefined
|
|
1162
|
+
sequence === undefined
|
|
1163
|
+
? this.firstOpenVersion(uri, text, td.version)
|
|
1164
|
+
: sequence.contentHash === textHash(text)
|
|
1165
|
+
? sequence.version
|
|
1166
|
+
: sequence.version + 1;
|
|
1051
1167
|
this.log(uri, `Open document: Version ${version} by ${this.formatClientId(clientId)} [first client${source}]`);
|
|
1052
1168
|
document = this.configuration.create(uri, td.languageId, version, text);
|
|
1053
1169
|
this.__syncedDocuments.set(uri, document);
|
|
@@ -1056,6 +1172,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1056
1172
|
// it from the file, and an editor opened its buffer from there. An
|
|
1057
1173
|
// editor that opens a buffer it never saved is taken as clean.
|
|
1058
1174
|
record.diskBaseline = td.text;
|
|
1175
|
+
if (this.__releasedDirty.delete(uri)) {
|
|
1176
|
+
record.dirty = true;
|
|
1177
|
+
}
|
|
1059
1178
|
this.refreshDirty(uri);
|
|
1060
1179
|
if (clientId === LANGUAGE_CLIENT_ID) {
|
|
1061
1180
|
// Baseline the shadow to what Monaco just opened so the next outbound
|
|
@@ -1130,6 +1249,26 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1130
1249
|
return true;
|
|
1131
1250
|
}
|
|
1132
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* The built root's recorded version, one on when `text` differs from the root's;
|
|
1254
|
+
* `declared` when the root records no store version. Seeded from `declared`, a write based
|
|
1255
|
+
* on the root passes the gate over other text, and the same text looks newer than its model.
|
|
1256
|
+
* A built root has no sequence only under a `LangiumDocuments` that does not reconcile at registration.
|
|
1257
|
+
*/
|
|
1258
|
+
protected firstOpenVersion(uri: CanonicalUri, text: string, declared: number): number {
|
|
1259
|
+
const built = this.services.workspace.LangiumDocuments.getDocument(UriUtils.toUri(uri));
|
|
1260
|
+
if (built === undefined) {
|
|
1261
|
+
return declared;
|
|
1262
|
+
}
|
|
1263
|
+
const ledger = this.services.workspace.ModelLedger;
|
|
1264
|
+
const root = built.parseResult.value;
|
|
1265
|
+
const recorded = ledger.versionOf(root);
|
|
1266
|
+
if (recorded === UNRECORDED_VERSION || recorded === STALE_VERSION) {
|
|
1267
|
+
return declared;
|
|
1268
|
+
}
|
|
1269
|
+
return (ledger.textOf(root) ?? built.textDocument.getText()) === text ? recorded : recorded + 1;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1133
1272
|
protected logClientJoined(uri: DocumentUri, clientId: string, version: number, existingClients: readonly string[]): void {
|
|
1134
1273
|
this.log(
|
|
1135
1274
|
uri,
|
|
@@ -1214,43 +1353,76 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1214
1353
|
|
|
1215
1354
|
/**
|
|
1216
1355
|
* Current SHARED version of the document at `uri`: the open document's
|
|
1217
|
-
* version, else where
|
|
1218
|
-
*
|
|
1356
|
+
* version, else where its persisted sequence left off, else `0` for a URI
|
|
1357
|
+
* this store has never seen. The shared sequence is
|
|
1219
1358
|
* server-owned and monotonic across close/reopen cycles, and advances
|
|
1220
1359
|
* exactly when the synced content changes — which is what makes it a sound
|
|
1221
|
-
* optimistic-concurrency token (
|
|
1360
|
+
* optimistic-concurrency token (base-version gates): version unchanged ⇔
|
|
1222
1361
|
* content unchanged.
|
|
1223
1362
|
*/
|
|
1224
|
-
version(uri: DocumentUri):
|
|
1363
|
+
version(uri: DocumentUri): TextVersion {
|
|
1225
1364
|
return this.get(uri)?.version ?? this.__versionSequences.get(this.documentKey(uri))?.version ?? 0;
|
|
1226
1365
|
}
|
|
1227
1366
|
|
|
1367
|
+
/**
|
|
1368
|
+
* The text the store holds for `uri`: an open document's, or for a closed
|
|
1369
|
+
* one, the clean text its version sequence left off at, which for a deleted
|
|
1370
|
+
* document is its last text. `undefined` for a URI no build and no client
|
|
1371
|
+
* gave the store.
|
|
1372
|
+
*/
|
|
1373
|
+
textState(uri: DocumentUri): TextState | undefined {
|
|
1374
|
+
const document = this.get(uri);
|
|
1375
|
+
if (document) {
|
|
1376
|
+
return { version: document.version, hash: this.heldTextHash(document), dirty: this.isDirty(uri) };
|
|
1377
|
+
}
|
|
1378
|
+
const sequence = this.__versionSequences.get(this.documentKey(uri));
|
|
1379
|
+
return sequence && { version: sequence.version, hash: sequence.contentHash, dirty: false };
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* {@link textHash} of `document`'s text, taken once per version: the store
|
|
1384
|
+
* moves a document's version with every change of its text.
|
|
1385
|
+
*/
|
|
1386
|
+
protected heldTextHash(document: T): string {
|
|
1387
|
+
const taken = this.__textHashes.get(document);
|
|
1388
|
+
if (taken?.version === document.version) {
|
|
1389
|
+
return taken.hash;
|
|
1390
|
+
}
|
|
1391
|
+
const hash = textHash(document.getText());
|
|
1392
|
+
this.__textHashes.set(document, { version: document.version, hash });
|
|
1393
|
+
return hash;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1228
1396
|
/**
|
|
1229
1397
|
* Reconcile the persisted version sequence with content that reached the
|
|
1230
1398
|
* build OUTSIDE the store's write paths — a closed document rebuilt from
|
|
1231
1399
|
* disk (last-close revert) or replaced by a watched-file change. Steps the
|
|
1232
1400
|
* sequence iff `text` differs from the sequence's last-known content and
|
|
1233
1401
|
* returns the resulting sequence version so the caller can re-stamp the
|
|
1234
|
-
* rebuilt document (
|
|
1402
|
+
* rebuilt document (`VersionSyncService.modelProduced`) —
|
|
1235
1403
|
* keeping the "version advances iff content changes" invariant for
|
|
1236
1404
|
* documents no client currently holds.
|
|
1237
1405
|
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1406
|
+
* For an open document, its version when it holds `text`, since a build
|
|
1407
|
+
* that read the file while the document was closed can finish after an
|
|
1408
|
+
* open; else `undefined`, and the caller has to build the document again.
|
|
1409
|
+
*
|
|
1410
|
+
* A document without a sequence starts one at `0` with `text`: snapshots
|
|
1411
|
+
* hand out versions for documents no client opened, so an uncounted change
|
|
1412
|
+
* would let a write based on the old text pass the gate.
|
|
1243
1413
|
*/
|
|
1244
1414
|
reconcileExternalContent(uri: DocumentUri, text: string): number | undefined {
|
|
1245
1415
|
const key = this.documentKey(uri);
|
|
1246
|
-
|
|
1247
|
-
|
|
1416
|
+
const open = this.__syncedDocuments.get(key);
|
|
1417
|
+
if (open !== undefined) {
|
|
1418
|
+
return open.getText() === text ? open.version : undefined;
|
|
1248
1419
|
}
|
|
1420
|
+
const hash = textHash(text);
|
|
1249
1421
|
const sequence = this.__versionSequences.get(key);
|
|
1250
1422
|
if (sequence === undefined) {
|
|
1251
|
-
|
|
1423
|
+
this.__versionSequences.set(key, { version: 0, contentHash: hash });
|
|
1424
|
+
return 0;
|
|
1252
1425
|
}
|
|
1253
|
-
const hash = textHash(text);
|
|
1254
1426
|
if (hash === sequence.contentHash) {
|
|
1255
1427
|
return sequence.version;
|
|
1256
1428
|
}
|
|
@@ -1284,7 +1456,7 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1284
1456
|
*
|
|
1285
1457
|
* A changed text is a new version authored by {@link INTEGRITY_CLIENT_ID}.
|
|
1286
1458
|
* Kept at the old version, the repair is invisible to every reader keyed on
|
|
1287
|
-
* versions: a write based on the unrepaired version passes the
|
|
1459
|
+
* versions: a write based on the unrepaired version passes the base-version gate
|
|
1288
1460
|
* and replaces the repair, and an echo filter credits the repair to the
|
|
1289
1461
|
* client whose edit it corrected. No change event is fired: the repair
|
|
1290
1462
|
* rides the build already under way, and an event would re-enter it.
|
|
@@ -1421,7 +1593,12 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1421
1593
|
return this.__documents.get(this.documentKey(uri))?.dirty ?? false;
|
|
1422
1594
|
}
|
|
1423
1595
|
|
|
1424
|
-
/**
|
|
1596
|
+
/**
|
|
1597
|
+
* Fires each time the answer of {@link isDirty} changes. A dirty document's
|
|
1598
|
+
* release fires once a parse of the file reaches the store, at that text's
|
|
1599
|
+
* version, or without text once its revert removed the document or could
|
|
1600
|
+
* not rebuild it, though {@link isDirty} answers clean from the release on.
|
|
1601
|
+
*/
|
|
1425
1602
|
get onDidChangeDirty(): Event<DocumentDirtyChangedEvent> {
|
|
1426
1603
|
return this.dirtyChangedEmitter.event;
|
|
1427
1604
|
}
|
|
@@ -1472,7 +1649,9 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
|
|
|
1472
1649
|
const dirty = document.getText() !== record.diskBaseline;
|
|
1473
1650
|
if (dirty !== (record.dirty ?? false)) {
|
|
1474
1651
|
record.dirty = dirty;
|
|
1475
|
-
this.dirtyChangedEmitter.fire(
|
|
1652
|
+
this.dirtyChangedEmitter.fire(
|
|
1653
|
+
Object.freeze({ uri, text: { version: document.version, hash: this.heldTextHash(document), dirty } })
|
|
1654
|
+
);
|
|
1476
1655
|
}
|
|
1477
1656
|
}
|
|
1478
1657
|
|
package/src/documents/index.ts
CHANGED
|
@@ -12,6 +12,8 @@ export * from './client-session-errors.js';
|
|
|
12
12
|
export * from './client-session-registry.js';
|
|
13
13
|
export * from './language-client-text-shadow.js';
|
|
14
14
|
export * from './hydranium-text-documents.js';
|
|
15
|
+
export * from './model-ledger.js';
|
|
16
|
+
export * from './version-sync-service.js';
|
|
15
17
|
export * from './ast-document-manager.js';
|
|
16
18
|
export * from './self-save-registry.js';
|
|
17
19
|
export * from './file-system-task-queue.js';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/********************************************************************************
|
|
2
|
+
* Copyright (c) 2026 EclipseSource and others.
|
|
3
|
+
*
|
|
4
|
+
* This program and the accompanying materials are made available under the
|
|
5
|
+
* terms of the MIT License which is available in the project root.
|
|
6
|
+
*
|
|
7
|
+
* SPDX-License-Identifier: MIT
|
|
8
|
+
********************************************************************************/
|
|
9
|
+
|
|
10
|
+
import { type AstNode } from '@hydranium/langium';
|
|
11
|
+
import { asModelVersion, type ModelVersion, UNRECORDED_VERSION } from '@hydranium/protocol';
|
|
12
|
+
|
|
13
|
+
/** What {@link ModelLedger} holds for one root. */
|
|
14
|
+
export interface ModelRecord {
|
|
15
|
+
readonly version: ModelVersion;
|
|
16
|
+
/** For a root whose CST no longer is the text it describes: changed in place, or shed. */
|
|
17
|
+
readonly text?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The version of the text each root was parsed from, and that text where the
|
|
22
|
+
* root's CST does not hold it. Stores what it is told; the rest of the
|
|
23
|
+
* framework relies on these:
|
|
24
|
+
*
|
|
25
|
+
* - every workspace root is recorded through `VersionSyncService.modelProduced` when produced;
|
|
26
|
+
* - a version is recorded only for text the store held at that version, else `STALE_VERSION`;
|
|
27
|
+
* - the builder's placeholder for a file it has not parsed carries no version;
|
|
28
|
+
* - re-recording a root without text keeps the text recorded before.
|
|
29
|
+
*/
|
|
30
|
+
export interface ModelLedger {
|
|
31
|
+
record(root: AstNode, version: number, text?: string): void;
|
|
32
|
+
/** `UNRECORDED_VERSION`, which every write conflicts with, for a root nothing recorded. */
|
|
33
|
+
versionOf(root: AstNode): ModelVersion;
|
|
34
|
+
/** The recorded text, else the root's CST text; `undefined` for a root with neither. */
|
|
35
|
+
textOf(root: AstNode): string | undefined;
|
|
36
|
+
/** Mark `root`, registered before any parse, as no parse of any text, dropping its record. */
|
|
37
|
+
markPlaceholder(root: AstNode): void;
|
|
38
|
+
/** Keyed on the root, not its state: a rebuilt document is reset below `Parsed` but keeps its root. */
|
|
39
|
+
isPlaceholder(root: AstNode): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class DefaultModelLedger implements ModelLedger {
|
|
43
|
+
protected readonly records = new WeakMap<AstNode, ModelRecord>();
|
|
44
|
+
protected readonly placeholders = new WeakSet<AstNode>();
|
|
45
|
+
|
|
46
|
+
record(root: AstNode, version: number, text?: string): void {
|
|
47
|
+
this.records.set(root, { version: asModelVersion(version), text: text ?? this.records.get(root)?.text });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
versionOf(root: AstNode): ModelVersion {
|
|
51
|
+
return this.records.get(root)?.version ?? UNRECORDED_VERSION;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
textOf(root: AstNode): string | undefined {
|
|
55
|
+
return this.records.get(root)?.text ?? root.$cstNode?.root.fullText;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
markPlaceholder(root: AstNode): void {
|
|
59
|
+
this.placeholders.add(root);
|
|
60
|
+
this.records.delete(root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isPlaceholder(root: AstNode): boolean {
|
|
64
|
+
return this.placeholders.has(root);
|
|
65
|
+
}
|
|
66
|
+
}
|