@hydranium/core 1.0.0-next.135 → 1.0.0-next.140
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/ast-document-manager.d.ts +96 -9
- package/lib/documents/ast-document-manager.d.ts.map +1 -1
- package/lib/documents/ast-document-manager.js +129 -20
- package/lib/documents/ast-document-manager.js.map +1 -1
- package/lib/langium/document-builder/document-builder.d.ts +65 -12
- package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
- package/lib/langium/document-builder/document-builder.js +106 -24
- package/lib/langium/document-builder/document-builder.js.map +1 -1
- package/lib/langium/model-service/client-session.d.ts +9 -1
- package/lib/langium/model-service/client-session.d.ts.map +1 -1
- package/lib/langium/model-service/client-session.js +19 -3
- package/lib/langium/model-service/client-session.js.map +1 -1
- package/lib/langium/model-service/model-service.d.ts +16 -15
- package/lib/langium/model-service/model-service.d.ts.map +1 -1
- package/lib/langium/model-service/model-service.js +4 -5
- package/lib/langium/model-service/model-service.js.map +1 -1
- package/lib/langium/workspace/write-lock-scope.d.ts +3 -2
- package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
- package/lib/langium/workspace/write-lock-scope.js +3 -2
- package/lib/langium/workspace/write-lock-scope.js.map +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts +1 -1
- package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
- package/lib/testing/stub-ast-document-manager.js +9 -0
- package/lib/testing/stub-ast-document-manager.js.map +1 -1
- package/lib/testing/stub-document-builder.d.ts +7 -6
- package/lib/testing/stub-document-builder.d.ts.map +1 -1
- package/lib/testing/stub-document-builder.js +26 -0
- package/lib/testing/stub-document-builder.js.map +1 -1
- package/package.json +5 -5
- package/src/documents/ast-document-manager.ts +158 -25
- package/src/langium/document-builder/document-builder.ts +115 -23
- package/src/langium/model-service/client-session.ts +29 -4
- package/src/langium/model-service/model-service.ts +18 -17
- package/src/langium/workspace/write-lock-scope.ts +3 -2
- package/src/testing/stub-ast-document-manager.ts +21 -1
- package/src/testing/stub-document-builder.ts +36 -9
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
} from '@hydranium/protocol';
|
|
20
20
|
import {
|
|
21
21
|
type AstNode,
|
|
22
|
-
type DocumentBuilder,
|
|
23
22
|
DocumentState,
|
|
24
23
|
type FileSystemProvider,
|
|
25
24
|
type LangiumDocument,
|
|
@@ -83,7 +82,7 @@ import { TextDocumentIdentifier, type TextDocumentItem } from 'vscode-languagese
|
|
|
83
82
|
import { type TextDocument } from 'vscode-languageserver-textdocument';
|
|
84
83
|
import { URI } from '@hydranium/langium';
|
|
85
84
|
import { type ServerSharedServices } from '../langium/module.js';
|
|
86
|
-
import { labelPhaseListener } from '../langium/document-builder/index.js';
|
|
85
|
+
import { type HydraniumDocumentBuilder, labelPhaseListener } from '../langium/document-builder/index.js';
|
|
87
86
|
import { UNKNOWN_CLIENT_ID } from './client-ids.js';
|
|
88
87
|
import { type HydraniumTextDocuments } from './hydranium-text-documents.js';
|
|
89
88
|
import { type SelfSaveRegistry } from './self-save-registry.js';
|
|
@@ -177,6 +176,17 @@ export type AstDocumentSavedEvent<TAst extends AstNode, TDiagnostic extends AstD
|
|
|
177
176
|
AstDocument<TAst, TDiagnostic>
|
|
178
177
|
>;
|
|
179
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Why an update event fired and whose write it echoes, as
|
|
181
|
+
* {@link AstDocumentManager.attributeUpdate} answers for a document. The
|
|
182
|
+
* fields mean what they mean on `TransferUpdatedEvent`.
|
|
183
|
+
*/
|
|
184
|
+
export interface UpdateAttribution {
|
|
185
|
+
reason: 'changed' | 'rebuilt';
|
|
186
|
+
sourceClientId: string;
|
|
187
|
+
causedBy: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
180
190
|
/** Construction options for {@link AstDocumentManager}. */
|
|
181
191
|
export interface AstDocumentManagerOptions extends LogNameOptions {
|
|
182
192
|
/**
|
|
@@ -227,6 +237,17 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
|
|
|
227
237
|
|
|
228
238
|
/** Client id that authored the document's current version, or `undefined` for a framework-internal build. */
|
|
229
239
|
getAuthor(document: LangiumDocument): string | undefined;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The reason, source and cause of an update event for `document` emitted
|
|
243
|
+
* now, from a phase listener of the build that carries it. Every head takes
|
|
244
|
+
* its update events' attribution from here, so they name the same client
|
|
245
|
+
* for one build. For a document a client opened, while rebuilds validate,
|
|
246
|
+
* the answer changes once its `Validated` listeners have all run: a later
|
|
247
|
+
* event at that version is `'rebuilt'`. `TransferDocumentUpdateReason`
|
|
248
|
+
* names the cases that fall back to the last update.
|
|
249
|
+
*/
|
|
250
|
+
attributeUpdate(document: LangiumDocument): UpdateAttribution;
|
|
230
251
|
}
|
|
231
252
|
|
|
232
253
|
/**
|
|
@@ -253,7 +274,40 @@ export class DefaultAstDocumentManager<
|
|
|
253
274
|
TAst extends AstNode,
|
|
254
275
|
TDiagnostic extends AstDiagnostic = AstDiagnostic
|
|
255
276
|
> implements AstDocumentManager<TAst, TDiagnostic> {
|
|
277
|
+
/** The last `DocumentBuilder.update`'s URIs; the reason for a document the store does not track. */
|
|
256
278
|
protected lastUpdate?: UpdateInfo;
|
|
279
|
+
/**
|
|
280
|
+
* Canonical URIs a client has opened since the file was last deleted,
|
|
281
|
+
* whose versions the store keeps. One no client has opened keeps Langium's
|
|
282
|
+
* version `0` through every change of its content, so its version says
|
|
283
|
+
* nothing. Kept past the last close, since the store continues the version
|
|
284
|
+
* sequence on the next open; a delivery can land after that close.
|
|
285
|
+
*/
|
|
286
|
+
protected readonly trackedUris = new Set<CanonicalUri>();
|
|
287
|
+
/**
|
|
288
|
+
* Per tracked URI, the version whose `Validated` listeners last all ran,
|
|
289
|
+
* which is the version an update event was delivered for. Dropped on
|
|
290
|
+
* deletion, so a file created again with the same text is changed again.
|
|
291
|
+
*/
|
|
292
|
+
protected readonly deliveredVersions = new Map<CanonicalUri, number>();
|
|
293
|
+
/**
|
|
294
|
+
* Whose writes the build under way carries, set when it starts: the author
|
|
295
|
+
* of every open document's undelivered version, and the unknown-client id
|
|
296
|
+
* for a change no write explains, such as a deletion, a document no client
|
|
297
|
+
* has open, or a write that changed nothing. An undelivered version is one
|
|
298
|
+
* whose build has not validated it yet, so a write whose build was
|
|
299
|
+
* cancelled stays a cause of the build that takes over.
|
|
300
|
+
*
|
|
301
|
+
* Errs toward more than one cause, and so toward the unknown client, which
|
|
302
|
+
* a reader treats as foreign: an answer it repeats, never one it misses.
|
|
303
|
+
* A change no write explains counts for its own build only, so a build
|
|
304
|
+
* that takes over from a cancelled one loses it. One field for every build:
|
|
305
|
+
* a build started outside the workspace lock, such as a re-queue of an
|
|
306
|
+
* orphaned document, reads the causes of whichever build started last.
|
|
307
|
+
* An open document no build delivers, one a `shouldValidate` override
|
|
308
|
+
* skips, stays undelivered, so it is a cause of every build while open.
|
|
309
|
+
*/
|
|
310
|
+
protected readonly buildCauses = new Set<string>();
|
|
257
311
|
|
|
258
312
|
/** Per canonical URI, the newest save queued for it, which {@link coalesceSaves} lets older queued saves defer to. */
|
|
259
313
|
protected readonly newestSaves = new Map<CanonicalUri, Promise<void>>();
|
|
@@ -262,7 +316,7 @@ export class DefaultAstDocumentManager<
|
|
|
262
316
|
protected readonly textDocuments: HydraniumTextDocuments<TextDocument>;
|
|
263
317
|
protected readonly fileSystemProvider: WritableFileSystemProvider;
|
|
264
318
|
protected readonly langiumDocs: LangiumDocuments;
|
|
265
|
-
protected readonly documentBuilder:
|
|
319
|
+
protected readonly documentBuilder: HydraniumDocumentBuilder;
|
|
266
320
|
protected readonly uriPolicy: DocumentUriPolicy;
|
|
267
321
|
protected readonly tracer: Tracer;
|
|
268
322
|
|
|
@@ -277,19 +331,40 @@ export class DefaultAstDocumentManager<
|
|
|
277
331
|
this.uriPolicy = services.workspace.DocumentUriPolicy;
|
|
278
332
|
this.tracer = services.Tracer.for(options.logName ?? 'AstDocumentManager').trace('instantiated');
|
|
279
333
|
this.coalesceSaves = options.coalesceSaves ?? false;
|
|
280
|
-
this.textDocuments.onDidOpen(event =>
|
|
281
|
-
this.
|
|
282
|
-
|
|
334
|
+
this.textDocuments.onDidOpen(event => {
|
|
335
|
+
this.trackedUris.add(this.uriPolicy.canonicalUri(event.document.uri));
|
|
336
|
+
return this.open({ clientId: event.clientId, uri: event.document.uri, languageId: event.document.languageId });
|
|
337
|
+
});
|
|
283
338
|
this.textDocuments.onDidClose(event => this.close({ clientId: event.clientId, uri: event.document.uri }));
|
|
284
339
|
this.documentBuilder.onUpdate((changed, deleted) => {
|
|
285
340
|
this.lastUpdate = { changed, deleted };
|
|
341
|
+
this.buildCauses.clear();
|
|
342
|
+
for (const textDocument of this.textDocuments.all()) {
|
|
343
|
+
if (!this.isDelivered(textDocument.uri, textDocument.version)) {
|
|
344
|
+
this.buildCauses.add(this.textDocuments.getAuthor(textDocument.uri, textDocument.version) ?? UNKNOWN_CLIENT_ID);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const unexplained = changed.some(uri => {
|
|
348
|
+
const textDocument = this.textDocuments.get(uri.toString());
|
|
349
|
+
return textDocument === undefined || this.isDelivered(textDocument.uri, textDocument.version);
|
|
350
|
+
});
|
|
351
|
+
if (unexplained || deleted.length > 0) {
|
|
352
|
+
this.buildCauses.add(UNKNOWN_CLIENT_ID);
|
|
353
|
+
}
|
|
286
354
|
// The only place a deletion is observable: `DocumentBuilder.update`
|
|
287
355
|
// removes a deleted document before it builds anything, so no phase
|
|
288
356
|
// listener ever sees it.
|
|
289
357
|
for (const uri of deleted) {
|
|
358
|
+
const key = this.uriPolicy.canonicalUri(uri.toString());
|
|
359
|
+
this.trackedUris.delete(key);
|
|
360
|
+
this.deliveredVersions.delete(key);
|
|
290
361
|
this.textDocuments.notifyDocumentDeleted(uri.toString());
|
|
291
362
|
}
|
|
292
363
|
});
|
|
364
|
+
// Once every listener ran, not in a listener of its own: listeners after
|
|
365
|
+
// it could still be skipped by a cancel, and the next build would then
|
|
366
|
+
// report as rebuilt a version they never delivered.
|
|
367
|
+
this.documentBuilder.onDocumentPhaseDelivered(DocumentState.Validated, (document, version) => this.markDelivered(document, version));
|
|
293
368
|
// Content transitions for CLOSED documents bypass the text store: the
|
|
294
369
|
// last-close revert and watched-file changes rebuild from disk into a
|
|
295
370
|
// factory-fresh text document carrying the factory's own version. At the
|
|
@@ -381,21 +456,16 @@ export class DefaultAstDocumentManager<
|
|
|
381
456
|
if (this.uriPolicy.canonicalUri(document.uri) !== target) {
|
|
382
457
|
return;
|
|
383
458
|
}
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
459
|
+
// No `'deleted'` reason: `DocumentBuilder.update` drops a deleted
|
|
460
|
+
// document from `LangiumDocuments` before deriving the rebuild set
|
|
461
|
+
// from it, so a deleted URI is never built and never reaches this
|
|
462
|
+
// phase listener. A subscriber needing deletions has to be told on
|
|
463
|
+
// a channel that does not require a built document.
|
|
388
464
|
const event: TransferUpdatedEvent<AstDocument<TAst, TDiagnostic>> = {
|
|
389
465
|
document: this.toAstDocument(document),
|
|
390
|
-
|
|
391
|
-
// No `'deleted'` arm: `DocumentBuilder.update` drops a deleted
|
|
392
|
-
// document from `LangiumDocuments` before deriving the rebuild set
|
|
393
|
-
// from it, so a deleted URI is never built and never reaches this
|
|
394
|
-
// phase listener. A subscriber needing deletions has to be told on
|
|
395
|
-
// a channel that does not require a built document.
|
|
396
|
-
reason: this.lastUpdate?.changed.some(changed => UriUtils.equals(changed, document.uri)) ? 'changed' : 'rebuilt'
|
|
466
|
+
...this.attributeUpdate(document)
|
|
397
467
|
};
|
|
398
|
-
this.tracer.with(uri).trace(`emitUpdate start: source=${sourceClientId}, reason=${event.reason}`);
|
|
468
|
+
this.tracer.with(uri).trace(`emitUpdate start: source=${event.sourceClientId}, reason=${event.reason}, cause=${event.causedBy}`);
|
|
399
469
|
listener(event);
|
|
400
470
|
this.tracer.with(uri).trace('emitUpdate listener returned');
|
|
401
471
|
};
|
|
@@ -404,18 +474,81 @@ export class DefaultAstDocumentManager<
|
|
|
404
474
|
|
|
405
475
|
/**
|
|
406
476
|
* The client that authored `document`'s current version, or `undefined` when
|
|
407
|
-
* no client wrote it
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
* `resolveSourceClientId`).
|
|
477
|
+
* no client wrote it: a document no client has open. A rebuild that keeps
|
|
478
|
+
* the version keeps its author, so this does not say who caused a build;
|
|
479
|
+
* {@link attributeUpdate} does.
|
|
480
|
+
* Honest about absence so a routing consumer tests `undefined` directly
|
|
481
|
+
* rather than against a sentinel; the presentation default
|
|
482
|
+
* ({@link UNKNOWN_CLIENT_ID}) is applied where an event needs a value.
|
|
414
483
|
*/
|
|
415
484
|
getAuthor(document: LangiumDocument): string | undefined {
|
|
416
485
|
return this.textDocuments.getAuthor(document.textDocument.uri, document.textDocument.version);
|
|
417
486
|
}
|
|
418
487
|
|
|
488
|
+
/**
|
|
489
|
+
* `'changed'` for the first delivery of the document's version, crediting
|
|
490
|
+
* its author; `'rebuilt'` for a later one, crediting nobody, since it echoes
|
|
491
|
+
* no write, and naming the build's single cause if it has one.
|
|
492
|
+
*
|
|
493
|
+
* A document the rule does not apply to (see {@link isTracked}) falls back
|
|
494
|
+
* to the last update: it is `'changed'` when its URI was passed to
|
|
495
|
+
* `DocumentBuilder.update`. An open document that a validating build skips,
|
|
496
|
+
* through a `shouldValidate` override, is never delivered, so each of its
|
|
497
|
+
* events is its author's change.
|
|
498
|
+
*/
|
|
499
|
+
attributeUpdate(document: LangiumDocument): UpdateAttribution {
|
|
500
|
+
const key = this.uriPolicy.canonicalUri(document.uri.toString());
|
|
501
|
+
const changed = this.isTracked(key)
|
|
502
|
+
? this.deliveredVersions.get(key) !== document.textDocument.version
|
|
503
|
+
: this.lastUpdate?.changed.some(uri => UriUtils.equals(uri, document.uri)) === true;
|
|
504
|
+
if (changed) {
|
|
505
|
+
// Presentation boundary: the event field is a concrete `string`, so an
|
|
506
|
+
// unauthored document surfaces the readable default rather than
|
|
507
|
+
// `undefined` to subscribers and logs.
|
|
508
|
+
const author = this.getAuthor(document) ?? UNKNOWN_CLIENT_ID;
|
|
509
|
+
return { reason: 'changed', sourceClientId: author, causedBy: author };
|
|
510
|
+
}
|
|
511
|
+
const [cause] = this.buildCauses;
|
|
512
|
+
return { reason: 'rebuilt', sourceClientId: UNKNOWN_CLIENT_ID, causedBy: this.buildCauses.size === 1 ? cause : UNKNOWN_CLIENT_ID };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Whether the version rule applies to `uri`: the store keeps its version,
|
|
517
|
+
* and rebuilds validate. With `updateBuildOptions.validation` off no version
|
|
518
|
+
* is ever delivered, and the rule would report every event of an open
|
|
519
|
+
* document as its author's change.
|
|
520
|
+
*/
|
|
521
|
+
protected isTracked(uri: CanonicalUri): boolean {
|
|
522
|
+
return this.keepsVersion(uri) && Boolean(this.documentBuilder.updateBuildOptions.validation);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Whether the store keeps `uri`'s version: a client opened it since its
|
|
527
|
+
* file was last deleted, or has it open still, as an editor keeps a
|
|
528
|
+
* deleted file's buffer.
|
|
529
|
+
*/
|
|
530
|
+
protected keepsVersion(uri: CanonicalUri): boolean {
|
|
531
|
+
return this.trackedUris.has(uri) || this.textDocuments.isOpen(uri);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Record that `version` of `document` was delivered, when the store tracks
|
|
536
|
+
* it. The version the listeners saw, not the document's current one: a
|
|
537
|
+
* write during them has moved that on, and marking it would report the
|
|
538
|
+
* write's own build as rebuilt.
|
|
539
|
+
*/
|
|
540
|
+
protected markDelivered(document: LangiumDocument, version: number): void {
|
|
541
|
+
const key = this.uriPolicy.canonicalUri(document.uri.toString());
|
|
542
|
+
if (this.keepsVersion(key)) {
|
|
543
|
+
this.deliveredVersions.set(key, version);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Whether `version` of `uri` was delivered. */
|
|
548
|
+
protected isDelivered(uri: string, version: number): boolean {
|
|
549
|
+
return this.deliveredVersions.get(this.uriPolicy.canonicalUri(uri)) === version;
|
|
550
|
+
}
|
|
551
|
+
|
|
419
552
|
async open(args: OpenModelArgs): Promise<Disposable> {
|
|
420
553
|
// `open()` is the RPC-level "ensure this document is loaded" call. It is issued
|
|
421
554
|
// by a session opening the document, including by every additional client
|
|
@@ -15,16 +15,18 @@ import {
|
|
|
15
15
|
type DocumentPhaseListener,
|
|
16
16
|
DocumentState,
|
|
17
17
|
type LangiumDocument,
|
|
18
|
+
MultiMap,
|
|
18
19
|
OperationCancelled,
|
|
19
20
|
type URI,
|
|
20
21
|
UriUtils,
|
|
22
|
+
type WorkspaceLock,
|
|
21
23
|
interruptAndCheck,
|
|
22
24
|
isOperationCancelled
|
|
23
25
|
} from '@hydranium/langium';
|
|
24
26
|
// `Diagnostic` as a VALUE: `renderDiagnostics` needs its `getMessageString`
|
|
25
27
|
// namespace helper to read the `string | MarkupContent` union without
|
|
26
28
|
// restating it.
|
|
27
|
-
import { CancellationToken, Diagnostic } from 'vscode-languageserver-protocol';
|
|
29
|
+
import { CancellationToken, Diagnostic, Disposable } from 'vscode-languageserver-protocol';
|
|
28
30
|
import { type LogNameOptions } from '../diagnostics/logger.js';
|
|
29
31
|
import type { MessageRenderer } from '../../messages/renderer.js';
|
|
30
32
|
import { CST_REHYDRATION_RESET_STATE, isCstShed } from '../residency/cst-residency-service.js';
|
|
@@ -127,7 +129,8 @@ export interface DocumentBuilderOptions extends LogNameOptions {
|
|
|
127
129
|
* Extends Langium's {@link DefaultDocumentBuilder} with:
|
|
128
130
|
*
|
|
129
131
|
* - **Bug-fixes** (always on): an {@link awaitDocumentState} that waits where
|
|
130
|
-
* the default rejects,
|
|
132
|
+
* the default rejects (re-queuing, under the workspace lock, a document no
|
|
133
|
+
* build will carry), a {@link prepareBuild} that keeps a cancelled
|
|
131
134
|
* non-validating build from suppressing validation, and a
|
|
132
135
|
* {@link shouldRelink} that never judges a document unaffected on an index
|
|
133
136
|
* that does not describe it.
|
|
@@ -173,6 +176,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
173
176
|
protected readonly uriPolicy: DocumentUriPolicy;
|
|
174
177
|
protected readonly clock: Clock;
|
|
175
178
|
protected readonly messageRenderer: MessageRenderer;
|
|
179
|
+
/** The lock a re-queued build takes; see {@link requeueOrphaned}. */
|
|
180
|
+
protected readonly workspaceLock: WorkspaceLock;
|
|
176
181
|
/** Narrower handle on the same registry as the inherited `serviceRegistry`, for {@link ExtendedServiceRegistry.registrations}. */
|
|
177
182
|
protected readonly languageRegistry: ExtendedServiceRegistry;
|
|
178
183
|
protected languageFileExtensions: string[] = [];
|
|
@@ -199,6 +204,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
199
204
|
* that carries it is already gone by the time its successor is opened.
|
|
200
205
|
*/
|
|
201
206
|
protected lastCancelledTraceId?: number;
|
|
207
|
+
/** Registered through {@link onDocumentPhaseDelivered}, by phase. */
|
|
208
|
+
protected readonly documentPhaseDeliveredListeners = new MultiMap<DocumentState, (document: LangiumDocument, version: number) => void>();
|
|
202
209
|
|
|
203
210
|
constructor(services: ServerSharedServicesMinimal, options: DocumentBuilderOptions = {}) {
|
|
204
211
|
super(services);
|
|
@@ -206,6 +213,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
206
213
|
this.uriPolicy = services.workspace.DocumentUriPolicy;
|
|
207
214
|
this.clock = services.Clock;
|
|
208
215
|
this.messageRenderer = services.MessageRenderer;
|
|
216
|
+
this.workspaceLock = services.workspace.WorkspaceLock;
|
|
209
217
|
this.tracer = services.Tracer.for(options.logName ?? 'DocumentBuilder').trace('instantiated');
|
|
210
218
|
this.logLevel = options.logLevel ?? 'debug';
|
|
211
219
|
this.loggedPhases = options.loggedPhases ?? DEFAULT_LOGGED_PHASES;
|
|
@@ -238,6 +246,24 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
238
246
|
this.pendingUpdateReason = reason;
|
|
239
247
|
}
|
|
240
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Call `listener` with a document once every {@link onDocumentPhase}
|
|
251
|
+
* listener of `state` ran for it and none was skipped by cancellation, and
|
|
252
|
+
* with the text version the phase listeners were called at. A write during
|
|
253
|
+
* them moves the document's own version on, past what they saw.
|
|
254
|
+
*
|
|
255
|
+
* Only here is it known what the phase listeners delivered. Langium sets the
|
|
256
|
+
* document's state before it notifies, and a cancel between two listeners
|
|
257
|
+
* skips the rest, leaving the document at the phase with those listeners
|
|
258
|
+
* never run and nothing to run them later. A build-phase listener comes too
|
|
259
|
+
* late: a cancel after this document but before the batch ends skips it,
|
|
260
|
+
* although every listener of this document ran.
|
|
261
|
+
*/
|
|
262
|
+
onDocumentPhaseDelivered(state: DocumentState, listener: (document: LangiumDocument, version: number) => void): Disposable {
|
|
263
|
+
this.documentPhaseDeliveredListeners.add(state, listener);
|
|
264
|
+
return Disposable.create(() => this.documentPhaseDeliveredListeners.delete(state, listener));
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
/**
|
|
242
268
|
* Diagnostic snapshot for use in timeout/error messages around document
|
|
243
269
|
* state.
|
|
@@ -292,13 +318,24 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
292
318
|
* {@link MAX_STALLED_REQUEUES} builds that fail to advance the document, so a
|
|
293
319
|
* document the builder will never carry to `state` degrades to a pending wait
|
|
294
320
|
* plus a warning rather than an endless build loop.
|
|
321
|
+
*
|
|
322
|
+
* A wait for `Validated` on a document that a validating build skips
|
|
323
|
+
* resolves once that build has indexed its references, without diagnostics:
|
|
324
|
+
* see {@link skipsValidation}. Waiting on would never end, and re-queuing
|
|
325
|
+
* would skip it again.
|
|
326
|
+
*
|
|
327
|
+
* A re-queued build takes the {@link WorkspaceLock} (see
|
|
328
|
+
* {@link requeueOrphaned}), so a caller that awaits this wait while it holds
|
|
329
|
+
* the lock, inside a read or write action, deadlocks on a document that needs
|
|
330
|
+
* a re-queue: the build waits for that action to end. Await it outside the
|
|
331
|
+
* lock.
|
|
295
332
|
*/
|
|
296
333
|
protected override awaitDocumentState(state: DocumentState, uri: URI, cancelToken: CancellationToken): Promise<URI> {
|
|
297
334
|
const document = this.langiumDocuments.getDocument(uri);
|
|
298
335
|
if (!document) {
|
|
299
336
|
return super.awaitDocumentState(state, uri, cancelToken);
|
|
300
337
|
}
|
|
301
|
-
if (document.state >= state) {
|
|
338
|
+
if (document.state >= state || (state === DocumentState.Validated && this.skipsValidation(document))) {
|
|
302
339
|
return Promise.resolve(uri);
|
|
303
340
|
}
|
|
304
341
|
return new Promise<URI>((resolve, reject) => {
|
|
@@ -338,8 +375,22 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
338
375
|
}
|
|
339
376
|
}, 'awaitDocumentState')
|
|
340
377
|
);
|
|
378
|
+
// The phase after which a validating build either validates the
|
|
379
|
+
// document or has skipped it.
|
|
380
|
+
const skipDisposable =
|
|
381
|
+
state === DocumentState.Validated
|
|
382
|
+
? this.onDocumentPhase(
|
|
383
|
+
DocumentState.IndexedReferences,
|
|
384
|
+
labelPhaseListener((doc: LangiumDocument): void => {
|
|
385
|
+
if (UriUtils.equals(doc.uri, uri) && this.skipsValidation(doc)) {
|
|
386
|
+
cleanup();
|
|
387
|
+
resolve(doc.uri);
|
|
388
|
+
}
|
|
389
|
+
}, 'awaitDocumentState.skipsValidation')
|
|
390
|
+
)
|
|
391
|
+
: Disposable.create(() => undefined);
|
|
341
392
|
const buildDisposable = this.onBuildPhase(DocumentState.Validated, () => {
|
|
342
|
-
if (document.state >= state) {
|
|
393
|
+
if (document.state >= state || (state === DocumentState.Validated && this.skipsValidation(document))) {
|
|
343
394
|
cleanup();
|
|
344
395
|
resolve(uri);
|
|
345
396
|
} else {
|
|
@@ -353,6 +404,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
353
404
|
});
|
|
354
405
|
const cleanup = (): void => {
|
|
355
406
|
phaseDisposable.dispose();
|
|
407
|
+
skipDisposable.dispose();
|
|
356
408
|
buildDisposable.dispose();
|
|
357
409
|
cancelDisposable.dispose();
|
|
358
410
|
};
|
|
@@ -366,6 +418,23 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
366
418
|
});
|
|
367
419
|
}
|
|
368
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Whether the build that carries or last carried `document` has indexed its
|
|
423
|
+
* references, asks for validation, and still skips it, which is an override
|
|
424
|
+
* of `shouldValidate` excluding it. A build that does not ask for
|
|
425
|
+
* validation, such as the workspace's initial one, is not a skip: the next
|
|
426
|
+
* validating build validates the document. Nor is a document below
|
|
427
|
+
* `IndexedReferences`: the build writes its options before the first phase,
|
|
428
|
+
* so the document would pass for skipped before it is even parsed.
|
|
429
|
+
*/
|
|
430
|
+
protected skipsValidation(document: LangiumDocument): boolean {
|
|
431
|
+
return (
|
|
432
|
+
document.state >= DocumentState.IndexedReferences &&
|
|
433
|
+
Boolean(this.getBuildOptions(document).validation) &&
|
|
434
|
+
!this.shouldValidate(document)
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
369
438
|
/**
|
|
370
439
|
* Whether no build will advance `document` to `state`, so a wait on it can
|
|
371
440
|
* only be resolved by starting one.
|
|
@@ -395,14 +464,33 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
395
464
|
* notification, so awaiting here would invert the dependency. A rejection is
|
|
396
465
|
* logged rather than swallowed — it leaves the waiter pending until its own
|
|
397
466
|
* cancellation token fires, which is worth a line in the log.
|
|
467
|
+
*
|
|
468
|
+
* The build is an ordinary {@link WorkspaceLock} write, entered from a read.
|
|
469
|
+
* The read waits until no write runs or is queued, so the build neither runs
|
|
470
|
+
* beside a locked one nor cancels the build a re-queue is fired from. Unlocked,
|
|
471
|
+
* it runs beside a locked build that cannot cancel it: both validate the same
|
|
472
|
+
* version and deliver it twice. Taking the write directly, without the read,
|
|
473
|
+
* cancels the running build and skips the rest of its build-phase listeners.
|
|
474
|
+
* The read must not await the write, which queues behind that read.
|
|
475
|
+
*
|
|
476
|
+
* A write that ran while the read waited may have carried the document to
|
|
477
|
+
* `state` already, and a second build would deliver that version again; the
|
|
478
|
+
* read skips the write then.
|
|
398
479
|
*/
|
|
399
480
|
protected requeueOrphaned(document: LangiumDocument, state: DocumentState, reason: string): void {
|
|
400
481
|
const tracer = this.tracer.withUri(document.uri.toString());
|
|
401
482
|
tracer.info(`Re-queuing orphaned document (at '${DocumentState[document.state]}', needs '${DocumentState[state]}'): ${reason}`);
|
|
402
|
-
this.
|
|
403
|
-
if (
|
|
404
|
-
|
|
483
|
+
void this.workspaceLock.read(() => {
|
|
484
|
+
if (document.state >= state) {
|
|
485
|
+
return;
|
|
405
486
|
}
|
|
487
|
+
this.workspaceLock
|
|
488
|
+
.write(token => this.update([document.uri], [], token))
|
|
489
|
+
.catch((err: unknown) => {
|
|
490
|
+
if (!isOperationCancelled(err)) {
|
|
491
|
+
tracer.error(`Re-queue build failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
406
494
|
});
|
|
407
495
|
}
|
|
408
496
|
|
|
@@ -727,19 +815,20 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
727
815
|
* readable by the time Langium's `update` consults `shouldRelink`.
|
|
728
816
|
*
|
|
729
817
|
* Teardown is preemption-correct, which is the reason this is framework code
|
|
730
|
-
* rather than a recipe.
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
818
|
+
* rather than a recipe. A build outside the workspace lock overlaps another:
|
|
819
|
+
* the later one installs itself as {@link activeSession} while the earlier
|
|
820
|
+
* one is still running, and the earlier one's `finally` runs LAST. Clearing
|
|
821
|
+
* unconditionally there would discard the later build's state mid-build.
|
|
822
|
+
* Only the session that is still current clears — and the check is
|
|
823
|
+
* reference equality on the session object,
|
|
736
824
|
* not on {@link BuildSession.traceId}, which is `undefined` for every build
|
|
737
825
|
* whenever the timing level is suppressed and would compare equal to itself
|
|
738
826
|
* across two different builds.
|
|
739
827
|
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
828
|
+
* Builds outside the lock are not hypothetical even without an adopter: the
|
|
829
|
+
* model service with `serializeBuilds` off starts one. Two locked builds
|
|
830
|
+
* never overlap, since the lock starts a write only once the write it
|
|
831
|
+
* cancelled has unwound.
|
|
743
832
|
*/
|
|
744
833
|
protected runInSession(context: BuildSessionContext, label: string, body: () => Promise<void>): Promise<void> {
|
|
745
834
|
// Read before installing the new session: the id being superseded belongs
|
|
@@ -904,7 +993,7 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
904
993
|
}
|
|
905
994
|
|
|
906
995
|
// ============================================================
|
|
907
|
-
//
|
|
996
|
+
// notifyDocumentPhase — the delivery hook and the slow-listener breakdown
|
|
908
997
|
// ============================================================
|
|
909
998
|
|
|
910
999
|
override async notifyDocumentPhase(document: LangiumDocument, state: DocumentState, cancelToken: CancellationToken): Promise<void> {
|
|
@@ -922,13 +1011,8 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
922
1011
|
this.dedupeDiagnostics(document);
|
|
923
1012
|
this.renderDiagnostics(document);
|
|
924
1013
|
}
|
|
925
|
-
if (this.logLevel === 'off') {
|
|
926
|
-
return super.notifyDocumentPhase(document, state, cancelToken);
|
|
927
|
-
}
|
|
928
1014
|
const listeners = this.documentPhaseListeners.get(state).slice();
|
|
929
|
-
|
|
930
|
-
return;
|
|
931
|
-
}
|
|
1015
|
+
const version = document.textDocument.version;
|
|
932
1016
|
const perListenerMs: number[] = [];
|
|
933
1017
|
let cancelledListeners = 0;
|
|
934
1018
|
const { elapsedMs: totalMs } = await this.clock.measure(async () => {
|
|
@@ -947,6 +1031,14 @@ export class HydraniumDocumentBuilder extends DefaultDocumentBuilder {
|
|
|
947
1031
|
perListenerMs.push(elapsedMs);
|
|
948
1032
|
}
|
|
949
1033
|
});
|
|
1034
|
+
if (cancelledListeners === 0) {
|
|
1035
|
+
for (const delivered of this.documentPhaseDeliveredListeners.get(state).slice()) {
|
|
1036
|
+
delivered(document, version);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if (this.logLevel === 'off' || listeners.length === 0) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
950
1042
|
if (cancelledListeners > 0) {
|
|
951
1043
|
this.tracer
|
|
952
1044
|
.withUri(document.uri.toString())
|
|
@@ -18,12 +18,13 @@ import {
|
|
|
18
18
|
type Tracer,
|
|
19
19
|
type TransferElement
|
|
20
20
|
} from '@hydranium/protocol';
|
|
21
|
-
import { type AstNode, UriUtils } from '@hydranium/langium';
|
|
21
|
+
import { type AstNode, type DocumentBuilder, DocumentState, UriUtils } from '@hydranium/langium';
|
|
22
22
|
import { type CancellationToken, type Disposable } from 'vscode-languageserver';
|
|
23
23
|
import { type AstDocument } from '../../documents/ast-document-manager.js';
|
|
24
24
|
import { DocumentNotOpenError, SessionClosedError } from '../../documents/client-session-errors.js';
|
|
25
25
|
import { type OpenOptions, type SessionEndCause } from '../../documents/client-session-registry.js';
|
|
26
26
|
import { type LogNameOptions } from '../diagnostics/logger.js';
|
|
27
|
+
import { IntegrityService } from '../integrity/integrity-service.js';
|
|
27
28
|
import { type ServerSharedServices } from '../module.js';
|
|
28
29
|
import { type AstDiagnostic } from '../validation/document-validator.js';
|
|
29
30
|
import { type ModelService } from './model-service.js';
|
|
@@ -90,11 +91,19 @@ export interface ClientSession<
|
|
|
90
91
|
* URI at most one succeeds.
|
|
91
92
|
*/
|
|
92
93
|
create(uri: string, text: string): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Write `args.model` into a document this session has open. Resolves to the
|
|
96
|
+
* rebuilt document once it is validated, in whichever build carried the
|
|
97
|
+
* write, so the answer holds every diagnostic its update event does: that
|
|
98
|
+
* event names this session, and a client dropping its own echo would
|
|
99
|
+
* otherwise never see them.
|
|
100
|
+
*/
|
|
93
101
|
update(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>>;
|
|
94
102
|
/**
|
|
95
103
|
* Write several documents this session has open, all or none: a
|
|
96
104
|
* `ConflictError` or `DocumentNotOpenError` for any of them is thrown before
|
|
97
|
-
* any text applies. Resolves to the rebuilt documents, in the order given
|
|
105
|
+
* any text applies. Resolves to the rebuilt documents, in the order given,
|
|
106
|
+
* each validated as {@link update}'s is.
|
|
98
107
|
*/
|
|
99
108
|
updateAll(args: ClientSessionUpdateAllArgs<TTransfer>, cancelToken?: CancellationToken): Promise<AstDocument<TAst, TDiagnostic>[]>;
|
|
100
109
|
/** Write `args.model` as {@link update} does, then persist the document. */
|
|
@@ -120,6 +129,21 @@ export interface ClientSession<
|
|
|
120
129
|
dispose(cause?: SessionEndCause): void;
|
|
121
130
|
}
|
|
122
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The phase a session write waits for before it answers: `Validated`, or
|
|
134
|
+
* {@link IntegrityService.SettledState} when rebuilds do not validate, since
|
|
135
|
+
* no build then reaches `Validated` and the wait would never end. Read from
|
|
136
|
+
* `updateBuildOptions`, which every rebuild takes; a document a
|
|
137
|
+
* `shouldValidate` override skips is the builder's to resolve.
|
|
138
|
+
*
|
|
139
|
+
* Waiting for the write's own build is not enough: a later write cancels it,
|
|
140
|
+
* and the build that takes over validates the document after the answer was
|
|
141
|
+
* taken.
|
|
142
|
+
*/
|
|
143
|
+
function answerState(builder: DocumentBuilder): DocumentState {
|
|
144
|
+
return builder.updateBuildOptions.validation ? DocumentState.Validated : IntegrityService.SettledState;
|
|
145
|
+
}
|
|
146
|
+
|
|
123
147
|
/**
|
|
124
148
|
* The framework's {@link ClientSession}, built by
|
|
125
149
|
* {@link DefaultClientSessionFactory}.
|
|
@@ -308,7 +332,7 @@ export class DefaultClientSession<
|
|
|
308
332
|
// An override that awaits before calling the base can outlast the build
|
|
309
333
|
// this write already has, and then builds it twice; see
|
|
310
334
|
// `ModelService.rebuild`.
|
|
311
|
-
const doc = await run('rebuild', () => service.rebuild(uri,
|
|
335
|
+
const doc = await run('rebuild', () => service.rebuild(uri, answerState(this.services.workspace.DocumentBuilder), cancelToken));
|
|
312
336
|
const finalVersion = textDocuments.version(uri);
|
|
313
337
|
if (finalVersion > appliedVersion) {
|
|
314
338
|
this.tracer.debug(`Update to v${appliedVersion} ready at v${finalVersion} (changed again before it settled)`);
|
|
@@ -363,7 +387,8 @@ export class DefaultClientSession<
|
|
|
363
387
|
check();
|
|
364
388
|
const applied = uris.map((uri, i) => this.services.workspace.AstDocumentManager.update(uri, texts[i], this.clientId));
|
|
365
389
|
await Promise.all(applied);
|
|
366
|
-
|
|
390
|
+
const state = answerState(this.services.workspace.DocumentBuilder);
|
|
391
|
+
return Promise.all(uris.map(uri => service.rebuild(uri, state, cancelToken)));
|
|
367
392
|
}
|
|
368
393
|
|
|
369
394
|
/**
|