@hydranium/core 1.0.0-next.272 → 1.0.0-next.275

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 (93) hide show
  1. package/lib/documents/ast-document-manager.d.ts +37 -4
  2. package/lib/documents/ast-document-manager.d.ts.map +1 -1
  3. package/lib/documents/ast-document-manager.js +54 -6
  4. package/lib/documents/ast-document-manager.js.map +1 -1
  5. package/lib/documents/dirty-state-tracker.d.ts +15 -0
  6. package/lib/documents/dirty-state-tracker.d.ts.map +1 -1
  7. package/lib/documents/dirty-state-tracker.js +14 -1
  8. package/lib/documents/dirty-state-tracker.js.map +1 -1
  9. package/lib/documents/hydranium-text-documents.d.ts +14 -0
  10. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  11. package/lib/documents/hydranium-text-documents.js +22 -0
  12. package/lib/documents/hydranium-text-documents.js.map +1 -1
  13. package/lib/langium/bootstrap.d.ts +15 -11
  14. package/lib/langium/bootstrap.d.ts.map +1 -1
  15. package/lib/langium/bootstrap.js +14 -8
  16. package/lib/langium/bootstrap.js.map +1 -1
  17. package/lib/langium/integration-services.d.ts +4 -5
  18. package/lib/langium/integration-services.d.ts.map +1 -1
  19. package/lib/langium/integration-services.js.map +1 -1
  20. package/lib/langium/model-service/client-session.d.ts +35 -6
  21. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  22. package/lib/langium/model-service/client-session.js +20 -2
  23. package/lib/langium/model-service/client-session.js.map +1 -1
  24. package/lib/langium/model-service/model-events.d.ts +5 -0
  25. package/lib/langium/model-service/model-events.d.ts.map +1 -1
  26. package/lib/langium/model-service/model-service.d.ts +7 -0
  27. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  28. package/lib/langium/model-service/model-service.js +1 -1
  29. package/lib/langium/model-service/model-service.js.map +1 -1
  30. package/lib/langium/module.d.ts +7 -1
  31. package/lib/langium/module.d.ts.map +1 -1
  32. package/lib/langium/module.js +4 -0
  33. package/lib/langium/module.js.map +1 -1
  34. package/lib/langium/workspace/file-system-provider.d.ts +13 -5
  35. package/lib/langium/workspace/file-system-provider.d.ts.map +1 -1
  36. package/lib/langium/workspace/file-system-provider.js +11 -5
  37. package/lib/langium/workspace/file-system-provider.js.map +1 -1
  38. package/lib/langium/workspace/in-memory-file-system-provider.d.ts +4 -2
  39. package/lib/langium/workspace/in-memory-file-system-provider.d.ts.map +1 -1
  40. package/lib/langium/workspace/in-memory-file-system-provider.js +9 -3
  41. package/lib/langium/workspace/in-memory-file-system-provider.js.map +1 -1
  42. package/lib/langium/workspace/persistent-file-system-provider.d.ts +6 -1
  43. package/lib/langium/workspace/persistent-file-system-provider.d.ts.map +1 -1
  44. package/lib/langium/workspace/persistent-file-system-provider.js +12 -5
  45. package/lib/langium/workspace/persistent-file-system-provider.js.map +1 -1
  46. package/lib/langium/workspace/virtual-document.d.ts +2 -1
  47. package/lib/langium/workspace/virtual-document.d.ts.map +1 -1
  48. package/lib/langium/workspace/virtual-document.js +5 -2
  49. package/lib/langium/workspace/virtual-document.js.map +1 -1
  50. package/lib/langium/workspace/write-lock-scope.d.ts +4 -2
  51. package/lib/langium/workspace/write-lock-scope.d.ts.map +1 -1
  52. package/lib/langium/workspace/write-lock-scope.js +6 -4
  53. package/lib/langium/workspace/write-lock-scope.js.map +1 -1
  54. package/lib/lsp/hydranium-document-update-handler.d.ts +14 -1
  55. package/lib/lsp/hydranium-document-update-handler.d.ts.map +1 -1
  56. package/lib/lsp/hydranium-document-update-handler.js +27 -1
  57. package/lib/lsp/hydranium-document-update-handler.js.map +1 -1
  58. package/lib/messages/index.d.ts +1 -1
  59. package/lib/messages/index.d.ts.map +1 -1
  60. package/lib/messages/index.js +1 -1
  61. package/lib/messages/index.js.map +1 -1
  62. package/lib/node/node-file-system-provider.d.ts +6 -0
  63. package/lib/node/node-file-system-provider.d.ts.map +1 -1
  64. package/lib/node/node-file-system-provider.js +22 -1
  65. package/lib/node/node-file-system-provider.js.map +1 -1
  66. package/lib/testing/stub-ast-document-manager.d.ts +1 -1
  67. package/lib/testing/stub-ast-document-manager.d.ts.map +1 -1
  68. package/lib/testing/stub-ast-document-manager.js +10 -0
  69. package/lib/testing/stub-ast-document-manager.js.map +1 -1
  70. package/lib/testing/stub-hydranium-text-documents.d.ts +1 -1
  71. package/lib/testing/stub-hydranium-text-documents.d.ts.map +1 -1
  72. package/lib/testing/stub-hydranium-text-documents.js +17 -0
  73. package/lib/testing/stub-hydranium-text-documents.js.map +1 -1
  74. package/package.json +5 -5
  75. package/src/documents/ast-document-manager.ts +86 -6
  76. package/src/documents/dirty-state-tracker.ts +29 -2
  77. package/src/documents/hydranium-text-documents.ts +24 -0
  78. package/src/langium/bootstrap.ts +18 -15
  79. package/src/langium/integration-services.ts +4 -5
  80. package/src/langium/model-service/client-session.ts +48 -7
  81. package/src/langium/model-service/model-events.ts +5 -0
  82. package/src/langium/model-service/model-service.ts +8 -1
  83. package/src/langium/module.ts +11 -1
  84. package/src/langium/workspace/file-system-provider.ts +19 -5
  85. package/src/langium/workspace/in-memory-file-system-provider.ts +13 -3
  86. package/src/langium/workspace/persistent-file-system-provider.ts +12 -5
  87. package/src/langium/workspace/virtual-document.ts +6 -2
  88. package/src/langium/workspace/write-lock-scope.ts +6 -4
  89. package/src/lsp/hydranium-document-update-handler.ts +30 -1
  90. package/src/messages/index.ts +1 -1
  91. package/src/node/node-file-system-provider.ts +22 -1
  92. package/src/testing/stub-ast-document-manager.ts +22 -1
  93. package/src/testing/stub-hydranium-text-documents.ts +19 -0
