@hydranium/core 1.0.0-next.123 → 1.0.0-next.127

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 (39) hide show
  1. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  2. package/lib/documents/ast-document-manager.js +7 -0
  3. package/lib/documents/ast-document-manager.js.map +1 -1
  4. package/lib/documents/hydranium-text-documents.d.ts +62 -0
  5. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  6. package/lib/documents/hydranium-text-documents.js +125 -14
  7. package/lib/documents/hydranium-text-documents.js.map +1 -1
  8. package/lib/langium/integrity/integrity-service.d.ts +3 -7
  9. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  10. package/lib/langium/integrity/integrity-service.js +8 -7
  11. package/lib/langium/integrity/integrity-service.js.map +1 -1
  12. package/lib/langium/model-service/model-service.d.ts +2 -2
  13. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  14. package/lib/langium/model-service/model-service.js +16 -25
  15. package/lib/langium/model-service/model-service.js.map +1 -1
  16. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  17. package/lib/lsp/hydranium-document-update-handler.js +12 -5
  18. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  19. package/lib/node/socket-launcher.d.ts +1 -1
  20. package/lib/node/socket-launcher.d.ts.map +1 -1
  21. package/lib/node/socket-launcher.js +1 -1
  22. package/lib/node/socket-launcher.js.map +1 -1
  23. package/lib/node/stdio-launcher.d.ts +1 -1
  24. package/lib/node/stdio-launcher.d.ts.map +1 -1
  25. package/lib/node/stdio-launcher.js +1 -1
  26. package/lib/node/stdio-launcher.js.map +1 -1
  27. package/lib/testing/stub-hydranium-text-documents.d.ts +5 -3
  28. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  29. package/lib/testing/stub-hydranium-text-documents.js +39 -0
  30. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  31. package/package.json +5 -5
  32. package/src/documents/ast-document-manager.ts +7 -0
  33. package/src/documents/hydranium-text-documents.ts +146 -14
  34. package/src/langium/integrity/integrity-service.ts +8 -7
  35. package/src/langium/model-service/model-service.ts +16 -25
  36. package/src/lsp/hydranium-document-update-handler.ts +12 -5
  37. package/src/node/socket-launcher.ts +1 -2
  38. package/src/node/stdio-launcher.ts +1 -2
  39. package/src/testing/stub-hydranium-text-documents.ts +51 -3
@@ -461,18 +461,14 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
461
461
  * authority, and those heads read the correction from the settled build.
462
462
  * In silent mode it is also written to disk when the text it was computed
463
463
  * from is what disk holds, since the repair is then the only difference;
464
- * otherwise it waits for the next save. Editor mode always waits, and a
465
- * repair of text the holder never changed then differs from disk with
466
- * nothing marking it unsaved.
464
+ * otherwise it waits for the next save. Editor mode always waits, and the
465
+ * repair leaves the document dirty until a save.
467
466
  * - **Closed, silent mode** → write to disk directly (disk is authoritative).
468
467
  * - **Closed, editor mode** → stage so the next open picks it up via applyEdit.
469
468
  *
470
469
  * A held URI is not closed. Writing it as one persists the holder's unsaved
471
470
  * edits with the repair, and staging it leaves text no open reads, because
472
- * only a first open consumes the stage. Leaving every held repair to a save
473
- * is not enough either: a data or GLSP head marks only its own edits unsaved,
474
- * so a repair of text it never changed would leave disk and store apart with
475
- * nothing showing it.
471
+ * only a first open consumes the stage.
476
472
  *
477
473
  * `parsedFrom` is the text the repaired AST was parsed from. Without it a
478
474
  * held URI's repair is left to the next save.
@@ -548,6 +544,10 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
548
544
  this.tracer.with(document.uri).debug(`Held document unreadable on disk, repair left to the next save: ${String(error)}`);
549
545
  return;
550
546
  }
547
+ // The file itself, not the store's disk baseline: the baseline moves
548
+ // only when the server hears of a change, so it can still hold the
549
+ // parsed text after another process rewrote the file and before the
550
+ // watcher reports it, and this write would then replace that change.
551
551
  if (onDisk !== parsedFrom) {
552
552
  return;
553
553
  }
@@ -556,6 +556,7 @@ export class DefaultIntegrityService<TRoot extends AstNode = AstNode> implements
556
556
  return;
557
557
  }
558
558
  await this.fileSystemProvider.writeFile(uri, repaired);
559
+ this.textDocuments.updateDiskBaseline(document.uri, repaired);
559
560
  });
560
561
  }
561
562
 
@@ -13,6 +13,7 @@ import {
13
13
  type MaybeObservableValue,
14
14
  type MaybePromise,
15
15
  ObservableValue,
16
+ TIMED_OUT,
16
17
  type TransferElement,
17
18
  type Tracer
18
19
  } from '@hydranium/protocol';
