@hydranium/cli 1.0.0-next.304 → 1.0.0-next.305

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.
@@ -28,6 +28,8 @@
28
28
  * Drives the PER-LANGUAGE generated symbols (`<grammar>GeneratedModule`,
29
29
  * `<grammar>LanguageMetaData`).
30
30
  * - `__GRAMMAR_ID__` — kebab grammar id: the `.langium` filename.
31
+ * - `__GRAMMAR_ID_CONST__` — SCREAMING_SNAKE grammar id, prefixing the
32
+ * diagram's type-id constants.
31
33
  * - `__LANGUAGE_ID__` — kebab routing key: the `langium-config` entry id and
32
34
  * its TextMate output.
33
35
  * - `__EXTENSION__` — the grammar's FIRST file extension, without the dot.
@@ -48,10 +50,12 @@
48
50
  * generator emits `interface <projectName>Element` as the base type every rule
49
51
  * extends, which a rule of that name would collide with.
50
52
  *
51
- * Kept as in-source strings (not on-disk assets) so no build-time copy step
52
- * is needed and the substitution is unit-testable. The `.ts` / `.langium` / JSON
53
- * templates are derived from the in-repo reference example so the generated project
54
- * compiles against the real `@hydranium/*` API once `langium generate` has run.
53
+ * A file that is fixed text with tokens lives in the `templates/` tree the
54
+ * package ships beside `lib`, at the path it is emitted at, tokens included
55
+ * (the ignore file without its dot); one computed from the composition is
56
+ * built here. The tree is read synchronously, so `runInit` stays synchronous,
57
+ * and it is neither formatted with the repo's settings nor given a license
58
+ * header, since either would change what `init` emits.
55
59
  *
56
60
  * Two conventions the templates follow deliberately:
57
61
  *
@@ -70,6 +74,7 @@
70
74
  * is what the detection cannot fix. A very long `--name` can still push a line
71
75
  * over; the adopter's own formatter settles it.
72
76
  */
77
+ import { readFileSync } from 'node:fs';
73
78
  import { readCliVersion } from '../cli-version.js';
74
79
  /**
75
80
  * The version `init` pins every `@hydranium/*` dependency at: the scaffolding
@@ -115,6 +120,7 @@ function project(text, composition) {
115
120
  */
