@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.
- package/lib/documents/file-system-task-queue.d.ts +3 -0
- package/lib/documents/file-system-task-queue.d.ts.map +1 -1
- package/lib/documents/file-system-task-queue.js +3 -0
- package/lib/documents/file-system-task-queue.js.map +1 -1
- package/lib/documents/hydranium-text-documents.d.ts +5 -1
- package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
- package/lib/documents/hydranium-text-documents.js +12 -5
- package/lib/documents/hydranium-text-documents.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +35 -12
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +63 -25
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/workspace/document-uri-policy.d.ts +9 -5
- package/lib/langium/workspace/document-uri-policy.d.ts.map +1 -1
- package/lib/langium/workspace/document-uri-policy.js +15 -8
- package/lib/langium/workspace/document-uri-policy.js.map +1 -1
- package/lib/langium/workspace/file-system-provider.d.ts +15 -4
- package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
- package/lib/langium/workspace/file-system-provider.js +13 -4
- package/lib/langium/workspace/file-system-provider.js.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.d.ts +41 -21
- package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
- package/lib/lsp/hydranium-document-update-handler.js +86 -33
- package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
- package/lib/node/node-file-system-provider.d.ts +7 -0
- package/lib/node/node-file-system-provider.d.ts.map +1 -1
- package/lib/node/node-file-system-provider.js +36 -1
- package/lib/node/node-file-system-provider.js.map +1 -1
- package/lib/testing/node/applied-edit-capture.d.ts +1 -1
- package/lib/testing/node/applied-edit-capture.d.ts.map +1 -1
- package/lib/testing/node/applied-edit-capture.js +3 -1
- package/lib/testing/node/applied-edit-capture.js.map +1 -1
- package/lib/testing/node/lsp-server-connection.d.ts +2 -1
- package/lib/testing/node/lsp-server-connection.d.ts.map +1 -1
- package/lib/testing/node/lsp-server-connection.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/file-system-task-queue.ts +6 -0
- package/src/documents/hydranium-text-documents.ts +13 -5
- package/src/langium/model-service/model-service.ts +79 -26
- package/src/langium/workspace/document-uri-policy.ts +17 -8
- package/src/langium/workspace/file-system-provider.ts +23 -4
- package/src/lsp/hydranium-document-update-handler.ts +101 -43
- package/src/node/node-file-system-provider.ts +36 -1
- package/src/testing/node/applied-edit-capture.ts +5 -3
- 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
|
|
177
|
-
* the scheme's provider. Where that provider lacks one,
|
|
178
|
-
*
|
|
179
|
-
* `exists`: the answers their
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* and
|
|
72
|
-
*
|
|
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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
* the
|
|
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
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* logged
|
|
274
|
-
*
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 = () => ({
|
|
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
|