@hydranium/core 1.0.0-next.284 → 1.0.0-next.287

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 (45) hide show
  1. package/lib/documents/file-system-task-queue.d.ts +3 -0
  2. package/lib/documents/file-system-task-queue.d.ts.map +1 -1
  3. package/lib/documents/file-system-task-queue.js +3 -0
  4. package/lib/documents/file-system-task-queue.js.map +1 -1
  5. package/lib/documents/hydranium-text-documents.d.ts +5 -1
  6. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  7. package/lib/documents/hydranium-text-documents.js +12 -5
  8. package/lib/documents/hydranium-text-documents.js.map +1 -1
  9. package/lib/langium/model-service/model-service.d.ts +35 -12
  10. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  11. package/lib/langium/model-service/model-service.js +63 -25
  12. package/lib/langium/model-service/model-service.js.map +1 -1
  13. package/lib/langium/workspace/document-uri-policy.d.ts +9 -5
  14. package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
  15. package/lib/langium/workspace/document-uri-policy.js +15 -8
  16. package/lib/langium/workspace/document-uri-policy.js.map +1 -1
  17. package/lib/langium/workspace/file-system-provider.d.ts +15 -4
  18. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  19. package/lib/langium/workspace/file-system-provider.js +13 -4
  20. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  21. package/lib/lsp/hydranium-document-update-handler.d.ts +41 -21
  22. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  23. package/lib/lsp/hydranium-document-update-handler.js +86 -33
  24. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  25. package/lib/node/node-file-system-provider.d.ts +7 -0
  26. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  27. package/lib/node/node-file-system-provider.js +36 -1
  28. package/lib/node/node-file-system-provider.js.map +1 -1
  29. package/lib/testing/node/applied-edit-capture.d.ts +1 -1
  30. package/lib/testing/node/applied-edit-capture.d.ts.map +1 -1
  31. package/lib/testing/node/applied-edit-capture.js +3 -1
  32. package/lib/testing/node/applied-edit-capture.js.map +1 -1
  33. package/lib/testing/node/lsp-server-connection.d.ts +2 -1
  34. package/lib/testing/node/lsp-server-connection.d.ts.map +1 -1
  35. package/lib/testing/node/lsp-server-connection.js.map +1 -1
  36. package/package.json +5 -5
  37. package/src/documents/file-system-task-queue.ts +6 -0
  38. package/src/documents/hydranium-text-documents.ts +13 -5
  39. package/src/langium/model-service/model-service.ts +79 -26
  40. package/src/langium/workspace/document-uri-policy.ts +17 -8
  41. package/src/langium/workspace/file-system-provider.ts +23 -4
  42. package/src/lsp/hydranium-document-update-handler.ts +101 -43
  43. package/src/node/node-file-system-provider.ts +36 -1
  44. package/src/testing/node/applied-edit-capture.ts +5 -3
  45. package/src/testing/node/lsp-server-connection.ts +2 -1
@@ -67,6 +67,15 @@ export interface WritableFileSystemProvider extends FileSystemProvider {
67
67
  * browser-neutral (no `node:fs` import).
68
68
  */
69
69
  realpath?(uri: URI): URI | undefined;
70
+ /**
71
+ * The path `uri` names with every link followed, a link to nothing
72
+ * included, so that `RealpathDocumentUriPolicy.canonicalUri` keys a file
73
+ * reached through a link the same before it exists and once it does.
74
+ * Where {@link realpath} resolves, the same answer; otherwise what is
75
+ * missing joined as spelled under the real path of what exists. Optional,
76
+ * as `realpath` is: without it an absent path keys by its syntactic form.
77
+ */
78
+ resolveLinks?(uri: URI): URI;
70
79
  /**
71
80
  * Whether a file is at `uri`, where a miss counts only once it has lasted:
72
81
  * on a disk where replacing a file can hide its name for a moment, a single
@@ -173,10 +182,11 @@ export interface FileSystemProviderRegistryOptions<
173
182
  * A directory is listed by the provider of its own scheme, so a workspace
174
183
  * root's tree comes from that provider alone.
175
184
  *
176
- * `realpath`, `mtimeMs` and `existsSettled` are always present and forward to
177
- * the scheme's provider. Where that provider lacks one, `realpath` returns the
178
- * URI unchanged, `mtimeMs` returns `undefined` and `existsSettled` answers as
179
- * `exists`: the answers their callers already give a provider without them.
185
+ * `realpath`, `resolveLinks`, `mtimeMs` and `existsSettled` are always present
186
+ * and forward to the scheme's provider. Where that provider lacks one,
187
+ * `realpath` and `resolveLinks` return the URI unchanged, `mtimeMs` returns
188
+ * `undefined` and `existsSettled` answers as `exists`: the answers their
189
+ * callers already give a provider without them.
180
190
  */
181
191
  export class DefaultFileSystemProviderRegistry<
182
192
  THost extends WritableFileSystemProvider = WritableFileSystemProvider
@@ -223,6 +233,15 @@ export class DefaultFileSystemProviderRegistry<
223
233
  return provider.realpath ? provider.realpath(uri) : uri;
224
234
  }
