@hydranium/core 1.0.0-next.31 → 1.0.0-next.32

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 (92) hide show
  1. package/lib/documents/hydranium-text-documents.d.ts.map +1 -1
  2. package/lib/documents/hydranium-text-documents.js +12 -1
  3. package/lib/documents/hydranium-text-documents.js.map +1 -1
  4. package/lib/index.d.ts +1 -0
  5. package/lib/index.d.ts.map +1 -1
  6. package/lib/index.js +1 -0
  7. package/lib/index.js.map +1 -1
  8. package/lib/langium/bootstrap.d.ts.map +1 -1
  9. package/lib/langium/bootstrap.js +8 -1
  10. package/lib/langium/bootstrap.js.map +1 -1
  11. package/lib/langium/document-builder/document-builder.d.ts +33 -1
  12. package/lib/langium/document-builder/document-builder.d.ts.map +1 -1
  13. package/lib/langium/document-builder/document-builder.js +68 -2
  14. package/lib/langium/document-builder/document-builder.js.map +1 -1
  15. package/lib/langium/model-service/model-service.d.ts +26 -0
  16. package/lib/langium/model-service/model-service.d.ts.map +1 -1
  17. package/lib/langium/model-service/model-service.js +31 -3
  18. package/lib/langium/model-service/model-service.js.map +1 -1
  19. package/lib/langium/module.d.ts +20 -0
  20. package/lib/langium/module.d.ts.map +1 -1
  21. package/lib/langium/module.js +4 -0
  22. package/lib/langium/module.js.map +1 -1
  23. package/lib/langium/naming/name-provider.d.ts +10 -0
  24. package/lib/langium/naming/name-provider.d.ts.map +1 -1
  25. package/lib/langium/naming/name-provider.js.map +1 -1
  26. package/lib/langium/naming/name-separator-validation.d.ts +23 -1
  27. package/lib/langium/naming/name-separator-validation.d.ts.map +1 -1
  28. package/lib/langium/naming/name-separator-validation.js +22 -0
  29. package/lib/langium/naming/name-separator-validation.js.map +1 -1
  30. package/lib/langium/shared-services.d.ts +13 -2
  31. package/lib/langium/shared-services.d.ts.map +1 -1
  32. package/lib/langium/shared-services.js.map +1 -1
  33. package/lib/langium/validation/document-validator.d.ts +66 -1
  34. package/lib/langium/validation/document-validator.d.ts.map +1 -1
  35. package/lib/langium/validation/document-validator.js +101 -1
  36. package/lib/langium/validation/document-validator.js.map +1 -1
  37. package/lib/langium/workspace/hydranium-workspace-manager.d.ts +11 -1
  38. package/lib/langium/workspace/hydranium-workspace-manager.d.ts.map +1 -1
  39. package/lib/langium/workspace/hydranium-workspace-manager.js +15 -0
  40. package/lib/langium/workspace/hydranium-workspace-manager.js.map +1 -1
  41. package/lib/langium/workspace/initialize-workspace.d.ts +22 -2
  42. package/lib/langium/workspace/initialize-workspace.d.ts.map +1 -1
  43. package/lib/langium/workspace/initialize-workspace.js +13 -5
  44. package/lib/langium/workspace/initialize-workspace.js.map +1 -1
  45. package/lib/locale/index.d.ts +10 -0
  46. package/lib/locale/index.d.ts.map +1 -0
  47. package/lib/locale/index.js +10 -0
  48. package/lib/locale/index.js.map +1 -0
  49. package/lib/locale/server-locale.d.ts +49 -0
  50. package/lib/locale/server-locale.d.ts.map +1 -0
  51. package/lib/locale/server-locale.js +51 -0
  52. package/lib/locale/server-locale.js.map +1 -0
  53. package/lib/messages/carriers.d.ts +7 -6
  54. package/lib/messages/carriers.d.ts.map +1 -1
  55. package/lib/messages/carriers.js +8 -7
  56. package/lib/messages/carriers.js.map +1 -1
  57. package/lib/messages/index.d.ts +3 -0
  58. package/lib/messages/index.d.ts.map +1 -1
  59. package/lib/messages/index.js +3 -0
  60. package/lib/messages/index.js.map +1 -1
  61. package/lib/messages/renderer.d.ts +117 -0
  62. package/lib/messages/renderer.d.ts.map +1 -0
  63. package/lib/messages/renderer.js +159 -0
  64. package/lib/messages/renderer.js.map +1 -0
  65. package/lib/testing/make-noop-shared-services.d.ts +14 -0
  66. package/lib/testing/make-noop-shared-services.d.ts.map +1 -1
  67. package/lib/testing/make-noop-shared-services.js +28 -2
  68. package/lib/testing/make-noop-shared-services.js.map +1 -1
  69. package/lib/testing/make-test-services.d.ts +27 -0
  70. package/lib/testing/make-test-services.d.ts.map +1 -1
  71. package/lib/testing/make-test-services.js +17 -1
  72. package/lib/testing/make-test-services.js.map +1 -1
  73. package/package.json +5 -5
  74. package/src/documents/hydranium-text-documents.ts +12 -1
  75. package/src/index.ts +1 -0
  76. package/src/langium/bootstrap.ts +8 -1
  77. package/src/langium/document-builder/document-builder.ts +70 -2
  78. package/src/langium/model-service/model-service.ts +33 -2
  79. package/src/langium/module.ts +22 -0
  80. package/src/langium/naming/name-provider.ts +10 -0
  81. package/src/langium/naming/name-separator-validation.ts +25 -3
  82. package/src/langium/shared-services.ts +13 -2
  83. package/src/langium/validation/document-validator.ts +117 -1
  84. package/src/langium/workspace/hydranium-workspace-manager.ts +18 -1
  85. package/src/langium/workspace/initialize-workspace.ts +34 -5
  86. package/src/locale/index.ts +10 -0
  87. package/src/locale/server-locale.ts +61 -0
  88. package/src/messages/carriers.ts +8 -8
  89. package/src/messages/index.ts +3 -0
  90. package/src/messages/renderer.ts +184 -0
  91. package/src/testing/make-noop-shared-services.ts +50 -2
  92. package/src/testing/make-test-services.ts +43 -1