@@ -24,6 +24,7 @@ import { type AstNode, DocumentState, type LangiumDocument, type LangiumDocument
24
24
  import { type LogNameOptions } from '../langium/diagnostics/logger.js';
25
25
  import { type AstDiagnostic } from '../langium/validation/document-validator.js';
26
26
  import { type WritableFileSystemProvider } from '../langium/workspace/file-system-provider.js';
27
+ import { ReentrantWriteLockError, isInsideWriteLock } from '../langium/workspace/write-lock-scope.js';
27
28
  import { Disposable } from 'vscode-languageserver';
28
29
  import { TextDocumentIdentifier, type TextDocumentItem } from 'vscode-languageserver-protocol';
29
30
  import { type TextDocument } from 'vscode-languageserver-textdocument';
@@ -151,6 +152,33 @@ export interface AstDocumentManager<TAst extends AstNode, TDiagnostic extends As
151
152
  * wrote.
152
153
  */
153
154
  save(uri: string, clientId: string): Promise<TextVersion>;
155
+ /**
156
+ * Remove the document's file through `FileSystemTaskQueue`, then the
157
+ * document, in a build that closes every open of it but the editor's.
158
+ * Resolves once that build has run, also when it fails after removing the
159
+ * document; rejects, having changed nothing, when the file cannot be
160
+ * removed.
161
+ *
162
+ * From the call until it settles, {@link isDeleting} answers `true`, which
163
+ * refuses every session's write of the document: one landing before the
164
+ * build would be lost with it, or, as a save, write the file back. Marked
165
+ * before the first await, so checks a caller runs in the same step as the
166
+ * call hold up to the removal. A second delete of the document while one is
167
+ * under way is refused, and so is a delete from inside a holder of the write
168
+ * lock, with `ReentrantWriteLockError`, since its builds take that lock.
169
+ *
170
+ * A change requested before the build starts merges over the deletion, and
171
+ * the document lives on with every open but `clientId`'s, which is closed.
172
+ */
173
+ delete(uri: string, clientId: string): Promise<void>;
174
+ /** Whether a {@link delete} of `uri` is under way. */
175
+ isDeleting(uri: string): boolean;
176
+ /**
177
+ * The client a deletion event for `uri` names: the one whose {@link delete}
178
+ * of it is under way, or the unknown-client id for a file removed outside
179
+ * any, as a file watcher reports one.
180
+ */
181
+ attributeDeletion(uri: string): string;
154
182
 
155
183
  getDocument(uri: string): LangiumDocument | undefined;
156
184
 
@@ -224,10 +252,11 @@ export class DefaultAstDocumentManager<
224
252
  /**
225
253
  * Whose writes the build under way carries, set when it starts: the author
226
254
  * of every open document's undelivered version, and the unknown-client id
227
- * for a change no write explains, such as a deletion, a document no client
228
- * has open, or a write that changed nothing. An undelivered version is one
229
- * whose build has not validated it yet, so a write whose build was
230
- * cancelled stays a cause of the build that takes over.
255
+ * for a change no write explains, such as a deletion no session's delete
256
+ * made, a document no client has open, or a write that changed nothing.
257
+ * An undelivered version is one whose build has not validated it yet, so a
258
+ * write whose build was cancelled stays a cause of the build that takes
259
+ * over.
231
260
  *
232
261
  * Errs toward more than one cause, and so toward the unknown client, which
233
262
  * a reader treats as foreign: an answer it repeats, never one it misses.
@@ -240,6 +269,9 @@ export class DefaultAstDocumentManager<
240
269
  */
241
270
  protected readonly buildCauses = new Set<string>();
242
271
 
272
+ /** Per canonical URI, the client whose {@link delete} of it is under way. */
273
+ protected readonly deletions = new Map<CanonicalUri, string>();
274
+
243
275
  /** Per canonical URI, the newest save queued for it, which {@link coalesceSaves} lets older queued saves defer to. */
244
276
  protected readonly newestSaves = new Map<CanonicalUri, Promise<TextVersion>>();
245
277
  protected readonly coalesceSaves: boolean;
@@ -279,9 +311,10 @@ export class DefaultAstDocumentManager<
279
311
  const textDocument = this.textDocuments.get(uri.toString());
280
312
  return textDocument === undefined || this.isDelivered(textDocument.uri, textDocument.version);
281
313
  });
282
- if (unexplained || deleted.length > 0) {
314
+ if (unexplained) {
283
315
  this.buildCauses.add(UNKNOWN_CLIENT_ID);
284
316
  }
317
+ deleted.forEach(uri => this.buildCauses.add(this.attributeDeletion(uri.toString())));
285
318
  // The only place a deletion is observable: `DocumentBuilder.update`
286
319
  // removes a deleted document before it builds anything, so no phase
287
320
  // listener ever sees it.
@@ -440,7 +473,7 @@ export class DefaultAstDocumentManager<
440
473
  // Text the caller supplies was not read from the file, so no file
441
474
  // is assumed: the document is dirty until its first save, whatever
442
475
  // the file holds.
443
- this.textDocuments.setDiskBaseline(args.uri, undefined);
476
+ this.textDocuments.markCreated(args.uri);
444
477
  }
445
478
  const built = this.getDocument(args.uri);
446
479
  if (creates && built) {
@@ -528,6 +561,53 @@ export class DefaultAstDocumentManager<
528
561
  return saved;
529
562
  }
530
563
 
564
+ async delete(uri: string, clientId: string): Promise<void> {
565
+ const canonical = this.uriPolicy.canonicalUri(uri);
566
+ const target = UriUtils.toUri(canonical);
567
+ // The builds below wait for a holder of the write lock, which would be
568
+ // waiting for this delete.
569
+ if (isInsideWriteLock()) {
570
+ throw new ReentrantWriteLockError(canonical);
571
+ }
572
+ // The mark holds one client: a second delete would share it, and the
573
+ // first to settle would clear it under the other.
574
+ if (this.deletions.has(canonical)) {
575
+ throw new Error(`Cannot delete ${uri}: a delete of it is under way`);
576
+ }
577
+ this.deletions.set(canonical, clientId);
578
+ try {
579
+ // A document no build has reached yet is not registered, and the
580
+ // deletion build would remove nothing and leave every open of it.
581
+ if (!this.langiumDocs.getDocument(target)) {
582
+ await this.documentBuilder.scheduleUpdate([target], []);
583
+ }
584
+ await this.services.workspace.FileSystemTaskQueue.enqueue(canonical, () => this.fileSystemProvider.deleteFile(target));
585
+ // Langium removes the document before the build's first await, so a
586
+ // failure is the dependents' build, and reaches no one else.
587
+ await this.documentBuilder
588
+ .scheduleUpdate([], [target])
589
+ .catch((error: unknown) =>
590
+ this.tracer.with(canonical).error(`The build after a deletion failed. ${describeErrorWithStack(error)}`)
591
+ );
592
+ // A change requested before that build started merges over the
593
+ // deletion, and the document lives on; the deleting client takes it as
594
+ // gone, so its open ends here.
595
+ if (this.textDocuments.isOpenInClient(canonical, clientId)) {
596
+ await this.close({ uri: canonical, clientId });
597
+ }
598
+ } finally {
599
+ this.deletions.delete(canonical);
600
+ }
601
+ }
602
+
603
+ isDeleting(uri: string): boolean {
604
+ return this.deletions.has(this.uriPolicy.canonicalUri(uri));
605
+ }
606
+
607
+ attributeDeletion(uri: string): string {
608
+ return this.deletions.get(this.uriPolicy.canonicalUri(uri)) ?? UNKNOWN_CLIENT_ID;
609
+ }
610
+
531
611
  /** Write `text` to `uri` unless the file already holds it, then announce the save. Runs inside the file's task queue. */