225
235
 
236
+ resolveLinks(uri: URI): URI {
237
+ const provider = this.providerFor(uri);
238
+ if (provider.resolveLinks) {
239
+ return provider.resolveLinks(uri);
240
+ }
241
+ // A provider with `realpath` alone still keys a present path by it.
242
+ return provider.realpath?.(uri) ?? uri;
243
+ }
244
+
226
245
  async existsSettled(uri: URI): Promise<boolean> {
227
246
  const provider = this.providerFor(uri);
228
247
  return provider.existsSettled ? provider.existsSettled(uri) : provider.exists(uri);
@@ -63,13 +63,15 @@ export interface HydraniumDocumentUpdateHandlerOptions {
63
63
  readonly bypassNonLanguageClientChanges?: boolean;
64
64
  /**
65
65
  * Longest an editor's save waits on the server, in milliseconds, at each of
66
- * its two steps: the `willSaveWaitUntil` answer waits for the server's disk
67
- * writes of the document already queued, and server writes queued after the
68
- * answer wait for the editor's `didSave`. Either wait that runs out is
69
- * logged, and the editor's write and the server's can then land in either
70
- * order. Default `1000`: VS Code gives up on an answer after about 1.5 s,
71
- * and switches the listener off for the session once four answers, over
72
- * all documents, timed out or failed.
66
+ * its two steps: the `willSaveWaitUntil` answer waits for the language
67
+ * client's answer to the push of the server's text and for the server's
68
+ * disk writes of the document already queued, and server writes queued
69
+ * after the answer wait for the editor's `didSave`. Either wait that runs
70
+ * out is logged, and the editor can then save without the server's text,
71
+ * or its write and the server's land in either order. Default `1000`: VS
72
+ * Code gives up on an answer after about 1.5 s, and switches the listener
73
+ * off for the session once four answers, over all documents, timed out or
74
+ * failed.
73
75
  */
74
76
  readonly willSaveGateMs?: number;
75
77
  }
@@ -96,10 +98,11 @@ export interface HydraniumDocumentUpdateHandlerOptions {
96
98
  * runs, so a subclass that formats build logs can tag its line with the
97
99
  * event that caused the build without per-adopter wiring to capture it.
98
100
  * 4. **Editor save gate** ({@link willSaveDocumentWaitUntil}) — the editor
99
- * writes the file itself, so the server's disk writes of that document
100
- * are ordered around it: the editor's save waits for the ones already
101
- * queued, and the ones queued during its save wait for its `didSave`.
102
- * Both waits are capped by
101
+ * writes the file itself, so its save waits for the language client to
102
+ * answer the push of the server's newer text, and the server's disk
103
+ * writes of that document are ordered around it: the editor's save waits
104
+ * for the ones already queued, and the ones queued during its save wait
105
+ * for its `didSave`. The answer and the hold are each capped by
103
106
  * {@link HydraniumDocumentUpdateHandlerOptions.willSaveGateMs}.
104
107
  *
105
108
  * What the build keeps after a document's last close is not dispatched here:
@@ -257,23 +260,27 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
257
260
  }
258
261
 
259
262
  /**
260
- * Answer an editor's `willSaveWaitUntil` with no edits once the server's
261
- * disk writes of the document already queued have landed, or after
262
- * {@link willSaveGateMs}, whichever is first. From the moment the queue
263
- * reaches this save, disk tasks of the document queued behind it wait for
264
- * the editor's `didSave`, reported through
263
+ * Answer an editor's `willSaveWaitUntil` with no edits once
264
+ * {@link settleBeforeSave} has resolved and then the server's disk writes
265
+ * of the document already queued have landed, or after
266
+ * {@link willSaveGateMs} for both together, whichever is first. From the
267
+ * moment the queue reaches this save, disk tasks of the document queued
268
+ * behind it wait for the editor's `didSave`, reported through
265
269
  * `HydraniumTextDocuments.onDidSaveInLanguageClient`, or for
266
270
  * {@link willSaveGateMs} more.
267
271
  *
268
272
  * The hold is a disk task that waits only for that signal or its timer:
269
273
  * waiting on a build, a save or anything else queued for the document would
270
- * wedge its queue. Resolves in every case, since a failed request counts
271
- * against the listener in VS Code; Langium drops the request's cancellation,
272
- * so an abandoned request still runs to the cap. A hold that runs out is
273
- * logged at debug only: an editor skips `didSave` for a save it cancels or
274
- * that changed nothing.
274
+ * wedge its queue, so the settle runs before it is queued. Past the cap the
275
+ * hold is queued while the settle still runs, so a write that build queues,
276
+ * such as an integrity repair, waits behind the hold. A settle that fails is
277
+ * logged and the save goes on. Resolves in every case, since a
278
+ * failed request counts against the listener in VS Code; Langium drops the
279
+ * request's cancellation, so an abandoned request still runs to the cap. A
280
+ * hold that runs out is logged at debug only: an editor skips `didSave` for
281
+ * a save it cancels or that changed nothing.
275
282
  */
276
- willSaveDocumentWaitUntil(event: TextDocumentWillSaveEvent<TextDocument>): Promise<TextEdit[]> {
283
+ async willSaveDocumentWaitUntil(event: TextDocumentWillSaveEvent<TextDocument>): Promise<TextEdit[]> {
277
284
  const uri = event.document.uri;
278
285
  this.editorSaves.get(uri)?.();
279
286
  const hold = new Deferred();
@@ -284,32 +291,83 @@ export class HydraniumDocumentUpdateHandler extends DefaultDocumentUpdateHandler
284
291
  hold.resolve();
285
292
  };
286
293
  this.editorSaves.set(uri, release);
287
- return new Promise<TextEdit[]>(resolve => {
288
- const clock = this.services.Clock;
289
- const cap = clock.setTimer(() => {
294
+ const clock = this.services.Clock;
295
+ const answer = new Deferred<TextEdit[]>();
296
+ let settling = true;
297
+ const cap = clock.setTimer(() => {
298
+ if (settling) {
299
+ // A push unanswered past the cap is the stale-save case, and server
300
+ // writes queued ahead of the hold race the save; a build past the
301
+ // cap alone is routine on a large model.
302
+ if (this.services.model.ModelService.isLanguageClientPushPending(uri)) {
303
+ this.logger.warn(
304
+ `willSaveWaitUntil for ${uri} answered after ${this.willSaveGateMs} ms with the language client's answer to a push of it still pending; the editor may save text older than the server's.`
305
+ );
306
+ } else if (!this.services.workspace.FileSystemTaskQueue.isIdle(uri)) {
307
+ this.logger.warn(
308
+ `willSaveWaitUntil for ${uri} answered after ${this.willSaveGateMs} ms with its settle still pending and server writes of it queued; the editor's save may race them.`
309
+ );
310
+ } else {
311
+ this.logger.debug(
312
+ `willSaveWaitUntil for ${uri} answered after ${this.willSaveGateMs} ms with its settle still pending, on its build or on a push a settle already timed out on.`
313
+ );
314
+ }
315
+ } else {
290
316
  this.logger.warn(
291
317
  `willSaveWaitUntil for ${uri} answered after ${this.willSaveGateMs} ms with server writes of it still queued; the editor's save may race them.`
292
318
  );
293
- resolve([]);
294
- }, this.willSaveGateMs);
319
+ }
320
+ answer.resolve([]);
321
+ }, this.willSaveGateMs);
322
+ // Caught on the settle itself, since a race that the cap has won drops a
323
+ // later rejection without a trace.
324
+ const settled = (async (): Promise<void> => {
295
325
  try {
296
- void this.services.workspace.FileSystemTaskQueue.enqueue(uri, async () => {
297
- cap.dispose();
298
- resolve([]);
299
- if ((await clock.raceTimer(hold.promise, this.willSaveGateMs)) === TIMED_OUT) {
300
- this.logger.debug(
301
- `No didSave for ${uri} within ${this.willSaveGateMs} ms; server writes of it no longer wait for the editor's save.`
302
- );
303
- release();
304
- }
305
- });
306
- } catch (err) {
307
- cap.dispose();
308
- this.logger.error(`willSaveWaitUntil for ${uri} answered without waiting. ${describeErrorWithStack(err)}`);
309
- release();
310
- resolve([]);
326
+ await this.settleBeforeSave(uri);
327
+ } catch (err: unknown) {
328
+ this.logger.error(`The settle before an editor save of ${uri} failed. ${describeErrorWithStack(err)}`);
311
329
  }
312
- });
330
+ })();
331
+ await Promise.race([settled, answer.promise]);
332
+ settling = false;
333
+ try {
334
+ void this.services.workspace.FileSystemTaskQueue.enqueue(uri, async () => {
335
+ cap.dispose();
336
+ answer.resolve([]);
337
+ if ((await clock.raceTimer(hold.promise, this.willSaveGateMs)) === TIMED_OUT) {
338
+ this.logger.debug(
339
+ `No didSave for ${uri} within ${this.willSaveGateMs} ms; server writes of it no longer wait for the editor's save.`
340
+ );
341
+ release();
342
+ }
343
+ });
344
+ } catch (err: unknown) {
345
+ cap.dispose();
346
+ this.logger.error(`willSaveWaitUntil for ${uri} answered without waiting for server writes. ${describeErrorWithStack(err)}`);
347
+ release();
348
+ answer.resolve([]);
349
+ }
350
+ return answer.promise;
351
+ }
352
+
353
+ /**
354
+ * What an editor's save of `uri` waits for before it joins the document's
355
+ * disk queue: the language client's answer to the push of the server's
356
+ * newer text, applied or refused and logged
357
+ * (`ModelService.settleLanguageClient`). The editor saves its own buffer,
358
+ * so without it the editor can save text older than the server's. The
359
+ * settle waits for a build of the document's latest text, so a change of it
360
+ * still debounced is flushed first, along with every other debounced
361
+ * change. Runs outside the disk queue, under the save's cap; an override
362
+ * must not wait on a disk task of the document, which past the cap queues
363
+ * behind the save's hold.
364
+ */
365
+ protected async settleBeforeSave(uri: string): Promise<void> {
366
+ const key = URI.parse(uri).toString();
367
+ if (this.pendingChanged.has(key) || this.pendingDeleted.has(key)) {
368
+ this.flushPending();
369
+ }
370
+ await this.services.model.ModelService.settleLanguageClient(uri);
313
371
  }
314
372
 
315
373
  /**
@@ -11,8 +11,9 @@ import { type Tracer } from '@hydranium/protocol';
11
11
  import { type FileSystemNode, URI, UriUtils } from '@hydranium/langium';
12
12
  import { NodeFileSystemProvider } from '@hydranium/langium/node';
13
13
  import { randomUUID } from 'node:crypto';
14
- import { realpathSync } from 'node:fs';
14
+ import { readlinkSync, realpathSync } from 'node:fs';
15
15
  import * as fsp from 'node:fs/promises';
16
+ import { basename, dirname, join, resolve } from 'node:path';
16
17
  import { type WritableFileSystemProvider } from '../langium/workspace/file-system-provider.js';
17
18
  import { isFileNotFound } from '../langium/workspace/file-not-found.js';
18
19
  import { type SelfSaveRegistry } from '../langium/workspace/self-save-registry.js';
@@ -314,6 +315,40 @@ export class DefaultFileSystemProvider extends NodeFileSystemProvider implements
314
315
  return undefined;
315
316
  }
316
317
  }
318
+
319
+ /**
320
+ * The path `uri` names with every link followed, a link to nothing
321
+ * included, so a file reached through a link keys the same before it
322
+ * exists and once it does. What is missing is joined as spelled under the
323
+ * real path of what exists. A non-`file:` URI passes through unchanged.
324
+ */
325
+ resolveLinks(uri: URI): URI {
326
+ return uri.scheme === 'file' ? URI.file(followLinks(uri.fsPath)) : uri;
327
+ }
328
+ }
329
+
330
+ /** `path` with its links followed; see `DefaultFileSystemProvider.resolveLinks`. */
331
+ function followLinks(path: string, hops = 0): string {
332
+ try {
333
+ return realpathSync(path);
334
+ } catch {
335
+ // Absent somewhere along the way.
336
+ }
337
+ // A link to nothing is followed to what it names; a chain is bounded as
338
+ // the kernel bounds one.
339
+ if (hops < 40) {
340
+ let target: string | undefined;
341
+ try {
342
+ target = readlinkSync(path);
343
+ } catch {
344
+ // Not a link, or not there.
345
+ }
346
+ if (target !== undefined) {
347
+ return followLinks(resolve(dirname(path), target), hops + 1);
348
+ }
349
+ }
350
+ const parent = dirname(path);
351
+ return parent === path ? path : join(followLinks(parent, hops), basename(path));
317
352
  }
