@hydranium/cli 1.0.0-next.308 → 1.0.0-next.309

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 (38) hide show
  1. package/README.md +11 -9
  2. package/lib/commands/headless-harness.js +1 -1
  3. package/lib/commands/headless-harness.js.map +1 -1
  4. package/lib/commands/init-args.d.ts.map +1 -1
  5. package/lib/commands/init-args.js +21 -16
  6. package/lib/commands/init-args.js.map +1 -1
  7. package/lib/commands/init-templates.d.ts +12 -6
  8. package/lib/commands/init-templates.d.ts.map +1 -1
  9. package/lib/commands/init-templates.js +394 -127
  10. package/lib/commands/init-templates.js.map +1 -1
  11. package/lib/commands/init-wizard.d.ts.map +1 -1
  12. package/lib/commands/init-wizard.js +8 -7
  13. package/lib/commands/init-wizard.js.map +1 -1
  14. package/lib/commands/init-workspace.d.ts +0 -8
  15. package/lib/commands/init-workspace.d.ts.map +1 -1
  16. package/lib/commands/init-workspace.js +0 -1
  17. package/lib/commands/init-workspace.js.map +1 -1
  18. package/lib/commands/init.d.ts +26 -1
  19. package/lib/commands/init.d.ts.map +1 -1
  20. package/lib/commands/init.js +64 -20
  21. package/lib/commands/init.js.map +1 -1
  22. package/package.json +11 -10
  23. package/src/commands/headless-harness.ts +1 -1
  24. package/src/commands/init-args.ts +21 -16
  25. package/src/commands/init-templates.ts +424 -133
  26. package/src/commands/init-wizard.ts +8 -7
  27. package/src/commands/init-workspace.ts +0 -9
  28. package/src/commands/init.ts +90 -20
  29. package/templates/gitignore +1 -0
  30. package/templates/protocol/gitignore +3 -0
  31. package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +41 -0
  32. package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +5 -4
  33. package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +6 -3
  34. package/templates/server/src/{services.ts → node/services.ts} +6 -2
  35. package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +28 -7
  36. package/templates/workspace/nodes.__EXTENSION__ +5 -0
  37. package/templates/workspace/references.__EXTENSION__ +2 -0
  38. package/templates/server/src/glsp/__GRAMMAR_ID__/types.ts +0 -26
@@ -55,7 +55,7 @@
55
55
  */
56
56
 
57
57
  import * as path from 'node:path';
58
- import { DEFAULT_INIT_HEADS, INIT_HEADS, type InitHead, isNonEmptyDir } from './init.js';
58
+ import { DEFAULT_INIT_HEADS, INIT_HEADS, type InitHead, isNonEmptyDir, isUsableExtension, memberPlacements } from './init.js';
59
59
  import { kebab } from './kebab.js';
60
60
  import { InitWizardCancelled, OPTIONAL_HEAD_CHOICES, type PromptPort } from './init-prompt.js';
61
61
  import { type WorkspaceDetection } from './init-workspace.js';
@@ -159,13 +159,13 @@ export function formatCommand(argv: readonly string[]): string {
159
159
  /** Validate a file extension, and reject one another grammar already claims. */
160
160
  function extensionProblem(value: string, taken: ReadonlySet<string>): string | undefined {
161
161
  const extension = value.replace(/^\./, '');
162
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(extension)) {
162
+ if (!isUsableExtension(extension)) {
163
163
  return `'${value}' is not a usable file extension.`;
164
164
  }
165
165
  // Checked here rather than left to the scaffolder so the answer is corrected
166
166
  // while the grammar that owns it is still on screen. Langium routes documents
167
167
  // to a language BY extension, so a duplicate is a silent mis-route.
168
- return taken.has(extension) ? `'.${extension}' is already claimed by another grammar.` : undefined;
168
+ return taken.has(extension.toLowerCase()) ? `'.${extension}' is already claimed by another grammar.` : undefined;
169
169
  }
170
170
 
171
171
  /**
@@ -269,10 +269,11 @@ async function askWorkspaceMembership(
269
269
  if (detection === undefined) {
270
270
  return { monorepo: false };
271
271
  }
272
+ const uncovered = memberPlacements(detection).filter(placement => placement.coveredBy === undefined);
272
273
  const covered =
273
- detection.coveredBy === undefined
274
- ? `The root manifest has no entry covering it, so one is printed for you to add.`
275
- : `Already covered by the "${detection.coveredBy}" workspaces entry.`;
274
+ uncovered.length === 0
275
+ ? 'Its packages are already covered by the workspaces entries.'
276
+ : `The root manifest has no entry covering ${uncovered.map(placement => placement.path).join(', ')}, so ${uncovered.length === 1 ? 'one is' : 'they are'} printed for you to add.`;
276
277
  // The placement is named, not just the root. The root alone reads as "this
277
278
  // ran somewhere I did not ask for" whenever the command was issued from a
278
279
  // different directory than the target, which is the normal case.
@@ -365,7 +366,7 @@ export async function runInitWizard(
365
366
  const grammar = await askGrammar(prompt, grammars.length, grammars.length === 0 ? name : undefined, takenNames, takenExtensions);
366
367
  grammars.push(grammar);
367
368
  takenNames.add(grammar.name);
368
- takenExtensions.add(grammar.extension);
369
+ takenExtensions.add(grammar.extension.toLowerCase());
369
370
  } while (await prompt.confirm({ message: 'Add another grammar?', initialValue: false }));
370
371
 
371
372
  const withDiagram = await askDiagramGrammar(prompt, heads, grammars);
@@ -169,14 +169,6 @@ export interface WorkspaceDetection {
169
169
  readonly workspaces: readonly string[];
170
170
  /** POSIX path of the target relative to {@link rootDir}, e.g. `packages/foo`. */
171
171
  readonly targetPath: string;
172
- /**
173
- * The `workspaces` glob already covering {@link targetPath}, if any.
174
- *
175
- * Absent means the root manifest needs a new entry — which is why this is
176
- * computed per target rather than assumed: a `packages/*` glob covers a new
177
- * package for free, while a repo that lists its examples one by one does not.
178
- */
179
- readonly coveredBy?: string;
180
172
  /** The npm scope the sibling packages agree on (`@acme`), when they agree. */
181
173
  readonly scope?: string;
182
174
  /**
@@ -454,7 +446,6 @@ export function detectWorkspace(targetDir: string, probe: WorkspaceProbe): Works
454
446
  rootDir,
455
447
  workspaces,
456
448
  targetPath,
457
- coveredBy: workspaces.find(glob => workspaceGlobCovers(glob, targetPath)),
458
449
  scope: inferPackageScope(rootDir, workspaces, probe),
459
450
  baseTsconfig: base && posixRelative(absoluteTarget, path.join(rootDir, base.file)),
460
451
  baseCompilerOptions: base?.compilerOptions,
@@ -11,7 +11,15 @@ import * as fs from 'node:fs';
11
11
  import * as path from 'node:path';
12
12
  import { buildInitTemplates, readFrameworkVersion, workspaceRootPins } from './init-templates.js';
13
13
  import { kebab } from './kebab.js';
14
- import { createNodeWorkspaceProbe, detectWorkspace, parseNpmVersion, type JsonValue, type WorkspaceProbe } from './init-workspace.js';
14
+ import {
15
+ createNodeWorkspaceProbe,
16
+ detectWorkspace,
17
+ parseNpmVersion,
18
+ workspaceGlobCovers,
19
+ type JsonValue,
20
+ type WorkspaceDetection,
21
+ type WorkspaceProbe
22
+ } from './init-workspace.js';
15
23
 
16
24
  /**
17
25
  * The protocol heads a scaffold can start, in composition order.
@@ -28,6 +36,35 @@ export type InitHead = (typeof INIT_HEADS)[number];
28
36
  /** The heads a scaffold emits when `--heads` is not given. */
29
37
  export const DEFAULT_INIT_HEADS: readonly InitHead[] = ['lsp', 'data'];
30
38
 
39
+ /**
40
+ * The library packages of a scaffolded project, each in the directory of its
41
+ * name, in the order a workspace lists them.
42
+ */
43
+ export const INIT_MEMBERS = ['protocol', 'server'] as const;
44
+
45
+ /** One library member — see {@link INIT_MEMBERS}. */
46
+ export type InitMember = (typeof INIT_MEMBERS)[number];
47
+
48
+ /** A member's path relative to the workspace root, and the `workspaces` entry already covering it. */
49
+ export interface InitMemberPlacement {
50
+ readonly path: string;
51
+ readonly coveredBy?: string;
52
+ }
53
+
54
+ /**
55
+ * Where each member lands in a detected workspace.
56
+ *
57
+ * Judged per member rather than for the project directory, which holds no
58
+ * package: a root listing its members one by one needs an entry for each,
59
+ * while a `packages/*` glob covers none of them one level deeper.
60
+ */
61
+ export function memberPlacements(detection: WorkspaceDetection): readonly InitMemberPlacement[] {
62
+ return INIT_MEMBERS.map(member => {
63
+ const memberPath = `${detection.targetPath}/${member}`;
64
+ return { path: memberPath, coveredBy: detection.workspaces.find(glob => workspaceGlobCovers(glob, memberPath)) };
65
+ });
66
+ }
67
+
31
68
  /** True for a string naming one of {@link INIT_HEADS}. */
32
69
  export function isInitHead(value: string): value is InitHead {
33
70
  return (INIT_HEADS as readonly string[]).includes(value);
@@ -254,11 +291,21 @@ function isPascalIdentifier(value: string): boolean {
254
291
  return /^[A-Za-z][A-Za-z0-9]*$/.test(value);
255
292
  }
256
293
 
257
- /** Strip a leading dot and reject an extension that is empty or dotted through. */
294
+ /**
295
+ * True for a file extension, without its leading dot, that is safe in an
296
+ * emitted file name and string literal: letters, digits and dashes.
297
+ */
298
+ export function isUsableExtension(extension: string): boolean {
299
+ return /^[A-Za-z0-9][A-Za-z0-9-]*$/.test(extension);
300
+ }
301
+
302
+ /** Strip a leading dot and reject an extension {@link isUsableExtension} refuses. */
258
303
  function normalizeExtension(value: string, grammar: string): string {
259
304
  const extension = value.replace(/^\./, '');
260
- if (extension.length === 0) {
261
- throw new Error(`Invalid extension '${value}' for grammar '${grammar}': an extension cannot be empty.`);
305
+ if (!isUsableExtension(extension)) {
306
+ throw new Error(
307
+ `Invalid extension '${value}' for grammar '${grammar}': use letters, digits and dashes, starting with a letter or digit.`
308
+ );
262
309
  }
263
310
  return extension;
264
311
  }
@@ -282,6 +329,11 @@ function resolveGrammar(spec: InitGrammarSpec, projectId: string, qualify: boole
282
329
  if (extensions.length === 0) {
283
330
  throw new Error(`Invalid --extensions for grammar '${spec.name}': at least one extension is required.`);
284
331
  }
332
+ if (spec.languageId !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(spec.languageId)) {
333
+ throw new Error(
334
+ `Invalid --language-id '${spec.languageId}' for grammar '${spec.name}': use lowercase letters, digits and dashes, e.g. 'my-lang'.`
335
+ );
336
+ }
285
337
  return {
286
338
  grammar: spec.name,
287
339
  grammarId,
@@ -369,15 +421,18 @@ function assertNoCollisions(grammars: readonly InitGrammarNames[]): void {
369
421
  }
370
422
  byLanguageId.set(grammar.languageId, grammar.grammar);
371
423
 
424
+ // Case-folded: a starter file is named by extension, and `nodes.Foo` and
425
+ // `nodes.foo` are one file on a case-insensitive file system.
372
426
  for (const extension of grammar.extensions) {
373
- const clashingExtension = byExtension.get(extension);
427
+ const key = extension.toLowerCase();
428
+ const clashingExtension = byExtension.get(key);
374
429
  if (clashingExtension !== undefined) {
375
430
  throw new Error(
376
431
  `File extension '.${extension}' is claimed by both '${clashingExtension}' and '${grammar.grammar}'. ` +
377
432
  'Langium routes documents by extension, so each grammar needs its own.'
378
433
  );
379
434
  }
380
- byExtension.set(extension, grammar.grammar);
435
+ byExtension.set(key, grammar.grammar);
381
436
  }
382
437
  }
383
438
  }
@@ -515,31 +570,36 @@ export function runInit(options: InitCommandOptions): void {
515
570
  write('\n');
516
571
  write(`Workspace root: ${detection.rootDir}\n`);
517
572
  if (detection.baseTsconfig !== undefined) {
518
- write(` tsconfig.json extends ${detection.baseTsconfig}, minus the options it inherits unchanged.\n`);
573
+ write(
574
+ ` Each package's tsconfig.json extends the root's ${path.posix.basename(detection.baseTsconfig)}, minus the options it inherits unchanged.\n`
575
+ );
519
576
  } else {
520
- write(' No root tsconfig carries compilerOptions, so the emitted one stands alone.\n');
577
+ write(" No root tsconfig carries compilerOptions, so each package's stands alone.\n");
521
578
  }
522
579
  if (detection.printWidth !== undefined) {
523
580
  write(` Sources wrapped at ${detection.printWidth} columns, from ${detection.rootDir}'s formatter config.\n`);
524
581
  }
525
582
  if (detection.oxlintConfig !== undefined || detection.eslintConfig !== undefined) {
526
583
  write(
527
- ` A lint script was added — ${detection.oxlintConfig ?? detection.eslintConfig} says this repo lints. Adjust it if your invocation differs.\n`
584
+ ` Each package got a lint script — ${detection.oxlintConfig ?? detection.eslintConfig} says this repo lints. Adjust them if your invocation differs.\n`
528
585
  );
529
586
  }
530
587
  // Printed rather than applied: `init` writes inside the target directory
531
588
  // and nowhere else, so a scaffold can never surprise-edit a manifest that
532
589
  // is under review, mid-rebase, or simply not the caller's to change.
533
- if (detection.coveredBy === undefined) {
534
- write(` Add this to "workspaces" in ${path.join(detection.rootDir, 'package.json')}:\n`);
535
- write(` "${detection.targetPath}"\n`);
536
- } else {
537
- write(` Already covered by the "${detection.coveredBy}" workspaces entry.\n`);
590
+ const placements = memberPlacements(detection);
591
+ const uncovered = placements.filter(placement => placement.coveredBy === undefined);
592
+ if (uncovered.length > 0) {
593
+ write(` Add ${uncovered.length === 1 ? 'this' : 'these'} to "workspaces" in ${path.join(detection.rootDir, 'package.json')}:\n`);
594
+ write(`${uncovered.map(placement => ` "${placement.path}"`).join(',\n')}\n`);
595
+ }
596
+ for (const placement of placements.filter(candidate => candidate.coveredBy !== undefined)) {
597
+ write(` ${placement.path} is already covered by the "${placement.coveredBy}" workspaces entry.\n`);
538
598
  }
539
599
  const pins = workspaceRootPins(composition.heads).map(([name, version]) => ` "${name}": "${version}"`);
540
600
  write(
541
601
  ` Add these to "devDependencies" in ${path.join(detection.rootDir, 'package.json')}, so another member's version of one ` +
542
- `cannot displace the copy this package shares with the framework:\n${pins.join(',\n')}\n`
602
+ `cannot displace the copy these packages share with the framework:\n${pins.join(',\n')}\n`
543
603
  );
544
604
  if (composition.packaging.scope === undefined && detection.scope !== undefined) {
545
605
  write(` Sibling packages all use the "${detection.scope}" scope; pass --scope ${detection.scope} to match them.\n`);
@@ -547,13 +607,23 @@ export function runInit(options: InitCommandOptions): void {
547
607
  }
548
608
  write('\n');
549
609
  write('Next steps:\n');
550
- write(` cd ${options.targetDir}\n`);
551
- write(' npm install\n');
552
- write(' npm run build # langium generate + tsc\n');
553
- write(' npm test # the scaffolded DI-composition test\n');
610
+ // A workspace member's project has no manifest of its own, so its steps run
611
+ // from the workspace root and name the server by `--prefix`.
612
+ const server = detection === undefined ? 'server' : `${detection.targetPath}/server`;
613
+ if (detection === undefined) {
614
+ write(` cd ${options.targetDir}\n`);
615
+ write(' npm install\n');
616
+ write(' npm run build # langium generate + tsc, every package\n');
617
+ write(' npm test # the server tests\n');
618
+ } else {
619
+ write(` cd ${detection.rootDir}\n`);
620
+ write(' npm install\n');
621
+ write(` npm --prefix ${server} run build # langium generate + tsc, protocol included\n`);
622
+ write(` npm --prefix ${server} test\n`);
623
+ }
554
624
  // `npx`, not a bare invocation: `@hydranium/cli` is a devDependency of the
555
625
  // scaffold, so the binary is on PATH inside an npm script and nowhere else.
556
- write(' npx hydranium-cli reflect --services ./lib/services.js\n');
626
+ write(` npx hydranium-cli reflect --services ./${server}/lib/node/services.js\n`);
557
627
  write('\n');
558
628
  write('The emitted sources carry no license header — run your own header tool if\n');
559
629
  write('your repo gates them. See the README section on that.\n');
@@ -0,0 +1 @@
1
+ node_modules/
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ lib/
3
+ *.tsbuildinfo
@@ -0,0 +1,41 @@
1
+ // GLSP diagram-type and element-type ids for the __GRAMMAR__ diagram, which the
2
+ // server stamps onto the graph and every client registers.
3
+ //
4
+ // Declared once, here, for both ends. A drift between them fails silently:
5
+ // sprotty's registries are exact-key maps with no prefix fallback, so an id the
6
+ // client does not know yields an element with none of a node's features,
7
+ // rendered by `MissingView` with only a `no registered view for type '…'`
8
+ // console warning. Nothing fails server-side.
9
+ //
10
+ // The ids stay namespaced under the GLSP defaults (`node:` / `edge:`) so a
11
+ // reader can tell a node id from an edge id at a glance.
12
+
13
+ import { DefaultTypes } from '@eclipse-glsp/protocol';
14
+ import { __GRAMMAR_ID_CONST___LANGUAGE } from './generated-hydranium/languages.js';
15
+
16
+ /**
17
+ * The GLSP diagram type — the string GLSP routes every per-diagram-type request
18
+ * by. A mismatch silently DROPS the request rather than reporting an unknown
19
+ * diagram type. Read from the language id, so renaming the language renames
20
+ * the diagram type at the next `generate`.
21
+ */
22
+ export const __GRAMMAR_ID_CONST___DIAGRAM_TYPE = __GRAMMAR_ID_CONST___LANGUAGE.languageId;
23
+
24
+ /** A `__NODE_RULE__` — the starter grammar's one named, referenceable node. */
25
+ export const __GRAMMAR_ID_CONST___NODE_TYPE = `${DefaultTypes.NODE}:__GRAMMAR_ID__-node`;
26
+
27
+ /** A resolved `target` reference, drawn as a connection between two nodes. */
28
+ export const __GRAMMAR_ID_CONST___EDGE_TYPE = `${DefaultTypes.EDGE}:__GRAMMAR_ID__-target`;
29
+
30
+ /**
31
+ * Every element type id the server stamps.
32
+ *
33
+ * A tuple, so {@link __GRAMMAR__ElementType} is a union of the ids rather than
34
+ * `string`: a client table keyed by that union does not compile while an id
35
+ * lacks its registration. Nothing holds the tuple to the constants above, so
36
+ * an id declared without joining it escapes that check.
37
+ */
38
+ export const __GRAMMAR_ID_CONST___ELEMENT_TYPES = [__GRAMMAR_ID_CONST___NODE_TYPE, __GRAMMAR_ID_CONST___EDGE_TYPE] as const;
39
+
40
+ /** One of the element type ids in {@link __GRAMMAR_ID_CONST___ELEMENT_TYPES}. */
41
+ export type __GRAMMAR__ElementType = (typeof __GRAMMAR_ID_CONST___ELEMENT_TYPES)[number];
@@ -21,17 +21,18 @@
21
21
  // starter grammar persists no bounds, so there is nowhere to put a coordinate: the
22
22
  // node is appended at the end of the document and the client places it.
23
23
  //
24
- // Like `types.ts`, `gmodel-factory.ts` and `diagram-configuration.ts`, this file
25
- // knows the starter grammar's shape — the `nodes` list and the `__NODE_RULE__`
26
- // it holds. Replacing the grammar means replacing these four together.
24
+ // Like protocol's `__GRAMMAR_ID__-diagram-types.ts`, `gmodel-factory.ts` and
25
+ // `diagram-configuration.ts`, this file knows the starter grammar's shape — the
26
+ // `nodes` list and the `__NODE_RULE__` it holds. Replacing the grammar means
27
+ // replacing these four together.
27
28
 
28
29
  import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
29
30
  import { appendChild } from '@hydranium/core';
30
31
  import { HydraniumGlspRecordingCommand } from '@hydranium/glsp-server';
31
32
  import { findNextUnique } from '@hydranium/protocol';
33
+ import { __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
32
34
  import { injectable } from 'inversify';
33
35
  import type { __NODE_RULE__ } from '../../language-server/ast.js';
34
- import { __GRAMMAR_ID_CONST___NODE_TYPE } from './types.js';
35
36
  import type { __GRAMMAR__GlspState } from './state.js';
36
37
 
37
38
  /** Proposed name for a new node, uniquified against the ones the document already has. */
@@ -6,12 +6,15 @@
6
6
  // pointing at an id that does not exist fails client-side with a far less
7
7
  // obvious error than a missing edge. An unresolved reference is SKIPPED rather
8
8
  // than treated as an error: a dangling reference is ordinary editing state, and
9
- // the LSP head already reports it as a diagnostic.
9
+ // the LSP head already reports it as a diagnostic. A reference into another
10
+ // document is skipped for the same reason: the graph holds only this
11
+ // document's nodes, so its target has no id here.
10
12
 
11
13
  import { DefaultTypes, GEdge, GGraph, GLabel, type GModelFactory, GNode, ModelState } from '@eclipse-glsp/server';
14
+ import { AstUtils } from '@hydranium/langium';
15
+ import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
12
16
  import { inject, injectable } from 'inversify';
13
17
  import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
14
- import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from './types.js';
15
18
  import type { __GRAMMAR__GlspState } from './state.js';
16
19
 
17
20
  @injectable()
@@ -40,7 +43,7 @@ export class __GRAMMAR__GModelFactory implements GModelFactory {
40
43
  }
41
44
  for (const node of root.nodes) {
42
45
  const target = node.target?.ref;
43
- if (!target) {
46
+ if (!target || AstUtils.findRootNode(target) !== root) {
44
47
  continue;
45
48
  }
46
49
  const sourceId = this.modelState.index.createId(node);
@@ -1,11 +1,15 @@
1
1
  // Zero-arg service factory for the headless `hydranium-cli` tooling: the
2
2
  // `reflect` / `lint-grammar` / `validate` subcommands import this via
3
- // `--services ./lib/services.js`. The head wires its own filesystem here.
3
+ // `--services ./lib/node/services.js`. The head wires its own filesystem here.
4
+ //
5
+ // Behind the package's `./node` entry rather than its root because it reads
6
+ // the real filesystem: the root stays loadable in a browser worker, which has
7
+ // no `node:fs`.
4
8
  //
5
9
  // The contract is language-count-agnostic — a second grammar needs no edit here.
6
10
 
7
11
  import { NodeFileSystem } from '@hydranium/core/node';
8
- import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
12
+ import { create__NAME__Services } from '../language-server/__PROJECT_ID__-module.js';
9
13
 
10
14
  export function createServices(): ReturnType<typeof create__NAME__Services> {
11
15
  return create__NAME__Services({ ...NodeFileSystem });
@@ -1,8 +1,9 @@
1
- // The starter create-node handler, run the way a client runs it: an in-process
2
- // GLSP server opens a document, a create-node operation adds a node, and an undo
3
- // and a redo take it out and put it back. Every step must keep the nodes the
4
- // document already had, and its comments: the diagram writes the document from
5
- // its AST, which carries none.
1
+ // The starter diagram, run the way a client runs it: an in-process GLSP server
2
+ // opens a document. A create-node operation adds a node, and an undo and a redo
3
+ // take it out and put it back; every step must keep the nodes the document
4
+ // already had, and its comments, since the diagram writes the document from its
5
+ // AST, which carries none. A reference into another document draws no edge,
6
+ // since the graph holds no node for its target.
6
7
 
7
8
  import 'reflect-metadata';
8
9
  import { type Action, CreateNodeOperation, RedoAction, ServerModule, SetDirtyStateAction, UndoAction } from '@eclipse-glsp/server';
@@ -11,11 +12,11 @@ import { makeScratchWorkspace, type ScratchWorkspace } from '@hydranium/core/tes
11
12
  import { HydraniumGlspAppModule } from '@hydranium/glsp-server';
12
13
  import { type GlspHarness, makeGlspHarness } from '@hydranium/glsp-server/testing';
13
14
  import { waitFor } from '@hydranium/protocol/testing';
15
+ import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
14
16
  import { afterEach, describe, expect, it } from 'vitest';
15
17
  import { __GRAMMAR__DiagramModule } from '../src/glsp/__GRAMMAR_ID__/diagram-module.js';
16
18
  import type { __GRAMMAR__GlspState } from '../src/glsp/__GRAMMAR_ID__/state.js';
17
- import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '../src/glsp/__GRAMMAR_ID__/types.js';
18
- import { createServices } from '../src/services.js';
19
+ import { createServices } from '../src/node/services.js';
19
20
 
20
21
  let workspace: ScratchWorkspace | undefined;
21
22
  let diagram: GlspHarness<__GRAMMAR__GlspState> | undefined;
@@ -65,4 +66,24 @@ describe('__GRAMMAR__ diagram', () => {
65
66
  redone: `${original}\nnode Node`
66
67
  });
67
68
  });
69
+
70
+ it('draws no edge for a reference that resolves into another document', async () => {
71
+ workspace = makeScratchWorkspace({ prefix: '__GRAMMAR_ID__-diagram-' });
72
+ workspace.write('declaring.__EXTENSION__', 'node second');
73
+ const file = workspace.write('referencing.__EXTENSION__', 'node first -> second');
74
+ const { shared } = createServices();
75
+ await initializeWorkspaceProgrammatically(shared, workspace.root);
76
+ diagram = makeGlspHarness<__GRAMMAR__GlspState>({
77
+ serverModule: new ServerModule().configureDiagramModule(new __GRAMMAR__DiagramModule()),
78
+ diagramType: __GRAMMAR_ID_CONST___DIAGRAM_TYPE,
79
+ appModules: [new HydraniumGlspAppModule({ shared })]
80
+ });
81
+ await diagram.start();
82
+ await diagram.openDocument(file);
83
+
84
+ // The reference resolves, so the missing edge is the document rule and
85
+ // not a dangling reference.
86
+ expect(diagram.state.sourceRoot?.nodes[0].target?.ref?.name).toBe('second');
87
+ expect(diagram.state.root.children.map(child => child.type)).toEqual([__GRAMMAR_ID_CONST___NODE_TYPE]);
88
+ });
68
89
  });
@@ -0,0 +1,5 @@
1
+ /** The first node. Hover a name to read its comment. */
2
+ node First
3
+
4
+ /** A second node, pointing at the first in the same file. */
5
+ node Second -> First
@@ -0,0 +1,2 @@
1
+ /** Points at a node declared in nodes.__EXTENSION__: hover the target to read its comment from there. */
2
+ node Third -> Second
@@ -1,26 +0,0 @@
1
- // GLSP diagram-type and element-type ids for the __GRAMMAR__ diagram.
2
- //
3
- // **Authoritative half of a client/server contract.** Every id here has to be
4
- // registered on the client too: sprotty's registries are exact-key maps with no
5
- // prefix fallback, so an id the client does not know yields an element with none
6
- // of a node's features, rendered by `MissingView` with only a
7
- // `no registered view for type '…'` console warning. Nothing fails server-side.
8
- //
9
- // The ids stay namespaced under the GLSP defaults (`node:` / `edge:`) so a
10
- // reader can tell a node id from an edge id at a glance.
11
-
12
- import { DefaultTypes } from '@eclipse-glsp/server';
13
-
14
- /**
15
- * The GLSP diagram type — the string GLSP routes every per-diagram-type request
16
- * by. Mirrored by the client; a mismatch silently DROPS the request rather than
17
- * reporting an unknown diagram type. Same value as the language id, so the two
18
- * cannot drift apart as a diagram gains element types.
19
- */
20
- export const __GRAMMAR_ID_CONST___DIAGRAM_TYPE = '__LANGUAGE_ID__';
21
-
22
- /** A `__NODE_RULE__` — the starter grammar's one named, referenceable node. */
23
- export const __GRAMMAR_ID_CONST___NODE_TYPE = `${DefaultTypes.NODE}:__GRAMMAR_ID__-node`;
24
-
25
- /** A resolved `target` reference, drawn as a connection between two nodes. */
26
- export const __GRAMMAR_ID_CONST___EDGE_TYPE = `${DefaultTypes.EDGE}:__GRAMMAR_ID__-target`;