532
612
  protected async writeSave(uri: CanonicalUri, text: string, clientId: string): Promise<void> {
533
613
  if (!(await this.matchesDisk(uri, text))) {
@@ -62,6 +62,18 @@ export interface DirtyStateTracker {
62
62
  * and for a document not tracked. A first open takes the opener's text.
63
63
  */
64
64
  hasDiskBaseline(key: CanonicalUri): boolean;
65
+ /**
66
+ * `key` was created from a session's text, so no file is assumed: no
67
+ * baseline, and {@link isUnsavedCreation} answers `true`. A no-op for a
68
+ * document not tracked.
69
+ */
70
+ markCreated(key: CanonicalUri, document: TextDocument): void;
71
+ /**
72
+ * Whether `key` is marked created and no baseline with text has been set
73
+ * since, by a save or a reload that found the file. A reload that finds no
74
+ * file leaves the mark. `false` for a document not tracked.
75
+ */
76
+ isUnsavedCreation(key: CanonicalUri): boolean;
65
77
  /**
66
78
  * The store released `key`. Answers the clean announcement a document
67
79
  * released dirty now owes; `undefined` for one released clean.
@@ -70,7 +82,7 @@ export interface DirtyStateTracker {
70
82
  }
71
83
 
72
84
  export class DefaultDirtyStateTracker implements DirtyStateTracker {
73
- protected readonly states = new Map<CanonicalUri, { baseline: string | undefined; dirty: boolean }>();
85
+ protected readonly states = new Map<CanonicalUri, { baseline: string | undefined; dirty: boolean; created: boolean }>();
74
86
  /** Per document released dirty, the token of the announcement it owes. */
75
87
  protected readonly owedClean = new Map<CanonicalUri, object>();
76
88
  protected readonly dirtyChangedEmitter = new Emitter<DocumentDirtyChangedEvent>();
@@ -87,7 +99,7 @@ export class DefaultDirtyStateTracker implements DirtyStateTracker {
87
99
  }
88
100
 
89
101
  track(key: CanonicalUri, document: TextDocument, diskText: string | undefined): void {
90
- this.states.set(key, { baseline: diskText, dirty: this.owedClean.delete(key) });
102
+ this.states.set(key, { baseline: diskText, dirty: this.owedClean.delete(key), created: false });
91
103
  this.refreshDirty(key, document);
92
104
  }
93
105
 
@@ -111,6 +123,7 @@ export class DefaultDirtyStateTracker implements DirtyStateTracker {
111
123
  return;
112
124
  }
113
125
  state.baseline = text;
126
+ state.created &&= text === undefined;
114
127
  this.refreshDirty(key, document);
115
128
  }
116
129
 
@@ -118,6 +131,20 @@ export class DefaultDirtyStateTracker implements DirtyStateTracker {
118
131
  return this.states.get(key)?.baseline !== undefined;
119
132
  }
120
133
 
134
+ markCreated(key: CanonicalUri, document: TextDocument): void {
135
+ const state = this.states.get(key);
136
+ if (!state) {
137
+ return;
138
+ }
139
+ state.baseline = undefined;
140
+ state.created = true;
141
+ this.refreshDirty(key, document);
142
+ }
143
+
144
+ isUnsavedCreation(key: CanonicalUri): boolean {
145
+ return this.states.get(key)?.created ?? false;
146
+ }
147
+
121
148
  release(key: CanonicalUri): CleanAnnouncement | undefined {
122
149
  const dirty = this.states.get(key)?.dirty;
123
150
  this.states.delete(key);
@@ -1192,6 +1192,30 @@ export class HydraniumTextDocuments<T extends TextDocument = TextDocument> exten
1192
1192
  }
1193
1193
  }
1194
1194
 
1195
+ /**
1196
+ * Record that `uri` was created from a session's text rather than read from
1197
+ * its file: it has no disk baseline, so it is dirty until its first save,
1198
+ * and {@link isUnsavedCreation} answers `true` until then. A no-op for a
1199
+ * URI the store does not hold.
1200
+ */
1201
+ markCreated(uri: DocumentUri): void {
1202
+ const key = this.documentKey(uri);
1203
+ const document = this.__syncedDocuments.get(key);
1204
+ if (document !== undefined) {
1205
+ this.dirtyStateTracker.markCreated(key, document);
1206
+ }
1207
+ }
1208
+
1209
+ /**
1210
+ * Whether `uri` was created from a session's text and the store has not
1211
+ * seen its file since, by a save or a reload that found it. A baseline
1212
+ * cleared because the file went missing does not count. `false` for a URI
1213
+ * the store does not hold.
1214
+ */
1215
+ isUnsavedCreation(uri: DocumentUri): boolean {
1216
+ return this.dirtyStateTracker.isUnsavedCreation(this.documentKey(uri));
1217
+ }
1218
+
1195
1219
  /**
1196
1220
  * Read the file behind `uri` through its disk queue and take it as the
1197
1221
  * baseline. A file that cannot be read counts as none, the side that
@@ -10,7 +10,7 @@
10
10
  import { type Logger } from '@hydranium/protocol';
11
11
  import { type LangiumSharedCoreServices } from '@hydranium/langium';
12
12
  import { type LangiumServices, type LangiumSharedServices } from '@hydranium/langium/lsp';
13
- import type { ServerSharedServicesMinimal } from './shared-services.js';
13
+ import type { ServerSharedServices } from './module.js';
14
14
  import { collectProducibleTypes } from './language-types.js';
15
15
  import { assertSingleLangiumCopy } from './single-langium-copy.js';
16
16
  import { DefaultAstDocumentManager } from '../documents/ast-document-manager.js';
@@ -327,17 +327,24 @@ export function warnOnUnexpectedBindings(shared: LangiumSharedServices, language
327
327
  * BuildPhasePass that sheds the CST of closed documents. The framework
328
328
  * default strategy is `always-keep`, so the pass runs but sheds nothing
329
329
  * until an adopter configures a shedding strategy.
330
+ * - **`workspace.AstDocumentManager`** — subscribes to the build pipeline
331
+ * and the text store to track opens, deletions and each build's causes.
332
+ * - **`model.ModelService`** — subscribes to the settled document phase to
333
+ * bring the language client up to date. A tree without an LSP `initialize`,
334
+ * such as a headless init, may not resolve it before the first build.
330
335
  *
331
336
  * Consumers with no additional eager targets can omit the third argument
332
337
  * to {@link bootstrapLangium} — the parameter default is this exact set.
333
338
  * Adopters needing extra eager targets spread this constant into their own
334
339
  * array and append their accessors.
335
340
  */
336
- export const DEFAULT_EAGER_SERVICES: ReadonlyArray<(services: ServerSharedServicesMinimal) => unknown> = [
341
+ export const DEFAULT_EAGER_SERVICES: ReadonlyArray<(services: ServerSharedServices) => unknown> = [
337
342
  services => services.workspace.ProjectManager,
338
343
  services => services.workspace.BuildPipelineIntegration,
339
344
  services => services.profilers.LangiumProfiler,
340
- services => services.workspace.CstResidencyService
345
+ services => services.workspace.CstResidencyService,
346
+ services => services.workspace.AstDocumentManager,
347
+ services => services.model.ModelService
341
348
  ];
342
349
 
343
350
  /**
@@ -387,7 +394,7 @@ export const DEFAULT_EAGER_SERVICES: ReadonlyArray<(services: ServerSharedServic
387
394
  * registers every language before validating slots or constructing the
388
395
  * eager set.
389
396
  */
390
- export function bootstrapLangium<TShared extends LangiumSharedServices & ServerSharedServicesMinimal, TLang extends LangiumServices>(
397
+ export function bootstrapLangium<TShared extends LangiumSharedServices & ServerSharedServices, TLang extends LangiumServices>(
391
398
  shared: TShared,
392
399
  language: TLang,
393
400
  eagerlyConstructedServices: ReadonlyArray<(services: TShared) => unknown> = DEFAULT_EAGER_SERVICES
@@ -420,22 +427,18 @@ export function bootstrapLangium<TShared extends LangiumSharedServices & ServerS
420
427
  * 4. {@link assertReflectionCoversLanguages} runs **once**, on the one
421
428
  * shared `AstReflection`.
422
429
  * 5. Eager accessors run **once**, after all registrations. They take
423
- * the shared tier, and the framework's eager set is shared-tier only
424
- * (`ProjectManager`, `BuildPipelineIntegration`, `LangiumProfiler`,
425
- * `CstResidencyService`) — `BuildPipelineIntegration` in particular
426
- * routes per document URI to each language's `IntegrityService` /
427
- * `AstExtensionService`, so it must be constructed knowing all of
428
- * them. Running the set per language would attach its
429
- * `documentBuilder` listeners N times and enforce each phase N times.
430
+ * the shared tier, and the framework's eager set is shared-tier only —
431
+ * `BuildPipelineIntegration` in particular routes per document URI to
432
+ * each language's `IntegrityService` / `AstExtensionService`, so it
433
+ * must be constructed knowing all of them. Running the set per language
434
+ * would attach its `documentBuilder` listeners N times and enforce each
435
+ * phase N times.
430
436
  *
431
437
  * Throws when `languages` is empty: a shared tier with no registered
432
438
  * language never validates its slots, which silently defers a
433
439
  * composition error to the first build.
434
440
  */
435
- export function bootstrapLangiumLanguages<
436
- TShared extends LangiumSharedServices & ServerSharedServicesMinimal,
437
- TLang extends LangiumServices
438
- >(
441
+ export function bootstrapLangiumLanguages<TShared extends LangiumSharedServices & ServerSharedServices, TLang extends LangiumServices>(
439
442
  shared: TShared,
440
443
  languages: ReadonlyArray<TLang>,
441
444
  eagerlyConstructedServices: ReadonlyArray<(services: TShared) => unknown> = DEFAULT_EAGER_SERVICES
@@ -18,8 +18,7 @@ import {
18
18
  } from '@hydranium/langium/lsp';
19
19
  import { DEFAULT_EAGER_SERVICES, bootstrapLangiumLanguages } from './bootstrap.js';
20
20
  import { createServerLanguageModule } from './language-module.js';
21
- import { createServerSharedModule, type ServerModuleContext } from './module.js';
22
- import type { ServerSharedServicesMinimal } from './shared-services.js';
21
+ import { createServerSharedModule, type ServerModuleContext, type ServerSharedServices } from './module.js';
23
22
 
24
23
  /**
25
24
  * Options accepted by {@link createIntegrationServices}. Wraps the
@@ -40,7 +39,7 @@ import type { ServerSharedServicesMinimal } from './shared-services.js';
40
39
  */
41
40
  export interface IntegrationServicesOptions<
42
41
  TContext extends ServerModuleContext,
43
- TShared extends LangiumSharedServices & ServerSharedServicesMinimal,
42
+ TShared extends LangiumSharedServices & ServerSharedServices,
44
43
  TLanguage extends LangiumServices
45
44
  > {
46
45
  /**
@@ -224,7 +223,7 @@ export interface IntegrationServicesOptions<
224
223
  * differs only in its grammar-generated module.
225
224
  */
226
225
  export interface AdditionalLanguageModules<
227
- TShared extends LangiumSharedServices & ServerSharedServicesMinimal,
226
+ TShared extends LangiumSharedServices & ServerSharedServices,
228
227
  TLanguage extends LangiumServices
229
228
  > {
230
229
  /**
@@ -260,7 +259,7 @@ export interface AdditionalLanguageModules<
260
259
  */
261
260
  export function createIntegrationServices<
262
261
  TContext extends ServerModuleContext,
263
- TShared extends LangiumSharedServices & ServerSharedServicesMinimal,
262
+ TShared extends LangiumSharedServices & ServerSharedServices,
264
263
  TLanguage extends LangiumServices
265
264
  >(
266
265
  options: IntegrationServicesOptions<TContext, TShared, TLanguage>
@@ -46,6 +46,9 @@ export interface ClientSessionWriteArgs<TTransfer> {
46
46
  /** What a session's `persist` writes: the text the store holds for `uri`. */
47
47
  export type ClientSessionPersistArgs = Omit<ClientSessionWriteArgs<never>, 'model'>;
48
48
 
49
+ /** What a session's `delete` removes: the document at `uri`, at the version it was based on. */
50
+ export type ClientSessionDeleteArgs = Omit<ClientSessionWriteArgs<never>, 'model'>;
51
+
49
52
  /** What a session's `updateAll` writes, all or none. */
50
53
  export interface ClientSessionUpdateAllArgs<TTransfer> {
51
54
  updates: ClientSessionWriteArgs<TTransfer>[];
@@ -55,11 +58,13 @@ export interface ClientSessionUpdateAllArgs<TTransfer> {
55
58
  * One participant's handle on the documents it works on, started by
56
59
  * `ModelService.createSession`.
57
60
  *
58
- * The session writes only what it has open: `update`, `save` and `persist`
59
- * fail with `DocumentNotOpenError` unless this session has the URI open in the
60
- * synchronous step that applies or takes the text, so a write either lands
61
- * while the document is open or fails. A document stays open until this
62
- * session closes it, the session ends, or the file is deleted.
61
+ * The session writes only what it has open: `update`, `save`, `persist` and
62
+ * `delete` fail with `DocumentNotOpenError` unless this session has the URI
63
+ * open in the synchronous step that applies or takes the text, or marks the
64
+ * document for deletion, so a write either lands while the document is open or
65
+ * fails. They fail the same way while any session's `delete` of the URI is
66
+ * under way. A document stays open until this session closes it, the session
67
+ * ends, or the file is deleted.
63
68
  *
64
69
  * One open per URI, without reference counting: opening a URI the session
65
70
  * already has open changes nothing, and one `close` ends it.
@@ -123,6 +128,22 @@ export interface ClientSession<
123
128
  * session, also when another client wrote the text.
124
129
  */
125
130
  persist(args: ClientSessionPersistArgs): Promise<TextVersion>;
131
+ /**
132
+ * Delete `args.uri` at once: its file, then the document, as a deletion a
133
+ * file watcher reports removes it. Every session's open of it closes, the
134
+ * editor's stays, and the deletion event names this session. Resolves once
135
+ * the build that removes the document has run. `args.baseVersion` is gated
136
+ * as {@link persist} gates it; a file already gone deletes only the
137
+ * document, and one that cannot be removed fails the call, deleting nothing.
138
+ * The file removed is the one the store keys the document by: under
139
+ * `RealpathDocumentUriPolicy`, a symlink's target rather than the link.
140
+ *
141
+ * A change requested before that build starts, by the editor or a file
142
+ * watcher, keeps the document, and only this session's open of it closes.
143
+ * From inside a build the call fails with `ReentrantWriteLockError`,
144
+ * whatever `ModelServiceOptions.allowReentrantBuilds` says.
145
+ */
146
+ delete(args: ClientSessionDeleteArgs): Promise<void>;
126
147
  /** Close this session's open of `uri`, at once. A no-op when it does not have `uri` open. */
127
148
  close(uri: string): Promise<void>;
128
149
  /**
@@ -258,6 +279,11 @@ export class DefaultClientSession<
258
279
  return this.closeDocument(uri);
259
280
  }
260
281
 
282
+ delete(args: ClientSessionDeleteArgs): Promise<void> {
283
+ this.assertLive();
284
+ return this.deleteDocument(args);
285
+ }
286
+
261
287
  withOpen<T>(uri: string, fn: () => MaybePromise<T>): Promise<T> {
262
288
  this.assertLive();
263
289
  return this.runWithOpen(uri, fn);
@@ -289,9 +315,10 @@ export class DefaultClientSession<
289
315
  }
290
316
  }
291
317
 
292
- /** Throw `DocumentNotOpenError` unless this session has `uri` open. */
318
+ /** Throw `DocumentNotOpenError` unless this session has `uri` open and no delete of it is under way. */
293
319
  protected assertOpen(uri: string): void {
294
- if (!this.services.workspace.TextDocuments.isOpenInClient(uri, this.clientId)) {
320
+ const workspace = this.services.workspace;
321
+ if (!workspace.TextDocuments.isOpenInClient(uri, this.clientId) || workspace.AstDocumentManager.isDeleting(uri)) {
295
322
  throw new DocumentNotOpenError(uri, this.clientId);
296
323
  }
297
324
  }
@@ -467,6 +494,20 @@ export class DefaultClientSession<
467
494
  return this.services.workspace.AstDocumentManager.save(uri, this.clientId);
468
495
  }
469
496
 
497
+ /**
498
+ * Delete `args.uri` under this session's id. The open check and the
499
+ * `baseVersion` gate sit in the same synchronous step as the manager's
500
+ * mark, which refuses every later write of the URI: an await between them
501
+ * lets another client's write land after the gate, and the delete then
502
+ * discards text this session never saw.
503
+ */
504
+ protected async deleteDocument(args: ClientSessionDeleteArgs): Promise<void> {
505
+ const uri = this.canonicalKey(args.uri);
506
+ this.assertOpen(uri);
507
+ this.assertBaseVersion(uri, args.baseVersion, this.services.workspace.TextDocuments.version(uri));
508
+ return this.services.workspace.AstDocumentManager.delete(uri, this.clientId);
509
+ }
510
+
470
511
  protected async closeDocument(uri: string): Promise<void> {
471
512
  await this.services.workspace.AstDocumentManager.close({ uri, clientId: this.clientId });
472
513
  }
@@ -60,6 +60,11 @@ export interface ModelsBuiltEvent {
60
60
  */
61
61
  export interface ModelDeletedEvent {
62
62
  readonly uri: CanonicalUri;
63
+ /**
64
+ * The session whose `delete` removed the file; the unknown-client id for a
65
+ * file removed outside any session, as a file watcher reports one.
66
+ */
67
+ readonly sourceClientId: string;
63
68
  }
64
69
 
65
70
  /** A document was saved: the AST manager's event, as `ModelService` hands it out. */
@@ -94,6 +94,9 @@ export interface ModelServiceOptions extends LogNameOptions {
94
94
  * because a document no build will carry is re-queued through the lock, and
95
95
  * the re-queue waits for the holder to end.
96
96
  *
97
+ * A session's delete is refused that way on either value, since its builds
98
+ * always take the lock.
99
+ *
97
100
  * Every other facade build takes the lock on either value. Unlocked, it can
98
101
  * overlap another build of the same document: both run a full validation
99
102
  * pass that Langium appends, and a caller can be handed a version that an
@@ -301,6 +304,10 @@ export interface ModelService<
301
304
  * at `UNRECORDED_VERSION`, whose `root` is `undefined` despite its type.
302
305
  */
303
306
  onModelSaved(listener: (event: ModelSavedEvent<TAst, TDiagnostic>) => void, filter?: ModelEventFilter): Disposable;
307
+ /**
308
+ * Fires for every document a build removes, with the session whose
309
+ * `delete` removed it, as `AstDocumentManager.attributeDeletion` names it.
310
+ */
304
311
  onModelDeleted(listener: (event: ModelDeletedEvent) => void, filter?: ModelEventFilter): Disposable;
305
312
  /** Fires when a document's text starts or stops differing from its file. */
306
313
  onDirtyChanged(listener: (event: ModelDirtyChangedEvent) => void, filter?: ModelEventFilter): Disposable;
@@ -1218,7 +1225,7 @@ export class DefaultModelService<
1218
1225
  for (const deletedUri of deleted) {
1219
1226
  const uri = this.uriPolicy.canonicalUri(deletedUri.toString());
1220
1227
  if (this.matches(target, uri)) {
1221
- listener(Object.freeze({ uri }));
1228
+ listener(Object.freeze({ uri, sourceClientId: this.services.workspace.AstDocumentManager.attributeDeletion(uri) }));
1222
1229
  }
1223
1230
  }
1224
1231
  });
@@ -243,7 +243,9 @@ export interface ServerAddedSharedServices<
243
243
  * there is only one, and `'plaintext'` when there are several, rather
244
244
  * than naming a language the document demonstrably is not. Consumers
245
245
  * that need to narrow the diagnostic type or override lifecycle
246
- * behaviour rebind this slot with a subclass.
246
+ * behaviour rebind this slot with a subclass. Subscribes to the build
247
+ * pipeline and the text store in its constructor, so it is eagerly
248
+ * constructed via `DEFAULT_EAGER_SERVICES`.
247
249
  */
248
250
  AstDocumentManager: AstDocumentManager<AstNode>;
249
251
  /**
@@ -317,6 +319,10 @@ export interface ServerAddedSharedServices<
317
319
  */
318
320
  model: {
319
321
  TransferEncoder: TransferEncoder<TDiagnostic>;
322
+ /**
323
+ * Subscribes to the settled document phase in its constructor, so it is
324
+ * eagerly constructed via `DEFAULT_EAGER_SERVICES`.
325
+ */
320
326
  ModelService: ModelService<AstNode>;
321
327
  /** Builds the handle of every session `ModelService.createSession` starts. */
322
328
  ClientSessionFactory: ClientSessionFactory;
@@ -559,6 +565,8 @@ export function createServerSharedModule(
559
565
  host: writable ? (fromContext as WritableFileSystemProvider) : new DefaultEmptyFileSystemProvider(services)
560
566
  });
561
567
  },
568
+ // Eagerly constructed, so its build-pipeline and text-store listeners
569
+ // attach before the first build.
562
570
  AstDocumentManager: services => new DefaultAstDocumentManager(services),
563
571
  FileSystemTaskQueue: services => new DefaultFileSystemTaskQueue(services),
564
572
  // Eagerly constructed, so its build-phase listeners attach before the
@@ -576,6 +584,8 @@ export function createServerSharedModule(
576
584
  // Generic walker — adopters with a typed `$type → wire shape` overlay
577
585
  // rebind this slot with a subclass.
578
586
  TransferEncoder: services => new DefaultTransferEncoder(services),
587
+ // Eagerly constructed, so its settled-phase listener attaches before
588
+ // the first build.
579
589
  ModelService: services => new DefaultModelService(services),
580
590
  ClientSessionFactory: services => new DefaultClientSessionFactory(services)
581
591
  },
@@ -28,6 +28,12 @@ import { type ServerSharedServicesMinimal } from '../shared-services.js';
28
28
  */
29
29
  export interface WritableFileSystemProvider extends FileSystemProvider {
30
30
  writeFile(uri: URI, content: string): Promise<void>;
31
+ /**
32
+ * Remove the file at `uri`: the name the URI gives, never the file a
33
+ * symlink there points at, leaving a parent directory it empties in place.
34
+ * A no-op when nothing is there; a directory is refused.
35
+ */
36
+ deleteFile(uri: URI): Promise<void>;
31
37
  /**
32
38
  * Last-modified time (ms) of the file at `uri`, or `undefined` if it can't
33
39
  * be determined (missing file, or a provider with no disk — in-memory /
@@ -78,11 +84,11 @@ export interface WritableFileSystemProvider extends FileSystemProvider {
78
84
 
79
85
  /**
80
86
  * Empty-filesystem {@link WritableFileSystemProvider}. For browser / test / CLI
81
- * hosts that don't have a real filesystem. All writes are no-ops. On the
82
- * portable `.` entry it is the {@link FileSystemProviderRegistry.host} when
83
- * `context.fileSystemProvider` gives no writable provider; the Node-backed
84
- * `DefaultFileSystemProvider` lives in `@hydranium/core/node` (it pulls
85
- * `node:fs`).
87
+ * hosts that don't have a real filesystem. All writes and deletes are no-ops.
88
+ * On the portable `.` entry it is the
89
+ * {@link FileSystemProviderRegistry.host} when `context.fileSystemProvider`
90
+ * gives no writable provider; the Node-backed `DefaultFileSystemProvider`
91
+ * lives in `@hydranium/core/node` (it pulls `node:fs`).
86
92
  */
87
93
  export class DefaultEmptyFileSystemProvider extends EmptyFileSystemProvider implements WritableFileSystemProvider {
88
94
  protected readonly tracer: Tracer;
@@ -99,6 +105,10 @@ export class DefaultEmptyFileSystemProvider extends EmptyFileSystemProvider impl
99
105
  // no-op
100
106
  }
101
107
 
108
+ async deleteFile(_uri: URI): Promise<void> {
109
+ // no-op
110
+ }
111
+
102
112
  // `async` so the base's refusal becomes a rejection: it throws
103
113
  // SYNCHRONOUSLY from a method declared to return a promise, and a
104
114
  // synchronous throw escapes the promise chain — `.catch()` never attaches
@@ -200,6 +210,10 @@ export class DefaultFileSystemProviderRegistry<
200
210
  return this.providerFor(uri).writeFile(uri, content);
201
211
  }
202
212
 
213
+ async deleteFile(uri: URI): Promise<void> {
214
+ return this.providerFor(uri).deleteFile(uri);
215
+ }
216
+
203
217
  async mtimeMs(uri: URI): Promise<number | undefined> {
204
218
  return this.providerFor(uri).mtimeMs?.(uri);
205
219
  }
@@ -43,6 +43,12 @@ export const UNSUPPORTED_WRITE = defineMessage(
43
43
  'Cannot write {uri}: the scheme names no location on disk'
44
44
  );
45
45
 
46
+ /** A delete addressed a URI the provider cannot reach on disk, as {@link UNSUPPORTED_WRITE} a write. */
47
+ export const UNSUPPORTED_DELETE = defineMessage(
48
+ 'hydranium/core/unsupported-delete',
49
+ 'Cannot delete {uri}: the scheme names no location on disk'
50
+ );
51
+
46
52
  /**
47
53
  * File content keyed by path, for seeding an {@link InMemoryFileSystemProvider}.
48
54
  *
@@ -136,9 +142,13 @@ export class InMemoryFileSystemProvider implements WritableFileSystemProvider {
136
142
  this.files.set(normalize(uri), content);
137
143
  }
138
144
 
139
- /** Remove the file at `uri`. A no-op when nothing is there. */
140
- deleteFile(uri: URI): void {
141
- this.files.delete(normalize(uri));
145
+ /** Remove the file at `uri`. A no-op when nothing is there; a directory is refused. */
146
+ async deleteFile(uri: URI): Promise<void> {
147
+ const key = normalize(uri);
148
+ if (this.hasChildren(key)) {
149
+ throw new Error(`Cannot delete ${uri.toString()}: it is a directory`);
150
+ }
151
+ this.files.delete(key);
142
152
  }
143
153
 
144
154
  async writeFile(uri: URI, content: string): Promise<void> {
@@ -167,7 +167,7 @@ export class PersistentFileSystemProvider extends InMemoryFileSystemProvider {
167
167
  // insertion order of a merged record.
168
168
  const uri = this.storeUri(path);
169
169
  if (content === TOMBSTONE) {
170
- super.deleteFile(uri);
170
+ this.files.delete(this.fileKey(uri));
171
171
  } else {
172
172
  super.setFile(uri, content);
173
173
  }
@@ -202,9 +202,16 @@ export class PersistentFileSystemProvider extends InMemoryFileSystemProvider {
202
202
  await this.persist(uri, content);
203
203
  }
204
204
 
205
- override deleteFile(uri: URI): void {
206
- super.deleteFile(uri);
207
- this.forget(uri).catch((error: unknown) => this.reportStoreFailure('forget', uri, error));
205
+ /**
206
+ * Mirror the deletion into the store and REJECT if it refuses, as
207
+ * {@link writeFile} does. The store goes first, so one that refuses leaves
208
+ * the file it still holds; a directory is refused before the store is asked.
209
+ */
210
+ override async deleteFile(uri: URI): Promise<void> {
211
+ if (!this.hasChildren(this.fileKey(uri))) {
212
+ await this.forget(uri);
213
+ }
214
+ await super.deleteFile(uri);
208
215
  }
209
216
 
210
217
  /** The absolute URI a store-space `path` addresses. */
@@ -245,7 +252,7 @@ export class PersistentFileSystemProvider extends InMemoryFileSystemProvider {
245
252
  return key;
246
253
  }
247
254
  if (!key.startsWith(this.storeRoot)) {
248
- throw new Error(`Cannot persist ${uri.toString()}: outside the store root ${this.storeRoot}`);
255
+ throw new Error(`${uri.toString()} is outside the store root ${this.storeRoot}`);
249
256
  }
250
257
  return key.slice(this.storeRoot.length);
251
258
  }