318
353
 
319
354
  /** The "no content here" rejection, for a URI naming no readable file. */
@@ -23,7 +23,7 @@ import type { AppliedEdit, NextAppliedEditOptions } from './lsp-server-connectio
23
23
  export interface AppliedEditCapture {
24
24
  readonly appliedEdits: ReadonlyArray<AppliedEdit>;
25
25
  nextAppliedEdit(uri: string, timeoutMsOrOptions?: number | NextAppliedEditOptions): Promise<AppliedEdit>;
26
- setApplyEditHandler(handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult): void;
26
+ setApplyEditHandler(handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult | Promise<ApplyWorkspaceEditResult>): void;
27
27
  dispose(): void;
28
28
  }
29
29
 
@@ -37,7 +37,9 @@ export interface AppliedEditCapture {
37
37
  export function captureAppliedEdits(client: ProtocolConnection, defaultTimeoutMs: number): AppliedEditCapture {
38
38
  const appliedEdits: AppliedEdit[] = [];
39
39
  const waiters: Array<(edit: AppliedEdit) => boolean> = [];
40
- let handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult = () => ({ applied: true });
40
+ let handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult | Promise<ApplyWorkspaceEditResult> = () => ({
41
+ applied: true
42
+ });
41
43
  const subscription = client.onRequest(ApplyWorkspaceEditRequest.type, (params: ApplyWorkspaceEditParams) => {
42
44
  const edit: AppliedEdit = { params, uris: editedUris(params.edit), text: insertedText(params.edit) };
43
45
  // Record before the handler runs, so a handler that rejects the edit or
@@ -92,7 +94,7 @@ export function captureAppliedEdits(client: ProtocolConnection, defaultTimeoutMs
92
94
  });
93
95
  },
94
96
 
95
- setApplyEditHandler(next: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult): void {
97
+ setApplyEditHandler(next: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult | Promise<ApplyWorkspaceEditResult>): void {
96
98
  handler = next;
97
99
  },
98
100
 
@@ -281,8 +281,9 @@ export interface LspServerConnection extends Harness {
281
281
  *
282
282
  * Throwing from the handler surfaces to the server as an RPC failure, which
283
283
  * is the other branch (`applyEditToLanguageClient` invalidates and rethrows).
284
+ * A handler returning a promise holds the answer until it settles.
284
285
  */
285
- setApplyEditHandler(handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult): void;
286
+ setApplyEditHandler(handler: (params: ApplyWorkspaceEditParams) => ApplyWorkspaceEditResult | Promise<ApplyWorkspaceEditResult>): void;
286
287
 
287
288
  /**
288
289
  * Release every resource: the diagnostics and applyEdit subscriptions, the