@hydranium/cli 1.0.0-next.28 → 1.0.0-next.281

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 (222) hide show
  1. package/README.md +75 -204
  2. package/bin/hydranium-cli.js +13 -0
  3. package/heap-analysis/heap/core/load.mjs +2 -2
  4. package/lib/cli-version.js.map +1 -1
  5. package/lib/cli.js +7 -2
  6. package/lib/cli.js.map +1 -1
  7. package/lib/commands/analyze-heap-args.js.map +1 -1
  8. package/lib/commands/analyze-heap.d.ts +3 -0
  9. package/lib/commands/analyze-heap.d.ts.map +1 -1
  10. package/lib/commands/analyze-heap.js +2 -1
  11. package/lib/commands/analyze-heap.js.map +1 -1
  12. package/lib/commands/ast-ground-truth-args.d.ts +3 -2
  13. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
  14. package/lib/commands/ast-ground-truth-args.js +11 -9
  15. package/lib/commands/ast-ground-truth-args.js.map +1 -1
  16. package/lib/commands/ast-ground-truth-driver.js +2 -1
  17. package/lib/commands/ast-ground-truth-driver.js.map +1 -1
  18. package/lib/commands/ast-ground-truth.js +1 -1
  19. package/lib/commands/ast-ground-truth.js.map +1 -1
  20. package/lib/commands/client-session.d.ts +17 -0
  21. package/lib/commands/client-session.d.ts.map +1 -0
  22. package/lib/commands/client-session.js +27 -0
  23. package/lib/commands/client-session.js.map +1 -0
  24. package/lib/commands/create-args.d.ts +17 -0
  25. package/lib/commands/create-args.d.ts.map +1 -0
  26. package/lib/commands/create-args.js +53 -0
  27. package/lib/commands/create-args.js.map +1 -0
  28. package/lib/commands/create.d.ts +38 -0
  29. package/lib/commands/create.d.ts.map +1 -0
  30. package/lib/commands/create.js +47 -0
  31. package/lib/commands/create.js.map +1 -0
  32. package/lib/commands/delete-args.d.ts +17 -0
  33. package/lib/commands/delete-args.d.ts.map +1 -0
  34. package/lib/commands/delete-args.js +49 -0
  35. package/lib/commands/delete-args.js.map +1 -0
  36. package/lib/commands/delete.d.ts +30 -0
  37. package/lib/commands/delete.d.ts.map +1 -0
  38. package/lib/commands/delete.js +32 -0
  39. package/lib/commands/delete.js.map +1 -0
  40. package/lib/commands/generate-ast-builder.d.ts +49 -0
  41. package/lib/commands/generate-ast-builder.d.ts.map +1 -0
  42. package/lib/commands/generate-ast-builder.js +200 -0
  43. package/lib/commands/generate-ast-builder.js.map +1 -0
  44. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
  45. package/lib/commands/generate-transfer-model-args.js +11 -1
  46. package/lib/commands/generate-transfer-model-args.js.map +1 -1
  47. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
  48. package/lib/commands/generate-transfer-model-config.js +6 -3
  49. package/lib/commands/generate-transfer-model-config.js.map +1 -1
  50. package/lib/commands/generate-transfer-model.d.ts +9 -0
  51. package/lib/commands/generate-transfer-model.d.ts.map +1 -1
  52. package/lib/commands/generate-transfer-model.js +18 -4
  53. package/lib/commands/generate-transfer-model.js.map +1 -1
  54. package/lib/commands/harness-args.d.ts +44 -42
  55. package/lib/commands/harness-args.d.ts.map +1 -1
  56. package/lib/commands/harness-args.js +109 -27
  57. package/lib/commands/harness-args.js.map +1 -1
  58. package/lib/commands/headless-harness.d.ts +37 -20
  59. package/lib/commands/headless-harness.d.ts.map +1 -1
  60. package/lib/commands/headless-harness.js +68 -17
  61. package/lib/commands/headless-harness.js.map +1 -1
  62. package/lib/commands/init-args.js +6 -6
  63. package/lib/commands/init-args.js.map +1 -1
  64. package/lib/commands/init-prompt.d.ts.map +1 -1
  65. package/lib/commands/init-templates.d.ts +8 -10
  66. package/lib/commands/init-templates.d.ts.map +1 -1
  67. package/lib/commands/init-templates.js +261 -167
  68. package/lib/commands/init-templates.js.map +1 -1
  69. package/lib/commands/init-wizard.js +4 -4
  70. package/lib/commands/init-wizard.js.map +1 -1
  71. package/lib/commands/init-workspace.d.ts +13 -3
  72. package/lib/commands/init-workspace.d.ts.map +1 -1
  73. package/lib/commands/init-workspace.js +21 -6
  74. package/lib/commands/init-workspace.js.map +1 -1
  75. package/lib/commands/init.d.ts +16 -10
  76. package/lib/commands/init.d.ts.map +1 -1
  77. package/lib/commands/init.js +17 -15
  78. package/lib/commands/init.js.map +1 -1
  79. package/lib/commands/lint-grammar-args.d.ts +3 -2
  80. package/lib/commands/lint-grammar-args.d.ts.map +1 -1
  81. package/lib/commands/lint-grammar-args.js +13 -10
  82. package/lib/commands/lint-grammar-args.js.map +1 -1
  83. package/lib/commands/lint-grammar-driver.js +2 -1
  84. package/lib/commands/lint-grammar-driver.js.map +1 -1
  85. package/lib/commands/lint-grammar-report.js.map +1 -1
  86. package/lib/commands/lint-grammar.js +1 -1
  87. package/lib/commands/lint-grammar.js.map +1 -1
  88. package/lib/commands/measure-memory-args.d.ts +3 -2
  89. package/lib/commands/measure-memory-args.d.ts.map +1 -1
  90. package/lib/commands/measure-memory-args.js +22 -11
  91. package/lib/commands/measure-memory-args.js.map +1 -1
  92. package/lib/commands/measure-memory-driver.js +5 -3
  93. package/lib/commands/measure-memory-driver.js.map +1 -1
  94. package/lib/commands/measure-memory.js +1 -1
  95. package/lib/commands/measure-memory.js.map +1 -1
  96. package/lib/commands/model-docs-args.d.ts +4 -3
  97. package/lib/commands/model-docs-args.d.ts.map +1 -1
  98. package/lib/commands/model-docs-args.js +12 -10
  99. package/lib/commands/model-docs-args.js.map +1 -1
  100. package/lib/commands/model-docs-driver.js +2 -1
  101. package/lib/commands/model-docs-driver.js.map +1 -1
  102. package/lib/commands/model-docs.js +1 -1
  103. package/lib/commands/model-docs.js.map +1 -1
  104. package/lib/commands/projects-args.d.ts.map +1 -1
  105. package/lib/commands/projects-args.js +5 -5
  106. package/lib/commands/projects-args.js.map +1 -1
  107. package/lib/commands/projects.d.ts +3 -4
  108. package/lib/commands/projects.d.ts.map +1 -1
  109. package/lib/commands/projects.js +2 -2
  110. package/lib/commands/projects.js.map +1 -1
  111. package/lib/commands/query-args.d.ts.map +1 -1
  112. package/lib/commands/query-args.js +5 -5
  113. package/lib/commands/query-args.js.map +1 -1
  114. package/lib/commands/query.d.ts +3 -4
  115. package/lib/commands/query.d.ts.map +1 -1
  116. package/lib/commands/query.js +2 -2
  117. package/lib/commands/query.js.map +1 -1
  118. package/lib/commands/reflect-args.d.ts +3 -2
  119. package/lib/commands/reflect-args.d.ts.map +1 -1
  120. package/lib/commands/reflect-args.js +13 -10
  121. package/lib/commands/reflect-args.js.map +1 -1
  122. package/lib/commands/reflect-driver.js +2 -1
  123. package/lib/commands/reflect-driver.js.map +1 -1
  124. package/lib/commands/reflect-report.js.map +1 -1
  125. package/lib/commands/reflect.js +1 -1
  126. package/lib/commands/reflect.js.map +1 -1
  127. package/lib/commands/save-args.d.ts.map +1 -1
  128. package/lib/commands/save-args.js +7 -4
  129. package/lib/commands/save-args.js.map +1 -1
  130. package/lib/commands/save.d.ts +8 -4
  131. package/lib/commands/save.d.ts.map +1 -1
  132. package/lib/commands/save.js +12 -23
  133. package/lib/commands/save.js.map +1 -1
  134. package/lib/commands/validate-args.d.ts +3 -2
  135. package/lib/commands/validate-args.d.ts.map +1 -1
  136. package/lib/commands/validate-args.js +13 -10
  137. package/lib/commands/validate-args.js.map +1 -1
  138. package/lib/commands/validate-driver.js +2 -1
  139. package/lib/commands/validate-driver.js.map +1 -1
  140. package/lib/commands/validate-report.js.map +1 -1
  141. package/lib/commands/validate.js +1 -1
  142. package/lib/commands/validate.js.map +1 -1
  143. package/lib/commands/watch-args.d.ts.map +1 -1
  144. package/lib/commands/watch-args.js +6 -4
  145. package/lib/commands/watch-args.js.map +1 -1
  146. package/lib/commands/watch.d.ts +3 -4
  147. package/lib/commands/watch.d.ts.map +1 -1
  148. package/lib/commands/watch.js +20 -2
  149. package/lib/commands/watch.js.map +1 -1
  150. package/lib/driver-heap.d.ts +52 -0
  151. package/lib/driver-heap.d.ts.map +1 -0
  152. package/lib/driver-heap.js +51 -0
  153. package/lib/driver-heap.js.map +1 -0
  154. package/lib/index.d.ts +3 -0
  155. package/lib/index.d.ts.map +1 -1
  156. package/lib/index.js +3 -0
  157. package/lib/index.js.map +1 -1
  158. package/lib/log-level.d.ts +21 -11
  159. package/lib/log-level.d.ts.map +1 -1
  160. package/lib/log-level.js +25 -14
  161. package/lib/log-level.js.map +1 -1
  162. package/lib/spawn-data-server.d.ts +9 -4
  163. package/lib/spawn-data-server.d.ts.map +1 -1
  164. package/lib/spawn-data-server.js +5 -4
  165. package/lib/spawn-data-server.js.map +1 -1
  166. package/lib/stderr-connection-logger.d.ts +12 -0
  167. package/lib/stderr-connection-logger.d.ts.map +1 -0
  168. package/lib/stderr-connection-logger.js +16 -0
  169. package/lib/stderr-connection-logger.js.map +1 -0
  170. package/lib/testing/echo-server.js +9 -1
  171. package/lib/testing/echo-server.js.map +1 -1
  172. package/package.json +50 -39
  173. package/src/cli.ts +7 -2
  174. package/src/commands/analyze-heap.ts +4 -1
  175. package/src/commands/ast-ground-truth-args.ts +15 -12
  176. package/src/commands/ast-ground-truth-driver.ts +2 -1
  177. package/src/commands/ast-ground-truth.ts +1 -1
  178. package/src/commands/client-session.ts +33 -0
  179. package/src/commands/create-args.ts +68 -0
  180. package/src/commands/create.ts +86 -0
  181. package/src/commands/delete-args.ts +64 -0
  182. package/src/commands/delete.ts +61 -0
  183. package/src/commands/generate-ast-builder.ts +246 -0
  184. package/src/commands/generate-transfer-model-args.ts +11 -1
  185. package/src/commands/generate-transfer-model-config.ts +6 -3
  186. package/src/commands/generate-transfer-model.ts +27 -4
  187. package/src/commands/harness-args.ts +95 -31
  188. package/src/commands/headless-harness.ts +86 -26
  189. package/src/commands/init-args.ts +6 -6
  190. package/src/commands/init-templates.ts +272 -169
  191. package/src/commands/init-wizard.ts +4 -4
  192. package/src/commands/init-workspace.ts +31 -7
  193. package/src/commands/init.ts +36 -26
  194. package/src/commands/lint-grammar-args.ts +17 -13
  195. package/src/commands/lint-grammar-driver.ts +2 -1
  196. package/src/commands/lint-grammar.ts +1 -1
  197. package/src/commands/measure-memory-args.ts +27 -14
  198. package/src/commands/measure-memory-driver.ts +5 -3
  199. package/src/commands/measure-memory.ts +1 -1
  200. package/src/commands/model-docs-args.ts +16 -13
  201. package/src/commands/model-docs-driver.ts +2 -1
  202. package/src/commands/model-docs.ts +1 -1
  203. package/src/commands/projects-args.ts +5 -4
  204. package/src/commands/projects.ts +4 -6
  205. package/src/commands/query-args.ts +5 -4
  206. package/src/commands/query.ts +4 -6
  207. package/src/commands/reflect-args.ts +17 -13
  208. package/src/commands/reflect-driver.ts +2 -1
  209. package/src/commands/reflect.ts +1 -1
  210. package/src/commands/save-args.ts +7 -3
  211. package/src/commands/save.ts +24 -27
  212. package/src/commands/validate-args.ts +17 -13
  213. package/src/commands/validate-driver.ts +2 -1
  214. package/src/commands/validate.ts +1 -1
  215. package/src/commands/watch-args.ts +6 -3
  216. package/src/commands/watch.ts +28 -7
  217. package/src/driver-heap.ts +65 -0
  218. package/src/index.ts +3 -0
  219. package/src/log-level.ts +42 -14
  220. package/src/spawn-data-server.ts +17 -5
  221. package/src/stderr-connection-logger.ts +18 -0
  222. package/src/testing/echo-server.ts +11 -2