@@ -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
+
10
+ import type { Tracer } from '@hydranium/protocol';
11
+ import { type LogNameOptions } from '../langium/diagnostics/logger.js';
12
+ import type { ServerSharedServicesMinimal } from '../langium/shared-services.js';
13
+
14
+ /** Construction options for {@link ServerLocale}. */
15
+ export type ServerLocaleOptions = LogNameOptions;
16
+
17
+ /**
18
+ * The locale the server was handed at init, for whoever needs to render in the
19
+ * reading user's language. Held apart from the message renderer that reads it,
20
+ * so replacing the renderer cannot drop locale handling.
21
+ *
22
+ * A plain string in and out. A consumer that must react to a change rather than
23
+ * read the current value wraps this itself — nothing does today, and both real
24
+ * hosts respawn the server on a display-language switch.
25
+ */
26
+ export class ServerLocale {
27
+ protected readonly tracer: Tracer;
28
+ protected current: string | undefined;
29
+
30
+ constructor(services: ServerSharedServicesMinimal, options: ServerLocaleOptions = {}) {
31
+ this.tracer = services.Tracer.for(options.logName ?? 'ServerLocale').trace('instantiated');
32
+ }
33
+
34
+ /** The locale, or `undefined` when no init supplied one — which means the framework's English. */
35
+ get value(): string | undefined {
36
+ return this.current;
37
+ }
38
+
39
+ /**
40
+ * Take the locale an init declared.
41
+ *
42
+ * **One locale per process, and the reason the OTHER heads need none of
43
+ * their own.** The data and GLSP heads publish a port over the LSP
44
+ * connection and are reached by forwarding a socket to it, so they are the
45
+ * same process as the LSP head that was handed this locale — there is no
46
+ * topology in which one of them serves a second frontend. The Theia backend
47
+ * is the case that would break it, and it holds no locale precisely because
48
+ * it serves every frontend at once; nothing there writes here.
49
+ *
50
+ * The framework accepts a locale and never sources one, and neither
51
+ * validates nor normalises the tag: rejecting an unfamiliar one would be
52
+ * selecting a locale.
53
+ *
54
+ * Override to ignore the argument, which is how a host pins a locale it
55
+ * already knows but has no LSP client to declare.
56
+ */
57
+ accept(locale: string): void {
58
+ this.current = locale;
59
+ this.tracer.debug(`locale set to '${locale}'`);
60
+ }
61
+ }
@@ -16,7 +16,7 @@ import {
16
16
  type ResolvedMessage
17
17
  } from '@hydranium/protocol';
