@hydranium/cli 1.0.0-next.22 → 1.0.0-next.221

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 (177) hide show
  1. package/README.md +25 -5
  2. package/bin/hydranium-cli.js +13 -0
  3. package/lib/cli-version.js.map +1 -1
  4. package/lib/cli.js +0 -0
  5. package/lib/commands/analyze-heap-args.js.map +1 -1
  6. package/lib/commands/analyze-heap.d.ts +3 -0
  7. package/lib/commands/analyze-heap.d.ts.map +1 -1
  8. package/lib/commands/analyze-heap.js +2 -1
  9. package/lib/commands/analyze-heap.js.map +1 -1
  10. package/lib/commands/ast-ground-truth-args.d.ts +3 -2
  11. package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
  12. package/lib/commands/ast-ground-truth-args.js +11 -9
  13. package/lib/commands/ast-ground-truth-args.js.map +1 -1
  14. package/lib/commands/ast-ground-truth.js +1 -1
  15. package/lib/commands/ast-ground-truth.js.map +1 -1
  16. package/lib/commands/generate-ast-builder.d.ts +49 -0
  17. package/lib/commands/generate-ast-builder.d.ts.map +1 -0
  18. package/lib/commands/generate-ast-builder.js +200 -0
  19. package/lib/commands/generate-ast-builder.js.map +1 -0
  20. package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
  21. package/lib/commands/generate-transfer-model-args.js +11 -1
  22. package/lib/commands/generate-transfer-model-args.js.map +1 -1
  23. package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
  24. package/lib/commands/generate-transfer-model-config.js +3 -1
  25. package/lib/commands/generate-transfer-model-config.js.map +1 -1
  26. package/lib/commands/generate-transfer-model.d.ts +9 -0
  27. package/lib/commands/generate-transfer-model.d.ts.map +1 -1
  28. package/lib/commands/generate-transfer-model.js +16 -3
  29. package/lib/commands/generate-transfer-model.js.map +1 -1
  30. package/lib/commands/harness-args.d.ts +37 -42
  31. package/lib/commands/harness-args.d.ts.map +1 -1
  32. package/lib/commands/harness-args.js +65 -25
  33. package/lib/commands/harness-args.js.map +1 -1
  34. package/lib/commands/headless-harness.d.ts +37 -20
  35. package/lib/commands/headless-harness.d.ts.map +1 -1
  36. package/lib/commands/headless-harness.js +68 -17
  37. package/lib/commands/headless-harness.js.map +1 -1
  38. package/lib/commands/init-args.js +4 -4
  39. package/lib/commands/init-args.js.map +1 -1
  40. package/lib/commands/init-prompt.d.ts.map +1 -1
  41. package/lib/commands/init-templates.d.ts +0 -9
  42. package/lib/commands/init-templates.d.ts.map +1 -1
  43. package/lib/commands/init-templates.js +230 -124
  44. package/lib/commands/init-templates.js.map +1 -1
  45. package/lib/commands/init-wizard.js.map +1 -1
  46. package/lib/commands/init-workspace.d.ts +13 -3
  47. package/lib/commands/init-workspace.d.ts.map +1 -1
  48. package/lib/commands/init-workspace.js +21 -6
  49. package/lib/commands/init-workspace.js.map +1 -1
  50. package/lib/commands/init.d.ts +13 -6
  51. package/lib/commands/init.d.ts.map +1 -1
  52. package/lib/commands/init.js +10 -12
  53. package/lib/commands/init.js.map +1 -1
  54. package/lib/commands/lint-grammar-args.d.ts +3 -2
  55. package/lib/commands/lint-grammar-args.d.ts.map +1 -1
  56. package/lib/commands/lint-grammar-args.js +13 -10
  57. package/lib/commands/lint-grammar-args.js.map +1 -1
  58. package/lib/commands/lint-grammar-report.js.map +1 -1
  59. package/lib/commands/lint-grammar.js +1 -1
  60. package/lib/commands/lint-grammar.js.map +1 -1
  61. package/lib/commands/measure-memory-args.d.ts +3 -2
  62. package/lib/commands/measure-memory-args.d.ts.map +1 -1
  63. package/lib/commands/measure-memory-args.js +20 -10
  64. package/lib/commands/measure-memory-args.js.map +1 -1
  65. package/lib/commands/measure-memory-driver.js +3 -2
  66. package/lib/commands/measure-memory-driver.js.map +1 -1
  67. package/lib/commands/measure-memory.js +1 -1
  68. package/lib/commands/measure-memory.js.map +1 -1
  69. package/lib/commands/model-docs-args.d.ts +4 -3
  70. package/lib/commands/model-docs-args.d.ts.map +1 -1
  71. package/lib/commands/model-docs-args.js +12 -10
  72. package/lib/commands/model-docs-args.js.map +1 -1
  73. package/lib/commands/model-docs.js +1 -1
  74. package/lib/commands/model-docs.js.map +1 -1
  75. package/lib/commands/projects-args.d.ts.map +1 -1
  76. package/lib/commands/projects-args.js +5 -5
  77. package/lib/commands/projects-args.js.map +1 -1
  78. package/lib/commands/projects.d.ts +3 -4
  79. package/lib/commands/projects.d.ts.map +1 -1
  80. package/lib/commands/projects.js +2 -2
  81. package/lib/commands/projects.js.map +1 -1
  82. package/lib/commands/query-args.d.ts.map +1 -1
  83. package/lib/commands/query-args.js +5 -5
  84. package/lib/commands/query-args.js.map +1 -1
  85. package/lib/commands/query.d.ts +3 -4
  86. package/lib/commands/query.d.ts.map +1 -1
  87. package/lib/commands/query.js +2 -2
  88. package/lib/commands/query.js.map +1 -1
  89. package/lib/commands/reflect-args.d.ts +3 -2
  90. package/lib/commands/reflect-args.d.ts.map +1 -1
  91. package/lib/commands/reflect-args.js +13 -10
  92. package/lib/commands/reflect-args.js.map +1 -1
  93. package/lib/commands/reflect-report.js.map +1 -1
  94. package/lib/commands/reflect.js +1 -1
  95. package/lib/commands/reflect.js.map +1 -1
  96. package/lib/commands/save-args.d.ts.map +1 -1
  97. package/lib/commands/save-args.js +6 -4
  98. package/lib/commands/save-args.js.map +1 -1
  99. package/lib/commands/save.d.ts +8 -4
  100. package/lib/commands/save.d.ts.map +1 -1
  101. package/lib/commands/save.js +31 -6
  102. package/lib/commands/save.js.map +1 -1
  103. package/lib/commands/validate-args.d.ts +3 -2
  104. package/lib/commands/validate-args.d.ts.map +1 -1
  105. package/lib/commands/validate-args.js +13 -10
  106. package/lib/commands/validate-args.js.map +1 -1
  107. package/lib/commands/validate-report.js.map +1 -1
  108. package/lib/commands/validate.js +1 -1
  109. package/lib/commands/validate.js.map +1 -1
  110. package/lib/commands/watch-args.d.ts.map +1 -1
  111. package/lib/commands/watch-args.js +6 -4
  112. package/lib/commands/watch-args.js.map +1 -1
  113. package/lib/commands/watch.d.ts +3 -4
  114. package/lib/commands/watch.d.ts.map +1 -1
  115. package/lib/commands/watch.js +20 -2
  116. package/lib/commands/watch.js.map +1 -1
  117. package/lib/driver-heap.d.ts +52 -0
  118. package/lib/driver-heap.d.ts.map +1 -0
  119. package/lib/driver-heap.js +51 -0
  120. package/lib/driver-heap.js.map +1 -0
  121. package/lib/index.d.ts +1 -0
  122. package/lib/index.d.ts.map +1 -1
  123. package/lib/index.js +1 -0
  124. package/lib/index.js.map +1 -1
  125. package/lib/log-level.d.ts +21 -11
  126. package/lib/log-level.d.ts.map +1 -1
  127. package/lib/log-level.js +25 -14
  128. package/lib/log-level.js.map +1 -1
  129. package/lib/spawn-data-server.d.ts +6 -1
  130. package/lib/spawn-data-server.d.ts.map +1 -1
  131. package/lib/spawn-data-server.js +2 -1
  132. package/lib/spawn-data-server.js.map +1 -1
  133. package/lib/stderr-connection-logger.d.ts +12 -0
  134. package/lib/stderr-connection-logger.d.ts.map +1 -0
  135. package/lib/stderr-connection-logger.js +16 -0
  136. package/lib/stderr-connection-logger.js.map +1 -0
  137. package/lib/testing/echo-server.js +8 -1
  138. package/lib/testing/echo-server.js.map +1 -1
  139. package/package.json +39 -39
  140. package/src/commands/analyze-heap.ts +4 -1
  141. package/src/commands/ast-ground-truth-args.ts +15 -12
  142. package/src/commands/ast-ground-truth.ts +1 -1
  143. package/src/commands/generate-ast-builder.ts +246 -0
  144. package/src/commands/generate-transfer-model-args.ts +11 -1
  145. package/src/commands/generate-transfer-model-config.ts +3 -1
  146. package/src/commands/generate-transfer-model.ts +25 -3
  147. package/src/commands/harness-args.ts +73 -28
  148. package/src/commands/headless-harness.ts +86 -26
  149. package/src/commands/init-args.ts +4 -4
  150. package/src/commands/init-templates.ts +239 -126
  151. package/src/commands/init-workspace.ts +31 -7
  152. package/src/commands/init.ts +24 -19
  153. package/src/commands/lint-grammar-args.ts +17 -13
  154. package/src/commands/lint-grammar.ts +1 -1
  155. package/src/commands/measure-memory-args.ts +25 -13
  156. package/src/commands/measure-memory-driver.ts +3 -2
  157. package/src/commands/measure-memory.ts +1 -1
  158. package/src/commands/model-docs-args.ts +16 -13
  159. package/src/commands/model-docs.ts +1 -1
  160. package/src/commands/projects-args.ts +5 -4
  161. package/src/commands/projects.ts +4 -6
  162. package/src/commands/query-args.ts +5 -4
  163. package/src/commands/query.ts +4 -6
  164. package/src/commands/reflect-args.ts +17 -13
  165. package/src/commands/reflect.ts +1 -1
  166. package/src/commands/save-args.ts +6 -3
  167. package/src/commands/save.ts +49 -10
  168. package/src/commands/validate-args.ts +17 -13
  169. package/src/commands/validate.ts +1 -1
  170. package/src/commands/watch-args.ts +6 -3
  171. package/src/commands/watch.ts +28 -7
  172. package/src/driver-heap.ts +65 -0
  173. package/src/index.ts +1 -0
  174. package/src/log-level.ts +42 -14
  175. package/src/spawn-data-server.ts +14 -2
  176. package/src/stderr-connection-logger.ts +18 -0
  177. package/src/testing/echo-server.ts +10 -2
@@ -82,16 +82,6 @@ export interface InitTemplate {
82
82
  content: string;
83
83
  }
84
84
 
85
- /**
86
- * The version every framework package carries before the first release.
87
- *
88
- * A scaffold made by a CLI still at this version pins a range the registry
89
- * cannot serve, so the emitted README keeps its yalc note and `init` keeps its
90
- * install warning while — and only while — it holds: from a published CLI the
91
- * derived pins resolve and either note would be false as printed.
92
- */
93
- export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
94
-
95
85
  /**
96
86
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
97
87
  * CLI's own.
@@ -177,7 +167,7 @@ function columnsFor(composition: InitComposition): number {
177
167
 
178
168
  /**
179
169
  * A named import on one line while it fits the column budget, else one symbol
180
- * per line. Emitted files are supposed to pass a prettier check as-is, and the
170
+ * per line. Emitted files are supposed to pass a format check as-is, and the
181
171
  * generated-module import grows by one symbol per grammar.
182
172
  */
183
173
  function importList(symbols: readonly string[], from: string, columns: number, typeOnly = false): string {
@@ -228,7 +218,12 @@ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
228
218
  const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
229
219
  lsp: {
230
220
  langium: '4.3.1',
231
- 'vscode-languageserver': '~10.0.1'
221
+ // Exact, as are the protocol and transport it pins, so Langium and the
222
+ // framework share their copies; across copies, an error a handler throws
223
+ // or returns loses its code, and a later patch can pin another protocol.
224
+ 'vscode-languageserver': '10.0.1',
225
+ 'vscode-jsonrpc': '9.0.0',
226
+ 'vscode-languageserver-protocol': '3.18.1'
232
227
  },
233
228
  data: {},
234
229
  glsp: {
@@ -241,6 +236,24 @@ const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, st
241
236
  }
242
237
  };