@@ -71,15 +71,6 @@
71
71
  * over; the adopter's own formatter settles it.
72
72
  */
73
73
  import { readCliVersion } from '../cli-version.js';
74
- /**
75
- * The version every framework package carries before the first release.
76
- *
77
- * A scaffold made by a CLI still at this version pins a range the registry
78
- * cannot serve, so the emitted README keeps its yalc note and `init` keeps its
79
- * install warning while — and only while — it holds: from a published CLI the
80
- * derived pins resolve and either note would be false as printed.
81
- */
82
- export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
83
74
  /**
84
75
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
85
76
  * CLI's own.
@@ -156,7 +147,7 @@ function columnsFor(composition) {
156
147
  }
157
148
  /**
158
149
  * A named import on one line while it fits the column budget, else one symbol
159
- * per line. Emitted files are supposed to pass a prettier check as-is, and the
150
+ * per line. Emitted files are supposed to pass a format check as-is, and the
160
151
  * generated-module import grows by one symbol per grammar.
161
152
  */
162
153
  function importList(symbols, from, columns, typeOnly = false) {
@@ -204,7 +195,12 @@ const HEAD_FRAMEWORK_DEPENDENCIES = {
204
195
  const HEAD_THIRD_PARTY_DEPENDENCIES = {
205
196
  lsp: {
206
197
  langium: '4.3.1',
207
- 'vscode-languageserver': '~10.0.1'
198
+ // Exact, as are the protocol and transport it pins, so Langium and the
199
+ // framework share their copies; across copies, an error a handler throws
200
+ // or returns loses its code, and a later patch can pin another protocol.
201
+ 'vscode-languageserver': '10.0.1',
202
+ 'vscode-jsonrpc': '9.0.0',
203
+ 'vscode-languageserver-protocol': '3.18.1'
208
204
  },
209
205
  data: {},
210
206
  glsp: {
@@ -216,6 +212,34 @@ const HEAD_THIRD_PARTY_DEPENDENCIES = {
216
212
  'reflect-metadata': '~0.2.2'
217
213
  }
218
214
  };
215
+ /**
216
+ * The exact pins of `heads`, for a workspace root to declare too. npm installs a
217
+ * root's own dependencies at the top of the tree, so another member's version of
218
+ * one nests beneath that member instead of taking the place of the copy this
219
+ * package shares with the framework.
220
+ */
221
+ export function workspaceRootPins(heads) {
222
+ return heads
223
+ .flatMap(head => Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]))
224
+ .filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version))
225
+ .sort(([left], [right]) => left.localeCompare(right));
226
+ }
227
+ /** Whether an npm version is 11.6.0 or later, the first that installs `vitest` 4.1's peer set; `11.6.0-rc.1` is earlier. */
228
+ function installsVitest41(npmVersion) {
229
+ const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
230
+ const [major, minor, patch] = release.split('.').map(Number);
231
+ return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
232
+ }
233
+ /**
234
+ * Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
235
+ * before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
236
+ * workspace root declaring a newer npm lifts it, and only when `init` runs under
237
+ * an npm known to be newer: npm does not enforce `packageManager`.
238
+ */
239
+ function pinsVitest(composition) {
240
+ const workspace = composition.packaging.workspace;
241
+ return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
242
+ }
219
243
  /** The dependency block for a head set, merged and sorted as npm writes it. */
220
244
  function dependencyBlock(composition, indent) {
221
245
  const merged = {};
@@ -234,7 +258,7 @@ function dependencyBlock(composition, indent) {
234
258
  // npm convention, and `npm install` rewrites `package.json` at two spaces
235
259
  // regardless — so three would not survive the adopter's first install.
236
260
  //
237
- // The KEY ORDER is `prettier-plugin-packagejson`'s canonical one, `private`
261
+ // The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
238
262
  // before `license` included. Any other order is rewritten by the scaffolded
239
263
  // project's own first `format` run — a diff on a file the adopter never touched
240
264
  // — and it makes a byte-compare against a formatted copy of this emission
@@ -265,16 +289,9 @@ const PACKAGE_JSON = `{
265
289
  "version": "0.0.0",
266
290
  __PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
267
291
  "keywords": [
268
- "hydranium",
269
- "langium",
270
- "language-server",
271
- "__PROJECT_ID__"
272
- ],
292
+ __KEYWORDS__ ],
273
293
  "license": "UNLICENSED",
274
294
  "author": "",
275
- "type": "module",
276
- "main": "lib/index.js",
277
- "types": "lib/index.d.ts",
278
295
  "bin": {
279
296
  __BIN__ },
280
297
  "files": [
@@ -282,11 +299,14 @@ __BIN__ },
282
299
  "src",
283
300
  "syntaxes"
284
301
  ],
302
+ "type": "module",
303
+ "main": "lib/index.js",
304
+ "types": "lib/index.d.ts",
285
305
  "scripts": {
286
306
  "build": "npm run generate && tsc",
287
- "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-transfer tsconfig.tsbuildinfo",
307
+ "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
288
308
  "generate": "npm run langium:generate && npm run generate:transfer-model",
289
- "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-transfer/transfer-model.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
309
+ "generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
290
310
  "langium:generate": "langium generate",
291
311
  "langium:watch": "langium generate --watch",
292
312
  __LINT__ "start": "node lib/main.js --stdio",
@@ -302,8 +322,8 @@ __DEPENDENCIES__
302
322
  "@types/node": "^22.0.0",
303
323
  "langium-cli": "4.3.0",
304
324
  "rimraf": "^5.0.0",
305
- "typescript": "^5.8.0",
306
- "vitest": "^4.0.0"
325
+ "typescript": "^7.0.2",
326
+ "vitest": "__VITEST__"
307
327
  },
308
328
  "engines": {
309
329
  "node": ">=22.13"
@@ -331,7 +351,7 @@ function npmRun(composition) {
331
351
  * name. Without the second key a scaffolded project has no command line that
332
352
  * reaches its own data head at all.
333
353
  *
334
- * Alphabetical, because `prettier-plugin-packagejson` sorts `bin` and any other
354
+ * Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
335
355
  * order is rewritten by the scaffolded project's first `format` run.
336
356
  */
337
357
  function binBlock(composition) {
@@ -345,15 +365,29 @@ function binBlock(composition) {
345
365
  function packageJson(composition) {
346
366
  const { scope } = composition.packaging;
347
367
  const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
348
- // Emitted only where a root eslint config says the repo lints. The absence is
368
+ const keywords = ['hydranium', 'langium', 'language-server', composition.projectId]
369
+ .sort()
370
+ .map(keyword => ` ${JSON.stringify(keyword)}`)
371
+ .join(',\n') + '\n';
372
+ // Emitted only where a root lint config says the repo lints. The absence is
349
373
  // the dangerous direction: a task runner runs a script only where one is
350
374
  // declared, so a package with no `lint` is SKIPPED rather than reported, and
351
375
  // that reads as a clean lint. A wrong invocation is one visible line to edit.
352
- const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
376
+ const workspace = composition.packaging.workspace;
377
+ const oxlintConfig = workspace?.oxlintConfig === undefined
378
+ ? undefined
379
+ : `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
380
+ const lint = workspace?.oxlintConfig !== undefined
381
+ ? ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${oxlintConfig} src test --max-warnings 0",\n`
382
+ : workspace?.eslintConfig !== undefined
383
+ ? ' "lint": "eslint src test --max-warnings 0",\n'
384
+ : '';
353
385
  return project(PACKAGE_JSON, composition)
354
386
  .replace('__PACKAGE_NAME__', packageName)
387
+ .replace('__KEYWORDS__', keywords)
355
388
  .replace('__BIN__', binBlock(composition))
356
389
  .replace('__LINT__', lint)
390
+ .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
357
391
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
358
392
  .replace('__NPM_RUN__', npmRun(composition))
359
393
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
@@ -406,6 +440,11 @@ const TSCONFIG_COMPILER_OPTIONS = [
406
440
  ['outDir', 'lib'],
407
441
  ['strict', true],
408
442
  ['esModuleInterop', true],
443
+ // A server that renders its own user-facing messages loads a catalogue, and
444
+ // a catalogue is JSON. Without this the import does not resolve at all, and
445
+ // without the `include` glob below a composite project rejects it with
446
+ // TS6307 naming neither cause.
447
+ ['resolveJsonModule', true],
409
448
  ['skipLibCheck', true],
410
449
  ['declaration', true],
411
450
  ['experimentalDecorators', true],
@@ -420,9 +459,19 @@ const TSCONFIG_COMPILER_OPTIONS = [
420
459
  * base is wrong rather than that the member is redundant.
421
460
  */
422
461
  const PACKAGE_LOCAL_OPTIONS = ['rootDir', 'outDir'];
423
- /** Compare two JSON option values. Sound here because compiler options are scalars and flat arrays. */
424
- function jsonEquals(left, right) {
425
- return JSON.stringify(left) === JSON.stringify(right);
462
+ /**
463
+ * The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
464
+ * `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
465
+ * An enum-valued option added there belongs here too.
466
+ */
467
+ const CASE_INSENSITIVE_OPTIONS = ['target', 'lib', 'module', 'moduleResolution'];
468
+ /** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
469
+ function optionEquals(key, left, right) {
470
+ const normalise = (value) => {
471
+ const text = JSON.stringify(value);
472
+ return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
473
+ };
474
+ return normalise(left) === normalise(right);
426
475
  }
427
476
  /**
428
477
  * `tsconfig.json`, extending the workspace's base config when there is one.
@@ -439,19 +488,20 @@ function tsconfigJson(composition) {
439
488
  ? TSCONFIG_COMPILER_OPTIONS
440
489
  : [
441
490
  ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
442
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !jsonEquals(base[key], value))
491
+ ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value))
443
492
  ];
444
493
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
445
494
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
446
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src"]\n}\n`;
495
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
447
496
  }
448
497
  // `isolatedModules` is what makes this check agree with the transform that
449
498
  // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
450
- // vitest compiles through esbuild — a per-file transform with no type
499
+ // vitest compiles each file on its own — a transform with no type
451
500
  // information, which cannot tell a re-exported TYPE from a re-exported value.
452
- // Without the flag `tsc` accepts `export { SomeType } from './x'` that esbuild
453
- // then emits as a real import of a symbol that does not exist at runtime, so
454
- // the typecheck passes and the suite fails with a confusing missing-export.
501
+ // Without the flag `tsc` accepts `export { SomeType } from './x'` that the
502
+ // transform then emits as a real import of a symbol that does not exist at
503
+ // runtime, so the typecheck passes and the suite fails with a confusing
504
+ // missing-export.
455
505
  const TSCONFIG_TEST = `{
456
506
  "extends": "./tsconfig.json",
457
507
  "compilerOptions": {
@@ -460,7 +510,15 @@ const TSCONFIG_TEST = `{
460
510
  "isolatedModules": true,
461
511
  "types": ["node"]
462
512
  },
463
- "include": ["src", "test"]
513
+ "include": ["src", "test", "src/**/*.json"]
514
+ }
515
+ `;
516
+ // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
517
+ // applies a tsconfig only to the files its own `include` lists, so without this
518
+ // a test declaring a decorated class fails to load.
519
+ const TEST_DIR_TSCONFIG = `{
520
+ "extends": "../tsconfig.test.json",
521
+ "include": ["../src", ".", "../src/**/*.json"]
464
522
  }
465
523
  `;
466
524
  const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
@@ -468,7 +526,7 @@ const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
468
526
  // Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
469
527
  // \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
470
528
  // compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
471
- // scanned; \`npm test\` runs \`typecheck:test\` first, because the esbuild
529
+ // scanned; \`npm test\` runs \`typecheck:test\` first, because vitest's
472
530
  // transform strips types without checking them.
473
531
 
474
532
  export default defineConfig({
@@ -478,6 +536,11 @@ export default defineConfig({
478
536
  }
479
537
  });
480
538
  `;
539
+ /**
540
+ * The `.gitignore`, written for a workspace member too: its root may not ignore
541
+ * `lib/` (a `yo langium` root ignores `out/`), and a rule the root repeats
542
+ * costs nothing.
543
+ */
481
544
  const GITIGNORE = `node_modules/
482
545
  lib/
483
546
  *.tsbuildinfo
@@ -485,26 +548,6 @@ lib/
485
548
  # Langium-generated TextMate grammar (rewritten by every \`langium generate\`).
486
549
  syntaxes/
487
550
  `;
488
- /**
489
- * The workspace-member `.gitignore` — one entry, and the one entry a root
490
- * cannot be assumed to have.
491
- *
492
- * A member inherits the root's rules, and `node_modules/`, `lib/` and
493
- * `*.tsbuildinfo` are in every monorepo root already, so repeating them here
494
- * would be three lines to keep in step for no coverage. `syntaxes/` is not like
495
- * them: it is a Langium artefact, so a root that has never held a Langium
496
- * package has no rule for it, and the first `langium generate` then offers
497
- * generated output up for commit with nothing to warn the adopter. Dropping the
498
- * whole file rather than this subset is the mistake this template exists to
499
- * undo.
500
- */
501
- const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
502
- # It has no reason to know about this one: the TextMate grammar is a Langium
503
- # artefact, rewritten by every \`langium generate\`, so a root that has never held
504
- # a Langium package ignores nothing here and the generated file is offered for
505
- # commit.
506
- syntaxes/
507
- `;
508
551
  /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
509
552
  const TERMINALS = `hidden terminal WS: /\\s+/;
510
553
  terminal ID: /[_a-zA-Z][\\w_]*/;
@@ -747,9 +790,9 @@ const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's
747
790
  //
748
791
  // Without it every structured write fails: the framework's default binding at
749
792
  // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
750
- // language knowledge no framework can derive. \`ModelService.update\` / \`save\`,
751
- // the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\` all reach
752
- // it.
793
+ // language knowledge no framework can derive. A client session's \`update\` /
794
+ // \`save\`, the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\`
795
+ // all reach it.
753
796
  //
754
797
  // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
755
798
  // output — its YAML and JSON subclasses are what it exists for — and cannot
@@ -925,7 +968,7 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
925
968
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
926
969
  // silently tells every typed client that a reference is a resolvable object
927
970
  // rather than a name.
928
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
971
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
929
972
  `
930
973
  : '';
931
974
  // The commands live in `head-ports.ts`, not here: a host shell has to name
@@ -947,19 +990,22 @@ const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: s
947
990
  return { dispose: () => undefined };
948
991
  });
949
992
  dataServer.started
950
- .then(() => {
951
- const { port } = dataServer;
952
- if (port === undefined) {
953
- // \`started\` resolves only once the address is resolved, so this is
954
- // unreachable; a non-null assertion in its place would publish
955
- // \`undefined\`, which the host cannot tell from an unreachable port.
956
- throw new Error('the data head started without a resolved port');
957
- }
958
- publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
959
- })
993
+ .then(
994
+ () => {
995
+ const { port } = dataServer;
996
+ if (port === undefined) {
997
+ // \`started\` resolves only once the address is resolved, so this is
998
+ // unreachable; a non-null assertion in its place would publish
999
+ // \`undefined\`, which the host cannot tell from an unreachable port.
1000
+ throw new Error('the data head started without a resolved port');
1001
+ }
1002
+ publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
1003
+ },
1004
+ // The launcher logged its own bind failure; the rejection is control flow.
1005
+ () => undefined
1006
+ )
960
1007
  .catch((error: unknown) => {
961
- const reason = error instanceof Error ? error.message : String(error);
962
- shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${reason}\`);
1008
+ shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
963
1009
  });
964
1010
  `
965
1011
  : '';
@@ -993,20 +1039,26 @@ void glspServer;
993
1039
  // script.${data
994
1040
  ? `
995
1041
  //
996
- // NOT the entry \`hydranium-cli query\` / \`save\` / \`projects\` / \`watch\` speak to:
997
- // stdio here carries LSP, and the data head is a socket whose port is published
998
- // over the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
1042
+ // NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
1043
+ // here carries LSP, and the data head is a socket whose port is published over
1044
+ // the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
999
1045
  : ''}
1000
1046
  //
1001
1047
  // Everything here runs at module scope, so this file is an executable rather
1002
1048
  // than a library entry — import \`./index.js\` instead to compose the language.
1003
1049
 
1004
1050
  ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1005
- ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}import { startLanguageServer } from '@hydranium/langium/lsp';
1051
+ ${data ? "import { DataServer } from '@hydranium/data-server';\nimport { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1052
+ // signature-compatible and delegates straight through; what it adds first is
1053
+ // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1054
+ // was never composed. Reaching for Langium's is the natural mistake and it is
1055
+ // silent: the server boots, links and completes, while echo suppression, the
1056
+ // didChangeContent debounce and the last-client-close rebuild are all inert.
1057
+ import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1006
1058
  import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1007
1059
  ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1008
1060
  ${portCommands === '' ? '' : '\n' + portCommands}
1009
- const connection = createConnection(ProposedFeatures.all);
1061
+ const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1010
1062
  const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1011
1063
  startLanguageServer(shared);
1012
1064
  ${dataBlock}${glspBlock}`;
@@ -1018,11 +1070,11 @@ ${dataBlock}${glspBlock}`;
1018
1070
  * Emitted with the `data` head because it is the ONLY command line that reaches
1019
1071
  * that head from outside the editor. `main.ts` gives stdio to LSP and publishes
1020
1072
  * the data head's socket port over the LSP connection, so a client that is not
1021
- * an LSP client cannot discover it — which is every one of `hydranium-cli`'s
1022
- * `query` / `save` / `projects` / `watch`, all of which drive the data protocol
1023
- * over a spawned child's stdin/stdout. Without this file a scaffolded project
1024
- * has no `--server` value, and the closest guess — the LSP entry — fails as an
1025
- * unrecognised method rather than as a missing head.
1073
+ * an LSP client cannot discover it — which is every data-server subcommand of
1074
+ * `hydranium-cli`, since each drives the data protocol over a spawned child's
1075
+ * stdin/stdout. Without this file a scaffolded project has no `--server` value,
1076
+ * and the closest guess — the LSP entry — fails as an unrecognised method
1077
+ * rather than as a missing head.
1026
1078
  *
1027
1079
  * A separate process rather than a flag on `main.ts`: only one protocol can own
1028
1080
  * stdio, so the two entries are mutually exclusive by construction.
@@ -1032,10 +1084,9 @@ function dataServerMainFile(composition) {
1032
1084
  const roots = [...composition.grammars.map(grammar => grammar.entryRule)].sort();
1033
1085
  const plural = roots.length > 1 ? 's' : '';
1034
1086
  const text = `${SHEBANG}// Standalone entry point: a data-server head on **stdio**, with no LSP head in
1035
- // the process. This is the transport \`hydranium-cli\` speaks — \`query\`, \`save\`,
1036
- // \`projects\` and \`watch\` all spawn a server command and drive JSON-RPC over its
1037
- // stdin/stdout — so it is what makes those subcommands usable against this
1038
- // language.
1087
+ // the process. This is the transport \`hydranium-cli\` speaks — its data-server
1088
+ // subcommands spawn a server command and drive JSON-RPC over its stdin/stdout —
1089
+ // so it is what makes those subcommands usable against this language.
1039
1090
  //
1040
1091
  // Invocation: \`node lib/data-server-main.js [<workspace-path>]\`, or the
1041
1092
  // package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
@@ -1054,7 +1105,7 @@ function dataServerMainFile(composition) {
1054
1105
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1055
1106
  import { DataServer } from '@hydranium/data-server';
1056
1107
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1057
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
1108
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1058
1109
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1059
1110
 
1060
1111
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
@@ -1170,8 +1221,8 @@ function serializationTest(composition) {
1170
1221
  const transferTypes = composition.grammars.map(grammar => `${grammar.entryRule} as Transfer${grammar.entryRule}`);
1171
1222
  const text = `// Round-trips each grammar through its serializer: parse the source, serialize
1172
1223
  // the model, compare the text. The serializer is the parser's inverse, so this
1173
- // is the assertion that keeps \`ModelService.update\` / \`save\` writing files the
1174
- // language server can read back.
1224
+ // is the assertion that keeps a client session's \`update\` / \`save\` writing
1225
+ // files the language server can read back.
1175
1226
  //
1176
1227
  // The transfer case is the one that catches the mistake worth catching.
1177
1228
  // \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
@@ -1181,7 +1232,7 @@ function serializationTest(composition) {
1181
1232
  import { parseHelper } from '@hydranium/core/testing';
1182
1233
  import { describe, expect, it } from 'vitest';
1183
1234
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1184
- ${importList(transferTypes, '../src/language-server/generated-transfer/transfer-model.js', columns, true)}
1235
+ ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1185
1236
  import { createServices } from '../src/services.js';
1186
1237
 
1187
1238
  ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
@@ -1340,20 +1391,6 @@ function readme(composition) {
1340
1391
  '',
1341
1392
  '## Getting started',
1342
1393
  '',
1343
- // Conditional on the pin this scaffold actually carries — see
1344
- // `UNPUBLISHED_FRAMEWORK_VERSION`.
1345
- ...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
1346
- ? [
1347
- '> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
1348
- '> pins below are placeholders and `npm install` will fail with a 404 until',
1349
- '> the framework is released. Until then, supply the packages from a local',
1350
- '> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
1351
- '> `file:` path or `npm link` is not enough, because the framework packages',
1352
- '> depend on each other by version and npm would try to fetch those from the',
1353
- '> registry too.',
1354
- ''
1355
- ]
1356
- : []),
1357
1394
  '```bash',
1358
1395
  'npm install',
1359
1396
  'npm run langium:generate # generate the AST from the grammar',
@@ -1461,6 +1498,24 @@ function readme(composition) {
1461
1498
  'atomic set and depends on a single physical copy, so a floating range can',
1462
1499
  'silently resolve a second one.',
1463
1500
  '',
1501
+ '`vscode-languageserver` is pinned to `10.0.1`, and',
1502
+ '`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.1` and',
1503
+ '`9.0.0` it pins, so Langium, the LSP connection and the framework share one',
1504
+ 'copy of each. With two, an error a Langium handler returns, such as a request',
1505
+ 'for a document the server does not have, reaches the client as a result, and a',
1506
+ 'framework error your own LSP handler throws loses its code. The Langium 4.4',
1507
+ 'upgrade',
1508
+ '([eclipse-emfcloud/hydranium#142](https://github.com/eclipse-emfcloud/hydranium/issues/142))',
1509
+ 'replaces these pins.',
1510
+ '',
1511
+ ...(pinsVitest(composition)
1512
+ ? [
1513
+ '`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
1514
+ 'release bundles. Raise it once every npm that installs this project is',
1515
+ '11.6 or later.',
1516
+ ''
1517
+ ]
1518
+ : []),
1464
1519
  '## If your repo gates license headers',
1465
1520
  '',
1466
1521
  'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
@@ -1479,10 +1534,9 @@ function readme(composition) {
1479
1534
  /**
1480
1535
  * The GLSP files for ONE grammar's diagram.
1481
1536
  *
1482
- * Per grammar rather than per project because a diagram type has exactly one
1483
- * grammar — `AbstractHydraniumGlspDiagramModule.declareLanguage()` returns that
1484
- * grammar's `LanguageMetaData`, and it is bound on the SESSION container so a
1485
- * grammar with no diagram can sit on the same server.
1537
+ * Per grammar rather than per project because the state, storage and GModel
1538
+ * factory are typed over that grammar's model, and a grammar with no diagram
1539
+ * can sit on the same server.
1486
1540
  *
1487
1541
  * The index and the computed-bounds handler are framework classes used
1488
1542
  * unmodified; storage and the submission handler are thin typed subclasses,
@@ -1547,8 +1601,11 @@ export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RUL
1547
1601
  `;
1548
1602
  const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1549
1603
  // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
1550
- // \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
1551
- // the multi-client text store → \`WritableFileSystemProvider\`).
1604
+ // \`saveSourceModel\` (flush the store's text for the primary and every tracked
1605
+ // secondary through the diagram's client session).
1606
+ //
1607
+ // No serializer runs on the save path — a save persists what the diagram's
1608
+ // operations already wrote to the store.
1552
1609
  //
1553
1610
  // The subclass exists so a bespoke load or save has a stable place to land.
1554
1611
 
@@ -1704,27 +1761,28 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1704
1761
  // read-only is a deletion the compiler checks, while read-only → editable is
1705
1762
  // authoring against a seam you have not used yet.
1706
1763
  //
1707
- // **It composes TEXT rather than mutating the AST.** The source model of
1708
- // \`FullTextHydraniumGlspState\` is the document text, and reading it back through
1709
- // \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
1710
- // this scaffold does not bind — so that getter throws until you do. Appending a
1711
- // declaration to the text the parser last read needs no serializer, which is what
1712
- // makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
1713
- // \`services.serializer.Serializer\` and this becomes a
1714
- // \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
1715
- // binding the diagram's own save action needs.
1764
+ // **It edits the operation's working copy.** A handler runs inside an operation,
1765
+ // where \`sourceRoot\` is a copy of the built root: the recording command's
1766
+ // runnable appends to the copy, and the operation writes the copy's text through
1767
+ // the grammar's \`Serializer\` once, gated on the version the copy was made at,
1768
+ // with undo and redo applying that one change. A command that writes the
1769
+ // document itself instead reads no text off the copy, which has no \`$document\`,
1770
+ // and the operation's own write then overwrites it.
1716
1771
  //
1717
1772
  // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1718
1773
  // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1719
1774
  // node is appended at the end of the document and the client places it.
1720
1775
  //
1721
1776
  // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
1722
- // knows the starter grammar's concrete syntax — the \`node\` keyword below is that
1723
- // grammar's. Replacing the grammar means replacing these four together.
1777
+ // knows the starter grammar's shape — the \`nodes\` list and the \`__NODE_RULE__\`
1778
+ // it holds. Replacing the grammar means replacing these four together.
1724
1779
 
1725
1780
  import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
1781
+ import { appendChild } from '@hydranium/core';
1782
+ import { HydraniumGlspRecordingCommand } from '@hydranium/glsp-server';
1726
1783
  import { findNextUnique } from '@hydranium/protocol';
1727
1784
  import { injectable } from 'inversify';
1785
+ import type { __NODE_RULE__ } from '../../language-server/ast.js';
1728
1786
  import { ${upper}_NODE_TYPE } from './types.js';
1729
1787
  import type { __GRAMMAR__GlspState } from './state.js';
1730
1788
 
@@ -1743,45 +1801,19 @@ export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperati
1743
1801
  if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1744
1802
  return undefined;
1745
1803
  }
1746
- const state = this.modelState;
1747
- const before = this.documentText();
1748
- const after = this.withNode(
1749
- before,
1750
- findNextUnique(
1804
+ return new HydraniumGlspRecordingCommand(this.modelState, this.label, () => {
1805
+ const root = this.modelState.sourceRoot;
1806
+ const name = findNextUnique(
1751
1807
  NODE_NAME_STEM,
1752
- state.sourceRoot.nodes.map(node => node.name)
1753
- )
1754
- );
1755
- // Whole-document undo, which is all a full-text source model can offer: it
1756
- // has exactly one field, so there is nothing to merge a concurrent edit into.
1757
- return {
1758
- execute: () => state.updateSourceModel({ text: after }),
1759
- undo: () => state.updateSourceModel({ text: before }),
1760
- redo: () => state.updateSourceModel({ text: after })
1761
- };
1762
- }
1763
-
1764
- /** The text the captured source root was parsed from — the baseline an edit appends to. */
1765
- protected documentText(): string {
1766
- return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
1767
- }
1768
-
1769
- /** \`text\` with one more node declaration, under exactly one trailing newline. */
1770
- protected withNode(text: string, name: string): string {
1771
- const body = text.trimEnd();
1772
- const declaration = \`node \${name}\`;
1773
- return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
1808
+ root.nodes.map(node => node.name)
1809
+ );
1810
+ appendChild(root, 'nodes', root.nodes, { $type: '__NODE_RULE__', name } as __NODE_RULE__);
1811
+ });
1774
1812
  }
1775
1813
  }
1776
1814
  `;
1777
1815
  const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
1778
1816
  //
1779
- // \`declareLanguage\` is the multi-grammar seam: it binds the grammar this diagram
1780
- // edits on the SESSION container, so \`modelState.diagramLanguage\` and the
1781
- // per-language lookups resolve to __LANGUAGE_ID__. That is what stops one
1782
- // diagram type from fighting over a process-wide binding when a project holds
1783
- // several grammars — a grammar with no diagram is registered on the same server.
1784
- //
1785
1817
  // \`configureActionHandlers\` REBINDS rather than adds: GLSP's own
1786
1818
  // \`DiagramModule\` already registers \`ComputedBoundsActionHandler\`, and two
1787
1819
  // handlers for one action would both run.
@@ -1803,9 +1835,7 @@ ${importList([
1803
1835
  'SourceModelStorage'
1804
1836
  ], '@eclipse-glsp/server', columns, true)}
1805
1837
  import { ComputedBoundsActionHandler } from '@eclipse-glsp/server';
1806
- import type { LanguageMetaData } from '@hydranium/langium';
1807
1838
  ${importList(['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex'], '@hydranium/glsp-server', columns)}
1808
- import { __GRAMMAR__LanguageMetaData } from '../../language-server/generated/module.js';
1809
1839
  import { __GRAMMAR__CreateNodeOperationHandler } from './create-node-operation-handler.js';
1810
1840
  import { __GRAMMAR__DiagramConfiguration } from './diagram-configuration.js';
1811
1841
  import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
@@ -1817,10 +1847,6 @@ import { ${upper}_DIAGRAM_TYPE } from './types.js';
1817
1847
  export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1818
1848
  readonly diagramType = ${upper}_DIAGRAM_TYPE;
1819
1849
 
1820
- protected override declareLanguage(): LanguageMetaData {
1821
- return __GRAMMAR__LanguageMetaData;
1822
- }
1823
-
1824
1850
  protected override bindModelState(): BindingTarget<ModelState> {
1825
1851
  return { service: __GRAMMAR__GlspState };
1826
1852
  }
@@ -1856,6 +1882,75 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1856
1882
  binding.add(__GRAMMAR__CreateNodeOperationHandler);
1857
1883
  }
1858
1884
  }
1885
+ `;
1886
+ const diagramTest = `// The starter create-node handler, run the way a client runs it: an in-process
1887
+ // GLSP server opens a document, a create-node operation adds a node, and an undo
1888
+ // and a redo take it out and put it back. Every step must keep the nodes the
1889
+ // document already had, and its comments: the diagram writes the document from
1890
+ // its AST, which carries none.
1891
+
1892
+ import 'reflect-metadata';
1893
+ import { type Action, CreateNodeOperation, RedoAction, ServerModule, SetDirtyStateAction, UndoAction } from '@eclipse-glsp/server';
1894
+ import { initializeWorkspaceProgrammatically } from '@hydranium/core';
1895
+ import { makeScratchWorkspace, type ScratchWorkspace } from '@hydranium/core/testing/node';
1896
+ import { HydraniumGlspAppModule } from '@hydranium/glsp-server';
1897
+ import { type GlspHarness, makeGlspHarness } from '@hydranium/glsp-server/testing';
1898
+ import { waitFor } from '@hydranium/protocol/testing';
1899
+ import { afterEach, describe, expect, it } from 'vitest';
1900
+ import { __GRAMMAR__DiagramModule } from '../src/glsp/__GRAMMAR_ID__/diagram-module.js';
1901
+ import type { __GRAMMAR__GlspState } from '../src/glsp/__GRAMMAR_ID__/state.js';
1902
+ import { ${upper}_DIAGRAM_TYPE, ${upper}_NODE_TYPE } from '../src/glsp/__GRAMMAR_ID__/types.js';
1903
+ import { createServices } from '../src/services.js';
1904
+
1905
+ let workspace: ScratchWorkspace | undefined;
1906
+ let diagram: GlspHarness<__GRAMMAR__GlspState> | undefined;
1907
+
1908
+ afterEach(() => {
1909
+ diagram?.dispose();
1910
+ workspace?.dispose();
1911
+ diagram = undefined;
1912
+ workspace = undefined;
1913
+ });
1914
+
1915
+ /** Send \`action\` and wait for the dirty state the server answers it with, which it sends once the edit is written. */
1916
+ async function send(harness: GlspHarness<__GRAMMAR__GlspState>, action: Action, reason: string): Promise<void> {
1917
+ const before = harness.actions.length;
1918
+ harness.dispatch(action);
1919
+ await waitFor(() => harness.actions.slice(before).some(sent => SetDirtyStateAction.is(sent) && sent.reason === reason), {
1920
+ message: \`no '\${reason}' dirty state\`
1921
+ });
1922
+ }
1923
+
1924
+ describe('__GRAMMAR__ diagram', () => {
1925
+ it('creates a node after the existing ones, keeping their comments, and undoes and redoes it', async () => {
1926
+ workspace = makeScratchWorkspace({ prefix: '__GRAMMAR_ID__-diagram-' });
1927
+ const original = '// keep this comment\\nnode first -> second\\n// about second\\nnode second';
1928
+ const file = workspace.write('model.__EXTENSION__', original);
1929
+ const { shared } = createServices();
1930
+ await initializeWorkspaceProgrammatically(shared, workspace.root);
1931
+ diagram = makeGlspHarness<__GRAMMAR__GlspState>({
1932
+ serverModule: new ServerModule().configureDiagramModule(new __GRAMMAR__DiagramModule()),
1933
+ diagramType: ${upper}_DIAGRAM_TYPE,
1934
+ appModules: [new HydraniumGlspAppModule({ shared })]
1935
+ });
1936
+ await diagram.start();
1937
+ await diagram.openDocument(file);
1938
+ const uri = workspace.uri('model.__EXTENSION__');
1939
+ const text = (): string | undefined => shared.workspace.TextDocuments.get(uri)?.getText();
1940
+
1941
+ await send(diagram, CreateNodeOperation.create(${upper}_NODE_TYPE), 'operation');
1942
+ const created = text();
1943
+ await send(diagram, UndoAction.create(), 'undo');
1944
+ const undone = text();
1945
+ await send(diagram, RedoAction.create(), 'redo');
1946
+
1947
+ expect({ created, undone, redone: text() }).toEqual({
1948
+ created: \`\${original}\\nnode Node\`,
1949
+ undone: original,
1950
+ redone: \`\${original}\\nnode Node\`
1951
+ });
1952
+ });
1953
+ });
1859
1954
  `;
1860
1955
  return [
1861
1956
  { path: `${dir}/types.ts`, content: render(types) },
@@ -1865,7 +1960,8 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1865
1960
  { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1866
1961
  { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1867
1962
  { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1868
- { path: `${dir}/diagram-module.ts`, content: render(module) }
1963
+ { path: `${dir}/diagram-module.ts`, content: render(module) },
1964
+ { path: `test/${grammar.grammarId}-diagram.test.ts`, content: render(diagramTest) }
1869
1965
  ];
1870
1966
  }
1871
1967
  /**
@@ -1890,11 +1986,9 @@ export function buildInitTemplates(composition) {
1890
1986
  { path: 'langium-config.json', content: langiumConfig(composition) },
1891
1987
  { path: 'tsconfig.json', content: tsconfigJson(composition) },
1892
1988
  { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
1989
+ { path: 'test/tsconfig.json', content: TEST_DIR_TSCONFIG },
1893
1990
  { path: 'vitest.config.ts', content: VITEST_CONFIG },
1894
- {
1895
- path: '.gitignore',
1896
- content: composition.packaging.workspace === undefined ? GITIGNORE : GITIGNORE_WORKSPACE_MEMBER
1897
- },
1991
+ { path: '.gitignore', content: GITIGNORE },
1898
1992
  { path: 'README.md', content: readme(composition) },
1899
1993
  ...grammarFiles,
1900
1994
  { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },