@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.
Files changed (66) hide show
  1. package/README.md +3 -1
  2. package/lib/documents/ast-document-manager.d.ts +52 -2
  3. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  4. package/lib/documents/ast-document-manager.js +67 -5
  5. package/lib/documents/ast-document-manager.js.map +1 -1
  6. package/lib/documents/client-session-errors.d.ts +44 -0
  7. package/lib/documents/client-session-errors.d.ts.map +1 -0
  8. package/lib/documents/client-session-errors.js +57 -0
  9. package/lib/documents/client-session-errors.js.map +1 -0
  10. package/lib/documents/client-session-registry.d.ts +118 -0
  11. package/lib/documents/client-session-registry.d.ts.map +1 -0
  12. package/lib/documents/client-session-registry.js +171 -0
  13. package/lib/documents/client-session-registry.js.map +1 -0
  14. package/lib/documents/hydranium-text-documents.d.ts +77 -25
  15. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  16. package/lib/documents/hydranium-text-documents.js +143 -52
  17. package/lib/documents/hydranium-text-documents.js.map +1 -1
  18. package/lib/documents/index.d.ts +2 -0
  19. package/lib/documents/index.d.ts.map +1 -1
  20. package/lib/documents/index.js +2 -0
  21. package/lib/documents/index.js.map +1 -1
  22. package/lib/langium/integrity/integrity-rule.d.ts +13 -5
  23. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  24. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  25. package/lib/langium/integrity/integrity-service.d.ts +37 -6
  26. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  27. package/lib/langium/integrity/integrity-service.js +81 -17
  28. package/lib/langium/integrity/integrity-service.js.map +1 -1
  29. package/lib/langium/model-service/client-session.d.ts +120 -0
  30. package/lib/langium/model-service/client-session.d.ts.map +1 -0
  31. package/lib/langium/model-service/client-session.js +175 -0
  32. package/lib/langium/model-service/client-session.js.map +1 -0
  33. package/lib/langium/model-service/index.d.ts +1 -0
  34. package/lib/langium/model-service/index.d.ts.map +1 -1
  35. package/lib/langium/model-service/index.js +1 -0
  36. package/lib/langium/model-service/index.js.map +1 -1
  37. package/lib/langium/model-service/model-service.d.ts +76 -8
  38. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  39. package/lib/langium/model-service/model-service.js +148 -21
  40. package/lib/langium/model-service/model-service.js.map +1 -1
  41. package/lib/lsp/hydranium-document-update-handler.d.ts +6 -0
  42. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  43. package/lib/lsp/hydranium-document-update-handler.js +20 -3
  44. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  45. package/lib/testing/stub-ast-document-manager.d.ts +4 -3
  46. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  47. package/lib/testing/stub-ast-document-manager.js +3 -0
  48. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  49. package/lib/testing/stub-hydranium-text-documents.d.ts +13 -5
  50. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  51. package/lib/testing/stub-hydranium-text-documents.js +45 -14
  52. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  53. package/package.json +5 -5
  54. package/src/documents/ast-document-manager.ts +107 -6
  55. package/src/documents/client-session-errors.ts +54 -0
  56. package/src/documents/client-session-registry.ts +211 -0
  57. package/src/documents/hydranium-text-documents.ts +158 -55
  58. package/src/documents/index.ts +2 -0
  59. package/src/langium/integrity/integrity-rule.ts +13 -5
  60. package/src/langium/integrity/integrity-service.ts +84 -17
  61. package/src/langium/model-service/client-session.ts +257 -0
  62. package/src/langium/model-service/index.ts +1 -0
  63. package/src/langium/model-service/model-service.ts +176 -21
  64. package/src/lsp/hydranium-document-update-handler.ts +22 -4
  65. package/src/testing/stub-ast-document-manager.ts +7 -2
  66. package/src/testing/stub-hydranium-text-documents.ts +62 -18
@@ -353,8 +353,10 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
353
353
  if (commit.status === 'stale') {
354
354
  // An edit landed after the parse this repair came from, so the repair
355
355
  // describes text the store has already replaced. Abandon it rather than
356
- // persist or stage it: that edit drives a build of its own, which
357
- // re-runs these rules against what the document actually says.
356
+ // hand it to `syncCorrections`: an override that persists from there
357
+ // would write a correction over the edit that superseded it. That edit
358
+ // drives a build of its own, which re-runs these rules against what the
359
+ // document actually says.
358
360
  //
359
361
  // Abandoning the WRITE is only half of it. The build carries on to its
360
362
  // settled phase with whatever AST is registered, and every settled
@@ -366,13 +368,15 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
366
368
  return;
367
369
  }
