@hydranium/core 1.0.0-next.375 → 1.0.0-next.378

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/lib/documents/client-session-errors.d.ts +1 -1
  2. package/lib/documents/client-session-errors.d.ts.map +1 -1
  3. package/lib/documents/client-session-errors.js +1 -1
  4. package/lib/documents/client-session-errors.js.map +1 -1
  5. package/lib/langium/integrity/integrity-rule.d.ts +5 -0
  6. package/lib/langium/integrity/integrity-rule.d.ts.map +1 -1
  7. package/lib/langium/integrity/integrity-rule.js.map +1 -1
  8. package/lib/langium/integrity/integrity-service.d.ts +9 -0
  9. package/lib/langium/integrity/integrity-service.d.ts.map +1 -1
  10. package/lib/langium/integrity/integrity-service.js +16 -2
  11. package/lib/langium/integrity/integrity-service.js.map +1 -1
  12. package/lib/langium/model-service/client-session.d.ts +39 -17
  13. package/lib/langium/model-service/client-session.d.ts.map +1 -1
  14. package/lib/langium/model-service/client-session.js +39 -15
  15. package/lib/langium/model-service/client-session.js.map +1 -1
  16. package/lib/langium/model-service/model-service.d.ts +2 -1
  17. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  18. package/lib/langium/model-service/model-service.js +4 -3
  19. package/lib/langium/model-service/model-service.js.map +1 -1
  20. package/lib/langium/trivia/comment-preserver.d.ts.map +1 -1
  21. package/lib/langium/trivia/comment-preserver.js +2 -1
  22. package/lib/langium/trivia/comment-preserver.js.map +1 -1
  23. package/lib/langium/workspace/index.d.ts +1 -0
  24. package/lib/langium/workspace/index.d.ts.map +1 -1
  25. package/lib/langium/workspace/index.js +1 -0
  26. package/lib/langium/workspace/index.js.map +1 -1
  27. package/lib/langium/workspace/syntax-errors.d.ts +23 -0
  28. package/lib/langium/workspace/syntax-errors.d.ts.map +1 -0
  29. package/lib/langium/workspace/syntax-errors.js +29 -0
  30. package/lib/langium/workspace/syntax-errors.js.map +1 -0
  31. package/package.json +5 -5
  32. package/src/documents/client-session-errors.ts +2 -0
  33. package/src/langium/integrity/integrity-rule.ts +5 -0
  34. package/src/langium/integrity/integrity-service.ts +16 -2
  35. package/src/langium/model-service/client-session.ts +66 -23
  36. package/src/langium/model-service/model-service.ts +6 -8
  37. package/src/langium/trivia/comment-preserver.ts +2 -1
  38. package/src/langium/workspace/index.ts +1 -0
  39. package/src/langium/workspace/syntax-errors.ts +34 -0
@@ -23,11 +23,12 @@ import {
23
23
  import { type AstNode, isOperationCancelled, UriUtils } from '@hydranium/langium';
24
24
  import { type CancellationToken, type Disposable, Emitter, type Event } from 'vscode-languageserver';
25
25
  import { type SavedAstDocument, type WrittenAstDocument } from '../../documents/ast-document-manager.js';
26
- import { DocumentNotOpenError, SessionClosedError } from '../../documents/client-session-errors.js';
26
+ import { DocumentNotOpenError, DocumentSyntaxError, SessionClosedError } from '../../documents/client-session-errors.js';
27
27
  import { type OpenOptions, type SessionEndCause } from '../../documents/client-session-registry.js';
28
28
  import { type LogNameOptions } from '../diagnostics/logger.js';
29
29
  import { type ServerSharedServices } from '../module.js';
30
30
  import { type AstDiagnostic } from '../validation/document-validator.js';
31
+ import { hasSkippedText } from '../workspace/syntax-errors.js';
31
32
  import { type ModelService } from './model-service.js';
32
33
 
33
34
  /** What a session's `update` and `save` write into one document; the session supplies its own client id. */
@@ -41,13 +42,19 @@ export interface ClientSessionWriteArgs<TTransfer> {
41
42
  * makes rather than a field it forgot.
42
43
  */
43
44
  baseVersion: BaseVersion;
45
+ /**
46
+ * Set when the write restores one the session made before a reconnect. Its
47
+ * model was written over text that parsed, so it is not refused when the
48
+ * text no longer does.
49
+ */
50
+ restore?: boolean;
44
51
  }
45
52
 
46
53
  /** What a session's `persist` writes: the text the store holds for `uri`. */
47
- export type ClientSessionPersistArgs = Omit<ClientSessionWriteArgs<never>, 'model'>;
54
+ export type ClientSessionPersistArgs = Omit<ClientSessionWriteArgs<never>, 'model' | 'restore'>;
48
55
 
49
56
  /** 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'>;
57
+ export type ClientSessionDeleteArgs = Omit<ClientSessionWriteArgs<never>, 'model' | 'restore'>;
51
58
 
52
59
  /** What a session's `updateAll` writes, all or none. */
53
60
  export interface ClientSessionUpdateAllArgs<TTransfer> {
@@ -117,20 +124,29 @@ export interface ClientSession<
117
124
  * write, so the answer holds every diagnostic its update event does: that
118
125
  * event names this session, and a client dropping its own echo would
119
126
  * otherwise never see them. `written` names the version the text took.
127
+ * A model, not text, written to a document whose last build lacks some of
128
+ * its text, see `hasSkippedText`, fails with `DocumentSyntaxError`, unless
129
+ * the write is a `restore`.
120
130
  */
121
131
  update(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<WrittenAstDocument<TAst, TDiagnostic>>;
122
132
  /**
123
133
  * Write several documents this session has open, all or none: a
124
- * `ConflictError` or `DocumentNotOpenError` for any of them is thrown before
125
- * any text applies. Resolves to the rebuilt documents, in the order given,
126
- * each validated as {@link update}'s is.
134
+ * `ConflictError`, `DocumentNotOpenError` or `DocumentSyntaxError` for any
135
+ * of them is thrown before any text applies. Resolves to the rebuilt
136
+ * documents, in the order given, each validated as {@link update}'s is.
127
137
  */
128
138
  updateAll(
129
139
  args: ClientSessionUpdateAllArgs<TTransfer>,
130
140
  cancelToken?: CancellationToken
131
141
  ): Promise<WrittenAstDocument<TAst, TDiagnostic>[]>;
132
- /** Write `args.model` as {@link update} does, then persist the document. */
133
- save(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<SavedAstDocument<TAst, TDiagnostic>>;
142
+ /**
143
+ * Write `args.model` as {@link update} does, then persist the document. A
144
+ * save is never a `restore`, so its model is always checked.
145
+ */
146
+ save(
147
+ args: Omit<ClientSessionWriteArgs<TTransfer>, 'restore'>,
148
+ cancelToken?: CancellationToken
149
+ ): Promise<SavedAstDocument<TAst, TDiagnostic>>;
134
150
  /**
135
151
  * Persist the text the store holds for `args.uri` as it is, with no update
136
152
  * and no serialisation. Resolves with the version of the text written, once
@@ -277,7 +293,10 @@ export class DefaultClientSession<
277
293
  return this.updateDocuments(args, cancelToken);
278
294
  }
279
295
 
280
- save(args: ClientSessionWriteArgs<TTransfer>, cancelToken?: CancellationToken): Promise<SavedAstDocument<TAst, TDiagnostic>> {
296
+ save(
297
+ args: Omit<ClientSessionWriteArgs<TTransfer>, 'restore'>,
298
+ cancelToken?: CancellationToken
299
+ ): Promise<SavedAstDocument<TAst, TDiagnostic>> {
281
300
  this.assertLive();
282
301
  return this.saveDocument(args, cancelToken);
283
302
  }
@@ -353,16 +372,29 @@ export class DefaultClientSession<
353
372
  }
354
373
  }
355
374
 
375
+ /**
376
+ * Throw `DocumentSyntaxError` when the parse of `uri`'s last build lacks
377
+ * some of its text, see `hasSkippedText`. A write of a model calls it beside
378
+ * {@link assertBaseVersion}: the model is serialized over the whole text,
379
+ * and one read from such a document lacks what the parser skipped.
380
+ */
381
+ protected assertParses(uri: string): void {
382
+ const document = this.modelService.getDocument(uri);
383
+ if (document && hasSkippedText(document)) {
384
+ throw new DocumentSyntaxError(uri);
385
+ }
386
+ }
387
+
356
388
  /**
357
389
  * Serialise `args.model`, apply it to the store under this session's id,
358
390
  * rebuild, and resolve to the rebuilt document.
359
391
  *
360
- * The open check and the `baseVersion` gate run at the door, so a write that
361
- * cannot land is refused before any adopter serialiser runs, and again in
362
- * the synchronous step that applies the text: made only before the
363
- * serialiser's await, a close during it lets the write land on a document
364
- * this session no longer has open, and two writes based on one version
365
- * both apply.
392
+ * The open check, the `baseVersion` gate and, for a model, the parse check
393
+ * run at the door, so a write that cannot land is refused before any
394
+ * adopter serialiser runs, and again in the synchronous step that applies
395
+ * the text: made only before the serialiser's await, a close during it
396
+ * lets the write land on a document this session no longer has open, and
397
+ * two writes based on one version both apply.
366
398
  *
367
399
  * Resolves with the latest build of the document, which may already carry
368
400
  * a newer write; that is logged at debug, and a slow update at warn when
@@ -381,13 +413,18 @@ export class DefaultClientSession<
381
413
  const profile = Logger.isLevelEnabled('debug') ? this.tracer.profile(`model-update ${uri}`) : undefined;
382
414
  const run = async <T>(stage: string, fn: () => MaybePromise<T>): Promise<T> => (profile ? profile.scope(stage, fn) : fn());
383
415
  const textDocuments = this.services.workspace.TextDocuments;
384
- this.assertOpen(uri);
385
- this.assertBaseVersion(uri, args.baseVersion, textDocuments.version(uri));
416
+ const check = (): void => {
417
+ this.assertOpen(uri);
418
+ this.assertBaseVersion(uri, args.baseVersion, textDocuments.version(uri));
419
+ if (typeof args.model !== 'string' && !args.restore) {
420
+ this.assertParses(uri);
421
+ }
422
+ };
423
+ check();
386
424
  service.assertCanBuild(uri);
387
425
  const text = await run('serialize', () => service.modelToText(uri, args.model, cancelToken));
388
426
  const appliedVersion = await run('apply', () => {
389
- this.assertOpen(uri);
390
- this.assertBaseVersion(uri, args.baseVersion, textDocuments.version(uri));
427
+ check();
391
428
  return this.services.workspace.AstDocumentManager.update(uri, text, this.clientId);
392
429
  });
393
430
  // Through the public `rebuild`, so an override of it stays in the path.
@@ -414,9 +451,10 @@ export class DefaultClientSession<
414
451
 
415
452
  /**
416
453
  * Write every document of `args.updates`, all or none: every model is
417
- * serialised first, then every open check and `baseVersion` gate runs and every
418
- * text is applied in one synchronous step. Checked at the door as well, so
419
- * a stale set is refused before any adopter serialiser runs.
454
+ * serialised first, then every open check, `baseVersion` gate and parse
455
+ * check runs and every text is applied in one synchronous step. Checked at
456
+ * the door as well, so a stale set is refused before any adopter serialiser
457
+ * runs.
420
458
  *
421
459
  * The apply step relies on `AstDocumentManager.update` applying its text
422
460
  * before its first await, as the default does: an override that awaits first
@@ -437,6 +475,9 @@ export class DefaultClientSession<
437
475
  updates.forEach((update, i) => {
438
476
  this.assertOpen(uris[i]);
439
477
  this.assertBaseVersion(uris[i], update.baseVersion, textDocuments.version(uris[i]));
478
+ if (typeof update.model !== 'string' && !update.restore) {
479
+ this.assertParses(uris[i]);
480
+ }
440
481
  service.assertCanBuild(uris[i]);
441
482
  });
442
483
  check();
@@ -479,12 +520,14 @@ export class DefaultClientSession<
479
520
  * Fails with `DocumentNotOpenError` and writes nothing when this session
480
521
  * closes the URI while the text is being built. Persisted at `'any'`: the
481
522
  * update already gated on `args.baseVersion` and moved the text past it.
523
+ * A `restore` in `args` is dropped: a save puts the model on disk, so
524
+ * it is always checked.
482
525
  */
483
526
  protected async saveDocument(
484
527
  args: ClientSessionWriteArgs<TTransfer>,
485
528
  cancelToken?: CancellationToken
486
529
  ): Promise<SavedAstDocument<TAst, TDiagnostic>> {
487
- const doc = await this.updateDocument(args, cancelToken);
530
+ const doc = await this.updateDocument({ ...args, restore: undefined }, cancelToken);
488
531
  const version = await this.persistDocument({ uri: args.uri, baseVersion: 'any' });
489
532
  return { ...doc, persisted: { version } };
490
533
  }
@@ -32,6 +32,7 @@ import {
32
32
  } from '@hydranium/langium';
33
33
  import { type AstDiagnostic } from '../validation/document-validator.js';
34
34
  import { type DocumentUriPolicy } from '../workspace/document-uri-policy.js';
35
+ import { hasSyntaxErrors } from '../workspace/syntax-errors.js';
35
36
  import { ReentrantWriteLockError, isInsideWriteLock, isWriteLockScopeInstalled } from '../workspace/write-lock-scope.js';
36
37
  import { type ApplyWorkspaceEditResult, CancellationToken, Disposable } from 'vscode-languageserver';
37
38
  import { AstDocument } from '../../documents/ast-document-manager.js';
@@ -123,7 +124,8 @@ export interface ModelServiceOptions extends LogNameOptions {
123
124
  * Costs a parse, an encode and a serialization per write whose text
124
125
  * differs; remembering the text last written instead would not cover a
125
126
  * file formatted outside the framework. Text with lexer or parser errors is
126
- * replaced as usual.
127
+ * replaced as usual by {@link ModelService.modelToText}, though a session
128
+ * refuses to write a model there.
127
129
  *
128
130
  * Accepts a {@link MaybeObservableValue} so it can be bound to a setting.
129
131
  */
@@ -1106,18 +1108,14 @@ export class DefaultModelService<
1106
1108
  // equal and apply the text from before the mutation. Parsed into a
1107
1109
  // document at `target`, so the encoder's hooks see the URI the written
1108
1110
  // side was encoded with.
1109
- const {
1110
- value: root,
1111
- lexerErrors,
1112
- parserErrors
1113
- } = this.services.workspace.LangiumDocumentFactory.fromString(parsedText, target).parseResult;
1114
- if (lexerErrors.length > 0 || parserErrors.length > 0) {
1111
+ const parsed = this.services.workspace.LangiumDocumentFactory.fromString(parsedText, target).parseResult;
1112
+ if (hasSyntaxErrors(parsed)) {
1115
1113
  return text;
1116
1114
  }
1117
1115
  // Through `serialize`, so an override of it applies to both sides. Not
1118
1116
  // rewritten: a rewrite that changes the document's model then compares
1119
1117
  // unequal and is written, rather than hidden behind the kept text.
1120
- const transfer = this.services.model.TransferEncoder.toTransfer(root, 'grammar') as TTransfer;
1118
+ const transfer = this.services.model.TransferEncoder.toTransfer(parsed.value, 'grammar') as TTransfer;
1121
1119
  const current = withTrivia(await this.serialize(uri, transfer));
1122
1120
  return current === text ? parsedText : text;
1123
1121
  }
@@ -22,6 +22,7 @@ import {
22
22
  } from '@hydranium/langium';
23
23
  import { type LogNameOptions } from '../diagnostics/logger.js';
24
24
  import { type HydraniumLanguageServices } from '../language-module.js';
25
+ import { hasSyntaxErrors } from '../workspace/syntax-errors.js';
25
26
  import { type TriviaContribution, type TriviaRegistry } from './trivia-contribution.js';
26
27
  import { type TriviaPreserver } from './trivia-preserver.js';
27
28
 
@@ -768,7 +769,7 @@ export class CommentPreserver implements TriviaPreserver<CommentTrivia> {
768
769
  } catch {
769
770
  document = undefined;
770
771
  }
771
- if (document !== undefined && (document.parseResult.parserErrors.length > 0 || document.parseResult.lexerErrors.length > 0)) {
772
+ if (document !== undefined && hasSyntaxErrors(document.parseResult)) {
772
773
  document = undefined;
773
774
  }
774
775
  if (memo !== undefined) {
@@ -13,6 +13,7 @@ export * from './file-system-provider.js';
13
13
  export * from './in-memory-file-system-provider.js';
14
14
  export * from './persistent-file-system-provider.js';
15
15
  export * from './self-save-registry.js';
16
+ export * from './syntax-errors.js';
16
17
  export * from './index-manager.js';
17
18
  export * from './initialize-workspace.js';
18
19
  export * from './langium-documents.js';
@@ -0,0 +1,34 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 EclipseSource and others.
3
+ *
4
+ * This program and the accompanying materials are made available under the
5
+ * terms of the MIT License which is available in the project root.
6
+ *
7
+ * SPDX-License-Identifier: MIT
8
+ ********************************************************************************/
9
+
10
+ import { type LangiumDocument, type ParseResult } from '@hydranium/langium';
11
+
12
+ /**
13
+ * Whether `parseResult` has a lexer error, a parser error or an error a token
14
+ * builder reported. The AST of such a parse lacks what the parser skipped, so
15
+ * text serialized from it deletes that. A client reads the same from
16
+ * diagnostics with `TransferDiagnostic.hasSyntaxErrors`, which can miss some.
17
+ */
18
+ export function hasSyntaxErrors(parseResult: ParseResult): boolean {
19
+ const lexing = parseResult.lexerReport?.diagnostics ?? [];
20
+ return (
21
+ parseResult.parserErrors.length > 0 ||
22
+ parseResult.lexerErrors.length > 0 ||
23
+ lexing.some(diagnostic => (diagnostic.severity ?? 'error') === 'error')
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Whether the parse of `document` lacks some of its text: it has syntax
29
+ * errors and the text is not blank. A model serialized from its AST deletes
30
+ * that text, so a write of one is refused. Blank text has nothing to lose.
31
+ */
32
+ export function hasSkippedText(document: LangiumDocument): boolean {
33
+ return hasSyntaxErrors(document.parseResult) && document.textDocument.getText().trim() !== '';
34
+ }