@hydranium/cli 1.0.0-next.307 → 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.
- package/README.md +11 -9
- package/lib/commands/headless-harness.js +1 -1
- package/lib/commands/headless-harness.js.map +1 -1
- package/lib/commands/init-args.d.ts.map +1 -1
- package/lib/commands/init-args.js +21 -16
- package/lib/commands/init-args.js.map +1 -1
- package/lib/commands/init-templates.d.ts +12 -6
- package/lib/commands/init-templates.d.ts.map +1 -1
- package/lib/commands/init-templates.js +394 -127
- package/lib/commands/init-templates.js.map +1 -1
- package/lib/commands/init-wizard.d.ts.map +1 -1
- package/lib/commands/init-wizard.js +8 -7
- package/lib/commands/init-wizard.js.map +1 -1
- package/lib/commands/init-workspace.d.ts +0 -8
- package/lib/commands/init-workspace.d.ts.map +1 -1
- package/lib/commands/init-workspace.js +0 -1
- package/lib/commands/init-workspace.js.map +1 -1
- package/lib/commands/init.d.ts +26 -1
- package/lib/commands/init.d.ts.map +1 -1
- package/lib/commands/init.js +64 -20
- package/lib/commands/init.js.map +1 -1
- package/package.json +11 -10
- package/src/commands/headless-harness.ts +1 -1
- package/src/commands/init-args.ts +21 -16
- package/src/commands/init-templates.ts +424 -133
- package/src/commands/init-wizard.ts +8 -7
- package/src/commands/init-workspace.ts +0 -9
- package/src/commands/init.ts +90 -20
- package/templates/gitignore +1 -0
- package/templates/protocol/gitignore +3 -0
- package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +41 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +5 -4
- package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +6 -3
- package/templates/server/src/{services.ts → node/services.ts} +6 -2
- package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +28 -7
- package/templates/workspace/nodes.__EXTENSION__ +5 -0
- package/templates/workspace/references.__EXTENSION__ +2 -0
- 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 (
|
|
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
|
-
|
|
274
|
-
?
|
|
275
|
-
: `
|
|
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,
|
package/src/commands/init.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
261
|
-
throw new Error(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
`
|
|
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
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
write(
|
|
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
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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(
|
|
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,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
|
|
25
|
-
// knows the starter grammar's shape — the
|
|
26
|
-
// it holds. Replacing the grammar means
|
|
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 '
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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
|
});
|
|
@@ -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`;
|