368
370
 
369
- // Keep the same version so HydraniumTextDocuments doesn't reject subsequent client edits.
370
- // Use manager.update so the `instanceof FullTextDocument` gate in the bare
371
- // `TextDocument.update` doesn't trip on adopter-custom text-document types.
371
+ // A committed repair is a new version; a document no client has open keeps
372
+ // its number here, and the re-versioning below moves it once the build
373
+ // has reconciled. Through the store's `update`, so the `instanceof
374
+ // FullTextDocument` gate in the bare `TextDocument.update` does not trip
375
+ // on adopter-custom text-document types.
372
376
  const textDocument =
373
377
  commit.status === 'committed' ? commit.document : this.textDocuments.update(document.textDocument, [{ text: newText }], version);
374
378
 
375
- await this.syncCorrections(textDocument);
379
+ await this.syncCorrections(textDocument, parsedFrom);
376
380
 
377
381
  await this.reconcileDocument(document, cancelToken);
378
382
 
@@ -448,19 +452,34 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
448
452
  * already in the synced store (in-place in {@link resyncDocument}); this
449
453
  * method routes the out-of-band part by document registration:
450
454
  *
451
- * - **Open in the language client** → nothing to push here. The AST mutation
452
- * rode the current build, so `ModelService.syncToLanguageClient` (the
453
- * persistent integrity-settled listener) mirrors the corrected text to the
454
- * client by *content* (shadow diff) — no authorship rewrite, no second
455
- * rebuild.
455
+ * - **Open in the language client** → nothing to push here. The AST
456
+ * mutation rode the current build, so `ModelService.syncToLanguageClient`
457
+ * (the persistent integrity-settled listener) mirrors it to the client by
458
+ * *content* (shadow diff), and the editor shows it as an unsaved change.
459
+ * - **Held only through the data or GLSP head** → the store is the
460
+ * authority, and those heads read the correction from the settled build.
461
+ * In silent mode it is also written to disk when the text it was computed
462
+ * from is what disk holds, since the repair is then the only difference;
463
+ * otherwise it waits for the next save. Editor mode always waits, and a
464
+ * repair of text the holder never changed then differs from disk with
465
+ * nothing marking it unsaved.
456
466
  * - **Closed, silent mode** → write to disk directly (disk is authoritative).
457
467
  * - **Closed, editor mode** → stage so the next open picks it up via applyEdit.
468
+ *
469
+ * A held URI is not closed. Writing it as one persists the holder's unsaved
470
+ * edits with the repair, and staging it leaves text no open reads, because
471
+ * only a first open consumes the stage. Leaving every held repair to a save
472
+ * is not enough either: a data or GLSP head marks only its own edits unsaved,
473
+ * so a repair of text it never changed would leave disk and store apart with
474
+ * nothing showing it.
475
+ *
476
+ * `parsedFrom` is the text the repaired AST was parsed from. Without it a
477
+ * held URI's repair is left to the next save.
458
478
  */
459
- protected async syncCorrections(document: TextDocument): Promise<void> {
460
- // Open files: delivery is the ModelService settled listener's job (by
461
- // content, via the shadow) — nothing to do here. Only the open/closed
462
- // question is asked, so no reverse address resolution is needed: a file
463
- // open under a symlinked path still answers `true`.
479
+ protected async syncCorrections(document: TextDocument, parsedFrom?: string): Promise<void> {
480
+ // Only the open/closed question is asked, so no reverse address
481
+ // resolution is needed: a file open under a symlinked path still answers
482
+ // `true`.
464
483
  if (this.textDocuments.isOpenInLanguageClient(document.uri)) {
465
484
  return;
466
485
  }
@@ -478,10 +497,22 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
478
497
  return;
479
498
  }
480
499
 
500
+ if (this.textDocuments.isOpenInAnyClient(document.uri)) {
501
+ if (this.syncMode === 'silent' && parsedFrom !== undefined) {
502
+ await this.persistIfDiskMatches(document, parsedFrom);
503
+ }
504
+ return;
505
+ }
506
+
481
507
  // Closed files, silent mode: write to disk directly.
482
508
  if (this.syncMode === 'silent') {
509
+ const repaired = document.getText();
483
510
  // Async write so the integrity pass does not block the event loop on a slow filesystem.
484
- await this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), document.getText());
511
+ // In the URI's disk queue, behind any save already queued: written
512
+ // beside one, the older repair can land last and stay on disk.
513
+ await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, () =>
514
+ this.fileSystemProvider.writeFile(UriUtils.toUri(document.uri), repaired)
515
+ );
485
516
  return;
486
517
  }
487
518
 
@@ -489,6 +520,42 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
489
520
  this.textDocuments.stagePendingContent(document.uri, document.getText());
490
521
  }
491
522
 
523
+ /**
524
+ * Write the repair in `document` to disk when disk still holds `parsedFrom`,
525
+ * the text the repair was computed from — so the write carries the repair
526
+ * and nothing a holder could still discard.
527
+ *
528
+ * The read, the compare and the write run as one task in the URI's disk
529
+ * queue, so no save of the file lands between them. The store is compared
530
+ * again once the read returns, and the write is skipped if it no longer
531
+ * holds the repair: an edit does not wait for the queue, and writing the
532
+ * older repair then puts text on disk that no participant holds. The
533
+ * skipped repair loses nothing: the edit drives a build of its own, and a
534
+ * save writes the store's text. A file that cannot be read is left alone
535
+ * too: there is no evidence it matches.
536
+ */
537
+ protected async persistIfDiskMatches(document: TextDocument, parsedFrom: string): Promise<void> {
538
+ const uri = UriUtils.toUri(document.uri);
539
+ const repaired = document.getText();
540
+ await this.services.shared.workspace.AstDocumentManager.queueDiskTask(document.uri, async () => {
541
+ let onDisk: string;
542
+ try {
543
+ onDisk = await this.fileSystemProvider.readFile(uri);
544
+ } catch (error: unknown) {
545
+ this.tracer.with(document.uri).debug(`Held document unreadable on disk, repair left to the next save: ${String(error)}`);
546
+ return;
547
+ }
548
+ if (onDisk !== parsedFrom) {
549
+ return;
550
+ }
551
+ if (this.textDocuments.get(document.uri)?.getText() !== repaired) {
552
+ this.tracer.with(document.uri).debug('Held document changed while disk was read, repair left to that change');
553
+ return;
554
+ }
555
+ await this.fileSystemProvider.writeFile(uri, repaired);
556
+ });
557
+ }
558
+
492
559
  /**
493
560
  * Format a rule's name for inclusion in user-facing diagnostic strings
494
561
  * (error messages thrown during registration). Defaults to
@@ -0,0 +1,257 @@
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
+
10
+ import { type MaybePromise, type TransferElement, type TransferSaveArgs, type TransferUpdateArgs } from '@hydranium/protocol';
11
+ import { type AstNode, UriUtils } from '@hydranium/langium';
12
+ import { type CancellationToken } from 'vscode-languageserver';
13
+ import { type AstDocument } from '../../documents/ast-document-manager.js';
14
+ import { SessionClosedError } from '../../documents/client-session-errors.js';
15
+ import { type OpenOptions } from '../../documents/client-session-registry.js';
16
+ import { type ServerSharedServices } from '../module.js';
17
+ import { type AstDiagnostic } from '../validation/document-validator.js';
18
+ import { type ModelService } from './model-service.js';
19
+
20
+ /**
21
+ * One participant's handle on the documents it works on, started by
22
+ * `ModelService.createSession`.
23
+ *
24
+ * The session writes only what it has open: `update` and `save` fail with
25
+ * `DocumentNotOpenError` unless this session has the URI open when the text is
26
+ * applied, and the check and the apply are one synchronous step, so a write
27
+ * either lands while the document is open or fails. A document stays open until
28
+ * this session closes it, the session ends, or the file is deleted.
29
+ *
30
+ * One open per URI, without reference counting: opening a URI the session
31
+ * already has open changes nothing, and one `close` ends it.
32
+ *
33
+ * After `dispose`, every other member throws `SessionClosedError`
34
+ * synchronously, before returning a promise.
35
+ *
36
+ * Reads need no session and live on `ModelService`.
37
+ */
38
+ export interface ClientSession<
39
+ TAst extends AstNode,
40
+ TDiagnostic extends AstDiagnostic = AstDiagnostic,
41
+ TTransfer extends TransferElement = TransferElement,
42
+ TOpenOptions extends OpenOptions = OpenOptions
43
+ > {
44
+ /** Unique in the process while the session is live: the author label on its writes and the key of its opens. */
45
+ readonly clientId: string;
46
+ readonly label: string;
47
+
48
+ /**
49
+ * Open `uri` for this session, reading it from disk unless some client has
50
+ * it open already. `options` are kept for this open until it closes; a
51
+ * repeat open, concurrent or not, keeps the options of the first.
52
+ */
53
+ open(uri: string, options?: TOpenOptions): Promise<void>;
54
+ /**
55
+ * Create a document with `text` and open it for this session. It reaches
56
+ * disk on the first `save`. Fails when the file exists or any client has the
57
+ * URI open, and of two creates of one URI at most one succeeds.
58
+ */
59
+ create(uri: string, text: string): Promise<void>;
60
+ update(args: Omit<TransferUpdateArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
61
+ /** Write `args.model` as {@link update} does, then persist the document. */
62
+ save(args: Omit<TransferSaveArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
63
+ /** Close this session's open of `uri`, at once. A no-op when it does not have `uri` open. */
64
+ close(uri: string): Promise<void>;
65
+ /**
66
+ * Open `uri`, run `fn`, and close `uri` when `fn` settles, whether it
67
+ * returned or threw. A URI the session already had open, or is still
68
+ * opening through another call, stays open: the close undoes only the open
69
+ * this call made.
70
+ */
71
+ withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T>;
72
+ /** Whether an event's `sourceClientId` names this session, i.e. the event echoes its own write. */
73
+ isOwnEcho(sourceClientId: string): boolean;
74
+ /** End the session: close everything it has open and free its id. Idempotent. */
75
+ dispose(): void;
76
+ }
77
+
78
+ /**
79
+ * The framework's {@link ClientSession}, built by
80
+ * `DefaultModelService.newSession`.
81
+ *
82
+ * Writes go through the owning `ModelService`, so its overrides apply to a
83
+ * session's writes as they do to anyone's. The open rule is enforced there, not
84
+ * here, because only the service reaches the point where the text is applied.
85
+ *
86
+ * An override of {@link dispose} calls `super.dispose()`: that is what closes
87
+ * the session's opens and frees its id.
88
+ */
89
+ export class DefaultClientSession<
90
+ TAst extends AstNode,
91
+ TDiagnostic extends AstDiagnostic = AstDiagnostic,
92
+ TTransfer extends TransferElement = TransferElement,
93
+ TOpenOptions extends OpenOptions = OpenOptions
94
+ > implements ClientSession<TAst, TDiagnostic, TTransfer, TOpenOptions> {
95
+ protected disposed = false;
96
+ /**
97
+ * This session's opens still under way, by canonical URI. Recorded before
98
+ * the first await, so a second open of the same URI joins the first rather
99
+ * than racing it, and `withOpen` can tell an open another call is making.
100
+ */
101
+ protected readonly pendingOpens = new Map<string, Promise<void>>();
102
+
103
+ constructor(
104
+ protected readonly modelService: ModelService<TAst, TDiagnostic, TTransfer>,
105
+ protected readonly services: ServerSharedServices,
106
+ readonly clientId: string,
107
+ readonly label: string
108
+ ) {}
109
+
110
+ open(uri: string, options?: TOpenOptions): Promise<void> {
111
+ this.assertLive();
112
+ return this.openDocument(uri, options);
113
+ }
114
+
115
+ create(uri: string, text: string): Promise<void> {
116
+ this.assertLive();
117
+ return this.createDocument(uri, text);
118
+ }
119
+
120
+ update(args: Omit<TransferUpdateArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
121
+ this.assertLive();
122
+ return this.modelService.update({ ...args, clientId: this.clientId }, cancelToken);
123
+ }
124
+
125
+ save(args: Omit<TransferSaveArgs<TTransfer>, 'clientId'>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>> {
126
+ this.assertLive();
127
+ return this.modelService.save({ ...args, clientId: this.clientId }, cancelToken);
128
+ }
129
+
130
+ close(uri: string): Promise<void> {
131
+ this.assertLive();
132
+ return this.modelService.close({ uri, clientId: this.clientId });
133
+ }
134
+
135
+ withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
136
+ this.assertLive();
137
+ return this.runWithOpen(uri, fn);
138
+ }
139
+
140
+ isOwnEcho(sourceClientId: string): boolean {
141
+ this.assertLive();
142
+ return sourceClientId === this.clientId;
143
+ }
144
+
145
+ dispose(): void {
146
+ if (this.disposed) {
147
+ return;
148
+ }
149
+ this.disposed = true;
150
+ this.services.workspace.TextDocuments.closeSession(this.clientId);
151
+ }
152
+
153
+ protected assertLive(): void {
154
+ if (this.disposed) {
155
+ throw new SessionClosedError(this.clientId);
156
+ }
157
+ }
158
+
159
+ /** Open `uri`, or join the open of it this session already has under way, whose options then stand. */
160
+ protected openDocument(uri: string, options: TOpenOptions | undefined): Promise<void> {
161
+ const key = this.canonicalKey(uri);
162
+ const pending = this.pendingOpens.get(key);
163
+ if (pending) {
164
+ return pending;
165
+ }
166
+ const opening = this.registerOpen(uri, options).finally(() => {
167
+ if (this.pendingOpens.get(key) === opening) {
168
+ this.pendingOpens.delete(key);
169
+ }
170
+ });
171
+ this.pendingOpens.set(key, opening);
172
+ return opening;
173
+ }
174
+
175
+ protected async registerOpen(uri: string, options: TOpenOptions | undefined): Promise<void> {
176
+ const textDocuments = this.services.workspace.TextDocuments;
177
+ const wasOpen = textDocuments.isOpenInClient(uri, this.clientId);
178
+ await this.modelService.open({ uri, clientId: this.clientId });
179
+ await this.rejectIfEnded(uri, wasOpen);
180
+ if (!wasOpen && options !== undefined) {
181
+ textDocuments.setOpenOptions(uri, this.clientId, options);
182
+ }
183
+ }
184
+
185
+ protected canonicalKey(uri: string): string {
186
+ return this.services.workspace.DocumentUriPolicy.canonicalUri(uri);
187
+ }
188
+
189
+ protected async createDocument(uri: string, text: string): Promise<void> {
190
+ if (await this.services.workspace.FileSystemProvider.exists(UriUtils.toUri(uri))) {
191
+ throw new Error(`Cannot create ${uri}: the file exists`);
192
+ }
193
+ this.assertLive();
194
+ if (this.modelService.isOpen(uri)) {
195
+ throw new Error(`Cannot create ${uri}: it is open in a client`);
196
+ }
197
+ // The check above cannot see an open that registers while this one is
198
+ // under way, and opening an already-open URI only attaches, dropping
199
+ // `text`. Only an open that creates the document fires `onDidOpen`, so
200
+ // hearing it for this session, during this open, is what proves the
201
+ // document holds `text`; the client list afterwards cannot, since a
202
+ // client that registered first may have closed again by then.
203
+ const key = this.canonicalKey(uri);
204
+ let created = false;
205
+ const listener = this.services.workspace.TextDocuments.onDidOpen(event => {
206
+ if (event.clientId === this.clientId && event.document.uri === key) {
207
+ created = true;
208
+ }
209
+ });
210
+ try {
211
+ await this.modelService.open({ uri, clientId: this.clientId, text });
212
+ } finally {
213
+ listener.dispose();
214
+ }
215
+ await this.rejectIfEnded(uri, false);
216
+ if (!created) {
217
+ await this.modelService.close({ uri, clientId: this.clientId });
218
+ throw new Error(`Cannot create ${uri}: it is open in a client`);
219
+ }
220
+ }
221
+
222
+ protected async runWithOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
223
+ // An open another call of this session is still making counts as made:
224
+ // closing it afterwards would take away an open that call asked for.
225
+ const alreadyOpen =
226
+ this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId) || this.pendingOpens.has(this.canonicalKey(uri));
227
+ await this.openDocument(uri, undefined);
228
+ try {
229
+ return await fn();
230
+ } finally {
231
+ if (!alreadyOpen && !this.disposed) {
232
+ await this.modelService.close({ uri, clientId: this.clientId });
233
+ }
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Undo an open that completed after the session ended, then fail it.
239
+ *
240
+ * Ending the session closed only what it had open at that moment. An open
241
+ * still reading from disk registers afterwards, under an id that is no
242
+ * longer a session, and nothing would ever close it.
243
+ *
244
+ * Opens are keyed by id, so once a new session holds the same id this open
245
+ * is indistinguishable from one of its own, and closing it would close the
246
+ * new session's. It is left to that session, which releases it at its end.
247
+ */
248
+ protected async rejectIfEnded(uri: string, wasOpen: boolean): Promise<void> {
249
+ if (!this.disposed) {
250
+ return;
251
+ }
252
+ if (!wasOpen && this.modelService.getSession(this.clientId) === undefined) {
253
+ await this.modelService.close({ uri, clientId: this.clientId });
254
+ }
255
+ throw new SessionClosedError(this.clientId);
256
+ }
257
+ }
@@ -8,3 +8,4 @@
8
8
  ********************************************************************************/
9
9
 
10
10
  export * from './model-service.js';
11
+ export * from './client-session.js';