116
121
  function grammarTier(text, grammar) {
117
122
  return text
123
+ .replace(/__GRAMMAR_ID_CONST__/g, screamingSnake(grammar.grammarId))
118
124
  .replace(/__GRAMMAR_ID__/g, grammar.grammarId)
119
125
  .replace(/__GRAMMAR__/g, grammar.grammar)
120
126
  .replace(/__LANGUAGE_ID__/g, grammar.languageId)
@@ -126,6 +132,20 @@ function grammarTier(text, grammar) {
126
132
  function render(text, composition, grammar) {
127
133
  return grammarTier(project(text, composition), grammar);
128
134
  }
135
+ /** The template tree, the same two levels up from `src/commands` and `lib/commands`. */
136
+ const TEMPLATES = new URL('../../templates/', import.meta.url);
137
+ /** One template file's text. */
138
+ function template(path) {
139
+ return readFileSync(new URL(path, TEMPLATES), 'utf-8');
140
+ }
141
+ /** A `server/` template emitted once per project, rendered with the project tokens. */
142
+ function projectTemplate(path, composition) {
143
+ return { path, content: project(template(`server/${path}`), composition) };
144
+ }
145
+ /** A `server/` template emitted once per grammar, its path rendered like its content. */
146
+ function grammarTemplate(path, composition, grammar) {
147
+ return { path: render(path, composition, grammar), content: render(template(`server/${path}`), composition, grammar) };
148
+ }
129
149
  /** A comma-separated list of single-quoted TypeScript/JS string literals. */
130
150
  function quotedList(values) {
131
151
  return values.map(value => `'${value}'`).join(', ');
@@ -494,84 +514,16 @@ function tsconfigJson(composition) {
494
514
  const body = options.map(([key, value]) => ` "${key}": ${JSON.stringify(value)}`).join(',\n');
495
515
  return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ["src", "src/**/*.json"]\n}\n`;
496
516
  }
497
- // `isolatedModules` is what makes this check agree with the transform that
498
- // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
499
- // vitest compiles each file on its own — a transform with no type
500
- // information, which cannot tell a re-exported TYPE from a re-exported value.
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.
505
- const TSCONFIG_TEST = `{
506
- "extends": "./tsconfig.json",
507
- "compilerOptions": {
508
- "rootDir": ".",
509
- "noEmit": true,
510
- "isolatedModules": true,
511
- "types": ["node"]
512
- },
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"]
522
- }
523
- `;
524
- const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
525
-
526
- // Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
527
- // \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
528
- // compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
529
- // scanned; \`npm test\` runs \`typecheck:test\` first, because vitest's
530
- // transform strips types without checking them.
531
-
532
- export default defineConfig({
533
- test: {
534
- environment: 'node',
535
- include: ['test/**/*.{test,spec}.ts']
536
- }
537
- });
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
- */
544
- const GITIGNORE = `node_modules/
545
- lib/
546
- *.tsbuildinfo
547
-
548
- # Langium-generated TextMate grammar (rewritten by every \`langium generate\`).
549
- syntaxes/
550
- `;
551
- /** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
552
- const TERMINALS = `hidden terminal WS: /\\s+/;
553
- terminal ID: /[_a-zA-Z][\\w_]*/;
554
- hidden terminal SL_COMMENT: /\\/\\/[^\\n\\r]*/;
555
- hidden terminal ML_COMMENT: /\\/\\*[\\s\\S]*?\\*\\//;
556
- `;
557
517
  /**
558
- * The shared lexical fragment, emitted only for a multi-grammar project.
559
- *
560
- * The alternative — every grammar declaring its own `WS` / `ID` — lets the token
561
- * sets drift apart.
518
+ * The terminals every grammar needs: the shared fragment's text after its
519
+ * leading comment run, so a lone grammar declares the same set.
562
520
  */
563
- const COMMON_GRAMMAR = `// Shared lexical basis for this project's grammars. Imported, never registered:
564
- // it has no entry rule and no \`langium-config.json\` entry, so it is a fragment
565
- // rather than a language.
566
- //
567
- // Keeping the tokens here rather than in one of the languages means a grammar
568
- // imports \`common\` for its TOKENS and another language only when it needs that
569
- // language's TYPES — separate reasons that are worth keeping separate.
570
-
571
- ${TERMINALS}`;
521
+ function terminals() {
522
+ return template('server/src/grammar/common.langium').replace(/^(\/\/.*\n)*\n/, '');
523
+ }
572
524
  /** One starter grammar. `shared` decides whether it imports the fragment or declares its own terminals. */
573
525
  function grammarFile(shared) {
574
- const lexis = shared ? "import './common'\n" : `\n${TERMINALS}`;
526
+ const lexis = shared ? "import './common'\n" : `\n${terminals()}`;
575
527
  return `grammar __GRAMMAR__
576
528
  ${shared ? '\n' + lexis : ''}
577
529
  // A minimal starter grammar — replace with your own language. \`__NODE_RULE__\` is
@@ -739,105 +691,6 @@ ${returnType}
739
691
  `;
740
692
  return project(text, composition);
741
693
  }
742
- const SERVICES = `// Zero-arg service factory for the headless \`hydranium-cli\` tooling: the
743
- // \`reflect\` / \`lint-grammar\` / \`validate\` subcommands import this via
744
- // \`--services ./lib/services.js\`. The head wires its own filesystem here.
745
- //
746
- // The contract is language-count-agnostic — a second grammar needs no edit here.
747
-
748
- import { NodeFileSystem } from '@hydranium/core/node';
749
- import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
750
-
751
- export function createServices(): ReturnType<typeof create__NAME__Services> {
752
- return create__NAME__Services({ ...NodeFileSystem });
753
- }
754
- `;
755
- const AST = `// The language's AST entry point: a re-export of what \`langium generate\`
756
- // emits, plus anywhere you augment those types.
757
- //
758
- // Import the AST from HERE rather than from \`./generated/ast.js\`, so any
759
- // augmentation below travels with every import. One \`langium-cli\` run over N
760
- // grammars emits ONE combined AST module sharing one reflection, so a further
761
- // grammar needs no change here.
762
- //
763
- // \`generate:transfer-model\` reads this file as its \`--augmentation-file\`: the
764
- // generated wire types are derived from the AST *as augmented*, not from the
765
- // raw generated module. Augment a type here and the transfer model follows.
766
- //
767
- // A \`@derived\` property is computed at build time rather than parsed, so it is
768
- // declared here and populated by an AST-extension contribution:
769
- //
770
- // declare module './generated/ast.js' {
771
- // interface __ENTRY_RULE__ {
772
- // /** @derived Populated by an \`ast.extensions.computedProperties\` contribution. */
773
- // readonly _summary?: string;
774
- // }
775
- // }
776
-
777
- export * from './generated/ast.js';
778
- `;
779
- /**
780
- * The starter serializer — the concrete-syntax emitter for ONE grammar.
781
- *
782
- * Emitted per grammar, not per project, because `services.serializer.Serializer`
783
- * is a per-language slot and a serializer is grammar-shaped by definition. That
784
- * is also why the framework refuses to default it: its `UnboundSerializer`
785
- * throws, naming the binding to add, rather than guessing a syntax.
786
- */
787
- const SERIALIZER = `// Concrete-syntax emitter for __GRAMMAR__ — the parser's inverse, turning a
788
- // model back into text this grammar accepts. Derived from the starter grammar
789
- // exactly as \`generated/ast.ts\` is, so replacing the grammar replaces this too.
790
- //
791
- // Without it every structured write fails: the framework's default binding at
792
- // \`services.serializer.Serializer\` THROWS, because a concrete syntax is
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.
796
- //
797
- // \`AbstractSerializer\`'s generic property walk lays out FORMAT-structured
798
- // output — its YAML and JSON subclasses are what it exists for — and cannot
799
- // produce a keyword-delimited syntax like \`node a -> b\`. So \`serializeNode\` is
800
- // a per-\`$type\` emitter here and the two array hooks are unreachable.
801
-
802
- import { AbstractSerializer } from '@hydranium/core';
803
- import type { AstNode } from '@hydranium/langium';
804
- import { type __ENTRY_RULE__, type __NODE_RULE__, is__ENTRY_RULE__, is__NODE_RULE__ } from './ast.js';
805
-
806
- export class __GRAMMAR__Serializer extends AbstractSerializer<__ENTRY_RULE__> {
807
- protected override serializeNode(node: AstNode | Record<string, unknown>): string {
808
- if (is__ENTRY_RULE__(node)) {
809
- return node.nodes.map(child => this.emitNode(child)).join('\\n');
810
- }
811
- if (is__NODE_RULE__(node)) {
812
- return this.emitNode(node);
813
- }
814
- // Defensive: a rule added to the grammar with no emitter added here.
815
- throw new Error(\`__GRAMMAR__Serializer: no emitter for $type \${(node as AstNode).$type}\`);
816
- }
817
-
818
- /** Unreachable — this grammar's one list is emitted by its \`__ENTRY_RULE__\` parent. */
819
- protected override serializeArray(): string {
820
- throw new Error('__GRAMMAR__Serializer: arrays are emitted by the per-$type parent, not the generic dispatch.');
821
- }
822
-
823
- /** Unreachable — same reasoning as {@link serializeArray}. */
824
- protected override serializeReferenceArray(): string {
825
- throw new Error('__GRAMMAR__Serializer: reference arrays are emitted by the per-$type parent, not the generic dispatch.');
826
- }
827
-
828
- /**
829
- * \`serializeReferenceText\` rather than \`node.target?.$refText\`: it is the one
830
- * read that spans BOTH input shapes. A transfer model reaching
831
- * \`serializeTransfer\` carries \`target\` as a plain string rather than a
832
- * \`Reference\`, and a serializer that reaches for \`$refText\` directly emits
833
- * the AST correctly and drops every reference on the transfer path.
834
- */
835
- private emitNode(node: __NODE_RULE__): string {
836
- const target = this.serializeReferenceText(node.target);
837
- return target === undefined ? \`node \${node.name}\` : \`node \${node.name} -> \${target}\`;
838
- }
839
- }
840
- `;
841
694
  /**
842
695
  * The exported constant names for the head set, in emission order. Both are
843
696
  * keyed by the PROJECT rather than by a language: one server of each kind per
@@ -1550,155 +1403,11 @@ function readme(composition) {
1550
1403
  */
1551
1404
  function glspFiles(composition, grammar) {
1552
1405
  const columns = columnsFor(composition);
1553
- const upper = screamingSnake(grammar.grammarId);
1554
- const dir = `src/glsp/${grammar.grammarId}`;
1555
- const render = (text) => grammarTier(project(text, composition), grammar);
1556
- const types = `// GLSP diagram-type and element-type ids for the __GRAMMAR__ diagram.
1557
- //
1558
- // **Authoritative half of a client/server contract.** Every id here has to be
1559
- // registered on the client too: sprotty's registries are exact-key maps with no
1560
- // prefix fallback, so an id the client does not know yields an element with none
1561
- // of a node's features, rendered by \`MissingView\` with only a
1562
- // \`no registered view for type '…'\` console warning. Nothing fails server-side.
1563
- //
1564
- // The ids stay namespaced under the GLSP defaults (\`node:\` / \`edge:\`) so a
1565
- // reader can tell a node id from an edge id at a glance.
1566
-
1567
- import { DefaultTypes } from '@eclipse-glsp/server';
1568
-
1569
- /**
1570
- * The GLSP diagram type — the string GLSP routes every per-diagram-type request
1571
- * by. Mirrored by the client; a mismatch silently DROPS the request rather than
1572
- * reporting an unknown diagram type. Same value as the language id, so the two
1573
- * cannot drift apart as a diagram gains element types.
1574
- */
1575
- export const ${upper}_DIAGRAM_TYPE = '__LANGUAGE_ID__';
1576
-
1577
- /** A \`__NODE_RULE__\` — the starter grammar's one named, referenceable node. */
1578
- export const ${upper}_NODE_TYPE = \`\${DefaultTypes.NODE}:__GRAMMAR_ID__-node\`;
1579
-
1580
- /** A resolved \`target\` reference, drawn as a connection between two nodes. */
1581
- export const ${upper}_EDGE_TYPE = \`\${DefaultTypes.EDGE}:__GRAMMAR_ID__-target\`;
1582
- `;
1583
- const state = `// GLSP state for the __GRAMMAR__ diagram.
1584
- //
1585
- // \`FullTextHydraniumGlspState\` is the simplest of the framework source-model
1586
- // strategies (the others project a structured transfer model, over one document
1587
- // or several): the source model is the whole document text, serialised through
1588
- // the per-URI \`Serializer\` and round-tripped by re-parsing. Both seams resolve
1589
- // through shared services, so narrowing the root type is all an adopter adds.
1590
- //
1591
- // It cannot field-merge — every concurrent edit on a whole-document model is a
1592
- // same-document collision, so undo / redo degrade to drop-on-divergence. Move to
1593
- // \`ReconcilingTransferHydraniumGlspState\` when you need field-level undo.
1594
-
1595
- import { FullTextHydraniumGlspState } from '@hydranium/glsp-server';
1596
- import { injectable } from 'inversify';
1597
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1598
-
1599
- @injectable()
1600
- export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RULE__> {}
1601
- `;
1602
- const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
1603
- // defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
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.
1609
- //
1610
- // The subclass exists so a bespoke load or save has a stable place to land.
1611
-
1612
- import { type FullTextSourceModel, HydraniumGlspStorage } from '@hydranium/glsp-server';
1613
- import { injectable } from 'inversify';
1614
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1615
-
1616
- @injectable()
1617
- export class __GRAMMAR__GlspStorage extends HydraniumGlspStorage<__ENTRY_RULE__, FullTextSourceModel> {}
1618
- `;
1619
- const submission = `// Submission handler for the __GRAMMAR__ diagram.
1620
- //
1621
- // Inherits the framework's \`readyEvent = IntegrityService.SettledState\`, which
1622
- // is load-bearing rather than incidental: the GModel factory resolves
1623
- // \`target.ref\`, so it needs a fully-linked AST. Without the gate those reads
1624
- // fire mid-build and warn about resolution before scopes are computed.
1625
- //
1626
- // Only \`formatSourceRoot\` is overridden, so the submit log names the model and
1627
- // its node count instead of a bare \`$type\`.
1628
-
1629
- import { ModelState } from '@eclipse-glsp/server';
1630
- import { type FullTextSourceModel, HydraniumGlspSubmissionHandler } from '@hydranium/glsp-server';
1631
- import { inject, injectable } from 'inversify';
1632
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1633
- import type { __GRAMMAR__GlspState } from './state.js';
1634
-
1635
- @injectable()
1636
- export class __GRAMMAR__SubmissionHandler extends HydraniumGlspSubmissionHandler<__ENTRY_RULE__, FullTextSourceModel> {
1637
- @inject(ModelState) declare protected modelState: __GRAMMAR__GlspState;
1638
-
1639
- protected override formatSourceRoot(root: __ENTRY_RULE__ | undefined): string {
1640
- return root ? \`__ENTRY_RULE__ nodes=\${root.nodes.length}\` : 'none';
1641
- }
1642
- }
1643
- `;
1644
- const factory = `// AST → GModel for the __GRAMMAR__ diagram: one node per \`__NODE_RULE__\`, one
1645
- // edge per resolved \`target\` reference.
1646
- //
1647
- // Ids come from the index rather than being composed here, so the id strategy
1648
- // stays in one place. Nodes are emitted before edges, because a GLSP edge
1649
- // pointing at an id that does not exist fails client-side with a far less
1650
- // obvious error than a missing edge. An unresolved reference is SKIPPED rather
1651
- // than treated as an error: a dangling reference is ordinary editing state, and
1652
- // the LSP head already reports it as a diagnostic.
1653
-
1654
- import { DefaultTypes, GEdge, GGraph, GLabel, type GModelFactory, GNode, ModelState } from '@eclipse-glsp/server';
1655
- import { inject, injectable } from 'inversify';
1656
- import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
1657
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1658
- import type { __GRAMMAR__GlspState } from './state.js';
1659
-
1660
- @injectable()
1661
- export class __GRAMMAR__GModelFactory implements GModelFactory {
1662
- @inject(ModelState) protected readonly modelState!: __GRAMMAR__GlspState;
1663
-
1664
- createModel(): void {
1665
- const root = this.modelState.sourceRoot;
1666
- const graph = GGraph.builder().id(this.modelState.sourceUri).build();
1667
- if (root) {
1668
- this.buildGraph(root, graph);
1669
- }
1670
- this.modelState.updateRoot(graph);
1671
- }
1672
-
1673
- protected buildGraph(root: __ENTRY_RULE__, graph: GGraph): void {
1674
- for (const node of root.nodes) {
1675
- const id = this.modelState.index.createId(node);
1676
- graph.children.push(
1677
- GNode.builder()
1678
- .id(id)
1679
- .type(${upper}_NODE_TYPE)
1680
- .add(GLabel.builder().id(\`\${id}_name\`).text(node.name).type(DefaultTypes.LABEL).build())
1681
- .build()
1682
- );
1683
- }
1684
- for (const node of root.nodes) {
1685
- const target = node.target?.ref;
1686
- if (!target) {
1687
- continue;
1688
- }
1689
- const sourceId = this.modelState.index.createId(node);
1690
- graph.children.push(
1691
- GEdge.builder()
1692
- .id(\`\${sourceId}_target\`)
1693
- .type(${upper}_EDGE_TYPE)
1694
- .sourceId(sourceId)
1695
- .targetId(this.modelState.index.createId(target))
1696
- .build()
1697
- );
1698
- }
1699
- }
1700
- }
1701
- `;
1406
+ const fixed = (file) => grammarTemplate(`src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
1407
+ const computed = (file, text) => ({
1408
+ path: `src/glsp/${grammar.grammarId}/${file}`,
1409
+ content: render(text, composition, grammar)
1410
+ });
1702
1411
  const configuration = `// Diagram configuration for the __GRAMMAR__ diagram.
1703
1412
  //
1704
1413
  // **Every hint is \`false\`, and that is not the same as read-only.** The starter
@@ -1718,7 +1427,7 @@ export class __GRAMMAR__GModelFactory implements GModelFactory {
1718
1427
  import { type GModelElementConstructor } from '@eclipse-glsp/graph';
1719
1428
  ${importList(['type DiagramConfiguration', 'type EdgeTypeHint', 'ServerLayoutKind', 'type ShapeTypeHint', 'getDefaultMapping'], '@eclipse-glsp/server', columns)}
1720
1429
  import { injectable } from 'inversify';
1721
- import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
1430
+ import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from './types.js';
1722
1431
 
1723
1432
  @injectable()
1724
1433
  export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
@@ -1730,7 +1439,7 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1730
1439
 
1731
1440
  readonly shapeTypeHints: ShapeTypeHint[] = [
1732
1441
  {
1733
- elementTypeId: ${upper}_NODE_TYPE,
1442
+ elementTypeId: __GRAMMAR_ID_CONST___NODE_TYPE,
1734
1443
  deletable: false,
1735
1444
  reparentable: false,
1736
1445
  repositionable: false,
@@ -1740,77 +1449,15 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
1740
1449
 
1741
1450
  readonly edgeTypeHints: EdgeTypeHint[] = [
1742
1451
  {
1743
- elementTypeId: ${upper}_EDGE_TYPE,
1452
+ elementTypeId: __GRAMMAR_ID_CONST___EDGE_TYPE,
1744
1453
  deletable: false,
1745
1454
  repositionable: false,
1746
1455
  routable: false,
1747
- sourceElementTypeIds: [${upper}_NODE_TYPE],
1748
- targetElementTypeIds: [${upper}_NODE_TYPE]
1456
+ sourceElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE],
1457
+ targetElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE]
1749
1458
  }
1750
1459
  ];
1751
1460
  }
1752
- `;
1753
- const createHandler = `// The starter operation handler for the __GRAMMAR__ diagram: the tool-palette
1754
- // entry that creates a \`__NODE_RULE__\`.
1755
- //
1756
- // **Deleting this file and its \`configureOperationHandlers\` registration in
1757
- // \`diagram-module.ts\` gives a read-only viewer**, and nothing else has to change
1758
- // — creation is offered through the palette rather than through a type hint, so
1759
- // every hint in \`diagram-configuration.ts\` is already \`false\`. The scaffold emits
1760
- // the editable direction because that asymmetry runs one way: editable →
1761
- // read-only is a deletion the compiler checks, while read-only → editable is
1762
- // authoring against a seam you have not used yet.
1763
- //
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.
1771
- //
1772
- // **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
1773
- // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
1774
- // node is appended at the end of the document and the client places it.
1775
- //
1776
- // Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
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.
1779
-
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';
1783
- import { findNextUnique } from '@hydranium/protocol';
1784
- import { injectable } from 'inversify';
1785
- import type { __NODE_RULE__ } from '../../language-server/ast.js';
1786
- import { ${upper}_NODE_TYPE } from './types.js';
1787
- import type { __GRAMMAR__GlspState } from './state.js';
1788
-
1789
- /** Proposed name for a new node, uniquified against the ones the document already has. */
1790
- const NODE_NAME_STEM = 'Node';
1791
-
1792
- @injectable()
1793
- export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperationHandler {
1794
- declare protected modelState: __GRAMMAR__GlspState;
1795
-
1796
- /** The palette's word for the thing it creates, so a noun rather than an action. */
1797
- override readonly label = '__NODE_RULE__';
1798
- elementTypeIds = [${upper}_NODE_TYPE];
1799
-
1800
- override createCommand(operation: CreateNodeOperation): MaybePromise<Command | undefined> {
1801
- if (!this.elementTypeIds.includes(operation.elementTypeId)) {
1802
- return undefined;
1803
- }
1804
- return new HydraniumGlspRecordingCommand(this.modelState, this.label, () => {
1805
- const root = this.modelState.sourceRoot;
1806
- const name = findNextUnique(
1807
- NODE_NAME_STEM,
1808
- root.nodes.map(node => node.name)
1809
- );
1810
- appendChild(root, 'nodes', root.nodes, { $type: '__NODE_RULE__', name } as __NODE_RULE__);
1811
- });
1812
- }
1813
- }
1814
1461
  `;
1815
1462
  const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
1816
1463
  //
@@ -1842,10 +1489,10 @@ import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
1842
1489
  import { __GRAMMAR__GlspState } from './state.js';
1843
1490
  import { __GRAMMAR__GlspStorage } from './storage.js';
1844
1491
  import { __GRAMMAR__SubmissionHandler } from './submission-handler.js';
1845
- import { ${upper}_DIAGRAM_TYPE } from './types.js';
1492
+ import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from './types.js';
1846
1493
 
1847
1494
  export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
1848
- readonly diagramType = ${upper}_DIAGRAM_TYPE;
1495
+ readonly diagramType = __GRAMMAR_ID_CONST___DIAGRAM_TYPE;
1849
1496
 
1850
1497
  protected override bindModelState(): BindingTarget<ModelState> {
1851
1498
  return { service: __GRAMMAR__GlspState };
@@ -1882,86 +1529,17 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
1882
1529
  binding.add(__GRAMMAR__CreateNodeOperationHandler);
1883
1530
  }
1884
1531
  }
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
- });
1954
1532
  `;
1955
1533
  return [
1956
- { path: `${dir}/types.ts`, content: render(types) },
1957
- { path: `${dir}/state.ts`, content: render(state) },
1958
- { path: `${dir}/storage.ts`, content: render(storage) },
1959
- { path: `${dir}/submission-handler.ts`, content: render(submission) },
1960
- { path: `${dir}/gmodel-factory.ts`, content: render(factory) },
1961
- { path: `${dir}/diagram-configuration.ts`, content: render(configuration) },
1962
- { path: `${dir}/create-node-operation-handler.ts`, content: render(createHandler) },
1963
- { path: `${dir}/diagram-module.ts`, content: render(module) },
1964
- { path: `test/${grammar.grammarId}-diagram.test.ts`, content: render(diagramTest) }
1534
+ fixed('types.ts'),
1535
+ fixed('state.ts'),
1536
+ fixed('storage.ts'),
1537
+ fixed('submission-handler.ts'),
1538
+ fixed('gmodel-factory.ts'),
1539
+ computed('diagram-configuration.ts', configuration),
1540
+ fixed('create-node-operation-handler.ts'),
1541
+ computed('diagram-module.ts', module),
1542
+ grammarTemplate('test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
1965
1543
  ];
1966
1544
  }
1967
1545
  /**
@@ -1977,28 +1555,46 @@ export function buildInitTemplates(composition) {
1977
1555
  path: `src/grammar/${grammar.grammarId}.langium`,
1978
1556
  content: render(grammarFile(multi), composition, grammar)
1979
1557
  }));
1558
+ // The shared lexical fragment, only for several grammars: each declaring its
1559
+ // own `WS` / `ID` lets the token sets drift apart.
1980
1560
  if (multi) {
1981
- grammarFiles.unshift({ path: 'src/grammar/common.langium', content: COMMON_GRAMMAR });
1561
+ grammarFiles.unshift(projectTemplate('src/grammar/common.langium', composition));
1982
1562
  }
1983
1563
  const diagramFiles = composition.grammars.filter(grammar => grammar.diagram).flatMap(grammar => glspFiles(composition, grammar));
1984
1564
  return [
1985
1565
  { path: 'package.json', content: packageJson(composition) },
1986
1566
  { path: 'langium-config.json', content: langiumConfig(composition) },
1987
1567
  { path: 'tsconfig.json', content: tsconfigJson(composition) },
1988
- { path: 'tsconfig.test.json', content: TSCONFIG_TEST },
1989
- { path: 'test/tsconfig.json', content: TEST_DIR_TSCONFIG },
1990
- { path: 'vitest.config.ts', content: VITEST_CONFIG },
1991
- { path: '.gitignore', content: GITIGNORE },
1568
+ // `isolatedModules` is what makes this check agree with the transform that
1569
+ // actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
1570
+ // vitest compiles each file on its own — a transform with no type
1571
+ // information, which cannot tell a re-exported TYPE from a re-exported value.
1572
+ // Without the flag `tsc` accepts `export { SomeType } from './x'` that the
1573
+ // transform then emits as a real import of a symbol that does not exist at
1574
+ // runtime, so the typecheck passes and the suite fails with a confusing
1575
+ // missing-export.
1576
+ projectTemplate('tsconfig.test.json', composition),
1577
+ // Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
1578
+ // applies a tsconfig only to the files its own `include` lists, so without this
1579
+ // a test declaring a decorated class fails to load.
1580
+ projectTemplate('test/tsconfig.json', composition),
1581
+ projectTemplate('vitest.config.ts', composition),
1582
+ // Written for a workspace member too: its root may not ignore `lib/` (a `yo
1583
+ // langium` root ignores `out/`), and a rule the root repeats costs nothing.
1584
+ // Stored without its dot, because `npm pack` drops a nested `.gitignore` from
1585
+ // the tarball and applies it as an ignore file.
1586
+ { path: '.gitignore', content: template('server/gitignore') },
1992
1587
  { path: 'README.md', content: readme(composition) },
1993
1588
  ...grammarFiles,
1994
1589
  { path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
1995
- { path: 'src/language-server/ast.ts', content: render(AST, composition, composition.grammars[0]) },
1996
- ...composition.grammars.map(grammar => ({
1997
- path: `src/language-server/${grammar.grammarId}-serializer.ts`,
1998
- content: render(SERIALIZER, composition, grammar)
1999
- })),
1590
+ grammarTemplate('src/language-server/ast.ts', composition, composition.grammars[0]),
1591
+ // Per grammar, because `services.serializer.Serializer` is a per-language slot
1592
+ // and a serializer is grammar-shaped by definition. That is also why the
1593
+ // framework refuses to default it: its `UnboundSerializer` throws, naming the
1594
+ // binding to add, rather than guessing a syntax.
1595
+ ...composition.grammars.map(grammar => grammarTemplate('src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar)),
2000
1596
  { path: 'src/index.ts', content: indexFile(composition) },
2001
- { path: 'src/services.ts', content: project(SERVICES, composition) },
1597
+ projectTemplate('src/services.ts', composition),
2002
1598
  { path: 'src/main.ts', content: mainFile(composition) },
2003
1599
  ...(composition.heads.includes('data') ? [{ path: 'src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
2004
1600
  ...(portCommandNames(composition).length ? [{ path: 'src/head-ports.ts', content: headPortsFile(composition) }] : []),