18
18
  import type { AstNode, DiagnosticData, DiagnosticInfo, Properties, ValidationAcceptor } from '@hydranium/langium';
19
- import type { Diagnostic } from 'vscode-languageserver-protocol';
19
+ import { Diagnostic } from 'vscode-languageserver-protocol';
20
20
 
21
21
  /**
22
22
  * Raise a validation diagnostic from a declaration, so the call site never
@@ -52,17 +52,17 @@ export function acceptMessage<S extends string, N extends AstNode, P extends Pro
52
52
  }
53
53
 
54
54
  /**
55
- * Recover the identity from a published diagnostic, for a surface that renders
56
- * diagnostics itself.
55
+ * Recover the identity from a published diagnostic, for a surface that
56
+ * identifies or renders diagnostics itself.
57
57
  *
58
- * The `MarkupContent` narrowing is not padding: `Diagnostic.message` is
59
- * `string | MarkupContent` in LSP 3.17+ and this does not compile without it.
60
- * Only the string form is a resolved English sentence.
58
+ * `Diagnostic.message` is `string | MarkupContent` in LSP 3.17+, so the text
59
+ * comes from upstream's own `Diagnostic.getMessageString` rather than a
60
+ * hand-rolled narrowing — consuming the library in its style, and one fewer
61
+ * place restating the union.
61
62
  */
62
63
  export function resolvedFromDiagnostic(diagnostic: Diagnostic): ResolvedMessage | undefined {
63
64
  if (!hasMessageIdentity(diagnostic.data)) {
64
65
  return undefined;
65
66
  }
66
- const text = typeof diagnostic.message === 'string' ? diagnostic.message : diagnostic.message.value;
67
- return { ...diagnostic.data.hydranium, text };
67
+ return { ...diagnostic.data.hydranium, text: Diagnostic.getMessageString(diagnostic) };
68
68
  }
@@ -18,7 +18,10 @@
18
18
  */
19
19
 
20
20
  export * from './carriers.js';
21
+ export * from './renderer.js';
21
22
 
23
+ export { MODEL_UPDATE_EDIT } from '../langium/model-service/model-service.js';
22
24
  export { SEPARATOR_IN_NAME } from '../langium/naming/name-separator-validation.js';
25
+ export { UNRESOLVED_REFERENCE } from '../langium/validation/document-validator.js';
23
26
  export { NO_LOADABLE_CONTENT } from '../langium/workspace/langium-documents.js';
24
27
  export { NO_SUCH_FILE, NO_SUCH_PATH } from '../langium/workspace/in-memory-file-system-provider.js';