243
238
 
239
+ /** 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. */
240
+ function installsVitest41(npmVersion: string | undefined): boolean {
241
+ const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
242
+ const [major, minor, patch] = release.split('.').map(Number);
243
+ return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
244
+ }
245
+
246
+ /**
247
+ * Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
248
+ * before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
249
+ * workspace root declaring a newer npm lifts it, and only when `init` runs under
250
+ * an npm known to be newer: npm does not enforce `packageManager`.
251
+ */
252
+ function pinsVitest(composition: InitComposition): boolean {
253
+ const workspace = composition.packaging.workspace;
254
+ return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
255
+ }
256
+
244
257
  /** The dependency block for a head set, merged and sorted as npm writes it. */
245
258
  function dependencyBlock(composition: InitComposition, indent: string): string {
246
259
  const merged: Record<string, string> = {};
@@ -260,7 +273,7 @@ function dependencyBlock(composition: InitComposition, indent: string): string {
260
273
  // npm convention, and `npm install` rewrites `package.json` at two spaces
261
274
  // regardless — so three would not survive the adopter's first install.
262
275
  //
263
- // The KEY ORDER is `prettier-plugin-packagejson`'s canonical one, `private`
276
+ // The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
264
277
  // before `license` included. Any other order is rewritten by the scaffolded
265
278
  // project's own first `format` run — a diff on a file the adopter never touched
266
279
  // — and it makes a byte-compare against a formatted copy of this emission
@@ -291,16 +304,9 @@ const PACKAGE_JSON = `{
291
304
  "version": "0.0.0",
292
305
  __PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
293
306
  "keywords": [
294
- "hydranium",
295
- "langium",
296
- "language-server",
297
- "__PROJECT_ID__"
298
- ],
307
+ __KEYWORDS__ ],
299
308
  "license": "UNLICENSED",
300
309
  "author": "",
301
- "type": "module",
302
- "main": "lib/index.js",
303
- "types": "lib/index.d.ts",
304
310
  "bin": {
305
311
  __BIN__ },
306
312
  "files": [
@@ -308,11 +314,14 @@ __BIN__ },
308
314
  "src",
309
315
  "syntaxes"
310
316
  ],
317
+ "type": "module",
318
+ "main": "lib/index.js",
319
+ "types": "lib/index.d.ts",
311
320
  "scripts": {
312
321
  "build": "npm run generate && tsc",
313
- "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-transfer tsconfig.tsbuildinfo",
322
+ "clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium tsconfig.tsbuildinfo",
314
323
  "generate": "npm run langium:generate && npm run generate:transfer-model",
315
- "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\\"",
324
+ "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\\"",
316
325
  "langium:generate": "langium generate",
317
326
  "langium:watch": "langium generate --watch",
318
327
  __LINT__ "start": "node lib/main.js --stdio",
@@ -328,8 +337,8 @@ __DEPENDENCIES__
328
337
  "@types/node": "^22.0.0",
329
338
  "langium-cli": "4.3.0",
330
339
  "rimraf": "^5.0.0",
331
- "typescript": "^5.8.0",
332
- "vitest": "^4.0.0"
340
+ "typescript": "^7.0.2",
341
+ "vitest": "__VITEST__"
333
342
  },
334
343
  "engines": {
335
344
  "node": ">=22.13"
@@ -359,7 +368,7 @@ function npmRun(composition: InitComposition): string {
359
368
  * name. Without the second key a scaffolded project has no command line that
360
369
  * reaches its own data head at all.
361
370
  *
362
- * Alphabetical, because `prettier-plugin-packagejson` sorts `bin` and any other
371
+ * Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
363
372
  * order is rewritten by the scaffolded project's first `format` run.
364
373
  */
365
374
  function binBlock(composition: InitComposition): string {
@@ -374,15 +383,32 @@ function binBlock(composition: InitComposition): string {
374
383
  function packageJson(composition: InitComposition): string {
375
384
  const { scope } = composition.packaging;
376
385
  const packageName = scope === undefined ? composition.projectId : `${scope}/${composition.projectId}`;
377
- // Emitted only where a root eslint config says the repo lints. The absence is
386
+ const keywords =
387
+ ['hydranium', 'langium', 'language-server', composition.projectId]
388
+ .sort()
389
+ .map(keyword => ` ${JSON.stringify(keyword)}`)
390
+ .join(',\n') + '\n';
391
+ // Emitted only where a root lint config says the repo lints. The absence is
378
392
  // the dangerous direction: a task runner runs a script only where one is
379
393
  // declared, so a package with no `lint` is SKIPPED rather than reported, and
380
394
  // that reads as a clean lint. A wrong invocation is one visible line to edit.
381
- const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
395
+ const workspace = composition.packaging.workspace;
396
+ const oxlintConfig =
397
+ workspace?.oxlintConfig === undefined
398
+ ? undefined
399
+ : `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
400
+ const lint =
401
+ workspace?.oxlintConfig !== undefined
402
+ ? ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${oxlintConfig} src test --max-warnings 0",\n`
403
+ : workspace?.eslintConfig !== undefined
404
+ ? ' "lint": "eslint src test --max-warnings 0",\n'
405
+ : '';
382
406
  return project(PACKAGE_JSON, composition)
383
407
  .replace('__PACKAGE_NAME__', packageName)
408
+ .replace('__KEYWORDS__', keywords)
384
409
  .replace('__BIN__', binBlock(composition))
385
410
  .replace('__LINT__', lint)
411
+ .replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
386
412
  .replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
387
413
  .replace('__NPM_RUN__', npmRun(composition))
388
414
  .replace('__FRAMEWORK_PIN__', frameworkPin(composition))
@@ -439,6 +465,11 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
439
465
  ['outDir', 'lib'],
440
466
  ['strict', true],
441
467
  ['esModuleInterop', true],
468
+ // A server that renders its own user-facing messages loads a catalogue, and
469
+ // a catalogue is JSON. Without this the import does not resolve at all, and
470
+ // without the `include` glob below a composite project rejects it with
471
+ // TS6307 naming neither cause.
472
+ ['resolveJsonModule', true],
442
473
  ['skipLibCheck', true],
443
474
  ['declaration', true],
444
475
  ['experimentalDecorators', true],
@@ -455,9 +486,20 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
455
486
  */
456
487
  const PACKAGE_LOCAL_OPTIONS: readonly string[] = ['rootDir', 'outDir'];
457
488
 
458
- /** Compare two JSON option values. Sound here because compiler options are scalars and flat arrays. */
459
- function jsonEquals(left: JsonValue | undefined, right: JsonValue): boolean {
460
- return JSON.stringify(left) === JSON.stringify(right);
489
+ /**
490
+ * The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
491
+ * `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
492
+ * An enum-valued option added there belongs here too.
493
+ */
494
+ const CASE_INSENSITIVE_OPTIONS: readonly string[] = ['target', 'lib', 'module', 'moduleResolution'];
495
+
496
+ /** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
497
+ function optionEquals(key: string, left: JsonValue | undefined, right: JsonValue): boolean {
498
+ const normalise = (value: JsonValue | undefined) => {
499
+ const text = JSON.stringify(value);
500
+ return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
501
+ };
502
+ return normalise(left) === normalise(right);
461
503
  }
462
504
 
463
505
  /**
@@ -476,11 +518,13 @@ function tsconfigJson(composition: InitComposition): string {
476
518
  ? TSCONFIG_COMPILER_OPTIONS
477
519
  : [
478
520
  ...TSCONFIG_COMPILER_OPTIONS.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
479
- ...TSCONFIG_COMPILER_OPTIONS.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !jsonEquals(base[key], value))
521
+ ...TSCONFIG_COMPILER_OPTIONS.filter(
522
+ ([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value)
523
+ )
480
524
  ];
481
525
  const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
482
526
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
483
- return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src"]\n}\n`;
527
+ return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
484
528
  }
485
529
 
486
530
  // `isolatedModules` is what makes this check agree with the transform that
@@ -498,7 +542,7 @@ const TSCONFIG_TEST = `{
498
542
  "isolatedModules": true,
499
543
  "types": ["node"]
500
544
  },
501
- "include": ["src", "test"]
545
+ "include": ["src", "test", "src/**/*.json"]
502
546
  }
503
547
  `;
504
548
 
@@ -518,6 +562,11 @@ export default defineConfig({
518
562
  });
519
563
  `;
520
564
 
565
+ /**
566
+ * The `.gitignore`, written for a workspace member too: its root may not ignore
567
+ * `lib/` (a `yo langium` root ignores `out/`), and a rule the root repeats
568
+ * costs nothing.
569
+ */
521
570
  const GITIGNORE = `node_modules/
522
571
  lib/
523
572
  *.tsbuildinfo
@@ -526,27 +575,6 @@ lib/
526
575
  syntaxes/
527
576
  `;
528
577
 
529
- /**
530
- * The workspace-member `.gitignore` — one entry, and the one entry a root
531
- * cannot be assumed to have.
532
- *
533
- * A member inherits the root's rules, and `node_modules/`, `lib/` and
534
- * `*.tsbuildinfo` are in every monorepo root already, so repeating them here
535
- * would be three lines to keep in step for no coverage. `syntaxes/` is not like
536
- * them: it is a Langium artefact, so a root that has never held a Langium
537
- * package has no rule for it, and the first `langium generate` then offers
538
- * generated output up for commit with nothing to warn the adopter. Dropping the
539
- * whole file rather than this subset is the mistake this template exists to
540
- * undo.
541
- */
542
- const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
543
- # It has no reason to know about this one: the TextMate grammar is a Langium
544
- # artefact, rewritten by every \`langium generate\`, so a root that has never held
545
- # a Langium package ignores nothing here and the generated file is offered for
546
- # commit.
547
- syntaxes/
548
- `;
549
-
550
578
  /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
551
579
  const TERMINALS = `hidden terminal WS: /\\s+/;
552
580
  terminal ID: /[_a-zA-Z][\\w_]*/;
@@ -708,13 +736,35 @@ export type __NAME__Services = LangiumServices &
708
736
  LspServerAddedServices & {
709
737
  shared: __NAME__SharedServices;
710
738
  };
739
+
740
+ /** What a host or a test may vary about this composition. */
741
+ export interface __NAME__Options {
742
+ /**
743
+ * Shared modules layered in after the framework's own bindings.
744
+ *
745
+ * The framework constructs most shared services with no options —
746
+ * \`DocumentBuilder: services => new HydraniumDocumentBuilder(services)\` — so
747
+ * rebinding the slot is the only way to boot one configured differently, and
748
+ * a factory that hard-codes its composition leaves a test nowhere to do it.
749
+ * That is what this is for: pass a module binding \`workspace.DocumentBuilder\`
750
+ * to exercise a builder option, or to substitute a subclass.
751
+ *
752
+ * Composed LAST, after \`__NAME__SharedModule\`, so it wins over every other
753
+ * tier including this file's own bindings — which is what makes it usable for
754
+ * a slot the adopter overrides. Production code should not reach for it.
755
+ */
756
+ readonly extraSharedModules?: ReadonlyArray<Module<__NAME__SharedServices, DeepPartial<__NAME__SharedServices>>>;
757
+ }
711
758
  ${configurationRoot}
712
759
  ${sharedModule}
713
760
 
714
761
  ${languageModules}
715
762
 
716
763
  /** Compose the Langium DI tree for __NAME__ — returns the shared + language services. */
717
- export function create__NAME__Services(context: Partial<ServerModuleContext> = EmptyFileSystem): {
764
+ export function create__NAME__Services(
765
+ context: Partial<ServerModuleContext> = EmptyFileSystem,
766
+ options: __NAME__Options = {}
767
+ ): {
718
768
  shared: __NAME__SharedServices;
719
769
  ${returnType}
720
770
  } {
@@ -724,7 +774,8 @@ ${returnType}
724
774
  sharedModules: {
725
775
  generated: __NAME__GeneratedSharedModule,
726
776
  adopter: __NAME__SharedModule,
727
- extra: [createLspServerSharedModule(fullContext)]
777
+ extra: [createLspServerSharedModule(fullContext)],
778
+ overrides: options.extraSharedModules
728
779
  },
729
780
  languageModules: {
730
781
  generated: ${primary.grammar}GeneratedModule,
@@ -791,9 +842,9 @@ const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's
791
842
  //
792
843
  // Without it every structured write fails: the framework's default binding at
793
844
  // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
794
- // language knowledge no framework can derive. \`ModelService.update\` / \`save\`,
795
- // the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\` all reach
796
- // it.
845
+ // language knowledge no framework can derive. A client session's \`update\` /
846
+ // \`save\`, the data head's \`saveModelDocument\` and a GLSP \`SaveModelAction\`
847
+ // all reach it.
797
848
  //
798
849
  // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
799
850
  // output — its YAML and JSON subclasses are what it exists for — and cannot
@@ -980,7 +1031,7 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
980
1031
  // \`TransferElement\` structurally, so naming the AST type here compiles fine and
981
1032
  // silently tells every typed client that a reference is a resolvable object
982
1033
  // rather than a name.
983
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
1034
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
984
1035
  `
985
1036
  : '';
986
1037
 
@@ -1063,11 +1114,17 @@ void glspServer;
1063
1114
  // than a library entry — import \`./index.js\` instead to compose the language.
1064
1115
 
1065
1116
  ${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
1066
- ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}import { startLanguageServer } from '@hydranium/langium/lsp';
1117
+ ${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
1118
+ // signature-compatible and delegates straight through; what it adds first is
1119
+ // \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
1120
+ // was never composed. Reaching for Langium's is the natural mistake and it is
1121
+ // silent: the server boots, links and completes, while echo suppression, the
1122
+ // didChangeContent debounce and the last-client-close rebuild are all inert.
1123
+ import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
1067
1124
  import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
1068
1125
  ${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1069
1126
  ${portCommands === '' ? '' : '\n' + portCommands}
1070
- const connection = createConnection(ProposedFeatures.all);
1127
+ const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
1071
1128
  const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
1072
1129
  startLanguageServer(shared);
1073
1130
  ${dataBlock}${glspBlock}`;
@@ -1117,7 +1174,7 @@ function dataServerMainFile(composition: InitComposition): string {
1117
1174
  ${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
1118
1175
  import { DataServer } from '@hydranium/data-server';
1119
1176
  // The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
1120
- ${importList(roots, './language-server/generated-transfer/transfer-model.js', columns, true)}
1177
+ ${importList(roots, './language-server/generated-hydranium/transfer-model.js', columns, true)}
1121
1178
  import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
1122
1179
 
1123
1180
  const { shared } = create__NAME__Services({ ...NodeFileSystem });
@@ -1236,8 +1293,8 @@ function serializationTest(composition: InitComposition): string {
1236
1293
  const transferTypes = composition.grammars.map(grammar => `${grammar.entryRule} as Transfer${grammar.entryRule}`);
1237
1294
  const text = `// Round-trips each grammar through its serializer: parse the source, serialize
1238
1295
  // the model, compare the text. The serializer is the parser's inverse, so this
1239
- // is the assertion that keeps \`ModelService.update\` / \`save\` writing files the
1240
- // language server can read back.
1296
+ // is the assertion that keeps a client session's \`update\` / \`save\` writing
1297
+ // files the language server can read back.
1241
1298
  //
1242
1299
  // The transfer case is the one that catches the mistake worth catching.
1243
1300
  // \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
@@ -1247,7 +1304,7 @@ function serializationTest(composition: InitComposition): string {
1247
1304
  import { parseHelper } from '@hydranium/core/testing';
1248
1305
  import { describe, expect, it } from 'vitest';
1249
1306
  ${importList(astTypes, '../src/language-server/ast.js', columns, true)}
1250
- ${importList(transferTypes, '../src/language-server/generated-transfer/transfer-model.js', columns, true)}
1307
+ ${importList(transferTypes, '../src/language-server/generated-hydranium/transfer-model.js', columns, true)}
1251
1308
  import { createServices } from '../src/services.js';
1252
1309
 
1253
1310
  ${composition.grammars.map(grammar => render(SERIALIZATION_SUITE, composition, grammar)).join('\n\n')}
@@ -1416,20 +1473,6 @@ function readme(composition: InitComposition): string {
1416
1473
  '',
1417
1474
  '## Getting started',
1418
1475
  '',
1419
- // Conditional on the pin this scaffold actually carries — see
1420
- // `UNPUBLISHED_FRAMEWORK_VERSION`.
1421
- ...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
1422
- ? [
1423
- '> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
1424
- '> pins below are placeholders and `npm install` will fail with a 404 until',
1425
- '> the framework is released. Until then, supply the packages from a local',
1426
- '> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
1427
- '> `file:` path or `npm link` is not enough, because the framework packages',
1428
- '> depend on each other by version and npm would try to fetch those from the',
1429
- '> registry too.',
1430
- ''
1431
- ]
1432
- : []),
1433
1476
  '```bash',
1434
1477
  'npm install',
1435
1478
  'npm run langium:generate # generate the AST from the grammar',
@@ -1543,6 +1586,24 @@ function readme(composition: InitComposition): string {
1543
1586
  'atomic set and depends on a single physical copy, so a floating range can',
1544
1587
  'silently resolve a second one.',
1545
1588
  '',
1589
+ '`vscode-languageserver` is pinned to `10.0.1`, and',
1590
+ '`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.1` and',
1591
+ '`9.0.0` it pins, so Langium, the LSP connection and the framework share one',
1592
+ 'copy of each. With two, an error a Langium handler returns, such as a request',
1593
+ 'for a document the server does not have, reaches the client as a result, and a',
1594
+ 'framework error your own LSP handler throws loses its code. The Langium 4.4',
1595
+ 'upgrade',
1596
+ '([eclipse-emfcloud/hydranium#142](https://github.com/eclipse-emfcloud/hydranium/issues/142))',
1597
+ 'replaces these pins.',
1598
+ '',
1599
+ ...(pinsVitest(composition)
1600
+ ? [
1601
+ '`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
1602
+ 'release bundles. Raise it once every npm that installs this project is',
1603
+ '11.6 or later.',
1604
+ ''
1605
+ ]
1606
+ : []),
1546
1607
  '## If your repo gates license headers',
1547
1608
  '',
1548
1609
  'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
@@ -1633,8 +1694,11 @@ export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RUL
1633
1694
 
1634
1695
  const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1635
1696
  // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
1636
- // \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
1637
- // the multi-client text store → \`WritableFileSystemProvider\`).
1697
+ // \`saveSourceModel\` (flush the store's text for the primary and every tracked
1698
+ // secondary through the diagram's client session).
1699
+ //
1700
+ // No serializer runs on the save path — a save persists what the diagram's
1701
+ // operations already wrote to the store.
1638
1702
  //
1639
1703
  // The subclass exists so a bespoke load or save has a stable place to land.
1640
1704
 
@@ -1798,27 +1862,28 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1798
1862
  // read-only is a deletion the compiler checks, while read-only → editable is
1799
1863
  // authoring against a seam you have not used yet.
1800
1864
  //
1801
- // **It composes TEXT rather than mutating the AST.** The source model of
1802
- // \`FullTextHydraniumGlspState\` is the document text, and reading it back through
1803
- // \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
1804
- // this scaffold does not bind — so that getter throws until you do. Appending a
1805
- // declaration to the text the parser last read needs no serializer, which is what
1806
- // makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
1807
- // \`services.serializer.Serializer\` and this becomes a
1808
- // \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
1809
- // binding the diagram's own save action needs.
1865
+ // **It edits the operation's working copy.** A handler runs inside an operation,
1866
+ // where \`sourceRoot\` is a copy of the built root: the recording command's
1867
+ // runnable appends to the copy, and the operation writes the copy's text through
1868
+ // the grammar's \`Serializer\` once, gated on the version the copy was made at,
1869
+ // with undo and redo applying that one change. A command that writes the
1870
+ // document itself instead reads no text off the copy, which has no \`$document\`,
1871
+ // and the operation's own write then overwrites it.
1810
1872
  //
1811
1873
  // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1812
1874
  // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1813
1875
  // node is appended at the end of the document and the client places it.
1814
1876
  //
1815
1877
  // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
1816
- // knows the starter grammar's concrete syntax — the \`node\` keyword below is that
1817
- // grammar's. Replacing the grammar means replacing these four together.
1878
+ // knows the starter grammar's shape — the \`nodes\` list and the \`__NODE_RULE__\`
1879
+ // it holds. Replacing the grammar means replacing these four together.
1818
1880
 
1819
1881
  import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
1882
+ import { appendChild } from '@hydranium/core';
1883
+ import { HydraniumGlspRecordingCommand } from '@hydranium/glsp-server';
1820
1884
  import { findNextUnique } from '@hydranium/protocol';
1821
1885
  import { injectable } from 'inversify';
1886
+ import type { __NODE_RULE__ } from '../../language-server/ast.js';
1822
1887
  import { ${upper}_NODE_TYPE } from './types.js';
1823
1888
  import type { __GRAMMAR__GlspState } from './state.js';
1824
1889
 
@@ -1837,34 +1902,14 @@ export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperati
1837
1902
  if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1838
1903
  return undefined;
1839
1904
  }
1840
- const state = this.modelState;
1841
- const before = this.documentText();
1842
- const after = this.withNode(
1843
- before,
1844
- findNextUnique(
1905
+ return new HydraniumGlspRecordingCommand(this.modelState, this.label, () => {
1906
+ const root = this.modelState.sourceRoot;
1907
+ const name = findNextUnique(
1845
1908
  NODE_NAME_STEM,
1846
- state.sourceRoot.nodes.map(node => node.name)
1847
- )
1848
- );
1849
- // Whole-document undo, which is all a full-text source model can offer: it
1850
- // has exactly one field, so there is nothing to merge a concurrent edit into.
1851
- return {
1852
- execute: () => state.updateSourceModel({ text: after }),
1853
- undo: () => state.updateSourceModel({ text: before }),
1854
- redo: () => state.updateSourceModel({ text: after })
1855
- };
1856
- }
1857
-
1858
- /** The text the captured source root was parsed from — the baseline an edit appends to. */
1859
- protected documentText(): string {
1860
- return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
1861
- }
1862
-
1863
- /** \`text\` with one more node declaration, under exactly one trailing newline. */
1864
- protected withNode(text: string, name: string): string {
1865
- const body = text.trimEnd();
1866
- const declaration = \`node \${name}\`;
1867
- return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
1909
+ root.nodes.map(node => node.name)
1910
+ );
1911
+ appendChild(root, 'nodes', root.nodes, { $type: '__NODE_RULE__', name } as __NODE_RULE__);
1912
+ });
1868
1913
  }
1869
1914
  }
1870
1915
  `;
@@ -1960,6 +2005,76 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1960
2005
  binding.add(__GRAMMAR__CreateNodeOperationHandler);
1961
2006
  }
1962
2007
  }
2008
+ `;
2009
+
2010
+ const diagramTest = `// The starter create-node handler, run the way a client runs it: an in-process
2011
+ // GLSP server opens a document, a create-node operation adds a node, and an undo
2012
+ // and a redo take it out and put it back. Every step must keep the nodes the
2013
+ // document already had, and its comments: the diagram writes the document from
2014
+ // its AST, which carries none.
2015
+
2016
+ import 'reflect-metadata';
2017
+ import { type Action, CreateNodeOperation, RedoAction, ServerModule, SetDirtyStateAction, UndoAction } from '@eclipse-glsp/server';
2018
+ import { initializeWorkspaceProgrammatically } from '@hydranium/core';
2019
+ import { makeScratchWorkspace, type ScratchWorkspace } from '@hydranium/core/testing/node';
2020
+ import { HydraniumGlspAppModule } from '@hydranium/glsp-server';
2021
+ import { type GlspHarness, makeGlspHarness } from '@hydranium/glsp-server/testing';
2022
+ import { waitFor } from '@hydranium/protocol/testing';
2023
+ import { afterEach, describe, expect, it } from 'vitest';
2024
+ import { __GRAMMAR__DiagramModule } from '../src/glsp/__GRAMMAR_ID__/diagram-module.js';
2025
+ import type { __GRAMMAR__GlspState } from '../src/glsp/__GRAMMAR_ID__/state.js';
2026
+ import { ${upper}_DIAGRAM_TYPE, ${upper}_NODE_TYPE } from '../src/glsp/__GRAMMAR_ID__/types.js';
2027
+ import { createServices } from '../src/services.js';
2028
+
2029
+ let workspace: ScratchWorkspace | undefined;
2030
+ let diagram: GlspHarness<__GRAMMAR__GlspState> | undefined;
2031
+
2032
+ afterEach(() => {
2033
+ diagram?.dispose();
2034
+ workspace?.dispose();
2035
+ diagram = undefined;
2036
+ workspace = undefined;
2037
+ });
2038
+
2039
+ /** Send \`action\` and wait for the dirty state the server answers it with, which it sends once the edit is written. */
2040
+ async function send(harness: GlspHarness<__GRAMMAR__GlspState>, action: Action, reason: string): Promise<void> {
2041
+ const before = harness.actions.length;
2042
+ harness.dispatch(action);
2043
+ await waitFor(() => harness.actions.slice(before).some(sent => SetDirtyStateAction.is(sent) && sent.reason === reason), {
2044
+ message: \`no '\${reason}' dirty state\`
2045
+ });
2046
+ }
2047
+
2048
+ describe('__GRAMMAR__ diagram', () => {
2049
+ it('creates a node after the existing ones, keeping their comments, and undoes and redoes it', async () => {
2050
+ workspace = makeScratchWorkspace({ prefix: '__GRAMMAR_ID__-diagram-' });
2051
+ const original = '// keep this comment\\nnode first -> second\\n// about second\\nnode second';
2052
+ const file = workspace.write('model.__EXTENSION__', original);
2053
+ const { shared } = createServices();
2054
+ await initializeWorkspaceProgrammatically(shared, workspace.root);
2055
+ diagram = makeGlspHarness<__GRAMMAR__GlspState>({
2056
+ serverModule: new ServerModule().configureDiagramModule(new __GRAMMAR__DiagramModule()),
2057
+ diagramType: ${upper}_DIAGRAM_TYPE,
2058
+ appModules: [new HydraniumGlspAppModule({ shared })]
2059
+ });
2060
+ await diagram.start();
2061
+ await diagram.openDocument(file);
2062
+ const uri = workspace.uri('model.__EXTENSION__');
2063
+ const text = (): string | undefined => shared.workspace.TextDocuments.get(uri)?.getText();
2064
+
2065
+ await send(diagram, CreateNodeOperation.create(${upper}_NODE_TYPE), 'operation');
2066
+ const created = text();
2067
+ await send(diagram, UndoAction.create(), 'undo');
2068
+ const undone = text();
2069
+ await send(diagram, RedoAction.create(), 'redo');
2070
+
2071
+ expect({ created, undone, redone: text() }).toEqual({
2072
+ created: \`\${original}\\nnode Node\`,
2073
+ undone: original,
2074
+ redone: \`\${original}\\nnode Node\`
2075
+ });
2076
+ });
2077
+ });
1963
2078
  `;
1964
2079
 
1965
2080
  return [
@@ -1970,7 +2085,8 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1970
2085
  { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1971
2086
  { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1972
2087
  { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1973
- { path: `${dir}/diagram-module.ts`, content: render(module) }
2088
+ { path: `${dir}/diagram-module.ts`, content: render(module) },
2089
+ { path: `test/${grammar.grammarId}-diagram.test.ts`, content: render(diagramTest) }
1974
2090
  ];
1975
2091
  }
1976
2092
 
@@ -1999,10 +2115,7 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
1999
2115
  { path: 'tsconfig.json', content: tsconfigJson(composition) },
2000
2116
  { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
2001
2117
  { path: 'vitest.config.ts', content: VITEST_CONFIG },
2002
- {
2003
- path: '.gitignore',
2004
- content: composition.packaging.workspace === undefined ? GITIGNORE : GITIGNORE_WORKSPACE_MEMBER
2005
- },
2118
+ { path: '.gitignore', content: GITIGNORE },
2006
2119
  { path: 'README.md', content: readme(composition) },
2007
2120
  ...grammarFiles,
2008
2121
  { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },