@hydranium/core 1.0.0-next.205 → 1.0.0-next.208

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/lib/documents/ast-document-manager.js +4 -4
  2. package/lib/documents/ast-document-manager.js.map +1 -1
  3. package/lib/documents/client-ids.d.ts +1 -1
  4. package/lib/documents/client-ids.d.ts.map +1 -1
  5. package/lib/documents/client-ids.js +1 -1
  6. package/lib/documents/client-ids.js.map +1 -1
  7. package/lib/documents/client-session-registry.d.ts +24 -17
  8. package/lib/documents/client-session-registry.d.ts.map +1 -1
  9. package/lib/documents/client-session-registry.js +49 -29
  10. package/lib/documents/client-session-registry.js.map +1 -1
  11. package/lib/documents/dirty-state-tracker.d.ts +79 -0
  12. package/lib/documents/dirty-state-tracker.d.ts.map +1 -0
  13. package/lib/documents/dirty-state-tracker.js +69 -0
  14. package/lib/documents/dirty-state-tracker.js.map +1 -0
  15. package/lib/documents/document-release-handler.d.ts +179 -0
  16. package/lib/documents/document-release-handler.d.ts.map +1 -0
  17. package/lib/documents/document-release-handler.js +283 -0
  18. package/lib/documents/document-release-handler.js.map +1 -0
  19. package/lib/documents/document-release-scheduler.d.ts +67 -0
  20. package/lib/documents/document-release-scheduler.d.ts.map +1 -0
  21. package/lib/documents/document-release-scheduler.js +80 -0
  22. package/lib/documents/document-release-scheduler.js.map +1 -0
  23. package/lib/documents/hydranium-text-documents.d.ts +137 -455
  24. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  25. package/lib/documents/hydranium-text-documents.js +373 -835
  26. package/lib/documents/hydranium-text-documents.js.map +1 -1
  27. package/lib/documents/index.d.ts +5 -1
  28. package/lib/documents/index.d.ts.map +1 -1
  29. package/lib/documents/index.js +5 -1
  30. package/lib/documents/index.js.map +1 -1
  31. package/lib/documents/language-client-shadow.d.ts +221 -0
  32. package/lib/documents/language-client-shadow.d.ts.map +1 -0
  33. package/lib/documents/language-client-shadow.js +288 -0
  34. package/lib/documents/language-client-shadow.js.map +1 -0
  35. package/lib/documents/text-ledger.d.ts +64 -0
  36. package/lib/documents/text-ledger.d.ts.map +1 -0
  37. package/lib/documents/text-ledger.js +63 -0
  38. package/lib/documents/text-ledger.js.map +1 -0
  39. package/lib/langium/document-builder/document-builder.d.ts +8 -7
  40. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  41. package/lib/langium/document-builder/document-builder.js +8 -7
  42. package/lib/langium/document-builder/document-builder.js.map +1 -1
  43. package/lib/langium/integrity/integrity-service.d.ts +1 -1
  44. package/lib/langium/integrity/integrity-service.js +4 -4
  45. package/lib/langium/integrity/integrity-service.js.map +1 -1
  46. package/lib/langium/model-service/client-session.d.ts +4 -4
  47. package/lib/langium/model-service/client-session.js +2 -2
  48. package/lib/langium/model-service/client-session.js.map +1 -1
  49. package/lib/langium/model-service/model-events.d.ts +8 -11
  50. package/lib/langium/model-service/model-events.d.ts.map +1 -1
  51. package/lib/langium/model-service/model-service.d.ts +18 -4
  52. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  53. package/lib/langium/model-service/model-service.js +4 -1
  54. package/lib/langium/model-service/model-service.js.map +1 -1
  55. package/lib/langium/module.d.ts +7 -0
  56. package/lib/langium/module.d.ts.map +1 -1
  57. package/lib/langium/module.js +3 -1
  58. package/lib/langium/module.js.map +1 -1
  59. package/lib/langium/workspace/file-not-found.d.ts +7 -1
  60. package/lib/langium/workspace/file-not-found.d.ts.map +1 -1
  61. package/lib/langium/workspace/file-not-found.js +14 -3
  62. package/lib/langium/workspace/file-not-found.js.map +1 -1
  63. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -6
  64. package/lib/lsp/hydranium-document-update-handler.js +10 -10
  65. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  66. package/lib/testing/make-test-services.d.ts +3 -0
  67. package/lib/testing/make-test-services.d.ts.map +1 -1
  68. package/lib/testing/make-test-services.js +5 -2
  69. package/lib/testing/make-test-services.js.map +1 -1
  70. package/lib/testing/stub-hydranium-text-documents.d.ts +9 -6
  71. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.js +25 -11
  73. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  74. package/package.json +5 -5
  75. package/src/documents/ast-document-manager.ts +4 -4
  76. package/src/documents/client-ids.ts +1 -1
  77. package/src/documents/client-session-registry.ts +55 -34
  78. package/src/documents/dirty-state-tracker.ts +130 -0
  79. package/src/documents/document-release-handler.ts +365 -0
  80. package/src/documents/document-release-scheduler.ts +121 -0
  81. package/src/documents/hydranium-text-documents.ts +407 -1033
  82. package/src/documents/index.ts +5 -1
  83. package/src/documents/language-client-shadow.ts +463 -0
  84. package/src/documents/text-ledger.ts +112 -0
  85. package/src/langium/document-builder/document-builder.ts +8 -7
  86. package/src/langium/integrity/integrity-service.ts +4 -4
  87. package/src/langium/model-service/client-session.ts +6 -6
  88. package/src/langium/model-service/model-events.ts +12 -11
  89. package/src/langium/model-service/model-service.ts +22 -4
  90. package/src/langium/module.ts +9 -1
  91. package/src/langium/workspace/file-not-found.ts +15 -3
  92. package/src/lsp/hydranium-document-update-handler.ts +10 -10
  93. package/src/testing/make-test-services.ts +7 -2
  94. package/src/testing/stub-hydranium-text-documents.ts +41 -21
  95. package/lib/documents/language-client-text-shadow.d.ts +0 -133
  96. package/lib/documents/language-client-text-shadow.d.ts.map +0 -1
  97. package/lib/documents/language-client-text-shadow.js +0 -201
  98. package/lib/documents/language-client-text-shadow.js.map +0 -1
  99. package/src/documents/language-client-text-shadow.ts +0 -223
@@ -80,7 +80,7 @@ export interface ClientSession<
80
80
 
81
81
  /**
82
82
  * Open `uri` for this session, reading it from disk unless some client has
83
- * it open already or it holds this client's unsaved text within its revert
83
+ * it open already or it holds this client's unsaved text within its release
84
84
  * grace. `options` are kept for this open until it closes; a repeat open,
85
85
  * concurrent or not, keeps the options of the first.
86
86
  */
@@ -90,7 +90,7 @@ export interface ClientSession<
90
90
  /**
91
91
  * Create a document with `text` and open it for this session. It reaches
92
92
  * disk on the first `save`. Fails when the file exists, any client has the
93
- * URI open, or the URI waits out the revert grace, and of two creates of one
93
+ * URI open, or the URI waits out the release grace, and of two creates of one
94
94
  * URI at most one succeeds.
95
95
  *
96
96
  * Resolves with the version the created document took, which a write based
@@ -137,8 +137,8 @@ export interface ClientSession<
137
137
  * End the session: close everything it has open and free its id.
138
138
  * Idempotent. `cause` is `'lost'` when the session ends because its
139
139
  * client's connection went away, which lets each document it was the last
140
- * to have open wait out the store's revert grace; ending it for any other
141
- * reason reverts such a document at once.
140
+ * to have open wait out the store's release grace; ending it for any other
141
+ * reason releases such a document at once.
142
142
  */
143
143
  dispose(cause?: SessionEndCause): void;
144
144
  /**
@@ -497,8 +497,8 @@ export class DefaultClientSession<
497
497
  throw new Error(`Cannot create ${uri}: the file exists`);
498
498
  }
499
499
  this.assertLive();
500
- if (this.services.workspace.TextDocuments.isRevertPending(uri)) {
501
- throw new Error(`Cannot create ${uri}: it holds the unsaved text of a lost client, waiting out the revert grace`);
500
+ if (this.services.workspace.TextDocuments.isReleaseDeferred(uri)) {
501
+ throw new Error(`Cannot create ${uri}: it holds the unsaved text of a lost client, waiting out the release grace`);
502
502
  }
503
503
  if (this.modelService.isOpen(uri)) {
504
504
  throw new Error(`Cannot create ${uri}: it is open in a client`);
@@ -9,8 +9,9 @@
9
9
 
10
10
  import { type CanonicalUri } from '@hydranium/protocol';
11
11
  import { type AstNode, type DocumentState } from '@hydranium/langium';
12
- import { type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
13
- import { type DocumentDirtyChangedEvent } from '../../documents/hydranium-text-documents.js';
12
+ import { type AstDocumentSavedEvent, type AstDocumentUpdatedEvent } from '../../documents/ast-document-manager.js';
13
+ import { type DocumentDirtyChangedEvent } from '../../documents/dirty-state-tracker.js';
14
+ import { type DocumentReleasedEvent } from '../../documents/hydranium-text-documents.js';
14
15
  import { type AstDiagnostic } from '../validation/document-validator.js';
15
16
 
16
17
  /** Which documents a `ModelService` subscription hears about. */
@@ -61,14 +62,14 @@ export interface ModelDeletedEvent {
61
62
  readonly uri: CanonicalUri;
62
63
  }
63
64
 
64
- /** A document's text started or stopped differing from its file. */
65
+ /** A document was saved: the AST manager's event, as `ModelService` hands it out. */
66
+ export type ModelSavedEvent<TAst extends AstNode, TDiagnostic extends AstDiagnostic = AstDiagnostic> = AstDocumentSavedEvent<
67
+ TAst,
68
+ TDiagnostic
69
+ >;
70
+
71
+ /** A document's text started or stopped differing from its file: the store's event, as `ModelService` hands it out. */
65
72
  export type ModelDirtyChangedEvent = DocumentDirtyChangedEvent;
66
73
 
67
- /**
68
- * A document no client has open any more was released and reverts to disk.
69
- * The rebuild that reverts it follows as a {@link ModelUpdatedEvent}, unless a
70
- * client opens the document first or its file is deleted.
71
- */
72
- export interface ModelReleasedEvent {
73
- readonly uri: CanonicalUri;
74
- }
74
+ /** A document no client has open any more was released: the store's event, as `ModelService` hands it out. */
75
+ export type ModelReleasedEvent = DocumentReleasedEvent;
@@ -25,7 +25,7 @@ import { type AstDiagnostic } from '../validation/document-validator.js';
25
25
  import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
26
26
  import { ReentrantWriteLockError, isInsideWriteLock, isWriteLockScopeInstalled } from '../workspace/write-lock-scope.js';
27
27
  import { CancellationToken, Disposable } from 'vscode-languageserver';
28
- import { AstDocument, type AstDocumentSavedEvent } from '../../documents/ast-document-manager.js';
28
+ import { AstDocument } from '../../documents/ast-document-manager.js';
29
29
  import { isConnectionGoneError } from '../../util/connection-liveness.js';
30
30
  import { type LogNameOptions } from '../diagnostics/logger.js';
31
31
  import { IntegrityService } from '../integrity/integrity-service.js';
@@ -40,6 +40,7 @@ import {
40
40
  type ModelEventFilter,
41
41
  type ModelPhaseFilter,
42
42
  type ModelReleasedEvent,
43
+ type ModelSavedEvent,
43
44
  type ModelsBuiltEvent,
44
45
  type ModelUpdatedEvent
45
46
  } from './model-events.js';
@@ -261,6 +262,11 @@ export interface ModelService<
261
262
  validated(uri: string, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
262
263
 
263
264
  isOpen(uri: string): boolean;
265
+ /**
266
+ * The version the document at `uri` was opened at, which a client's write
267
+ * or an integrity repair steps past; `undefined` while it is not open.
268
+ */
269
+ openedVersion(uri: string): TextVersion | undefined;
264
270
  snapshot(uri: string): AstDocument<TAst, TDiagnostic> | undefined;
265
271
  /**
266
272
  * The text a session's write of `model` to `uri` applies: a textual model
@@ -285,9 +291,17 @@ export interface ModelService<
285
291
  * it. A document saved before its first build arrives as an empty envelope
286
292
  * at `UNRECORDED_VERSION`, whose `root` is `undefined` despite its type.
287
293
  */
288
- onModelSaved(listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void, filter?: ModelEventFilter): Disposable;
294
+ onModelSaved(listener: (event: ModelSavedEvent<TAst, TDiagnostic>) => void, filter?: ModelEventFilter): Disposable;
289
295
  onModelDeleted(listener: (event: ModelDeletedEvent) => void, filter?: ModelEventFilter): Disposable;
296
+ /** Fires when a document's text starts or stops differing from its file. */
290
297
  onDirtyChanged(listener: (event: ModelDirtyChangedEvent) => void, filter?: ModelEventFilter): Disposable;
298
+ /**
299
+ * Fires when the store releases a document no client has open any more.
300
+ * What the build keeps for it is up to the
301
+ * `DocumentReleaseHandler` slot; the default reverts it to disk, and that
302
+ * rebuild follows as an {@link onModelUpdated} event, unless a client opens
303
+ * the document first or its file is deleted.
304
+ */
291
305
  onModelReleased(listener: (event: ModelReleasedEvent) => void, filter?: ModelEventFilter): Disposable;
292
306
  /** Fires when `filter.clientId` closes `filter.uri`, its session ending included. */
293
307
  onClientClosed(listener: () => void, filter: { readonly uri: string; readonly clientId: string }): Disposable;
@@ -830,6 +844,10 @@ export class DefaultModelService<
830
844
  return this.services.workspace.AstDocumentManager.isOpen(uri);
831
845
  }
832
846
 
847
+ openedVersion(uri: string): TextVersion | undefined {
848
+ return this.services.workspace.TextDocuments.openedVersion(uri);
849
+ }
850
+
833
851
  /**
834
852
  * Snapshot of `uri` as it stands RIGHT NOW — the synchronous sibling of the
835
853
  * phase reads, which all wait. `undefined` when no document is registered,
@@ -1145,7 +1163,7 @@ export class DefaultModelService<
1145
1163
  });
1146
1164
  }
1147
1165
 
1148
- onModelSaved(listener: (event: AstDocumentSavedEvent<TAst, TDiagnostic>) => void, filter: ModelEventFilter = {}): Disposable {
1166
+ onModelSaved(listener: (event: ModelSavedEvent<TAst, TDiagnostic>) => void, filter: ModelEventFilter = {}): Disposable {
1149
1167
  const target = this.filterUri(filter);
1150
1168
  return this.services.workspace.TextDocuments.onDidSave(event => {
1151
1169
  // LangiumDocuments keys by the canonical form, the saved event by the
@@ -1180,7 +1198,7 @@ export class DefaultModelService<
1180
1198
 
1181
1199
  onModelReleased(listener: (event: ModelReleasedEvent) => void, filter: ModelEventFilter = {}): Disposable {
1182
1200
  const target = this.filterUri(filter);
1183
- return this.services.workspace.TextDocuments.onDidCloseLastOpen(event => {
1201
+ return this.services.workspace.TextDocuments.onDidReleaseDocument(event => {
1184
1202
  if (this.matches(target, event.uri)) {
1185
1203
  listener(event);
1186
1204
  }
@@ -38,6 +38,7 @@ import { DefaultAstDocumentManager, type AstDocumentManager } from '../documents
38
38
  import { DefaultSelfSaveRegistry, type SelfSaveRegistry } from './workspace/self-save-registry.js';
39
39
  import { DefaultFileSystemTaskQueue, type FileSystemTaskQueue } from '../documents/file-system-task-queue.js';
40
40
  import { DefaultModelLedger, type ModelLedger } from '../documents/model-ledger.js';
41
+ import { DefaultDocumentReleaseHandler, type DocumentReleaseHandler } from '../documents/document-release-handler.js';
41
42
  import { DefaultVersionSyncService, type VersionSyncService } from '../documents/version-sync-service.js';
42
43
  import {
43
44
  DefaultEmptyFileSystemProvider,
@@ -286,6 +287,12 @@ export interface ServerAddedSharedServices<
286
287
  ModelLedger: ModelLedger;
287
288
  /** Reconciles each produced root with the store, and owns every build that syncs a root to its text or recovers one. */
