@hydranium/core 1.0.0-next.75 → 1.0.0-next.77

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.
@@ -19,7 +19,7 @@ export * from './carriers.js';
19
19
  export * from './renderer.js';
20
20
  export { MODEL_UPDATE_EDIT } from '../langium/model-service/model-service.js';
21
21
  export { SEPARATOR_IN_NAME } from '../langium/naming/name-separator-validation.js';
22
- export { LEXING_ERROR, UNRESOLVED_REFERENCE } from '../langium/validation/document-validator.js';
22
+ export { INVALID_DEDENT, UNEXPECTED_CHARACTER, MISSING_ITERATION, NO_VIABLE_ALTERNATIVE, TRAILING_INPUT, UNEXPECTED_TOKEN, UNPOPPABLE_LEXER_MODE, UNRESOLVED_REFERENCE } from '../langium/validation/document-validator.js';
23
23
  export { NO_LOADABLE_CONTENT } from '../langium/workspace/langium-documents.js';
24
24
  export { NO_SUCH_FILE, NO_SUCH_PATH } from '../langium/workspace/in-memory-file-system-provider.js';
25
25
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF;;;;;;;;GAQG;AAEH,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,MAAM,2CAA2C,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,gDAAgD,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,6CAA6C,CAAC;AACjG,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAChF,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,wDAAwD,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/messages/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF;;;;;;;;GAQG;AAEH,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAE9B,OAAO,EAAE,iBAAiB,EAAE,MAAM,2CAA2C,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,gDAAgD,CAAC;AACnF,OAAO,EACJ,cAAc,EACd,oBAAoB,EACpB,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,EACd,gBAAgB,EAChB,qBAAqB,EACrB,oBAAoB,EACtB,MAAM,6CAA6C,CAAC;AACrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,2CAA2C,CAAC;AAChF,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,wDAAwD,CAAC"}
@@ -0,0 +1,61 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, 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
+ /** Options for {@link heapCeilingArgs}. */
10
+ export interface HeapCeilingOptions {
11
+ /**
12
+ * Ceiling in MiB to pass when no cgroup limit is in force. Name the value a
13
+ * memory-heavy run on a workstation needs; it is ignored inside a container.
14
+ */
15
+ readonly desktopDefaultMb: number;
16
+ /**
17
+ * Caller-supplied override, typically an environment variable, and typically
18
+ * `undefined`. A finite value at or above {@link minMb} wins everywhere,
19
+ * container or not — an operator who states a number has decided. An explicit
20
+ * `0` means "let Node decide" and emits no flag. Anything unparseable is
21
+ * REPORTED through {@link warn} rather than ignored: `8G` is a natural thing
22
+ * to type into a variable measured in MiB, and silently dropping it leaves
23
+ * the operator believing a ceiling is in force.
24
+ */
25
+ readonly envValue?: string;
26
+ /**
27
+ * Smallest override to pass on. Below it, {@link warn} is called and NO flag
28
+ * is emitted: a value that small can only be a misconfiguration — GiB typed
29
+ * where MiB was meant — and V8 fatals at startup once it is small enough.
30
+ * Deliberately not clamped UP: running with a different number than the one
31
+ * configured hides the mistake instead of surfacing it. Omitted means no floor.
32
+ */
33
+ readonly minMb?: number;
34
+ /** Where a rejected override is reported. Defaults to `console.warn`. */
35
+ readonly warn?: (message: string) => void;
36
+ /** Cgroup limit in bytes; defaults to this process's. Injectable for tests. */
37
+ readonly constrained?: number;
38
+ /** Host physical memory in bytes; defaults to `os.totalmem()`. Injectable for tests. */
39
+ readonly total?: number;
40
+ }
41
+ /**
42
+ * Whether a cgroup memory limit is in force — i.e. whether the OOM killer is
43
+ * watching a ceiling lower than (or equal to) the machine's own.
44
+ *
45
+ * **`constrained > 0` is not the test, and that is the whole reason this is a
46
+ * named function.** With no limit set, cgroup v2 reports 2^64 and v1 reports
47
+ * ~2^63 rather than 0, so the naive test reads every desktop as a container.
48
+ * Neither sentinel can equal `os.totalmem()`, so comparing against the host
49
+ * total costs nothing and rules both out. The comparison is `<=` rather than
50
+ * `<` so a limit set to exactly the host's RAM still counts as a limit.
51
+ */
52
+ export declare function isMemoryConstrained(constrained?: number, total?: number): boolean;
53
+ /**
54
+ * The `execArgv` entries that set a child's heap ceiling: `[]` to let Node size
55
+ * the heap itself, or a single `--max-old-space-size=<mb>`.
56
+ *
57
+ * Returns an array rather than a string so a caller can spread it
58
+ * unconditionally into an argv it is building.
59
+ */
60
+ export declare function heapCeilingArgs(options: HeapCeilingOptions): string[];
61
+ //# sourceMappingURL=heap-ceiling.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heap-ceiling.d.ts","sourceRoot":"","sources":["../../src/node/heap-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAwBlF,2CAA2C;AAC3C,MAAM,WAAW,kBAAkB;IAChC;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC;;;;;;;;OAQG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,+EAA+E;IAC/E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,wFAAwF;IACxF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,SAAqC,EAAE,KAAK,SAAgB,GAAG,OAAO,CAEpH;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,EAAE,CAqBrE"}
@@ -0,0 +1,72 @@
1
+ /********************************************************************************
2
+ * Copyright (c) 2026 CrossBreeze, 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
+ * Choosing `--max-old-space-size` for a child process, which is a decision no
11
+ * caller gets right by picking a number.
12
+ *
13
+ * A fixed ceiling is sized for the machine the author had. Pass 8192 to a
14
+ * process under a 2 GiB cgroup limit and V8 lets old space grow past the limit
15
+ * without ever collecting hard, so the kernel OOM-kills the whole container
16
+ * before V8 has any reason to act — the parent sees a signal, not a heap error,
17
+ * and everything sharing the cgroup dies with it. Pass nothing on a large
18
+ * desktop and V8 sizes from physical RAM, which for a memory-heavy tool is well
19
+ * under what it needs.
20
+ *
21
+ * Both are the same mistake: a ceiling stated without reference to the limit
22
+ * that is actually in force. The rule here is to name the desktop default
23
+ * explicitly and step aside inside a container, where Node derives its own
24
+ * ceiling from the cgroup limit and a runaway heap therefore fails INSIDE the
25
+ * child — attributable, and reported as a heap error against one process rather
26
+ * than as a kill against every process in the cgroup.
27
+ */
28
+ import * as os from 'node:os';
29
+ /**
30
+ * Whether a cgroup memory limit is in force — i.e. whether the OOM killer is
31
+ * watching a ceiling lower than (or equal to) the machine's own.
32
+ *
33
+ * **`constrained > 0` is not the test, and that is the whole reason this is a
34
+ * named function.** With no limit set, cgroup v2 reports 2^64 and v1 reports
35
+ * ~2^63 rather than 0, so the naive test reads every desktop as a container.
36
+ * Neither sentinel can equal `os.totalmem()`, so comparing against the host
37
+ * total costs nothing and rules both out. The comparison is `<=` rather than
38
+ * `<` so a limit set to exactly the host's RAM still counts as a limit.
39
+ */
40
+ export function isMemoryConstrained(constrained = process.constrainedMemory?.() ?? 0, total = os.totalmem()) {
41
+ return constrained > 0 && constrained <= total;
42
+ }
43
+ /**
44
+ * The `execArgv` entries that set a child's heap ceiling: `[]` to let Node size
45
+ * the heap itself, or a single `--max-old-space-size=<mb>`.
46
+ *
47
+ * Returns an array rather than a string so a caller can spread it
48
+ * unconditionally into an argv it is building.
49
+ */
50
+ export function heapCeilingArgs(options) {
51
+ const { desktopDefaultMb, envValue, minMb, warn = (message) => console.warn(message) } = options;
52
+ if (envValue !== undefined) {
53
+ const mb = Number(envValue);
54
+ // An explicit 0 is a decision — "let Node decide" — so it passes quietly.
55
+ // Anything else that is not a positive number is a typo, and the operator
56
+ // who typed it is the one person who cannot tell it was dropped.
57
+ if (!Number.isFinite(mb) || mb < 0) {
58
+ warn(`Ignoring the heap ceiling '${envValue}': expected a whole number of MiB. Node will size the heap instead.`);
59
+ return [];
60
+ }
61
+ if (mb === 0) {
62
+ return [];
63
+ }
64
+ if (minMb !== undefined && mb < minMb) {
65
+ warn(`Ignoring a heap ceiling of ${envValue} MiB: below the ${minMb} MiB minimum. Node will size the heap instead.`);
66
+ return [];
67
+ }
68
+ return [`--max-old-space-size=${Math.floor(mb)}`];
69
+ }
70
+ return isMemoryConstrained(options.constrained, options.total) ? [] : [`--max-old-space-size=${desktopDefaultMb}`];
71
+ }
72
+ //# sourceMappingURL=heap-ceiling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heap-ceiling.js","sourceRoot":"","sources":["../../src/node/heap-ceiling.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAmC9B;;;;;;;;;;GAUG;AACH,MAAM,UAAU,mBAAmB,CAAC,WAAW,GAAG,OAAO,CAAC,iBAAiB,EAAE,EAAE,IAAI,CAAC,EAAE,KAAK,GAAG,EAAE,CAAC,QAAQ,EAAE;IACxG,OAAO,WAAW,GAAG,CAAC,IAAI,WAAW,IAAI,KAAK,CAAC;AAClD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,OAA2B;IACxD,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,KAAK,EAAE,IAAI,GAAG,CAAC,OAAe,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,GAAG,OAAO,CAAC;IACzG,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC5B,0EAA0E;QAC1E,0EAA0E;QAC1E,iEAAiE;QACjE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC;YAClC,IAAI,CAAC,8BAA8B,QAAQ,qEAAqE,CAAC,CAAC;YAClH,OAAO,EAAE,CAAC;QACb,CAAC;QACD,IAAI,EAAE,KAAK,CAAC,EAAE,CAAC;YACZ,OAAO,EAAE,CAAC;QACb,CAAC;QACD,IAAI,KAAK,KAAK,SAAS,IAAI,EAAE,GAAG,KAAK,EAAE,CAAC;YACrC,IAAI,CAAC,8BAA8B,QAAQ,mBAAmB,KAAK,gDAAgD,CAAC,CAAC;YACrH,OAAO,EAAE,CAAC;QACb,CAAC;QACD,OAAO,CAAC,wBAAwB,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IACrD,CAAC;IACD,OAAO,mBAAmB,CAAC,OAAO,CAAC,WAAW,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,wBAAwB,gBAAgB,EAAE,CAAC,CAAC;AACtH,CAAC"}
@@ -13,6 +13,7 @@ export * from './log-preamble-node.js';
13
13
  export * from './server-diagnostics.js';