@@ -0,0 +1,184 @@
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
+ import {
11
+ describeError,
12
+ renderFrameworkMessage,
13
+ resolve,
14
+ resolvedFromResponseError,
15
+ type MessageDefinition,
16
+ type ParamsArg,
17
+ type ResolvedMessage,
18
+ type Tracer
19
+ } from '@hydranium/protocol';
20
+ import { SimpleCache } from '@hydranium/langium';
21
+ import type { ResponseError } from 'vscode-jsonrpc';
22
+ import { Diagnostic } from 'vscode-languageserver-protocol';
23
+ import { type LogNameOptions } from '../langium/diagnostics/logger.js';
24
+ import type { ServerSharedServicesMinimal } from '../langium/shared-services.js';
25
+ import type { ServerLocale } from '../locale/server-locale.js';
26
+ import { resolvedFromDiagnostic } from './carriers.js';
27
+
28
+ /** Construction options for {@link ServerMessageRenderer}. */
29
+ export type ServerMessageRendererOptions = LogNameOptions;
30
+
31
+ /**
32
+ * Renders every user-facing message the server sends, in the locale the server
33
+ * was handed at init. The framework ships no catalogue, so its own behaviour is
34
+ * a pass-through.
35
+ *
36
+ * Adopters override {@link translationsFor}: the public render methods carry
37
+ * the no-throw contract, and the catalogue lookup they wrap is what can fail.
38
+ *
39
+ * Two render methods rather than one, because what is useful to an adopter is
40
+ * the carrier's structured fields and the two carriers have different ones. A
41
+ * single `render(text)` would force an adopter to match English prose, which
42
+ * breaks on the first Langium reword.
43
+ */
44
+ export class ServerMessageRenderer {
45
+ protected readonly tracer: Tracer;
46
+ protected readonly serverLocale: ServerLocale;
47
+ /**
48
+ * {@link translationsFor}'s answer per locale, `undefined` answers included —
49
+ * a `SimpleCache` distinguishes "absent" from "cached as none", which matters
50
+ * because shipping no catalogue is the framework's own hot path.
51
+ *
52
+ * `SimpleCache` rather than Langium's `WorkspaceCache`: the eviction axis
53
+ * there is document change, and a catalogue does not depend on documents, so
54
+ * it would evict on every build AND put a `DocumentBuilder` dependency on a
55
+ * service the shared tier declares free of one. Nothing evicts this: the
56
+ * locale arrives once per process. A subclass whose catalogue can change
57
+ * clears it.
58
+ */
59
+ protected readonly catalogues = new SimpleCache<string | undefined, Record<string, string> | undefined>();
60
+
61
+ constructor(services: ServerSharedServicesMinimal, options: ServerMessageRendererOptions = {}) {
62
+ this.serverLocale = services.ServerLocale;
63
+ this.tracer = services.Tracer.for(options.logName ?? 'MessageRenderer').trace('instantiated');
64
+ }
65
+
66
+ /**
67
+ * The sentence to publish for `diagnostic`, replacing its current `message`.
68
+ * Sees lexer and parser errors too, which Langium pushes onto the document
69
+ * without routing them through `toDiagnostic`.
70
+ *
71
+ * **Must not throw**, which is why the guard is here and not at the call
72
+ * site: this runs inside the `Validated` phase, and Langium's
73
+ * `notifyDocumentPhase` rethrows anything that is not a cancellation — so an
74
+ * escaping error leaves the document AT `Validated` with the phase's
75
+ * listeners never run, and the client receives no diagnostics for that file.
76
+ * A subclass overriding this method takes that contract on itself.
77
+ */
78
+ renderDiagnostic(diagnostic: Diagnostic): string {
79
+ try {
80
+ const resolved = resolvedFromDiagnostic(diagnostic);
81
+ return resolved ? this.render(resolved) : Diagnostic.getMessageString(diagnostic);
82
+ } catch (err: unknown) {
83
+ this.reportFailure(err, 'a diagnostic');
84
+ return Diagnostic.getMessageString(diagnostic);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The sentence to send for `error`, replacing its current `message`. Same
90
+ * no-throw contract as {@link renderDiagnostic}: an escaping error replaces a
91
+ * typed rejection the caller can handle with one it cannot.
92
+ */
93
+ renderError(error: ResponseError<unknown>): string {
94
+ try {
95
+ const resolved = resolvedFromResponseError(error);
96
+ return resolved ? this.render(resolved) : error.message;
97
+ } catch (err: unknown) {
98
+ this.reportFailure(err, 'an RPC error');
99
+ return error.message;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The sentence for a declaration the caller holds directly, for a carrier
105
+ * with no slot to put an identity in.
106
+ *
107
+ * GLSP's action protocol is the case: every member of its message, status
108
+ * and reject actions is prose or an enum, so a code cannot travel and the
109
+ * raise site is the last place that still knows which message this is.
110
+ * Prefer {@link renderDiagnostic} / {@link renderError} wherever a carrier
111
+ * does hold the identity — they keep the render off the raise site, so an
112
+ * adopter's own messages ride the same one binding.
113
+ *
114
+ * Same no-throw contract as the carrier methods.
115
+ */
116
+ renderMessage<S extends string>(message: MessageDefinition<S>, ...params: ParamsArg<S>): string {
117
+ // `resolve` is inside the guard with the render, not before it: it calls
118
+ // the declaration's own `format`, which for an adopter declaration is
119
+ // arbitrary code, and a throw there would escape a method whose contract
120
+ // says it cannot.
121
+ let resolved: ResolvedMessage | undefined;
122
+ try {
123
+ resolved = resolve(message, ...params);
124
+ return this.render(resolved);
125
+ } catch (err: unknown) {
126
+ this.reportFailure(err, `the message '${message.code}'`);
127
+ // Unset only when `format` ITSELF threw, which leaves the
128
+ // uninterpolated template as the only text there is. A failed render
129
+ // still has the resolved English.
130
+ return resolved?.text ?? message.text;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The catalogue for the current locale, or `undefined` for none — the
136
+ * framework's answer, shipping none. Overriding this leaves every identity
137
+ * decision, every pass-through and the no-throw guard in place.
138
+ *
139
+ * **Called once per locale, not once per message**, so an override may load
140
+ * a file or build a map without that cost landing per diagnostic. The
141
+ * corollary is that a catalogue mutated in place afterwards is not seen;
142
+ * clear {@link catalogues} to invalidate.
143
+ *
144
+ * An adopter wanting Langium's own uncoded sentences instead matches
145
+ * `Diagnostic.data.code` from an overridden {@link renderDiagnostic}, never
146
+ * the sentence.
147
+ */
148
+ protected translationsFor(_locale: string | undefined): Record<string, string> | undefined {
149
+ return undefined;
150
+ }
151
+
152
+ /**
153
+ * Resolve one identity against the current locale's catalogue.
154
+ *
155
+ * Reads the locale per call. Copying it into a field at construction pins
156
+ * whatever was there before init ran, which is always `undefined` — services
157
+ * compose first.
158
+ *
159
+ * The catalogue behind it is memoized, because a workspace-wide validation
160
+ * renders once per diagnostic and the naive override — parse a JSON file,
161
+ * build a map — then pays that per diagnostic rather than per locale.
162
+ */
163
+ protected render(message: ResolvedMessage): string {
164
+ const locale = this.serverLocale.value;
165
+ return renderFrameworkMessage(
166
+ message,
167
+ this.catalogues.get(locale, () => this.translationsFor(locale))
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Emit a failed render. Logged rather than swallowed: the fallback output is
173
+ * byte-identical to a correctly-configured default, so a throwing catalogue
174
+ * is otherwise invisible.
175
+ */
176
+ protected reportFailure(err: unknown, carrier: string): void {
177
+ this.tracer.error(this.formatRenderFailure(err, carrier));
178
+ }
179
+
180
+ /** Format the failed-render line. Override to name the adopter's catalogue entry. */
181
+ protected formatRenderFailure(err: unknown, carrier: string): string {
182
+ return `rendering ${carrier} failed; falling back to the server's own text. ${describeError(err)}`;
183
+ }
184
+ }
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { type Clock, DefaultTracer, type Logger, NoopLogger, SystemClock, type Tracer } from '@hydranium/protocol';
11
11
  import type { ServerSharedServicesMinimal } from '../langium/shared-services.js';
12
+ import { ServerLocale } from '../locale/server-locale.js';
13
+ import { ServerMessageRenderer } from '../messages/renderer.js';
12
14
 
13
15
  /**
14
16
  * Overrides for {@link makeNoopSharedServices}. The three observability slots
@@ -29,6 +31,18 @@ export interface NoopSharedServicesOverrides {
29
31
  Logger?: Logger;
30
32
  /** Bound on the top-level `Tracer` slot. Default: a `DefaultTracer` over `Logger` + `Clock`. */
31
33
  Tracer?: Tracer;
34
+ /** Factory for the `ServerLocale` slot. Default: a real one, reporting no locale. */
35
+ ServerLocale?: (services: ServerSharedServicesMinimal) => ServerLocale;
36
+ /**
37
+ * Factory for the `MessageRenderer` slot. Default: the real
38
+ * {@link ServerMessageRenderer}, whose no-catalogue behaviour is a
39
+ * pass-through — so the default is the framework's own behaviour, not a stub.
40
+ *
41
+ * A factory rather than an instance, because a renderer reads the tree it is
42
+ * bound into. Both slots are resolved lazily, so an override is honoured
43
+ * however late the caller reads them.
44
+ */
45
+ MessageRenderer?: (services: ServerSharedServicesMinimal) => ServerMessageRenderer;
32
46
  /** Per-slot `workspace` overrides. Slots left out resolve to `undefined`. */
33
47
  workspace?: Record<string, unknown>;
34
48
  /** Any other minimal slot (`ServiceRegistry`, `AstReflection`, `additionalDocuments`, …). */
@@ -63,11 +77,21 @@ export interface NoopSharedServicesOverrides {
63
77
  export function makeNoopSharedServices<T extends ServerSharedServicesMinimal = ServerSharedServicesMinimal>(
64
78
  overrides: NoopSharedServicesOverrides = {}
65
79
  ): T {
66
- const { Clock: clock, Logger: logger, Tracer: tracer, workspace, additionalDocuments, ...restTop } = overrides;
80
+ const {
81
+ Clock: clock,
82
+ Logger: logger,
83
+ Tracer: tracer,
84
+ ServerLocale: serverLocale,
85
+ MessageRenderer: messageRenderer,
86
+ workspace,
87
+ additionalDocuments,
88
+ ...restTop
89
+ } = overrides;
67
90
  const resolvedClock = clock ?? new SystemClock();
68
91
  const resolvedLogger = logger ?? new NoopLogger();
69
92
  const resolvedTracer = tracer ?? new DefaultTracer(resolvedLogger, resolvedClock);
70
- return {
93
+ // Assembled in two steps, because both defaults read the tree they belong to.
94
+ const services = {
71
95
  Clock: resolvedClock,
72
96
  Logger: resolvedLogger,
73
97
  Tracer: resolvedTracer,
@@ -75,4 +99,28 @@ export function makeNoopSharedServices<T extends ServerSharedServicesMinimal = S
75
99
  ...restTop,
76
100
  workspace: { ...(workspace ?? {}) }
77
101
  } as unknown as T;
102
+ // Lazy, like Langium's own per-slot construction — and load-bearing here:
103
+ // both services emit an `instantiated` trace, so building them eagerly puts
104
+ // two lines into the capture of every test that passes a capturing logger
105
+ // and asserts on emptiness.
106
+ defineLazySlot(services, 'ServerLocale', () => serverLocale?.(services) ?? new ServerLocale(services));
107
+ defineLazySlot(services, 'MessageRenderer', () => messageRenderer?.(services) ?? new ServerMessageRenderer(services));
108
+ return services;
109
+ }
110
+
111
+ /** Install `name` as a memoized getter, so nothing is constructed until it is read. */
112
+ function defineLazySlot(target: object, name: string, create: () => unknown): void {
113
+ let resolved: unknown;
114
+ let built = false;
115
+ Object.defineProperty(target, name, {
116
+ configurable: true,
117
+ enumerable: true,
118
+ get: () => {
119
+ if (!built) {
120
+ resolved = create();
121
+ built = true;
122
+ }
123
+ return resolved;
124
+ }
125
+ });
78
126
  }
@@ -18,6 +18,8 @@ import {
18
18
  type TransferDiagnostic,
19
19
  type TransferElement
20
20
  } from '@hydranium/protocol';
21
+ import { ServerLocale } from '../locale/server-locale.js';
22
+ import { ServerMessageRenderer } from '../messages/renderer.js';
21
23
  import type { Harness } from '@hydranium/protocol/testing';
22
24
  import { type AstNode, type AstNodeDescription, type WorkspaceLock } from '@hydranium/langium';
23
25
  import type { ModelService, ModelServiceOptions } from '../langium/model-service/model-service.js';
@@ -93,6 +95,15 @@ export interface TestSharedServices<
93
95
  TransferEncoder: TransferEncoder<unknown, TDiagnostic>;
94
96
  ModelService: ModelService<TAst, TDiagnostic, TTransfer>;
95
97
  };
98
+ /**
99
+ * The REAL services, not stubs — the framework renderer's no-catalogue
100
+ * behaviour is a pass-through, so the stub tree agrees with a production one
101
+ * unless a test installs a catalogue. Bound rather than omitted because
102
+ * `HydraniumDocumentBuilder` renders through the renderer on every
103
+ * `Validated` phase, where an omitted slot is a `TypeError`.
104
+ */
105
+ readonly ServerLocale: ServerLocale;
106
+ readonly MessageRenderer: ServerMessageRenderer;
96
107
  }
97
108
 
98
109
  /** Optional configuration for {@link makeTestServices}. */
@@ -112,6 +123,14 @@ export interface MakeTestServicesOptions<
112
123
  * for tests that don't assert on the produced text.
113
124
  */
114
125
  serialize?: (uri: string, root: TTransfer) => string;
126
+ /**
127
+ * Renderer bound on `MessageRenderer`. Default: the framework's own, which
128
+ * passes every sentence through unchanged. Supply one to install a catalogue
129
+ * or to drive the throwing path.
130
+ */
131
+ messageRenderer?: (services: ServerSharedServices<TProject>) => ServerMessageRenderer;
132
+ /** Locale handed to the bundle's {@link ServerLocale}. Default: none, i.e. the framework's English. */
133
+ locale?: string;
115
134
  /**
116
135
  * Languages to register on a {@link StubServiceRegistry} bound on the
117
136
  * `ServiceRegistry` slot. This is how a test gets multi-language routing:
@@ -233,6 +252,14 @@ export interface TestServicesBundle<
233
252
  readonly logger: Logger;
234
253
  /** The clock bound on the `Clock` slot — a `makeFakeClock()` if one was passed. */
235
254
  readonly clock: Clock;
255
+ /**
256
+ * The service bound on `ServerLocale`, so a test can hand over a locale
257
+ * mid-run — which is also how it verifies the renderer reads the locale per
258
+ * render rather than caching it at construction.
259
+ */
260
+ readonly serverLocale: ServerLocale;
261
+ /** The renderer bound on `MessageRenderer` — the framework's own unless one was supplied. */
262
+ readonly messageRenderer: ServerMessageRenderer;
236
263
  }
237
264
 
238
265
  /**
@@ -311,10 +338,23 @@ export function makeTestServices<
311
338
  ...(indexManager ? { IndexManager: indexManager } : {}),
312
339
  WorkspaceLock: new HydraniumWorkspaceLock()
313
340
  },
314
- model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject>['model']
341
+ model: {} as TestSharedServices<TAst, TDiagnostic, TTransfer, TProject>['model'],
342
+ ServerLocale: {} as ServerLocale,
343
+ MessageRenderer: {} as ServerMessageRenderer
315
344
  };
316
345
  const sharedServices = services as unknown as ServerSharedServices<TProject>;
317
346
 
347
+ // Patched in after the literal, like `model` below: both read the tree they
348
+ // belong to, and the renderer reads the locale service.
349
+ const mutableMessages = services as { ServerLocale: ServerLocale; MessageRenderer: ServerMessageRenderer };
350
+ const serverLocale = new ServerLocale(sharedServices);
351
+ if (options.locale) {
352
+ serverLocale.accept(options.locale);
353
+ }
354
+ mutableMessages.ServerLocale = serverLocale;
355
+ const messageRenderer = options.messageRenderer?.(sharedServices) ?? new ServerMessageRenderer(sharedServices);
356
+ mutableMessages.MessageRenderer = messageRenderer;
357
+
318
358
  const serialize = options.serialize ?? ((_uri: string, root: TTransfer) => JSON.stringify(root));
319
359
  const transferEncoder = options.transferEncoder
320
360
  ? options.transferEncoder(sharedServices)
@@ -346,6 +386,8 @@ export function makeTestServices<
346
386
  transferEncoder,
347
387
  logger,
348
388
  clock,
389
+ serverLocale,
390
+ messageRenderer,
349
391
  dispose: () => {
350
392
  // No-op: the bundled stubs hold only in-memory state (maps, arrays),
351
393
  // released with the bundle when it goes out of scope. The hook exists