288
289
  VersionSyncService: VersionSyncService;
290
+ /**
291
+ * What the build keeps for a document {@link HydraniumTextDocuments} has
292
+ * released, for every head. Bound to {@link DefaultDocumentReleaseHandler},
293
+ * which reverts it to disk.
294
+ */
295
+ DocumentReleaseHandler: DocumentReleaseHandler;
289
296
  };
290
297
  /**
291
298
  * In-process workspace facade ({@link ModelService}) + AST→transfer
@@ -562,7 +569,8 @@ export function createServerSharedModule(
562
569
  // first build.
563
570
  CstResidencyService: services => new DefaultCstResidencyService(services),
564
571
  ModelLedger: () => new DefaultModelLedger(),
565
- VersionSyncService: services => new DefaultVersionSyncService(services)
572
+ VersionSyncService: services => new DefaultVersionSyncService(services),
573
+ DocumentReleaseHandler: services => new DefaultDocumentReleaseHandler(services)
566
574
  },
567
575
  model: {
568
576
  // Generic walker — adopters with a typed `$type → wire shape` overlay
@@ -9,15 +9,27 @@
9
9
 
10
10
  import { type URI } from '@hydranium/langium';
11
11
 
12
- // Not re-exported from the workspace barrel: the framework's providers share
13
- // it, and it is no API of theirs.
12
+ // Not re-exported from the workspace barrel: the framework's providers and
13
+ // the release handler share it, and it is no API of theirs.
14
14
 
15
15
  /**
16
16
  * A missing-file error shaped as a Node file system raises it, with code
17
17
  * `ENOENT` and the path: callers tell a missing file from any other failure by
18
- * those two fields, whichever provider is bound. The last close relies on it to
18
+ * those two fields, whichever provider is bound. The release relies on it to
19
19
  * tell a document gone meanwhile from any other failed rebuild.
20
20
  */
21
21
  export function notFound(message: string, uri: URI): Error {
22
22
  return Object.assign(new Error(message), { code: 'ENOENT', path: uri.fsPath });
23
23
  }
24
+
25
+ /**
26
+ * Whether `err` reports that the file of `target` does not exist, as a Node
27
+ * file system and {@link notFound} do: code `ENOENT`, with `target`'s path. A
28
+ * missing file of another document is a different failure.
29
+ */
30
+ export function isFileNotFound(err: unknown, target: URI): boolean {
31
+ if (typeof err !== 'object' || err === null || !('code' in err) || err.code !== 'ENOENT') {
32
+ return false;
33
+ }
34
+ return !('path' in err) || err.path === target.fsPath;
35
+ }
@@ -93,12 +93,12 @@ export interface HydraniumDocumentUpdateHandlerOptions {
93
93
  * Both waits are capped by
94
94
  * {@link HydraniumDocumentUpdateHandlerOptions.willSaveGateMs}.
95
95
  *
96
- * The revert to disk after a document's last close is not dispatched here:
97
- * the text store runs it for every head, the LSP head included, so a
98
- * `didCloseDocument` added in a subclass would build the document twice. A
99
- * change still debounced for the document is dropped when the store releases
100
- * it: the store's revert rebuilds the document from the file system provider
101
- * or removes it.
96
+ * What the build keeps after a document's last close is not dispatched here:
97
+ * the store hands the released document to the `DocumentReleaseHandler` for
98
+ * every head, the LSP head included, so a `didCloseDocument` added in a
99
+ * subclass would build the document twice. A change still debounced for the
100
+ * document is dropped when the store releases it: the release handler rebuilds
101
+ * the document from the file system provider or removes it.
102
102
  *
103
103
  * Adopters with their own handler subclass should extend this class
104
104
  * (not Langium's `DefaultDocumentUpdateHandler`) so all of them are
@@ -175,10 +175,10 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
175
175
  uri => this.pendingChanged.has(uri.toString()) || this.pendingDeleted.has(uri.toString())
176
176
  );
177
177
  // A change still debounced for a document the store has released would
178
- // build it once more after the store's own revert: from the provider
179
- // again, or, for a document the revert removed, by reading a file that
180
- // is not there.
181
- this.textDocuments.onDidCloseLastOpen(event => {
178
+ // build it once more after the release handler's build: from the
179
+ // provider again, or, for a document the release removed, by reading a
180
+ // file that is not there.
181
+ this.textDocuments.onDidReleaseDocument(event => {
182
182
  this.pendingChanged.delete(URI.parse(event.uri).toString());
183
183
  if (this.pendingChanged.size === 0 && this.pendingDeleted.size === 0) {
184
184
  this.pendingReason = undefined;
@@ -36,6 +36,7 @@ import { DefaultDocumentUriPolicy, type DocumentUriPolicy } from '../langium/wor
36
36
  import { HydraniumWorkspaceLock } from '../langium/workspace/hydranium-workspace-lock.js';
37
37
  import { DefaultFileSystemTaskQueue, type FileSystemTaskQueue } from '../documents/file-system-task-queue.js';
38
38
  import { DefaultModelLedger, type ModelLedger } from '../documents/model-ledger.js';
39
+ import { DefaultDocumentReleaseHandler, type DocumentReleaseHandler } from '../documents/document-release-handler.js';
39
40
  import { DefaultVersionSyncService, type VersionSyncService } from '../documents/version-sync-service.js';
40
41
  import type { FakeDocumentOptions } from './fake-document.js';
41
42
  import { makeStubDocumentBuilder, type StubDocumentBuilder } from './stub-document-builder.js';
@@ -115,6 +116,8 @@ export interface TestSharedServices<
115
116
  ModelLedger: ModelLedger;
116
117
  /** The REAL service, over this tree's stubs; it reconciles nothing against the stub store. */
117
118
  VersionSyncService: VersionSyncService;
119
+ /** The REAL handler, over this tree's stubs; the stub store releases nothing to it. */
120
+ DocumentReleaseHandler: DocumentReleaseHandler;
118
121
  };
119
122
  readonly model: {
120
123
  TransferEncoder: TransferEncoder<TTransferDiagnostic>;
@@ -378,8 +381,9 @@ export function makeTestServices<
378
381
  WorkspaceLock: workspaceLock,
379
382
  FileSystemTaskQueue: new DefaultFileSystemTaskQueue({ workspace: { DocumentUriPolicy: documentUriPolicy } }),
380
383
  ModelLedger: modelLedger,
381
- // Patched in below: it reads the tree it belongs to.
382
- VersionSyncService: {} as VersionSyncService
384
+ // Patched in below, as both read the tree they belong to.
385
+ VersionSyncService: {} as VersionSyncService,
386
+ DocumentReleaseHandler: {} as DocumentReleaseHandler
383
387
  },
384
388
  model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject, TTransferDiagnostic>['model'],
385
389
  ServerLocale: {} as ServerLocale,
@@ -390,6 +394,7 @@ export function makeTestServices<
390
394
  // Patched in after the literal, like `model` below: both read the tree they
391
395
  // belong to, and the renderer reads the locale service.
392
396
  services.workspace.VersionSyncService = new DefaultVersionSyncService(sharedServices);
397
+ services.workspace.DocumentReleaseHandler = new DefaultDocumentReleaseHandler(sharedServices);
393
398
  const mutableMessages = services as { ServerLocale: ServerLocale; MessageRenderer: MessageRenderer };
394
399
  const serverLocale = new DefaultServerLocale(sharedServices);
395
400
  if (options.locale) {
@@ -13,11 +13,11 @@ import { Disposable, Emitter } from 'vscode-languageserver';
13
13
  import type { TextDocument } from 'vscode-languageserver-textdocument';
14
14
  import { LANGUAGE_CLIENT_ID } from '../documents/client-ids.js';
15
15
  import { ClientSessionRegistry } from '../documents/client-session-registry.js';
16
+ import type { DocumentDirtyChangedEvent } from '../documents/dirty-state-tracker.js';
16
17
  import type {
17
18
  ClientTextDocumentChangeEvent,
18
- DocumentDirtyChangedEvent,
19
19
  HydraniumTextDocuments,
20
- LastOpenClosedEvent
20
+ DocumentReleasedEvent
21
21
  } from '../documents/hydranium-text-documents.js';
22
22
 
23
23
  /** Snapshot of an open document tracked by the stub. */
@@ -33,11 +33,11 @@ export interface StubTextDocumentEntry {
33
33
  * reads from on test paths — the content channel
34
34
  * (`notifyDidChangeTextDocument` / `applyContentChange` / `version` /
35
35
  * `getAuthor`), the open-state probes (`isOpenInLanguageClient` /
36
- * `isOpenInAnyClient` / `isOpenInClient` / `isRevertPending` /
36
+ * `isOpenInAnyClient` / `isOpenInClient` / `isReleaseDeferred` /
37
37
  * `openDocuments`), the push channel
38
38
  * to the language client (`applyEditToLanguageClient` / `stagePendingContent`),
39
39
  * the save / close notifications, the dirty state (`isDirty` / `textState` /
40
- * `onDidChangeDirty` / `updateDiskBaseline`, against a baseline {@link seedOpen}
40
+ * `onDidChangeDirty` / `setDiskBaseline`, against a baseline {@link seedOpen}
41
41
  * sets and a save that carries its text moves), and the client-session table
42
42
  * (`registerSession` / `closeSession` / `onDidCloseSession`), which is a real
43
43
  * `ClientSessionRegistry` — plus test-only helpers:
@@ -77,7 +77,7 @@ export interface StubHydraniumTextDocuments extends Pick<
77
77
  | 'getAuthor'
78
78
  | 'isOpenInLanguageClient'
79
79
  | 'isOpenInAnyClient'
80
- | 'isRevertPending'
80
+ | 'isReleaseDeferred'
81
81
  | 'applyEditToLanguageClient'
82
82
  | 'stagePendingContent'
83
83
  | 'openDocuments'
@@ -86,11 +86,12 @@ export interface StubHydraniumTextDocuments extends Pick<
86
86
  | 'registerSession'
87
87
  | 'closeSession'
88
88
  | 'onDidCloseSession'
89
- | 'onDidCloseLastOpen'
89
+ | 'onDidReleaseDocument'
90
90
  | 'isDirty'
91
91
  | 'textState'
92
+ | 'openedVersion'
92
93
  | 'onDidChangeDirty'
93
- | 'updateDiskBaseline'
94
+ | 'setDiskBaseline'
94
95
  > {
95
96
  /**
96
97
  * Stub-tailored read accessor. Returns just the surface the framework's
@@ -114,9 +115,12 @@ export interface StubHydraniumTextDocuments extends Pick<
114
115
  /** Mark `uri` as open in the LSP textual language client (drives {@link isOpenInLanguageClient}). */
115
116
  seedOpenInLanguageClient(uri: string): void;
116
117
  /**
117
- * Synchronously deliver `onDidClose` to subscribers, then `onDidCloseLastOpen`
118
- * when no client has `uri` open any more. The stub keeps no revert grace, so
119
- * a last close is announced at once.
118
+ * Synchronously deliver `onDidClose` to subscribers, then `onDidReleaseDocument`
119
+ * when no client has `uri` open any more, then the dirty flip of a dropped
120
+ * holder: the real store's order. The stub keeps no release grace and runs
121
+ * no release handler, so the flip comes at once and without text, where the
122
+ * real store announces it once the handler settles, with text while the
123
+ * build still has the document.
120
124
  */
121
125
  fireClose(uri: string, clientId: string): void;
122
126
  /**
@@ -162,8 +166,9 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
162
166
  // stand in for canonical URIs (one stub-boundary cast, like `fireClose`).
163
167
  const sessions = new ClientSessionRegistry();
164
168
  const key = (uri: string): CanonicalUri => uri as CanonicalUri;
165
- const lastOpenClosed = new Emitter<LastOpenClosedEvent>();
169
+ const documentReleased = new Emitter<DocumentReleasedEvent>();
166
170
  const baselines = new Map<string, string | undefined>();
171
+ const openedVersions = new Map<string, number>();
167
172
  const dirty = new Set<string>();
168
173
  const dirtyChanged = new Emitter<DocumentDirtyChangedEvent>();
169
174
  // As the real store: dirty while held with text other than the baseline,
@@ -177,7 +182,12 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
177
182
  } else {
178
183
  dirty.delete(uri);
179
184
  }
180
- dirtyChanged.fire({ uri: key(uri), text: { version: held?.version ?? 0, hash: textHash(held?.text ?? ''), dirty: now } });
185
+ // No build behind the stub, so a document no longer held is announced without text.
186
+ dirtyChanged.fire(
187
+ held === undefined
188
+ ? { uri: key(uri) }
189
+ : { uri: key(uri), text: { version: held.version, hash: textHash(held.text), dirty: now } }
190
+ );
181
191
  }
182
192
  };
183
193
 
@@ -201,6 +211,9 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
201
211
  version(uri) {
202
212
  return docs.get(uri)?.version ?? 0;
203
213
  },
214
+ openedVersion(uri) {
215
+ return docs.has(uri) ? openedVersions.get(uri) : undefined;
216
+ },
204
217
  textState(uri) {
205
218
  const held = docs.get(uri);
206
219
  return held && { version: held.version, hash: textHash(held.text), dirty: dirty.has(uri) };
@@ -237,7 +250,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
237
250
  },
238
251
  notifyDidSaveTextDocument(event, clientId) {
239
252
  if (event.text !== undefined) {
240
- stub.updateDiskBaseline(event.textDocument.uri, event.text);
253
+ stub.setDiskBaseline(event.textDocument.uri, event.text);
241
254
  }
242
255
  saves.push({ uri: event.textDocument.uri, clientId });
243
256
  for (const listener of saveListeners.slice()) {
@@ -274,8 +287,9 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
274
287
  isOpenInClient(uri, clientId) {
275
288
  return sessions.isOpenIn(key(uri), clientId);
276
289
  },
277
- isRevertPending(uri) {
278
- return sessions.isRevertPending(key(uri));
290
+ isReleaseDeferred() {
291
+ // The stub keeps no release grace.
292
+ return false;
279
293
  },
280
294
  attachClient(uri, clientId) {
281
295
  return docs.has(uri) && sessions.addOpen(key(uri), clientId);
@@ -298,8 +312,8 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
298
312
  get onDidCloseSession() {
299
313
  return sessions.onDidCloseSession;
300
314
  },
301
- get onDidCloseLastOpen() {
302
- return lastOpenClosed.event;
315
+ get onDidReleaseDocument() {
316
+ return documentReleased.event;
303
317
  },
304
318
  isDirty(uri) {
305
319
  return dirty.has(uri);
@@ -307,7 +321,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
307
321
  get onDidChangeDirty() {
308
322
  return dirtyChanged.event;
309
323
  },
310
- updateDiskBaseline(uri, text) {
324
+ setDiskBaseline(uri, text) {
311
325
  if (docs.has(uri)) {
312
326
  baselines.set(uri, text);
313
327
  refreshDirty(uri);
@@ -331,6 +345,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
331
345
  seedOpen(uri, text, clientId) {
332
346
  sessions.addOpen(key(uri), clientId);
333
347
  docs.set(uri, { uri, version: 1, text, clientId });
348
+ openedVersions.set(uri, 1);
334
349
  baselines.set(uri, text);
335
350
  },
336
351
  seedOpenInLanguageClient(uri) {
@@ -342,9 +357,9 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
342
357
  // class: listeners detect the last-close transition by consulting
343
358
  // `isOpenInAnyClient`, which reads the already-decremented state.
344
359
  const holder = docs.get(uri);
345
- if (holder && holder.clientId === clientId) {
360
+ const dropped = holder !== undefined && holder.clientId === clientId;
361
+ if (dropped) {
346
362
  docs.delete(uri);
347
- refreshDirty(uri);
348
363
  }
349
364
  const removed = sessions.removeOpen(key(uri), clientId);
350
365
  if (clientId === LANGUAGE_CLIENT_ID) {
@@ -355,7 +370,11 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
355
370
  listener(event);
356
371
  }
357
372
  if (removed && !sessions.isOpen(key(uri))) {
358
- lastOpenClosed.fire({ uri: key(uri) });
373
+ documentReleased.fire({ uri: key(uri) });
374
+ }
375
+ // After the release event, in the order the real store announces them.
376
+ if (dropped) {
377
+ refreshDirty(uri);
359
378
  }
360
379
  },
361
380
  reset() {
@@ -366,6 +385,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
366
385
  appliedEdits.length = 0;
367
386
  staged.length = 0;
368
387
  baselines.clear();
388
+ openedVersions.clear();
369
389
  dirty.clear();
370
390
  saveListeners.length = 0;
371
391
  closeListeners.length = 0;
@@ -1,133 +0,0 @@
1
- /********************************************************************************
2
- * Copyright (c) 2026 CrossBreeze, 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
- import { type TextEdit } from 'vscode-languageserver';
10
- import { TextDocument } from 'vscode-languageserver-textdocument';
11
- /**
12
- * The minimum surface the shadow needs from a `HydraniumTextDocuments`:
13
- * a `create` method that materialises a fresh text document via the
14
- * configured `TextDocumentsConfiguration`. Declared as a narrow
15
- * structural interface so the manager satisfies it without an explicit
16
- * import (sidestepping the circular `HydraniumTextDocuments → shadow → manager`
17
- * dependency) and so tests can stub it with a minimal object literal.
18
- */
19
- export interface ShadowDocumentSource {
20
- create(uri: string, languageId: string, version: number, content: string): TextDocument;
21
- }
22
- /**
23
- * Whether `edits` is the single full-document replace {@link LanguageClientTextShadow.computeEdits}
24
- * emits when it has no usable baseline.
25
- *
26
- * The distinction matters at the `workspace/applyEdit` egress: a full replace is
27
- * position-INDEPENDENT and therefore lands correctly on any client buffer, while
28
- * a line-keyed diff only lands correctly on the exact text it was diffed
29
- * against. Only the latter needs a client-version gate.
30
- */
31
- export declare function isFullReplace(edits: readonly TextEdit[]): boolean;
32
- /**
33
- * Tracks the text content the language client (typically Monaco) is believed to
34
- * currently hold so outgoing `workspace/applyEdit` requests can ship minimal
35
- * diffs instead of full-document replaces.
36
- *
37
- * Why it matters: a full-range replace on a moderately-sized YAML or DSL source
38
- * forces Monaco to re-tokenise the entire document (observed multi-second hangs
39
- * on large diagrams), whereas line-level diffs usually reduce that to a handful
40
- * of small edits.
41
- *
42
- * Update points the caller is responsible for:
43
- * - {@link computeEdits} after each outgoing server→client sync (updates the shadow optimistically).
44
- * - {@link set} when the server receives a `didChange` from the language client (the client now
45
- * holds that text; we shouldn't resend it).
46
- * - {@link setOpenedText} when the client opens a document the server did not baseline for it —
47
- * an equality-only baseline, since that text is a snapshot rather than a tracked one.
48
- * - {@link invalidate} when the client rejects an edit (`applied=false`) or a doc is closed —
49
- * the shadow is now stale and the next sync must fall back to a full replace.
50
- */
51
- export declare class LanguageClientTextShadow {
52
- protected onFallback: (uri: string, reason: string) => void;
53
- protected documents: ShadowDocumentSource;
54
- protected shadow: Map<string, string>;
55
- /**
56
- * Per URI, the text the client declared it opened the document with, held
57
- * only until a real baseline exists. Consulted by {@link computeEdits} when
58
- * there is no shadow, and dropped on {@link invalidate}: a snapshot that
59
- * outlived what the client holds would let the equality check skip an edit
60
- * the client still needs.
61
- */
62
- protected opened: Map<string, string>;
63
- /**
64
- * @param onFallback invoked when {@link computeEdits} falls back to a full-range replace due to
65
- * apply-verify mismatch. Useful for logging regressions.
66
- * @param documents the {@link ShadowDocumentSource} used to materialise the throwaway probe
67
- * document in {@link computeEdits}. In production this is the owning
68
- * `HydraniumTextDocuments` (whose `create` threads through its configured
69
- * `TextDocumentsConfiguration`); tests pass a minimal stub. Routing
70
- * through the manager rather than `TextDocument.create` directly avoids the
71
- * `instanceof FullTextDocument` gate in the bare LSP `TextDocument.update`,
72
- * keeping the verifier honest for adopters with a custom text-document type.
73
- */
74
- constructor(onFallback: (uri: string, reason: string) => void, documents: ShadowDocumentSource);
75
- /** Record the text the language client is known to hold (e.g. after a `didChange` echo). */
76
- set(uri: string, text: string): void;
77
- /**
78
- * Record the buffer the language client declared at `didOpen`, for a URI
79
- * with no baseline yet.
80
- *
81
- * Deliberately NOT a {@link set}: this text is only ever compared for
82
- * EQUALITY, never diffed against. A line-keyed diff is position-dependent,
83
- * so keying one to a `didOpen` snapshot the client may have moved past
84
- * splices its buffer — the failure {@link computeEdits}'s full-replace
85
- * fallback exists to avoid.
86
- */
87
- setOpenedText(uri: string, text: string): void;
88
- /**
89
- * The text the language client is believed to hold: the tracked text, else
90
- * the buffer it declared at `didOpen`.
91
- *
92
- * Read at the `workspace/applyEdit` egress BEFORE {@link computeEdits},
93
- * which overwrites the tracked text and drops the opened snapshot. A change
94
- * the client sends carries ranges addressing this text and nothing else, so
95
- * losing it leaves that change unreconstructable — and applying its ranges
96
- * to the already-advanced synced text splices lines they never addressed.
97
- *
98
- * Deliberately wider than what {@link computeEdits} may diff from: an
99
- * opened snapshot is a position-dependent key to a buffer the client may
100
- * have moved past, so keying a diff to it splices that buffer.
101
- */
102
- clientText(uri: string): string | undefined;
103
- /**
104
- * Whether a tracked text exists for `uri`, which is what decides whether a
105
- * push can be diffed at all. Distinct from {@link clientText}, which also
106
- * answers from the opened snapshot and so cannot settle this.
107
- */
108
- isTracked(uri: string): boolean;
109
- /** Forget the shadow for a URI; next {@link computeEdits} falls back to a full-document replace. */
110
- invalidate(uri: string): void;
111
- /**
112
- * Return the minimal LSP {@link TextEdit}s required to bring the language client's content from
113
- * the tracked shadow to `newText`. Updates the shadow to `newText` so subsequent calls diff
114
- * against it.
115
- *
116
- * - Empty array when the shadow already equals `newText` (caller should skip the RPC),
117
- * or when there is no shadow and the client opened the document with exactly `newText`
118
- * (see {@link setOpenedText}) — pushing it anyway dirties the client's buffer on open.
119
- * - Full-range replace when there is no shadow (first sync or post-{@link invalidate}).
120
- * - Line-level diff otherwise. Before returning, we locally reconstruct `newText` by applying
121
- * the diff to `old`; if that doesn't match, we fall back to a full-range replace and notify
122
- * the fallback callback. This catches any algorithmic regression in {@link diffToEdits}
123
- * without risking client corruption.
124
- */
125
- computeEdits(uri: string, newText: string): TextEdit[];
126
- }
127
- /**
128
- * Convert a {@link diffLines} result to a list of LSP {@link TextEdit}s keyed on line positions
129
- * in the *old* text. Contiguous added/removed hunks are coalesced into a single replace so the
130
- * client sees one edit per changed region.
131
- */
132
- export declare function diffToEdits(oldText: string, newText: string): TextEdit[];
133
- //# sourceMappingURL=language-client-text-shadow.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"language-client-text-shadow.d.ts","sourceRoot":"","sources":["../../src/documents/language-client-text-shadow.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAGlF,OAAO,EAAS,KAAK,QAAQ,EAAY,MAAM,uBAAuB,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,MAAM,oCAAoC,CAAC;AAElE;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IAClC,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,YAAY,CAAC;CAC1F;AAKD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,GAAG,OAAO,CAMjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,wBAAwB;IAwB/B,SAAS,CAAC,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI;IAC3D,SAAS,CAAC,SAAS,EAAE,oBAAoB;IAxB5C,SAAS,CAAC,MAAM,sBAA6B;IAE7C;;;;;;OAMG;IACH,SAAS,CAAC,MAAM,sBAA6B;IAE7C;;;;;;;;;;OAUG;IACH,YACa,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,EACjD,SAAS,EAAE,oBAAoB,EACxC;IAEJ,4FAA4F;IAC5F,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAEnC;IAED;;;;;;;;;OASG;IACH,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAE7C;IAED;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE1C;IAED;;;;OAIG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAE9B;IAED,oGAAoG;IACpG,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAO5B;IAED;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,QAAQ,EAAE,CAqBrD;CACH;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,QAAQ,EAAE,CA+BxE"}