14
14
  export * from './ast-ground-truth.js';
15
15
  export * from './event-loop-monitor.js';
16
+ export * from './heap-ceiling.js';
16
17
  export * from './latency-from-env.js';
17
18
  export * from './measure-memory.js';
18
19
  export * from './memory-monitor.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAgBlF,cAAc,gCAAgC,CAAC;AAC/C,cAAc,oBAAoB,CAAC;AACnC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AAQxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAgBlF,cAAc,gCAAgC,CAAC;AAC/C,cAAc,oBAAoB,CAAC;AACnC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AAQxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC"}
package/lib/node/index.js CHANGED
@@ -32,6 +32,7 @@ export * from './server-diagnostics.js';
32
32
  // entry alongside the default policy.
33
33
  export * from './ast-ground-truth.js';
34
34
  export * from './event-loop-monitor.js';
35
+ export * from './heap-ceiling.js';
35
36
  export * from './latency-from-env.js';
36
37
  export * from './measure-memory.js';
37
38
  export * from './memory-monitor.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,iFAAiF;AACjF,6EAA6E;AAC7E,iFAAiF;AACjF,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,mBAAmB;AACnB,iFAAiF;AACjF,iFAAiF;AACjF,8CAA8C;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAEvE,cAAc,gCAAgC,CAAC;AAC/C,cAAc,oBAAoB,CAAC;AACnC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AAExC,8EAA8E;AAC9E,+EAA+E;AAC/E,0EAA0E;AAC1E,sEAAsE;AACtE,6EAA6E;AAC7E,sCAAsC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AAExC,sBAAsB,EAAE,CAAC;AACzB,yBAAyB,EAAE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;kFAOkF;AAElF,iFAAiF;AACjF,6EAA6E;AAC7E,iFAAiF;AACjF,EAAE;AACF,8EAA8E;AAC9E,8EAA8E;AAC9E,4EAA4E;AAC5E,mBAAmB;AACnB,iFAAiF;AACjF,iFAAiF;AACjF,8CAA8C;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAE,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAEvE,cAAc,gCAAgC,CAAC;AAC/C,cAAc,oBAAoB,CAAC;AACnC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AAExC,8EAA8E;AAC9E,+EAA+E;AAC/E,0EAA0E;AAC1E,sEAAsE;AACtE,6EAA6E;AAC7E,sCAAsC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,qBAAqB,CAAC;AACpC,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,sBAAsB,CAAC;AACrC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AAExC,sBAAsB,EAAE,CAAC;AACzB,yBAAyB,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydranium/core",
3
- "version": "1.0.0-next.75",
3
+ "version": "1.0.0-next.77",
4
4
  "description": "Foundational runtime of the hydranium framework: AST coordination layer (services, integrity, AST extension, serialization, multi-client documents) plus the LSP textual head at the /lsp subpath. Consumed by protocol-head packages (@hydranium/data-server, @hydranium/glsp-server).",
5
5
  "keywords": [
6
6
  "hydranium",
@@ -104,8 +104,8 @@
104
104
  "diff": "^5.2.0"
105
105
  },
106
106
  "devDependencies": {
107
- "@hydranium/langium": "1.0.0-next.75",
108
- "@hydranium/protocol": "1.0.0-next.75",
107
+ "@hydranium/langium": "1.0.0-next.77",
108
+ "@hydranium/protocol": "1.0.0-next.77",
109
109
  "@playwright/test": "^1.40.0",
110
110
  "@types/diff": "^5.2.0",
111
111
  "rimraf": "^5.0.0",
@@ -117,8 +117,8 @@
117
117
  "vscode-languageserver-types": "^3.17.5"
118
118
  },
119
119
  "peerDependencies": {
120
- "@hydranium/langium": "1.0.0-next.75",
121
- "@hydranium/protocol": "1.0.0-next.75",
120
+ "@hydranium/langium": "1.0.0-next.77",
121
+ "@hydranium/protocol": "1.0.0-next.77",
122
122
  "@playwright/test": "^1.40.0",
123
123
  "vscode-jsonrpc": "9.0.1",
124
124
  "vscode-languageserver": "~10.0.1",
@@ -10,7 +10,12 @@
10
10
  import {
11
11
  defineMessage,
12
12
  describeError,
13
+ interpolate,
14
+ type MessageDefinition,
15
+ type MessageParams,
13
16
  messageData,
17
+ type ParamsArg,
18
+ type ParamsOf,
14
19
  type LogThreshold,
15
20
  type MaybeObservableValue,
16
21
  ObservableValue,
@@ -26,6 +31,7 @@ import {
26
31
  DocumentValidator,
27
32
  type LangiumDocument,
28
33
  type LangiumCoreServices,
34
+ type Lexer,
29
35
  type ValidateSingleNodeOptions,
30
36
  type ValidationOptions,
31
37
  type ValidationSeverity
@@ -107,11 +113,117 @@ export const UNRESOLVED_REFERENCE = defineMessage(
107
113
  * means: chevrotain reports how many characters the lexer discarded to recover,
108
114
  * which for a single stray character is one and for a run of them is the run.
109
115
  */
110
- export const LEXING_ERROR = defineMessage(
111
- 'hydranium/core/lexing-error',
116
+ export const UNEXPECTED_CHARACTER = defineMessage(
117
+ 'hydranium/core/unexpected-character',
112
118
  'unexpected character: ->{character}<- at offset: {offset}, skipped {skipped} characters.'
113
119
  );
114
120
 
121
+ /**
122
+ * A token the grammar does not admit at this position.
123
+ *
124
+ * **The English is byte-identical to Langium's
125
+ * `LangiumParserErrorMessageProvider.buildMismatchTokenMessage`, and must stay
126
+ * so**, on the same rule as {@link UNRESOLVED_REFERENCE}.
127
+ *
128
+ * `expected` is a token type name, which is grammar vocabulary rather than
129
+ * translatable text — only the frame around it is. Promoting it into the code
130
+ * instead would make the catalogue as large as the grammar.
131
+ */
132
+ export const UNEXPECTED_TOKEN = defineMessage(
133
+ 'hydranium/core/unexpected-token',
134
+ "Expecting token of type '{expected}' but found `{found}`."
135
+ );
136
+
137
+ /**
138
+ * Input left over once the entry rule had matched.
139
+ *
140
+ * **The English is byte-identical to Langium's
141
+ * `LangiumParserErrorMessageProvider.buildNotAllInputParsedMessage`, and must
142
+ * stay so.**
143
+ */
144
+ export const TRAILING_INPUT = defineMessage('hydranium/core/trailing-input', 'Expecting end of file but found `{found}`.');
145
+
146
+ /**
147
+ * A mode-popping token reached with nothing on the lexer's mode stack.
148
+ *
149
+ * **The English is byte-identical to CHEVROTAIN's
150
+ * `defaultLexerErrorProvider.buildUnableToPopLexerModeMessage`, and must stay
151
+ * so**, the closing clause running on without punctuation exactly as upstream
152
+ * writes it.
153
+ *
154
+ * Reachable only from a multi-mode lexer, which a grammar opts into through a
155
+ * custom token builder.
156
+ */
157
+ export const UNPOPPABLE_LEXER_MODE = defineMessage(
158
+ 'hydranium/core/unpoppable-lexer-mode',
159
+ 'Unable to pop Lexer Mode after encountering Token ->{image}<- The Mode Stack is empty'
160
+ );
161
+
162
+ /**
163
+ * A dedent that lines up with no enclosing indentation level.
164
+ *
165
+ * **The English is byte-identical to LANGIUM's `IndentationAwareTokenBuilder`,
166
+ * and must stay so** — the one lexing sentence Langium words itself rather than
167
+ * relaying from chevrotain, which is why it renders through no
168
+ * `ILexerErrorMessageProvider` and an adopter cannot reach it by replacing one.
169
+ *
170
+ * `stack` is the indentation stack as the template literal upstream stringifies
171
+ * it, comma-joined: structure would have to survive {@link MessageParams}, which
172
+ * holds scalars, and a translation has nothing to say about the numbers anyway.
173
+ */
174
+ export const INVALID_DEDENT = defineMessage(
175
+ 'hydranium/core/invalid-dedent',
176
+ 'Invalid dedent level {level} at offset: {offset}. Current indentation stack: {stack}'
177
+ );
178
+
179
+ /**
180
+ * A position where none of a rule's alternatives can start.
181
+ *
182
+ * **The English is byte-identical to CHEVROTAIN's
183
+ * `defaultParserErrorProvider.buildNoViableAltMessage`, and must stay so** —
184
+ * one layer further out than {@link UNEXPECTED_TOKEN}, because Langium overrides
185
+ * only two of the four parser sentences and passes this one straight through.
186
+ *
187
+ * `sequences` is the token-sequence list chevrotain generated, carried whole
188
+ * rather than as structure: {@link MessageParams} holds scalars, and the list is
189
+ * token names in any case, so only the frame around it is translatable.
190
+ */
191
+ export const NO_VIABLE_ALTERNATIVE = defineMessage(
192
+ 'hydranium/core/no-viable-alternative',
193
+ "Expecting: one of these possible Token sequences:\n{sequences}\nbut found: '{found}'"
194
+ );
195
+
196
+ /**
197
+ * A repetition that had to match at least once and matched nothing.
198
+ *
199
+ * **The English is byte-identical to CHEVROTAIN's
200
+ * `defaultParserErrorProvider.buildEarlyExitMessage`, and must stay so**,
201
+ * including the doubled colon after `sequences::`, which is upstream's and not a
202
+ * typo to repair here — a divergence would silently stop the identity attaching.
203
+ *
204
+ * Named for the empty repetition rather than for chevrotain's `EarlyExit`, which
205
+ * describes its own control flow rather than the reader's problem.
206
+ */
207
+ export const MISSING_ITERATION = defineMessage(
208
+ 'hydranium/core/missing-iteration',
209
+ "Expecting: expecting at least one iteration which starts with one of these possible Token sequences::\n <{sequences}>\nbut found: '{found}'"
210
+ );
211
+
212
+ /**
213
+ * The two sentences whose only free parameter is a generated list, in the order
214
+ * {@link HydraniumDocumentValidator.identifyParsingError} tries them. Order is
215
+ * free: their frames share a prefix but diverge before the list begins, so at
216
+ * most one can match.
217
+ */
218
+ const SEQUENCE_LISTING_MESSAGES = [NO_VIABLE_ALTERNATIVE, MISSING_ITERATION];
219
+
220
+ /**
221
+ * Splits a template into alternating frame and placeholder NAME, for
222
+ * {@link HydraniumDocumentValidator.sliceFramed}. The capturing group is what
223
+ * keeps the names in the result rather than discarding them.
224
+ */
225
+ const PLACEHOLDER_CAPTURE = /\{([^}]+)\}/;
226
+
115
227
  /**
116
228
  * Langium's own `data.code` values for a diagnostic that came out of the lexer.
117
229
  *
@@ -174,6 +286,12 @@ export class HydraniumDocumentValidator extends DefaultDocumentValidator {
174
286
  protected readonly tracer: Tracer;
175
287
  protected readonly astNodeLocator: AstNodeLocator;
176
288
  protected readonly reflection: AstReflection;
289
+ protected readonly lexer: Lexer;
290
+ /**
291
+ * {@link tokenTypeNames}' answer. Nothing invalidates it: the vocabulary
292
+ * comes from the grammar, which is fixed once the DI tree is composed.
293
+ */
294
+ protected expectedNames?: readonly string[];
177
295
  protected readonly logLevel: ObservableValue<LogThreshold>;
178
296
  protected readonly logAfterMs: ObservableValue<number>;
179
297
  protected readonly validateVirtualDocuments: ObservableValue<boolean>;
@@ -184,6 +302,7 @@ export class HydraniumDocumentValidator extends DefaultDocumentValidator {
184
302
  this.tracer = services.shared.Tracer.for(options.logName ?? 'DocumentValidator').trace('instantiated');
185
303
  this.astNodeLocator = services.workspace.AstNodeLocator;
186
304
  this.reflection = services.shared.AstReflection;
305
+ this.lexer = services.parser.Lexer;
187
306
  this.logLevel = ObservableValue.from(options.logLevel ?? 'debug');
188
307
  this.logAfterMs = ObservableValue.from(options.logAfterMs ?? 20);
189
308
  this.validateVirtualDocuments = ObservableValue.from(options.validateVirtualDocuments ?? false);
@@ -216,18 +335,22 @@ export class HydraniumDocumentValidator extends DefaultDocumentValidator {
216
335
  }
217
336
 
218
337
  /**
219
- * Langium's validation pass, plus the framework identity on the lexing
220
- * diagnostics it produced.
338
+ * Langium's validation pass, plus the framework identity on the lexing and
339
+ * parsing diagnostics it produced.
340
+ *
341
+ * **Here rather than in an override of `processLexingErrors` /
342
+ * `processParsingErrors`, and the reason is that neither seam can see what
343
+ * the identity needs.** Langium hands them a `ParseResult`, which carries the
344
+ * AST and the error lists but not the source text — and the offending
345
+ * CHARACTER, or the offending TOKEN, is the parameter a translation exists to
346
+ * interpolate. The document does carry it, and this is the innermost point
347
+ * that still holds one.
221
348
  *
222
- * **Here rather than in an override of `processLexingErrors`, and the reason
223
- * is that the seam cannot see what the identity needs.** Langium hands that
224
- * method a `ParseResult`, which carries the AST and the error lists but not
225
- * the source text — and the offending CHARACTER is the parameter a
226
- * translation exists to interpolate. The document does carry it, and this is
227
- * the innermost point that still holds one. Reconstructing the character by
228
- * matching it out of the finished sentence was the alternative, and it
229
- * inverts the direction the rest of this file works in: every other identity
230
- * here is derived from structured fields and CHECKED against the prose.
349
+ * Correlating the finished diagnostics back to the `ParseResult`'s error
350
+ * lists positionally is the alternative, and it is unsound for the parsing
351
+ * half: `processParsingErrors` DROPS an error whose token offsets are `NaN`
352
+ * and whose exception carries no `previousToken`, so the two lists differ in
353
+ * length exactly when recovery has been at work.
231
354
  *
232
355
  * Inside the timing span rather than around it, so the line accounts for the
233
356
  * whole pass.
@@ -241,15 +364,19 @@ export class HydraniumDocumentValidator extends DefaultDocumentValidator {
241
364
  // Mapped in place over the array Langium built, rather than filtered and
242
365
  // re-concatenated: the publish order is the diagnostic order and a
243
366
  // reordering would move a squiggle's entry in the problems list.
367
+ // Each pass guards on its own `data.code`, so at most one can claim a
368
+ // given diagnostic and the order between them carries no meaning.
244
369
  for (let index = 0; index < diagnostics.length; index++) {
245
370
  diagnostics[index] = this.identifyLexingError(document, diagnostics[index]);
371
+ diagnostics[index] = this.identifyParsingError(document, diagnostics[index]);
246
372
  }
247
373
  return diagnostics;
248
374
  }
249
375
 
250
376
  /**
251
- * Add the {@link LEXING_ERROR} identity to one diagnostic, or return it
252
- * untouched when it is not the sentence that identity renders.
377
+ * Add whichever lexing identity renders `diagnostic`'s sentence, or return it
378
+ * untouched when none of them does — a custom lexer's own report, which
379
+ * Langium admits through `lexerReport`, being the case that reaches the end.
253
380
  *
254
381
  * The parameters come from the diagnostic's own RANGE, not from
255
382
  * `parseResult.lexerErrors`. Both hold the same numbers, and the range is the
@@ -269,19 +396,165 @@ export class HydraniumDocumentValidator extends DefaultDocumentValidator {
269
396
  return diagnostic;
270
397
  }
271
398
  const text = document.textDocument;
399
+ const message = Diagnostic.getMessageString(diagnostic);
272
400
  const offset = text.offsetAt(diagnostic.range.start);
273
- const params = {
401
+ const stray = {
274
402
  character: text.getText().charAt(offset),
275
403
  offset,
276
404
  skipped: text.offsetAt(diagnostic.range.end) - offset
277
405
  };
278
- if (Diagnostic.getMessageString(diagnostic) !== LEXING_ERROR.format(params)) {
406
+ if (message === UNEXPECTED_CHARACTER.format(stray)) {
407
+ return this.identified(diagnostic, UNEXPECTED_CHARACTER, stray);
408
+ }
409
+ // The mode-pop error's range spans the offending token exactly, upstream
410
+ // reporting its `startOffset` with the image's own length.
411
+ const image = text.getText(diagnostic.range);
412
+ if (message === UNPOPPABLE_LEXER_MODE.format({ image })) {
413
+ return this.identified(diagnostic, UNPOPPABLE_LEXER_MODE, { image });
414
+ }
415
+ // `level` and `stack` are lexer state that reaches no field here, so they
416
+ // are sliced; `offset` is given, which is what anchors the slice.
417
+ const dedent = this.sliceFramed(message, INVALID_DEDENT, { offset });
418
+ return dedent === undefined ? diagnostic : this.identified(diagnostic, INVALID_DEDENT, dedent);
419
+ }
420
+
421
+ /**
422
+ * Add whichever parser identity renders `diagnostic`'s sentence, or return it
423
+ * untouched when none of them does — an adopter that replaced the parser
424
+ * error-message provider being the case that reaches the last branch.
425
+ *
426
+ * `found` comes from the diagnostic's own RANGE, for the same reason
427
+ * {@link identifyLexingError} reads its parameters there. A token whose
428
+ * offsets chevrotain could not report collapses to a zero-width range, which
429
+ * yields the empty string the sentence already renders for it.
430
+ *
431
+ * **`expected` is recovered by SEARCHING the vocabulary, because nothing
432
+ * carries it.** Chevrotain hands the expected token type to the message
433
+ * provider and keeps it on neither the exception nor the diagnostic, so the
434
+ * only structured source left is the grammar's own vocabulary: every token
435
+ * type is offered to the same `format` the identity renders with, and the one
436
+ * that reproduces the message supplies the parameter. Matching it out of the
437
+ * prose instead would derive a parameter from a sentence that a Langium
438
+ * reword can change without warning. Two distinct names cannot render one
439
+ * sentence, so the match is unique where it exists.
440
+ */
441
+ protected identifyParsingError(document: LangiumDocument, diagnostic: Diagnostic): Diagnostic {
442
+ const data = diagnostic.data as { code?: unknown } | undefined;
443
+ if (data?.code !== DocumentValidator.ParsingError) {
279
444
  return diagnostic;
280
445
  }
281
- // Merged OVER Langium's data, so `data.code` survives for the readers that
282
- // switch on it — `stopAfterLexingErrors` is one, and the GLSP head's
283
- // read-only decision is another.
284
- return { ...diagnostic, code: LEXING_ERROR.code, data: { ...data, ...messageData(LEXING_ERROR, params) } };
446
+ const message = Diagnostic.getMessageString(diagnostic);
447
+ const found = document.textDocument.getText(diagnostic.range);
448
+ if (message === TRAILING_INPUT.format({ found })) {
449
+ return this.identified(diagnostic, TRAILING_INPUT, { found });
450
+ }
451
+ const expected = this.tokenTypeNames().find(name => UNEXPECTED_TOKEN.format({ expected: name, found }) === message);
452
+ if (expected !== undefined) {
453
+ return this.identified(diagnostic, UNEXPECTED_TOKEN, { expected, found });
454
+ }
455
+ for (const definition of SEQUENCE_LISTING_MESSAGES) {
456
+ const params = this.sliceFramed(message, definition, { found });
457
+ if (params !== undefined) {
458
+ return this.identified(diagnostic, definition, params);
459
+ }
460
+ }
461
+ return diagnostic;
462
+ }
463
+
464
+ /** `diagnostic` carrying `definition`'s identity, over whatever `data` it already had. */
465
+ protected identified<S extends string>(diagnostic: Diagnostic, definition: MessageDefinition<S>, ...params: ParamsArg<S>): Diagnostic {
466
+ // Merged OVER the existing data, so Langium's `code` survives for the
467
+ // readers that switch on it — `stopAfterParsingErrors` is one, and the
468
+ // GLSP head's read-only decision is another.
469
+ const data = diagnostic.data as object | undefined;
470
+ return { ...diagnostic, code: definition.code, data: { ...data, ...messageData(definition, ...params) } };
471
+ }
472
+
473
+ /**
474
+ * `definition`'s parameters as they appear in `message`, taking the ones in
475
+ * `known` as given, or `undefined` when `message` is not that sentence.
476
+ *
477
+ * **The only parameters in this file taken OUT of the prose rather than
478
+ * derived and checked against it, and the bound on that is what `known`
479
+ * is for.** A generated token-sequence list, a lexer's indentation stack: for
480
+ * these there is no finite candidate set to offer to `format` the way a token
481
+ * type name can be, and no structured field carries them. What keeps the read
482
+ * honest is that everything else in the sentence is either a fixed literal or
483
+ * a parameter the caller derived independently, so the frame either matches
484
+ * exactly or the sentence is declined.
485
+ *
486
+ * **The trailing format-and-compare is load-bearing once more than one
487
+ * parameter is unknown.** Each is captured up to the NEXT fixed separator, so
488
+ * a separator that also occurs inside a value would cut early — re-rendering
489
+ * catches that, where for a single unknown between two affixes it could not
490
+ * fail. Two adjacent placeholders are declined outright: nothing marks the
491
+ * boundary, so no capture is recoverable.
492
+ *
493
+ * The frame is split off the declaration's own `text`, so it cannot drift
494
+ * from the sentence `format` renders.
495
+ */
496
+ protected sliceFramed<S extends string>(
497
+ message: string,
498
+ definition: MessageDefinition<S>,
499
+ known: MessageParams
500
+ ): ParamsOf<S> | undefined {
501
+ // `split` with a capturing group interleaves literals and placeholder
502
+ // names, so even indices are frame and odd ones are parameters.
503
+ const parts = definition.text.split(PLACEHOLDER_CAPTURE);
504
+ const params: Record<string, string | number> = { ...known };
505
+ let cursor = 0;
506
+ for (let index = 0; index < parts.length; index += 2) {
507
+ const literal = parts[index];
508
+ if (!message.startsWith(literal, cursor)) {
509
+ return undefined;
510
+ }
511
+ cursor += literal.length;
512
+ const name = parts[index + 1];
513
+ if (name === undefined) {
514
+ break;
515
+ }
516
+ const given = params[name];
517
+ if (given !== undefined) {
518
+ const rendered = String(given);
519
+ if (!message.startsWith(rendered, cursor)) {
520
+ return undefined;
521
+ }
522
+ cursor += rendered.length;
523
+ continue;
524
+ }
525
+ const separator = parts[index + 2];
526
+ const trailing = parts[index + 3] === undefined;
527
+ if (separator === '' && !trailing) {
528
+ return undefined;
529
+ }
530
+ const at = trailing && separator === '' ? message.length : message.indexOf(separator, cursor);
531
+ if (at < cursor) {
532
+ return undefined;
533
+ }
534
+ params[name] = message.slice(cursor, at);
535
+ cursor = at;
536
+ }
537
+ if (cursor !== message.length || interpolate(definition.text, params) !== message) {
538
+ return undefined;
539
+ }
540
+ // Narrowing only: every placeholder the template names was either given or
541
+ // captured above, which is precisely what `ParamsOf` requires.
542
+ return params as ParamsOf<S>;
543
+ }
544
+
545
+ /**
546
+ * Every token type name the grammar declares, as
547
+ * {@link identifyParsingError} candidates.
548
+ *
549
+ * Read off the lexer, whose `definition` the `Lexer` INTERFACE declares —
550
+ * the parser holds the same vocabulary only behind Langium's default class,
551
+ * where an adopter's replacement need not keep it. A token type the parser
552
+ * expects but the lexer does not declare yields no match, which declines the
553
+ * identity rather than attaching a wrong one.
554
+ */
555
+ protected tokenTypeNames(): readonly string[] {
556
+ this.expectedNames ??= Object.keys(this.lexer.definition);
557
+ return this.expectedNames;
285
558
  }
286
559
 
287
560
  /**
@@ -22,6 +22,15 @@ export * from './renderer.js';
22
22
 
23
23
  export { MODEL_UPDATE_EDIT } from '../langium/model-service/model-service.js';
24
24
  export { SEPARATOR_IN_NAME } from '../langium/naming/name-separator-validation.js';
25
- export { LEXING_ERROR, UNRESOLVED_REFERENCE } from '../langium/validation/document-validator.js';
25
+ export {
26
+ INVALID_DEDENT,
27
+ UNEXPECTED_CHARACTER,
28
+ MISSING_ITERATION,
29
+ NO_VIABLE_ALTERNATIVE,
30
+ TRAILING_INPUT,
31
+ UNEXPECTED_TOKEN,
32
+ UNPOPPABLE_LEXER_MODE,
33
+ UNRESOLVED_REFERENCE
34
+ } from '../langium/validation/document-validator.js';
26
35
  export { NO_LOADABLE_CONTENT } from '../langium/workspace/langium-documents.js';
27
36
  export { NO_SUCH_FILE, NO_SUCH_PATH } from '../langium/workspace/in-memory-file-system-provider.js';