@@ -48,18 +49,9 @@ import { type ClientSession } from './client-session.js';
48
49
  */
49
50
  export const MODEL_UPDATE_EDIT = defineMessage('hydranium/core/model-update-edit', 'Update Model');
50
51
 
51
- /** Max time {@link ModelService.settleSave} waits for the build to settle and the sync chain to drain. */
52
+ /** Max time {@link DefaultModelService.settleSave} waits for the build to settle and the sync chain to drain. */
52
53
  const SAVE_SETTLE_TIMEOUT_MS = 10_000;
53
54
 
54
- /**
55
- * Marks the {@link SAVE_SETTLE_TIMEOUT_MS} branch of
56
- * {@link ModelService.settleSave}'s race so it stays distinguishable from a
57
- * genuine rejection (a cancelled token, an `applyEdit` reverse-RPC error, a
58
- * build throw). With a plain `Error` the only log line an adopter has blames
59
- * the timeout for every one of them, which points debugging at the wrong layer.
60
- */
61
- class SaveSettleTimeoutError extends Error {}
62
-
63
55
  /**
64
56
  * Constructor options for {@link ModelService}. All fields are optional,
65
57
  * and the defaults are the behaviour described on each one.
@@ -672,8 +664,8 @@ export class DefaultModelService<
672
664
  * so every language client reflects the latest content before a save returns.
673
665
  * No-op tail for headless adopters — `syncChains` is empty without an LSP
674
666
  * client. Bounded by {@link SAVE_SETTLE_TIMEOUT_MS} so a hung build, or an
675
- * applyEdit reverse-RPC deadlock, can't freeze the caller; on timeout it logs
676
- * a warning and returns rather than throwing.
667
+ * applyEdit reverse-RPC deadlock, can't freeze the caller; on timeout or a
668
+ * failed wait it logs a warning and returns rather than throwing.
677
669
  *
678
670
  * Adopters with a save flow that must converge editor + disk before returning
679
671
  * (e.g. a dual form/code editor that would otherwise show a content-conflict
@@ -682,22 +674,21 @@ export class DefaultModelService<
682
674
  protected async settleSave(uri: string, cancelToken?: CancellationToken): Promise<void> {
683
675
  const canonical = this.uriPolicy.canonicalUri(uri);
684
676
  const key = UriUtils.toUri(canonical).toString();
685
- const timeout = new Promise<void>((_, reject) =>
686
- setTimeout(() => reject(new SaveSettleTimeoutError('settle timeout')), SAVE_SETTLE_TIMEOUT_MS)
687
- );
677
+ // One race over both waits, so the bound is one deadline for the pair;
678
+ // a race per wait would let the save take twice the bound.
679
+ const settled = (async (): Promise<void> => {
680
+ await this.waitForDocumentStateCanonical(canonical, IntegrityService.SettledState, cancelToken);
681
+ await this.syncChains.get(key);
682
+ })();
688
683
  try {
689
- await Promise.race([this.waitForDocumentStateCanonical(canonical, IntegrityService.SettledState, cancelToken), timeout]);
690
- const pending = this.syncChains.get(key);
691
- if (pending) {
692
- await Promise.race([pending, timeout]);
693
- }
694
- } catch (err: unknown) {
695
- if (err instanceof SaveSettleTimeoutError) {
684
+ if ((await this.services.Clock.raceTimer(settled, SAVE_SETTLE_TIMEOUT_MS)) === TIMED_OUT) {
696
685
  this.tracer.withUri(key).warn(`Save settle exceeded ${SAVE_SETTLE_TIMEOUT_MS}ms — returning anyway`);
697
- } else {
698
- const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
699
- this.tracer.withUri(key).warn(`Save settle failed before the timeout — returning anyway. ${detail}`);
700
686
  }
687
+ } catch (err: unknown) {
688
+ // A failed wait, such as a cancelled token or a document the builder
689
+ // does not hold, gets its own line so it is not blamed on the bound.
690
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
691
+ this.tracer.withUri(key).warn(`Save settle failed before the timeout — returning anyway. ${detail}`);
701
692
  }
702
693
  }
703
694
 
@@ -7,7 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Debouncer, Deferred, type Logger, ObservableValue, type MaybeObservableValue } from '@hydranium/protocol';
10
+ import { Debouncer, Deferred, type Logger, ObservableValue, type MaybeObservableValue, TIMED_OUT } from '@hydranium/protocol';
11
11
  import { URI, UriUtils } from '@hydranium/langium';
12
12
  import { DefaultDocumentUpdateHandler } from '@hydranium/langium/lsp';
13
13
  import { HYDRANIUM_BUILD_REASONS, type HydraniumDocumentBuilder } from '../langium/document-builder/document-builder.js';
@@ -212,6 +212,15 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
212
212
  if (filtered.changes.length === 0) {
213
213
  return;
214
214
  }
215
+ // Only an open document keeps the file's text as its disk
216
+ // baseline; the store ignores the rest. The self-save filter has
217
+ // dropped the server's own writes, which set the baseline when
218
+ // they wrote.
219
+ for (const change of filtered.changes) {
220
+ this.textDocuments
221
+ .reloadDiskBaseline(change.uri)
222
+ .catch((err: unknown) => this.logger.error(`Disk baseline not reloaded for ${change.uri}. ${String(err)}`));
223
+ }
215
224
  // External file-system changes are authoritative — overwrite any
216
225
  // pending reason (typically empty here; could be `didChangeContent`
217
226
  // if a watcher event raced an in-flight Monaco edit, in which case
@@ -273,14 +282,12 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
273
282
  void this.services.workspace.FileSystemTaskQueue.enqueue(uri, async () => {
274
283
  cap.dispose();
275
284
  resolve([]);
276
- const bound = clock.setTimer(() => {
285
+ if ((await clock.raceTimer(hold.promise, this.willSaveGateMs)) === TIMED_OUT) {
277
286
  this.logger.debug(
278
287
  `No didSave for ${uri} within ${this.willSaveGateMs} ms; server writes of it no longer wait for the editor's save.`
279
288
  );
280
289
  release();
281
- }, this.willSaveGateMs);
282
- await hold.promise;
283
- bound.dispose();
290
+ }
284
291
  });
285
292
  } catch (err) {
286
293
  cap.dispose();
@@ -7,9 +7,8 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Deferred } from '@hydranium/langium';
11
10
  import * as net from 'node:net';
12
- import type { Disposable, Logger } from '@hydranium/protocol';
11
+ import { Deferred, type Disposable, type Logger } from '@hydranium/protocol';
13
12
  import { createMessageConnection, SocketMessageReader, SocketMessageWriter } from 'vscode-jsonrpc/node';
14
13
  import type { MessageConnection } from 'vscode-jsonrpc';
15
14
  import type { IntegratedServer } from '../launcher/integrated-server.js';
@@ -7,8 +7,7 @@
7
7
  * SPDX-License-Identifier: MIT
8
8
  ********************************************************************************/
9
9
 
10
- import { Deferred } from '@hydranium/langium';
11
- import type { Disposable, Logger } from '@hydranium/protocol';
10
+ import { Deferred, type Disposable, type Logger } from '@hydranium/protocol';
12
11
  import type { MessageConnection } from 'vscode-jsonrpc';
13
12
  import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from 'vscode-jsonrpc/node';
14
13
  import type { IntegratedServer } from '../launcher/integrated-server.js';
@@ -13,7 +13,12 @@ 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 { ClientTextDocumentChangeEvent, HydraniumTextDocuments, LastOpenClosedEvent } from '../documents/hydranium-text-documents.js';
16
+ import type {
17
+ ClientTextDocumentChangeEvent,
18
+ DocumentDirtyChangedEvent,
19
+ HydraniumTextDocuments,
20
+ LastOpenClosedEvent
21
+ } from '../documents/hydranium-text-documents.js';
17
22
 
18
23
  /** Snapshot of an open document tracked by the stub. */
19
24
  export interface StubTextDocumentEntry {
@@ -31,7 +36,9 @@ export interface StubTextDocumentEntry {
31
36
  * `isOpenInAnyClient` / `isOpenInClient` / `isRevertPending` /
32
37
  * `openDocuments`), the push channel
33
38
  * to the language client (`applyEditToLanguageClient` / `stagePendingContent`),
34
- * the save / close notifications, and the client-session table
39
+ * the save / close notifications, the dirty state (`isDirty` /
40
+ * `onDidChangeDirty` / `updateDiskBaseline`, against a baseline {@link seedOpen}
41
+ * sets and a save that carries its text moves), and the client-session table
35
42
  * (`registerSession` / `closeSession` / `onDidCloseSession`), which is a real
36
43
  * `ClientSessionRegistry` — plus test-only helpers:
37
44
  *
@@ -80,6 +87,9 @@ export interface StubHydraniumTextDocuments extends Pick<
80
87
  | 'closeSession'
81
88
  | 'onDidCloseSession'
82
89
  | 'onDidCloseLastOpen'
90
+ | 'isDirty'
91
+ | 'onDidChangeDirty'
92
+ | 'updateDiskBaseline'
83
93
  > {
84
94
  /**
85
95
  * Stub-tailored read accessor. Returns just the surface the framework's
@@ -98,7 +108,7 @@ export interface StubHydraniumTextDocuments extends Pick<
98
108
  onDidSave(listener: (event: { document: { uri: string }; clientId: string }) => void): Disposable;
99
109
  /** Real-shaped close subscription — listener gets {@link ClientTextDocumentChangeEvent}. */
100
110
  onDidClose(listener: (event: ClientTextDocumentChangeEvent<TextDocument>) => void): Disposable;
101
- /** Pre-populate an open document without firing change events. */
111
+ /** Pre-populate an open document without firing change events; `text` is also its disk baseline. */
102
112
  seedOpen(uri: string, text: string, clientId: string): void;
103
113
  /** Mark `uri` as open in the LSP textual language client (drives {@link isOpenInLanguageClient}). */
104
114
  seedOpenInLanguageClient(uri: string): void;
@@ -152,6 +162,23 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
152
162
  const sessions = new ClientSessionRegistry();
153
163
  const key = (uri: string): CanonicalUri => uri as CanonicalUri;
154
164
  const lastOpenClosed = new Emitter<LastOpenClosedEvent>();
165
+ const baselines = new Map<string, string | undefined>();
166
+ const dirty = new Set<string>();
167
+ const dirtyChanged = new Emitter<DocumentDirtyChangedEvent>();
168
+ // As the real store: dirty while held with text other than the baseline,
169
+ // announced only when the answer changes.
170
+ const refreshDirty = (uri: string): void => {
171
+ const held = docs.get(uri);
172
+ const now = held !== undefined && held.text !== baselines.get(uri);
173
+ if (now !== dirty.has(uri)) {
174
+ if (now) {
175
+ dirty.add(uri);
176
+ } else {
177
+ dirty.delete(uri);
178
+ }
179
+ dirtyChanged.fire({ uri: key(uri), dirty: now });
180
+ }
181
+ };
155
182
 
156
183
  const stub: StubHydraniumTextDocuments = {
157
184
  get changes() {
@@ -186,6 +213,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
186
213
  }
187
214
  docs.set(event.textDocument.uri, next);
188
215
  changes.push(next);
216
+ refreshDirty(event.textDocument.uri);
189
217
  },
190
218
  applyContentChange(uri, text, clientId) {
191
219
  const existing = docs.get(uri);
@@ -199,9 +227,13 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
199
227
  const next: StubTextDocumentEntry = changed ? { uri, version: existing.version + 1, text, clientId } : existing;
200
228
  docs.set(uri, next);
201
229
  changes.push({ uri, version: next.version, text, clientId });
230
+ refreshDirty(uri);
202
231
  return next.version;
203
232
  },
204
233
  notifyDidSaveTextDocument(event, clientId) {
234
+ if (event.text !== undefined) {
235
+ stub.updateDiskBaseline(event.textDocument.uri, event.text);
236
+ }
205
237
  saves.push({ uri: event.textDocument.uri, clientId });
206
238
  for (const listener of saveListeners.slice()) {
207
239
  listener({ document: { uri: event.textDocument.uri }, clientId });
@@ -264,6 +296,18 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
264
296
  get onDidCloseLastOpen() {
265
297
  return lastOpenClosed.event;
266
298
  },
299
+ isDirty(uri) {
300
+ return dirty.has(uri);
301
+ },
302
+ get onDidChangeDirty() {
303
+ return dirtyChanged.event;
304
+ },
305
+ updateDiskBaseline(uri, text) {
306
+ if (docs.has(uri)) {
307
+ baselines.set(uri, text);
308
+ refreshDirty(uri);
309
+ }
310
+ },
267
311
  openDocuments() {
268
312
  // From the registry that also answers `isOpenInClient`, which both
269
313
  // seeding channels feed, so the two never disagree about an open.
@@ -282,6 +326,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
282
326
  seedOpen(uri, text, clientId) {
283
327
  sessions.addOpen(key(uri), clientId);
284
328
  docs.set(uri, { uri, version: 1, text, clientId });
329
+ baselines.set(uri, text);
285
330
  },
286
331
  seedOpenInLanguageClient(uri) {
287
332
  sessions.addOpen(key(uri), LANGUAGE_CLIENT_ID);
@@ -294,6 +339,7 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
294
339
  const holder = docs.get(uri);
295
340
  if (holder && holder.clientId === clientId) {
296
341
  docs.delete(uri);
342
+ refreshDirty(uri);
297
343
  }
298
344
  const removed = sessions.removeOpen(key(uri), clientId);
299
345
  if (clientId === LANGUAGE_CLIENT_ID) {
@@ -314,6 +360,8 @@ export function makeStubHydraniumTextDocuments(): StubHydraniumTextDocuments {
314
360
  languageClientOpen.clear();
315
361
  appliedEdits.length = 0;
316
362
  staged.length = 0;
363
+ baselines.clear();
364
+ dirty.clear();
317
365
  saveListeners.length = 0;
318
366
  closeListeners.length = 0;
319
367
  applyEditHandler = () => ({ applied: true });