@hydranium/cli 1.0.0-next.4 → 1.0.0-next.400
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 +102 -204
- package/bin/hydranium-cli.js +13 -0
- package/heap-analysis/heap/core/load.mjs +2 -2
- package/heap-analysis/heap/langium/langium.classifier.mjs +22 -11
- package/lib/cli-version.js.map +1 -1
- package/lib/cli.js +7 -2
- package/lib/cli.js.map +1 -1
- package/lib/commands/analyze-heap-args.d.ts.map +1 -1
- package/lib/commands/analyze-heap-args.js +1 -2
- package/lib/commands/analyze-heap-args.js.map +1 -1
- package/lib/commands/analyze-heap.d.ts +3 -0
- package/lib/commands/analyze-heap.d.ts.map +1 -1
- package/lib/commands/analyze-heap.js +2 -1
- package/lib/commands/analyze-heap.js.map +1 -1
- package/lib/commands/ast-ground-truth-args.d.ts +3 -2
- package/lib/commands/ast-ground-truth-args.d.ts.map +1 -1
- package/lib/commands/ast-ground-truth-args.js +11 -9
- package/lib/commands/ast-ground-truth-args.js.map +1 -1
- package/lib/commands/ast-ground-truth-driver.js +4 -2
- package/lib/commands/ast-ground-truth-driver.js.map +1 -1
- package/lib/commands/ast-ground-truth.js +1 -1
- package/lib/commands/ast-ground-truth.js.map +1 -1
- package/lib/commands/client-session.d.ts +17 -0
- package/lib/commands/client-session.d.ts.map +1 -0
- package/lib/commands/client-session.js +27 -0
- package/lib/commands/client-session.js.map +1 -0
- package/lib/commands/create-args.d.ts +17 -0
- package/lib/commands/create-args.d.ts.map +1 -0
- package/lib/commands/create-args.js +53 -0
- package/lib/commands/create-args.js.map +1 -0
- package/lib/commands/create.d.ts +38 -0
- package/lib/commands/create.d.ts.map +1 -0
- package/lib/commands/create.js +43 -0
- package/lib/commands/create.js.map +1 -0
- package/lib/commands/delete-args.d.ts +17 -0
- package/lib/commands/delete-args.d.ts.map +1 -0
- package/lib/commands/delete-args.js +49 -0
- package/lib/commands/delete-args.js.map +1 -0
- package/lib/commands/delete.d.ts +30 -0
- package/lib/commands/delete.d.ts.map +1 -0
- package/lib/commands/delete.js +32 -0
- package/lib/commands/delete.js.map +1 -0
- package/lib/commands/generate-ast-builder.d.ts +49 -0
- package/lib/commands/generate-ast-builder.d.ts.map +1 -0
- package/lib/commands/generate-ast-builder.js +191 -0
- package/lib/commands/generate-ast-builder.js.map +1 -0
- package/lib/commands/generate-languages.d.ts +35 -0
- package/lib/commands/generate-languages.d.ts.map +1 -0
- package/lib/commands/generate-languages.js +105 -0
- package/lib/commands/generate-languages.js.map +1 -0
- package/lib/commands/generate-transfer-builder.d.ts +33 -0
- package/lib/commands/generate-transfer-builder.d.ts.map +1 -0
- package/lib/commands/generate-transfer-builder.js +86 -0
- package/lib/commands/generate-transfer-builder.js.map +1 -0
- package/lib/commands/generate-transfer-model-args.d.ts.map +1 -1
- package/lib/commands/generate-transfer-model-args.js +30 -5
- package/lib/commands/generate-transfer-model-args.js.map +1 -1
- package/lib/commands/generate-transfer-model-config.d.ts.map +1 -1
- package/lib/commands/generate-transfer-model-config.js +10 -3
- package/lib/commands/generate-transfer-model-config.js.map +1 -1
- package/lib/commands/generate-transfer-model.d.ts +49 -9
- package/lib/commands/generate-transfer-model.d.ts.map +1 -1
- package/lib/commands/generate-transfer-model.js +235 -70
- package/lib/commands/generate-transfer-model.js.map +1 -1
- package/lib/commands/generated-module-path.d.ts +16 -0
- package/lib/commands/generated-module-path.d.ts.map +1 -0
- package/lib/commands/generated-module-path.js +21 -0
- package/lib/commands/generated-module-path.js.map +1 -0
- package/lib/commands/harness-args.d.ts +44 -42
- package/lib/commands/harness-args.d.ts.map +1 -1
- package/lib/commands/harness-args.js +109 -27
- package/lib/commands/harness-args.js.map +1 -1
- package/lib/commands/headless-harness.d.ts +37 -20
- package/lib/commands/headless-harness.d.ts.map +1 -1
- package/lib/commands/headless-harness.js +71 -19
- package/lib/commands/headless-harness.js.map +1 -1
- package/lib/commands/init-args.d.ts +2 -0
- package/lib/commands/init-args.d.ts.map +1 -1
- package/lib/commands/init-args.js +74 -31
- package/lib/commands/init-args.js.map +1 -1
- package/lib/commands/init-hosts.d.ts +24 -0
- package/lib/commands/init-hosts.d.ts.map +1 -0
- package/lib/commands/init-hosts.js +37 -0
- package/lib/commands/init-hosts.js.map +1 -0
- package/lib/commands/init-prompt.d.ts +3 -3
- package/lib/commands/init-prompt.d.ts.map +1 -1
- package/lib/commands/init-prompt.js +4 -5
- package/lib/commands/init-prompt.js.map +1 -1
- package/lib/commands/init-templates.d.ts +20 -14
- package/lib/commands/init-templates.d.ts.map +1 -1
- package/lib/commands/init-templates.js +3015 -750
- package/lib/commands/init-templates.js.map +1 -1
- package/lib/commands/init-wizard.d.ts +3 -0
- package/lib/commands/init-wizard.d.ts.map +1 -1
- package/lib/commands/init-wizard.js +46 -31
- package/lib/commands/init-wizard.js.map +1 -1
- package/lib/commands/init-workspace.d.ts +13 -11
- package/lib/commands/init-workspace.d.ts.map +1 -1
- package/lib/commands/init-workspace.js +21 -7
- package/lib/commands/init-workspace.js.map +1 -1
- package/lib/commands/init.d.ts +58 -22
- package/lib/commands/init.d.ts.map +1 -1
- package/lib/commands/init.js +203 -48
- package/lib/commands/init.js.map +1 -1
- package/lib/commands/kebab.d.ts +15 -0
- package/lib/commands/kebab.d.ts.map +1 -0
- package/lib/commands/kebab.js +17 -0
- package/lib/commands/kebab.js.map +1 -0
- package/lib/commands/lint-grammar-args.d.ts +3 -2
- package/lib/commands/lint-grammar-args.d.ts.map +1 -1
- package/lib/commands/lint-grammar-args.js +13 -10
- package/lib/commands/lint-grammar-args.js.map +1 -1
- package/lib/commands/lint-grammar-driver.js +2 -1
- package/lib/commands/lint-grammar-driver.js.map +1 -1
- package/lib/commands/lint-grammar-report.js.map +1 -1
- package/lib/commands/lint-grammar.js +1 -1
- package/lib/commands/lint-grammar.js.map +1 -1
- package/lib/commands/measure-memory-args.d.ts +3 -2
- package/lib/commands/measure-memory-args.d.ts.map +1 -1
- package/lib/commands/measure-memory-args.js +22 -11
- package/lib/commands/measure-memory-args.js.map +1 -1
- package/lib/commands/measure-memory-driver.js +5 -3
- package/lib/commands/measure-memory-driver.js.map +1 -1
- package/lib/commands/measure-memory.js +1 -1
- package/lib/commands/measure-memory.js.map +1 -1
- package/lib/commands/model-docs-args.d.ts +4 -3
- package/lib/commands/model-docs-args.d.ts.map +1 -1
- package/lib/commands/model-docs-args.js +12 -10
- package/lib/commands/model-docs-args.js.map +1 -1
- package/lib/commands/model-docs-driver.js +2 -1
- package/lib/commands/model-docs-driver.js.map +1 -1
- package/lib/commands/model-docs.js +1 -1
- package/lib/commands/model-docs.js.map +1 -1
- package/lib/commands/projects-args.d.ts.map +1 -1
- package/lib/commands/projects-args.js +5 -5
- package/lib/commands/projects-args.js.map +1 -1
- package/lib/commands/projects.d.ts +3 -4
- package/lib/commands/projects.d.ts.map +1 -1
- package/lib/commands/projects.js +2 -2
- package/lib/commands/projects.js.map +1 -1
- package/lib/commands/query-args.d.ts.map +1 -1
- package/lib/commands/query-args.js +6 -5
- package/lib/commands/query-args.js.map +1 -1
- package/lib/commands/query.d.ts +6 -5
- package/lib/commands/query.d.ts.map +1 -1
- package/lib/commands/query.js +5 -4
- package/lib/commands/query.js.map +1 -1
- package/lib/commands/reflect-args.d.ts +3 -2
- package/lib/commands/reflect-args.d.ts.map +1 -1
- package/lib/commands/reflect-args.js +13 -10
- package/lib/commands/reflect-args.js.map +1 -1
- package/lib/commands/reflect-driver.js +2 -1
- package/lib/commands/reflect-driver.js.map +1 -1
- package/lib/commands/reflect-report.js.map +1 -1
- package/lib/commands/reflect.js +1 -1
- package/lib/commands/reflect.js.map +1 -1
- package/lib/commands/save-args.d.ts.map +1 -1
- package/lib/commands/save-args.js +7 -4
- package/lib/commands/save-args.js.map +1 -1
- package/lib/commands/save.d.ts +8 -4
- package/lib/commands/save.d.ts.map +1 -1
- package/lib/commands/save.js +12 -23
- package/lib/commands/save.js.map +1 -1
- package/lib/commands/validate-args.d.ts +3 -2
- package/lib/commands/validate-args.d.ts.map +1 -1
- package/lib/commands/validate-args.js +13 -10
- package/lib/commands/validate-args.js.map +1 -1
- package/lib/commands/validate-driver.js +2 -1
- package/lib/commands/validate-driver.js.map +1 -1
- package/lib/commands/validate-report.js.map +1 -1
- package/lib/commands/validate.js +1 -1
- package/lib/commands/validate.js.map +1 -1
- package/lib/commands/watch-args.d.ts.map +1 -1
- package/lib/commands/watch-args.js +6 -4
- package/lib/commands/watch-args.js.map +1 -1
- package/lib/commands/watch.d.ts +3 -4
- package/lib/commands/watch.d.ts.map +1 -1
- package/lib/commands/watch.js +20 -2
- package/lib/commands/watch.js.map +1 -1
- package/lib/driver-heap.d.ts +52 -0
- package/lib/driver-heap.d.ts.map +1 -0
- package/lib/driver-heap.js +51 -0
- package/lib/driver-heap.js.map +1 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/log-level.d.ts +21 -11
- package/lib/log-level.d.ts.map +1 -1
- package/lib/log-level.js +25 -14
- package/lib/log-level.js.map +1 -1
- package/lib/spawn-data-server.d.ts +9 -4
- package/lib/spawn-data-server.d.ts.map +1 -1
- package/lib/spawn-data-server.js +5 -4
- package/lib/spawn-data-server.js.map +1 -1
- package/lib/stderr-connection-logger.d.ts +12 -0
- package/lib/stderr-connection-logger.d.ts.map +1 -0
- package/lib/stderr-connection-logger.js +16 -0
- package/lib/stderr-connection-logger.js.map +1 -0
- package/lib/testing/echo-server.js +9 -1
- package/lib/testing/echo-server.js.map +1 -1
- package/package.json +90 -38
- package/src/cli.ts +7 -2
- package/src/commands/analyze-heap-args.ts +1 -2
- package/src/commands/analyze-heap.ts +4 -1
- package/src/commands/ast-ground-truth-args.ts +15 -12
- package/src/commands/ast-ground-truth-driver.ts +4 -2
- package/src/commands/ast-ground-truth.ts +1 -1
- package/src/commands/client-session.ts +33 -0
- package/src/commands/create-args.ts +68 -0
- package/src/commands/create.ts +83 -0
- package/src/commands/delete-args.ts +64 -0
- package/src/commands/delete.ts +61 -0
- package/src/commands/generate-ast-builder.ts +236 -0
- package/src/commands/generate-languages.ts +132 -0
- package/src/commands/generate-transfer-builder.ts +101 -0
- package/src/commands/generate-transfer-model-args.ts +30 -5
- package/src/commands/generate-transfer-model-config.ts +10 -3
- package/src/commands/generate-transfer-model.ts +316 -68
- package/src/commands/generated-module-path.ts +22 -0
- package/src/commands/harness-args.ts +99 -31
- package/src/commands/headless-harness.ts +89 -28
- package/src/commands/init-args.ts +76 -31
- package/src/commands/init-hosts.ts +51 -0
- package/src/commands/init-prompt.ts +5 -6
- package/src/commands/init-templates.ts +3183 -760
- package/src/commands/init-wizard.ts +55 -31
- package/src/commands/init-workspace.ts +31 -16
- package/src/commands/init.ts +261 -63
- package/src/commands/kebab.ts +17 -0
- package/src/commands/lint-grammar-args.ts +17 -13
- package/src/commands/lint-grammar-driver.ts +2 -1
- package/src/commands/lint-grammar.ts +1 -1
- package/src/commands/measure-memory-args.ts +27 -14
- package/src/commands/measure-memory-driver.ts +5 -3
- package/src/commands/measure-memory.ts +1 -1
- package/src/commands/model-docs-args.ts +16 -13
- package/src/commands/model-docs-driver.ts +2 -1
- package/src/commands/model-docs.ts +1 -1
- package/src/commands/projects-args.ts +5 -4
- package/src/commands/projects.ts +4 -6
- package/src/commands/query-args.ts +6 -4
- package/src/commands/query.ts +9 -9
- package/src/commands/reflect-args.ts +17 -13
- package/src/commands/reflect-driver.ts +2 -1
- package/src/commands/reflect.ts +1 -1
- package/src/commands/save-args.ts +7 -3
- package/src/commands/save.ts +24 -27
- package/src/commands/validate-args.ts +17 -13
- package/src/commands/validate-driver.ts +2 -1
- package/src/commands/validate.ts +1 -1
- package/src/commands/watch-args.ts +6 -3
- package/src/commands/watch.ts +28 -7
- package/src/driver-heap.ts +65 -0
- package/src/index.ts +3 -0
- package/src/log-level.ts +42 -14
- package/src/spawn-data-server.ts +17 -5
- package/src/stderr-connection-logger.ts +18 -0
- package/src/testing/echo-server.ts +11 -2
- package/templates/client/gitignore +3 -0
- package/templates/client/src/__GRAMMAR_ID__-diagram-module.ts +82 -0
- package/templates/editorconfig +15 -0
- package/templates/gitignore +1 -0
- package/templates/host-browser/esbuild.mjs +82 -0
- package/templates/host-browser/gitignore +4 -0
- package/templates/host-browser/hydranium-mark.svg +40 -0
- package/templates/host-browser/index.html +287 -0
- package/templates/host-browser/playwright.config.mts +24 -0
- package/templates/host-browser/src/data-status.ts +15 -0
- package/templates/host-browser/src/diagram-save.ts +60 -0
- package/templates/host-browser/src/diagram.ts +121 -0
- package/templates/host-browser/src/editor.ts +102 -0
- package/templates/host-browser/src/log.ts +14 -0
- package/templates/host-browser/src/monaco-editor-core-worker.d.ts +7 -0
- package/templates/host-browser/src/monaco-editor-worker.ts +10 -0
- package/templates/host-browser/src/posix-path-shim.ts +23 -0
- package/templates/host-browser/src/shown-file.ts +61 -0
- package/templates/host-browser/src/workspace.ts +5 -0
- package/templates/host-browser/tsconfig.e2e.json +16 -0
- package/templates/host-browser/tsconfig.page.json +16 -0
- package/templates/host-browser/tsconfig.worker.json +16 -0
- package/templates/host-theia-browser/esbuild.mjs +24 -0
- package/templates/host-theia-browser/gitignore +9 -0
- package/templates/host-theia-browser/playwright.config.mts +42 -0
- package/templates/host-theia-browser/start.mjs +50 -0
- package/templates/host-theia-browser/tsconfig.e2e.json +16 -0
- package/templates/host-theia-electron/esbuild.mjs +27 -0
- package/templates/host-theia-electron/gitignore +8 -0
- package/templates/host-theia-electron/playwright.config.mts +12 -0
- package/templates/host-theia-electron/start.mjs +47 -0
- package/templates/host-theia-electron/tsconfig.e2e.json +16 -0
- package/templates/host-vscode/esbuild.mjs +38 -0
- package/templates/host-vscode/gitignore +7 -0
- package/templates/host-vscode/playwright.config.mts +14 -0
- package/templates/host-vscode/src/__GRAMMAR_ID__-diagram-editor.ts +19 -0
- package/templates/host-vscode/src/show-model-command.ts +24 -0
- package/templates/host-vscode/src/webview/__GRAMMAR_ID__-diagram.ts +18 -0
- package/templates/host-vscode/src/webview/diagram.css +40 -0
- package/templates/host-vscode/start.mjs +22 -0
- package/templates/host-vscode/test/install-vscode.mts +21 -0
- package/templates/host-vscode/test/vscode-version.mts +10 -0
- package/templates/host-vscode/tsconfig.e2e.json +17 -0
- package/templates/host-vscode/tsconfig.json +19 -0
- package/templates/host-vscode/tsconfig.webview.json +17 -0
- package/templates/oxlintrc.json +4 -0
- package/templates/protocol/gitignore +3 -0
- package/templates/protocol/src/__GRAMMAR_ID__-diagram-types.ts +47 -0
- package/templates/server/gitignore +6 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/create-node-operation-handler.ts +62 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/delete-operation-handler.ts +63 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/gmodel-factory.ts +144 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/state.ts +18 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/storage.ts +16 -0
- package/templates/server/src/glsp/__GRAMMAR_ID__/submission-handler.ts +24 -0
- package/templates/server/src/grammar/common.langium +12 -0
- package/templates/server/src/language-server/__GRAMMAR_ID__-serializer.ts +53 -0
- package/templates/server/src/language-server/__GRAMMAR_ID__-validation.ts +30 -0
- package/templates/server/src/language-server/ast.ts +23 -0
- package/templates/server/src/node/services.ts +16 -0
- package/templates/server/test/__GRAMMAR_ID__-diagram.test.ts +403 -0
- package/templates/server/test/tsconfig.json +4 -0
- package/templates/server/tsconfig.test.json +10 -0
- package/templates/server/vitest.config.ts +14 -0
- package/templates/theia/gitignore +3 -0
- package/templates/theia/src/browser/__GRAMMAR_ID__-diagram-frontend-module.ts +65 -0
- package/templates/theia/src/browser/__PROJECT_ID__-connection-preload-module.ts +15 -0
- package/templates/theia/src/browser/__PROJECT_ID__-data-connection.ts +15 -0
- package/templates/theia/src/browser/__PROJECT_ID__-data-port.ts +11 -0
- package/templates/theia/src/browser/__PROJECT_ID__-glsp-client-contribution.ts +13 -0
- package/templates/theia/src/browser/__PROJECT_ID__-show-model-command.ts +51 -0
- package/templates/theia/src/common/__PROJECT_ID__-glsp-contribution.ts +3 -0
- package/templates/theia/src/node/__PROJECT_ID__-connection-backend-module.ts +12 -0
- package/templates/theia/src/node/__PROJECT_ID__-data-backend-module.ts +15 -0
- package/templates/theia/src/node/__PROJECT_ID__-glsp-backend-module.ts +16 -0
- package/templates/theia/style/diagram.css +20 -0
- package/templates/theia/tsconfig.json +24 -0
- package/templates/vscode-servers/gitignore +4 -0
- package/templates/vscode-servers/tsconfig.json +19 -0
- package/templates/workspace/nodes.__EXTENSION__ +5 -0
- package/templates/workspace/references.__EXTENSION__ +2 -0
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
* `bin` key, the DI module filename, and the data-server port command.
|
|
23
23
|
* - `__CONFIG_ROOT_CONST__` — SCREAMING_SNAKE project id, naming the
|
|
24
24
|
* `lsp.configurationRoot` constant a multi-grammar project binds.
|
|
25
|
+
* - `__PROTOCOL_PACKAGE__` — the protocol member's package name, scope
|
|
26
|
+
* included, which the server and every client import ids and wire types
|
|
27
|
+
* from.
|
|
28
|
+
* - `__SERVER_PACKAGE__` / `__CLIENT_PACKAGE__` — the server and client
|
|
29
|
+
* members' package names, which a host imports the language and the diagram
|
|
30
|
+
* definition from.
|
|
25
31
|
*
|
|
26
32
|
* GRAMMAR tokens, substituted once per grammar:
|
|
27
33
|
*
|
|
@@ -29,6 +35,10 @@
|
|
|
29
35
|
* Drives the PER-LANGUAGE generated symbols (`<grammar>GeneratedModule`,
|
|
30
36
|
* `<grammar>LanguageMetaData`).
|
|
31
37
|
* - `__GRAMMAR_ID__` — kebab grammar id: the `.langium` filename.
|
|
38
|
+
* - `__GRAMMAR_ID_CONST__` — SCREAMING_SNAKE grammar id, prefixing the
|
|
39
|
+
* diagram's type-id constants.
|
|
40
|
+
* - `__GRAMMAR_CAMEL__` — lowerCamelCase grammar name, naming the client's
|
|
41
|
+
* diagram module constant.
|
|
32
42
|
* - `__LANGUAGE_ID__` — kebab routing key: the `langium-config` entry id and
|
|
33
43
|
* its TextMate output.
|
|
34
44
|
* - `__EXTENSION__` — the grammar's FIRST file extension, without the dot.
|
|
@@ -49,10 +59,22 @@
|
|
|
49
59
|
* generator emits `interface <projectName>Element` as the base type every rule
|
|
50
60
|
* extends, which a rule of that name would collide with.
|
|
51
61
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
62
|
+
* The project is its packages and a starter workspace, each in its own
|
|
63
|
+
* directory: `server/` (the grammar, the language and its heads), `protocol/`
|
|
64
|
+
* (what the server and its clients both import, so a client never installs
|
|
65
|
+
* Langium), `client/` with a diagram (its host-neutral client side),
|
|
66
|
+
* `host-<name>/` per host, `vscode-servers/` with the VS Code host (the server
|
|
67
|
+
* launch it shares with a Theia app), and `workspace/` (starter model files
|
|
68
|
+
* every host and the CLI open). A standalone project adds a root manifest
|
|
69
|
+
* naming them as npm workspaces; a workspace member joins the surrounding root
|
|
70
|
+
* instead.
|
|
71
|
+
*
|
|
72
|
+
* A file that is fixed text with tokens lives in the `templates/` tree the
|
|
73
|
+
* package ships beside `lib`, at the path it is emitted at, tokens included
|
|
74
|
+
* (each ignore file without its dot); one computed from the composition is
|
|
75
|
+
* built here. The tree is read synchronously, so `runInit` stays synchronous,
|
|
76
|
+
* and it is neither formatted with the repo's settings nor given a license
|
|
77
|
+
* header, since either would change what `init` emits.
|
|
56
78
|
*
|
|
57
79
|
* Two conventions the templates follow deliberately:
|
|
58
80
|
*
|
|
@@ -72,8 +94,10 @@
|
|
|
72
94
|
* over; the adopter's own formatter settles it.
|
|
73
95
|
*/
|
|
74
96
|
|
|
97
|
+
import { readFileSync } from 'node:fs';
|
|
75
98
|
import { readCliVersion } from '../cli-version.js';
|
|
76
|
-
import type
|
|
99
|
+
import { type InitComposition, type InitFile, type InitGrammarNames, type InitHead, type InitMember, initMembers } from './init.js';
|
|
100
|
+
import { isTheiaHost } from './init-hosts.js';
|
|
77
101
|
import type { JsonValue } from './init-workspace.js';
|
|
78
102
|
|
|
79
103
|
/** One template file: its path (tokens allowed) and its content (tokens allowed). */
|
|
@@ -82,16 +106,6 @@ export interface InitTemplate {
|
|
|
82
106
|
content: string;
|
|
83
107
|
}
|
|
84
108
|
|
|
85
|
-
/**
|
|
86
|
-
* The version every framework package carries before the first release.
|
|
87
|
-
*
|
|
88
|
-
* A scaffold made by a CLI still at this version pins a range the registry
|
|
89
|
-
* cannot serve, so the emitted README keeps its yalc note and `init` keeps its
|
|
90
|
-
* install warning while — and only while — it holds: from a published CLI the
|
|
91
|
-
* derived pins resolve and either note would be false as printed.
|
|
92
|
-
*/
|
|
93
|
-
export const UNPUBLISHED_FRAMEWORK_VERSION = '0.0.0';
|
|
94
|
-
|
|
95
109
|
/**
|
|
96
110
|
* The version `init` pins every `@hydranium/*` dependency at: the scaffolding
|
|
97
111
|
* CLI's own.
|
|
@@ -122,12 +136,55 @@ function screamingSnake(projectId: string): string {
|
|
|
122
136
|
return projectId.replace(/-/g, '_').toUpperCase();
|
|
123
137
|
}
|
|
124
138
|
|
|
139
|
+
const VSCODE_EXTENSION_MEMBERS: readonly InitMember[] = ['vscode-servers', 'host-vscode'];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A member's package name: `<id>-<member>`, under the scope when there is one,
|
|
143
|
+
* except for the VS Code extensions, whose scoped name `vsce` refuses to
|
|
144
|
+
* package.
|
|
145
|
+
*
|
|
146
|
+
* Suffixed even for the server, because a project of several packages has no
|
|
147
|
+
* member entitled to the bare id, and a later client or host would otherwise
|
|
148
|
+
* read as a part of the server.
|
|
149
|
+
*/
|
|
150
|
+
export function memberPackageName(composition: InitComposition, member: InitMember): string {
|
|
151
|
+
const { scope } = composition.packaging;
|
|
152
|
+
const name = `${composition.projectId}-${member}`;
|
|
153
|
+
return scope === undefined || VSCODE_EXTENSION_MEMBERS.includes(member) ? name : `${scope}/${name}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The composition as one member sees it: inside a workspace, its placement
|
|
158
|
+
* sits one directory below the project's, so its `extends` and its lint
|
|
159
|
+
* config path reach one level further up.
|
|
160
|
+
*/
|
|
161
|
+
function forMember(composition: InitComposition, member: InitMember): InitComposition {
|
|
162
|
+
const workspace = composition.packaging.workspace;
|
|
163
|
+
if (workspace === undefined) {
|
|
164
|
+
return composition;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...composition,
|
|
168
|
+
packaging: {
|
|
169
|
+
...composition.packaging,
|
|
170
|
+
workspace: {
|
|
171
|
+
...workspace,
|
|
172
|
+
targetPath: `${workspace.targetPath}/${member}`,
|
|
173
|
+
baseTsconfig: workspace.baseTsconfig === undefined ? undefined : `../${workspace.baseTsconfig}`
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
125
179
|
/** Substitute the project-tier tokens. */
|
|
126
180
|
function project(text: string, composition: InitComposition): string {
|
|
127
181
|
return text
|
|
128
182
|
.replace(/__NAME__/g, composition.name)
|
|
129
183
|
.replace(/__PROJECT_ID__/g, composition.projectId)
|
|
130
|
-
.replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId))
|
|
184
|
+
.replace(/__CONFIG_ROOT_CONST__/g, screamingSnake(composition.projectId))
|
|
185
|
+
.replace(/__PROTOCOL_PACKAGE__/g, memberPackageName(composition, 'protocol'))
|
|
186
|
+
.replace(/__SERVER_PACKAGE__/g, memberPackageName(composition, 'server'))
|
|
187
|
+
.replace(/__CLIENT_PACKAGE__/g, memberPackageName(composition, 'client'));
|
|
131
188
|
}
|
|
132
189
|
|
|
133
190
|
/**
|
|
@@ -140,7 +197,9 @@ function project(text: string, composition: InitComposition): string {
|
|
|
140
197
|
*/
|
|
141
198
|
function grammarTier(text: string, grammar: InitGrammarNames): string {
|
|
142
199
|
return text
|
|
200
|
+
.replace(/__GRAMMAR_ID_CONST__/g, screamingSnake(grammar.grammarId))
|
|
143
201
|
.replace(/__GRAMMAR_ID__/g, grammar.grammarId)
|
|
202
|
+
.replace(/__GRAMMAR_CAMEL__/g, grammar.grammar.charAt(0).toLowerCase() + grammar.grammar.slice(1))
|
|
144
203
|
.replace(/__GRAMMAR__/g, grammar.grammar)
|
|
145
204
|
.replace(/__LANGUAGE_ID__/g, grammar.languageId)
|
|
146
205
|
.replace(/__EXTENSION__/g, grammar.extensions[0])
|
|
@@ -153,6 +212,30 @@ function render(text: string, composition: InitComposition, grammar: InitGrammar
|
|
|
153
212
|
return grammarTier(project(text, composition), grammar);
|
|
154
213
|
}
|
|
155
214
|
|
|
215
|
+
/** The template tree, the same two levels up from `src/commands` and `lib/commands`. */
|
|
216
|
+
const TEMPLATES = new URL('../../templates/', import.meta.url);
|
|
217
|
+
|
|
218
|
+
/** One template file's text. */
|
|
219
|
+
function template(path: string): string {
|
|
220
|
+
return readFileSync(new URL(path, TEMPLATES), 'utf-8');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A template emitted once per project at its own path, rendered with the project tokens. */
|
|
224
|
+
function projectTemplate(path: string, composition: InitComposition): InitFile {
|
|
225
|
+
return { path, content: project(template(path), composition) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A template emitted once per grammar, its path rendered like its content. */
|
|
229
|
+
function grammarTemplate(path: string, composition: InitComposition, grammar: InitGrammarNames): InitFile {
|
|
230
|
+
return { path: render(path, composition, grammar), content: render(template(path), composition, grammar) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** An ignore file, stored without its dot because `npm pack` drops a nested `.gitignore` and applies it as an ignore file. */
|
|
234
|
+
function ignoreFile(directory: string): InitFile {
|
|
235
|
+
const prefix = directory === '' ? '' : `${directory}/`;
|
|
236
|
+
return { path: `${prefix}.gitignore`, content: template(`${prefix}gitignore`) };
|
|
237
|
+
}
|
|
238
|
+
|
|
156
239
|
/** A comma-separated list of single-quoted TypeScript/JS string literals. */
|
|
157
240
|
function quotedList(values: readonly string[]): string {
|
|
158
241
|
return values.map(value => `'${value}'`).join(', ');
|
|
@@ -161,14 +244,15 @@ function quotedList(values: readonly string[]): string {
|
|
|
161
244
|
/**
|
|
162
245
|
* The column budget the emitted sources wrap at when nothing better is known.
|
|
163
246
|
*
|
|
164
|
-
* **Not "prettier's default"** — that is 80.
|
|
165
|
-
* a default was wrong in a way that mattered: no single width is stable for an
|
|
247
|
+
* **Not "prettier's default"** — that is 80. No single width is stable for an
|
|
166
248
|
* unknown repo, so emitted code only passes a formatter check relative to a
|
|
167
249
|
* config. A workspace scaffold detects the surrounding repo's `printWidth` and
|
|
168
250
|
* uses it (see {@link columnsFor}); a standalone one has no config to read and
|
|
169
|
-
* falls back here.
|
|
251
|
+
* falls back here. It is the framework's own width, so a fixed template, which
|
|
252
|
+
* wraps at no budget, reads the same in a standalone scaffold as in the
|
|
253
|
+
* examples this repo formats.
|
|
170
254
|
*/
|
|
171
|
-
const DEFAULT_COLUMNS =
|
|
255
|
+
const DEFAULT_COLUMNS = 140;
|
|
172
256
|
|
|
173
257
|
/** The width THIS composition wraps at: the detected workspace one, else {@link DEFAULT_COLUMNS}. */
|
|
174
258
|
function columnsFor(composition: InitComposition): number {
|
|
@@ -177,7 +261,7 @@ function columnsFor(composition: InitComposition): number {
|
|
|
177
261
|
|
|
178
262
|
/**
|
|
179
263
|
* A named import on one line while it fits the column budget, else one symbol
|
|
180
|
-
* per line. Emitted files are supposed to pass a
|
|
264
|
+
* per line. Emitted files are supposed to pass a format check as-is, and the
|
|
181
265
|
* generated-module import grows by one symbol per grammar.
|
|
182
266
|
*/
|
|
183
267
|
function importList(symbols: readonly string[], from: string, columns: number, typeOnly = false): string {
|
|
@@ -198,9 +282,13 @@ function arrayLiteral(elements: readonly string[], indent: string, columns: numb
|
|
|
198
282
|
* The FRAMEWORK packages each head adds, carrying no version: every
|
|
199
283
|
* `@hydranium/*` pin is derived at scaffold time from {@link frameworkPin}, so
|
|
200
284
|
* none of them can name a version its siblings were not published at.
|
|
285
|
+
*
|
|
286
|
+
* `base` is every head set's: the services every head serves from are composed
|
|
287
|
+
* the same with or without an LSP head.
|
|
201
288
|
*/
|
|
202
|
-
const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
|
|
203
|
-
|
|
289
|
+
const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead | 'base', readonly string[]> = {
|
|
290
|
+
base: ['@hydranium/core', '@hydranium/langium', '@hydranium/protocol'],
|
|
291
|
+
lsp: [],
|
|
204
292
|
data: ['@hydranium/data-server'],
|
|
205
293
|
glsp: ['@hydranium/glsp-server']
|
|
206
294
|
};
|
|
@@ -221,46 +309,207 @@ const HEAD_FRAMEWORK_DEPENDENCIES: Record<InitHead, readonly string[]> = {
|
|
|
221
309
|
* `overrides` block exists to prevent — so a repo gate holds each entry here
|
|
222
310
|
* against the manifest that declares it.
|
|
223
311
|
*
|
|
224
|
-
* `langium` is required
|
|
312
|
+
* `langium` is required even though hand-written code goes through the
|
|
225
313
|
* `@hydranium/langium` chokepoint, because `langium-cli` emits direct imports
|
|
226
|
-
* into the generated files and regenerates them on every build.
|
|
314
|
+
* into the generated files and regenerates them on every build. The `vscode-*`
|
|
315
|
+
* packages are required peers of `@hydranium/core`, so they are `base` too.
|
|
227
316
|
*/
|
|
228
|
-
const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
|
|
229
|
-
|
|
230
|
-
langium: '4.
|
|
231
|
-
|
|
317
|
+
const HEAD_THIRD_PARTY_DEPENDENCIES: Record<InitHead | 'base', Readonly<Record<string, string>>> = {
|
|
318
|
+
base: {
|
|
319
|
+
langium: '4.4.0',
|
|
320
|
+
// Exact, as are the protocol and transport it pins, so Langium and the
|
|
321
|
+
// framework share their copies; across copies, an error a handler throws
|
|
322
|
+
// or returns loses its code, and a later patch can pin another protocol.
|
|
323
|
+
'vscode-languageserver': '10.1.2',
|
|
324
|
+
'vscode-jsonrpc': '9.0.3',
|
|
325
|
+
'vscode-languageserver-protocol': '3.18.4',
|
|
326
|
+
// The copy that protocol pins; a root without it takes the one Theia pins.
|
|
327
|
+
'vscode-languageserver-types': '3.18.4'
|
|
232
328
|
},
|
|
329
|
+
lsp: {},
|
|
233
330
|
data: {},
|
|
234
331
|
glsp: {
|
|
235
|
-
'@eclipse-glsp/graph': '2.
|
|
236
|
-
'@eclipse-glsp/server': '2.
|
|
237
|
-
// `^6.1.3` and NOT `^6.0.0`: `@eclipse-glsp/server
|
|
332
|
+
'@eclipse-glsp/graph': '2.8.0',
|
|
333
|
+
'@eclipse-glsp/server': '2.8.0',
|
|
334
|
+
// `^6.1.3` and NOT `^6.0.0`: `@eclipse-glsp/server` requires it, and
|
|
238
335
|
// `^6.0.0` resolved only because the hoisted copy happened to satisfy it.
|
|
239
336
|
inversify: '^6.1.3',
|
|
240
337
|
'reflect-metadata': '~0.2.2'
|
|
241
338
|
}
|
|
242
339
|
};
|
|
243
340
|
|
|
244
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* What each head adds to the protocol member, which carries only what a
|
|
343
|
+
* client may install. Keyed by head because the `glsp` head implies a diagram,
|
|
344
|
+
* whose element ids extend GLSP's default type names.
|
|
345
|
+
*/
|
|
346
|
+
const PROTOCOL_THIRD_PARTY_DEPENDENCIES: Record<InitHead, Readonly<Record<string, string>>> = {
|
|
347
|
+
lsp: {},
|
|
348
|
+
data: {},
|
|
349
|
+
glsp: { '@eclipse-glsp/protocol': '2.8.0' }
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/** The client member's packages: the diagram definition and what it registers its elements with. */
|
|
353
|
+
const CLIENT_FRAMEWORK_DEPENDENCIES: readonly string[] = ['@hydranium/glsp-client'];
|
|
354
|
+
const CLIENT_THIRD_PARTY_DEPENDENCIES: Readonly<Record<string, string>> = {
|
|
355
|
+
'@eclipse-glsp/client': '2.8.0',
|
|
356
|
+
inversify: '^6.1.3'
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* What each head adds to the browser host, which bundles the server into its
|
|
361
|
+
* worker and the head's client into its page. `base` is what every page needs.
|
|
362
|
+
*
|
|
363
|
+
* `monaco-editor-core` is exact for the reason `langium` is: Monaco's models
|
|
364
|
+
* live in one copy, and `@hydranium/client-browser` must reach the page's.
|
|
365
|
+
*/
|
|
366
|
+
const HOST_BROWSER_FRAMEWORK_DEPENDENCIES: Record<InitHead | 'base', readonly string[]> = {
|
|
367
|
+
base: ['@hydranium/client-browser', '@hydranium/core', '@hydranium/protocol'],
|
|
368
|
+
lsp: [],
|
|
369
|
+
data: ['@hydranium/data-server'],
|
|
370
|
+
glsp: ['@hydranium/glsp-client', '@hydranium/glsp-server']
|
|
371
|
+
};
|
|
372
|
+
const HOST_BROWSER_THIRD_PARTY_DEPENDENCIES: Record<InitHead | 'base', Readonly<Record<string, string>>> = {
|
|
373
|
+
base: { 'vscode-jsonrpc': '9.0.3', 'vscode-languageserver-protocol': '3.18.4' },
|
|
374
|
+
lsp: { 'monaco-editor-core': '0.56.0' },
|
|
375
|
+
data: {},
|
|
376
|
+
glsp: { '@eclipse-glsp/server': '2.8.0', 'reflect-metadata': '~0.2.2' }
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** The browser host's tools: the bundler, the smoke tier and the type check. */
|
|
380
|
+
const HOST_BROWSER_DEV_DEPENDENCIES: Readonly<Record<string, string>> = {
|
|
381
|
+
'@playwright/test': '^1.55.0',
|
|
382
|
+
'@types/node': '^22.0.0',
|
|
383
|
+
esbuild: '^0.28.0',
|
|
384
|
+
rimraf: '^5.0.0',
|
|
385
|
+
typescript: '^7.0.2'
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** The language client both VS Code extensions start the server with. */
|
|
389
|
+
const VSCODE_LANGUAGE_CLIENT: Readonly<Record<string, string>> = { 'vscode-languageclient': '10.1.2' };
|
|
390
|
+
|
|
391
|
+
/** The VS Code API's types, at the oldest VS Code both extensions run in, which `engines` names too. */
|
|
392
|
+
const VSCODE_ENGINE = '^1.101.0';
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* What the GLSP head adds to the VS Code extension: the connector in the
|
|
396
|
+
* extension host and the diagram webview's starter, which bundles the client
|
|
397
|
+
* member.
|
|
398
|
+
*/
|
|
399
|
+
const HOST_VSCODE_GLSP_FRAMEWORK_DEPENDENCIES: readonly string[] = [
|
|
400
|
+
'@hydranium/client-vscode',
|
|
401
|
+
'@hydranium/glsp-client',
|
|
402
|
+
'@hydranium/glsp-client-vscode',
|
|
403
|
+
'@hydranium/protocol'
|
|
404
|
+
];
|
|
405
|
+
const HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES: Readonly<Record<string, string>> = {
|
|
406
|
+
'@eclipse-glsp/client': '2.8.0',
|
|
407
|
+
'@eclipse-glsp/vscode-integration': '2.8.0',
|
|
408
|
+
'@eclipse-glsp/vscode-integration-webview': '2.8.0',
|
|
409
|
+
inversify: '^6.1.3',
|
|
410
|
+
'vscode-jsonrpc': '9.0.3'
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** What the data head adds to the VS Code extension: the Show Model command's socket to it. */
|
|
414
|
+
const HOST_VSCODE_DATA_FRAMEWORK_DEPENDENCIES: readonly string[] = [
|
|
415
|
+
'@hydranium/client-vscode',
|
|
416
|
+
'@hydranium/data-client-vscode',
|
|
417
|
+
'@hydranium/protocol'
|
|
418
|
+
];
|
|
419
|
+
// Exact, as the server's is: the socket's connection and the framework's must be one copy.
|
|
420
|
+
const HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES: Readonly<Record<string, string>> = { 'vscode-jsonrpc': '9.0.3' };
|
|
421
|
+
|
|
422
|
+
/** The VS Code extension's tools: the smoke tier, the download it runs in, the type check and the watch. */
|
|
423
|
+
const HOST_VSCODE_DEV_DEPENDENCIES: Readonly<Record<string, string>> = {
|
|
424
|
+
'@playwright/test': '^1.55.0',
|
|
425
|
+
'@types/node': '^22.0.0',
|
|
426
|
+
'@types/vscode': VSCODE_ENGINE,
|
|
427
|
+
'@vscode/test-electron': '^3.1.0',
|
|
428
|
+
concurrently: '^9.0.0',
|
|
429
|
+
rimraf: '^5.0.0',
|
|
430
|
+
typescript: '^7.0.2'
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The exact pins of every member the composition emits, for a workspace root
|
|
435
|
+
* to declare too. npm installs a root's own dependencies at the top of the
|
|
436
|
+
* tree, so another member's version of one nests beneath that member instead of
|
|
437
|
+
* taking the place of the copy this package shares with the framework.
|
|
438
|
+
*/
|
|
439
|
+
export function workspaceRootPins(composition: InitComposition): ReadonlyArray<readonly [string, string]> {
|
|
440
|
+
const { heads } = composition;
|
|
441
|
+
const members = initMembers(heads, composition.hosts);
|
|
442
|
+
const pins = new Map([
|
|
443
|
+
...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES.base),
|
|
444
|
+
...heads.flatMap(head => [
|
|
445
|
+
...Object.entries(HEAD_THIRD_PARTY_DEPENDENCIES[head]),
|
|
446
|
+
...Object.entries(PROTOCOL_THIRD_PARTY_DEPENDENCIES[head])
|
|
447
|
+
]),
|
|
448
|
+
...(members.includes('client') ? Object.entries(CLIENT_THIRD_PARTY_DEPENDENCIES) : []),
|
|
449
|
+
...(members.includes('host-browser')
|
|
450
|
+
? (['base', ...heads] as const).flatMap(head => Object.entries(HOST_BROWSER_THIRD_PARTY_DEPENDENCIES[head]))
|
|
451
|
+
: []),
|
|
452
|
+
...(members.includes('vscode-servers') ? Object.entries(VSCODE_LANGUAGE_CLIENT) : []),
|
|
453
|
+
...(members.includes('host-vscode') && heads.includes('glsp') ? Object.entries(HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES) : []),
|
|
454
|
+
...(members.includes('host-vscode') && heads.includes('data') ? Object.entries(HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES) : []),
|
|
455
|
+
...(members.includes('theia') ? theiaThirdPartyDependencies(heads) : []),
|
|
456
|
+
...(members.includes('host-theia-browser') ? hostTheiaBrowserTheiaDependencies() : []),
|
|
457
|
+
...(members.includes('host-theia-electron') ? hostTheiaElectronPins() : [])
|
|
458
|
+
]);
|
|
459
|
+
return [...pins].filter(([, version]) => /^\d+\.\d+\.\d+$/.test(version)).sort(([left], [right]) => left.localeCompare(right));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Whether an npm version is 11.6.0 or later, the first that installs `vitest` 4.1's peer set; `11.6.0-rc.1` is earlier. */
|
|
463
|
+
function installsVitest41(npmVersion: string | undefined): boolean {
|
|
464
|
+
const [release, prerelease] = (npmVersion ?? '0.0.0').split('-', 2);
|
|
465
|
+
const [major, minor, patch] = release.split('.').map(Number);
|
|
466
|
+
return major > 11 || (major === 11 && (minor > 6 || (minor === 6 && (patch > 0 || prerelease === undefined))));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Whether the scaffold holds `vitest` below 4.1, whose peer set crashes npm
|
|
471
|
+
* before 11.6 (every npm a Node 22 release bundles) on the first install. Only a
|
|
472
|
+
* workspace root declaring a newer npm lifts it, and only when `init` runs under
|
|
473
|
+
* an npm known to be newer: npm does not enforce `packageManager`.
|
|
474
|
+
*/
|
|
475
|
+
function pinsVitest(composition: InitComposition): boolean {
|
|
476
|
+
const workspace = composition.packaging.workspace;
|
|
477
|
+
return !(installsVitest41(workspace?.packageManagerNpm) && installsVitest41(workspace?.npmVersion));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The version a member pins a sibling member at: exact, at the version every
|
|
482
|
+
* member starts with. npm links a sibling only when the range admits its local
|
|
483
|
+
* version, and otherwise looks for it in the registry, where it was never
|
|
484
|
+
* published.
|
|
485
|
+
*/
|
|
486
|
+
const MEMBER_PIN = '0.0.0';
|
|
487
|
+
|
|
488
|
+
/** A dependency block, sorted as npm writes it. */
|
|
489
|
+
function sortedBlock(dependencies: Readonly<Record<string, string>>, indent: string): string {
|
|
490
|
+
return Object.keys(dependencies)
|
|
491
|
+
.sort()
|
|
492
|
+
.map(name => `${indent}"${name}": "${dependencies[name]}"`)
|
|
493
|
+
.join(',\n');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The server's dependency block: the base and head set's packages and the protocol member. */
|
|
245
497
|
function dependencyBlock(composition: InitComposition, indent: string): string {
|
|
246
|
-
const merged: Record<string, string> = {};
|
|
247
|
-
for (const head of composition.heads) {
|
|
498
|
+
const merged: Record<string, string> = { [memberPackageName(composition, 'protocol')]: MEMBER_PIN };
|
|
499
|
+
for (const head of ['base', ...composition.heads] as const) {
|
|
248
500
|
Object.assign(merged, HEAD_THIRD_PARTY_DEPENDENCIES[head]);
|
|
249
501
|
for (const framework of HEAD_FRAMEWORK_DEPENDENCIES[head]) {
|
|
250
502
|
merged[framework] = frameworkPin(composition);
|
|
251
503
|
}
|
|
252
504
|
}
|
|
253
|
-
return
|
|
254
|
-
.sort()
|
|
255
|
-
.map(name => `${indent}"${name}": "${merged[name]}"`)
|
|
256
|
-
.join(',\n');
|
|
505
|
+
return sortedBlock(merged, indent);
|
|
257
506
|
}
|
|
258
507
|
|
|
259
508
|
// JSON at two-space indent, not the three the `.ts` templates use: that is the
|
|
260
509
|
// npm convention, and `npm install` rewrites `package.json` at two spaces
|
|
261
510
|
// regardless — so three would not survive the adopter's first install.
|
|
262
511
|
//
|
|
263
|
-
// The KEY ORDER is
|
|
512
|
+
// The KEY ORDER is the one Oxfmt's `sortPackageJson` canonicalises to, `private`
|
|
264
513
|
// before `license` included. Any other order is rewritten by the scaffolded
|
|
265
514
|
// project's own first `format` run — a diff on a file the adopter never touched
|
|
266
515
|
// — and it makes a byte-compare against a formatted copy of this emission
|
|
@@ -280,6 +529,11 @@ function dependencyBlock(composition: InitComposition, indent: string): string {
|
|
|
280
529
|
// from this package's own grammar and can reach a consumer from nowhere else; a
|
|
281
530
|
// listed path that does not exist yet is inert.
|
|
282
531
|
//
|
|
532
|
+
// `exports` puts `createServices`, the one module reading the real filesystem,
|
|
533
|
+
// behind `./node`, so the root loads in a browser worker that has no `node:fs`.
|
|
534
|
+
// `./lib/*` and `./package.json` stay reachable for a host that resolves
|
|
535
|
+
// `<server>/lib/main.js` to spawn the server.
|
|
536
|
+
//
|
|
283
537
|
// `repository` is deliberately NOT emitted while `author` is emitted empty. An
|
|
284
538
|
// empty `author` is the form `npm init` itself writes and asserts nothing, but
|
|
285
539
|
// `repository` is CONSUMED — the registry page, `npm repo` and publish
|
|
@@ -291,16 +545,9 @@ const PACKAGE_JSON = `{
|
|
|
291
545
|
"version": "0.0.0",
|
|
292
546
|
__PRIVATE__ "description": "__NAME__ language server, built with the Hydranium framework.",
|
|
293
547
|
"keywords": [
|
|
294
|
-
|
|
295
|
-
"langium",
|
|
296
|
-
"language-server",
|
|
297
|
-
"__PROJECT_ID__"
|
|
298
|
-
],
|
|
548
|
+
__KEYWORDS__ ],
|
|
299
549
|
"license": "UNLICENSED",
|
|
300
550
|
"author": "",
|
|
301
|
-
"type": "module",
|
|
302
|
-
"main": "lib/index.js",
|
|
303
|
-
"types": "lib/index.d.ts",
|
|
304
551
|
"bin": {
|
|
305
552
|
__BIN__ },
|
|
306
553
|
"files": [
|
|
@@ -308,17 +555,32 @@ __BIN__ },
|
|
|
308
555
|
"src",
|
|
309
556
|
"syntaxes"
|
|
310
557
|
],
|
|
558
|
+
"type": "module",
|
|
559
|
+
"main": "lib/index.js",
|
|
560
|
+
"types": "lib/index.d.ts",
|
|
561
|
+
"exports": {
|
|
562
|
+
".": {
|
|
563
|
+
"types": "./lib/index.d.ts",
|
|
564
|
+
"default": "./lib/index.js"
|
|
565
|
+
},
|
|
566
|
+
"./node": {
|
|
567
|
+
"types": "./lib/node/services.d.ts",
|
|
568
|
+
"default": "./lib/node/services.js"
|
|
569
|
+
},
|
|
570
|
+
"./lib/*": "./lib/*",
|
|
571
|
+
"./package.json": "./package.json"
|
|
572
|
+
},
|
|
311
573
|
"scripts": {
|
|
312
|
-
"build": "npm run generate && tsc",
|
|
313
|
-
"clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-
|
|
574
|
+
"build": "npm run generate && tsc -b",
|
|
575
|
+
"clean": "rimraf lib syntaxes src/language-server/generated src/language-server/generated-hydranium test/generated-hydranium tsconfig.tsbuildinfo",
|
|
314
576
|
"generate": "npm run langium:generate && npm run generate:transfer-model",
|
|
315
|
-
"generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file src/language-server/generated-transfer/transfer-
|
|
577
|
+
"generate:transfer-model": "hydranium-cli generate-transfer-model --ast-file src/language-server/generated/ast.ts --augmentation-file src/language-server/ast.ts --out-file ../protocol/src/generated-hydranium/transfer-model.ts --ast-builder-file src/language-server/generated-hydranium/ast-builder.ts --transfer-builder-file test/generated-hydranium/transfer-builder.ts --languages-file ../protocol/src/generated-hydranium/languages.ts --element-type-name __NAME__Element --terminals-name __NAME__Terminals --regen-command \\"Run: __NPM_RUN__ generate:transfer-model\\"",
|
|
316
578
|
"langium:generate": "langium generate",
|
|
317
579
|
"langium:watch": "langium generate --watch",
|
|
318
|
-
__LINT__ "start": "node lib/main.
|
|
580
|
+
__LINT__ "start": "node lib/main.js__START_ARGS__",
|
|
319
581
|
"test": "npm run typecheck:test && vitest run",
|
|
320
582
|
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
|
321
|
-
"watch": "tsc --watch"
|
|
583
|
+
"watch": "concurrently --kill-others --names langium,transfer-model,tsc \\"npm run langium:watch\\" \\"npm run generate:transfer-model -- --watch\\" \\"tsc -b --watch\\""
|
|
322
584
|
},
|
|
323
585
|
"dependencies": {
|
|
324
586
|
__DEPENDENCIES__
|
|
@@ -326,10 +588,11 @@ __DEPENDENCIES__
|
|
|
326
588
|
"devDependencies": {
|
|
327
589
|
"@hydranium/cli": "__FRAMEWORK_PIN__",
|
|
328
590
|
"@types/node": "^22.0.0",
|
|
329
|
-
"
|
|
591
|
+
"concurrently": "^9.0.0",
|
|
592
|
+
"langium-cli": "4.4.0",
|
|
330
593
|
"rimraf": "^5.0.0",
|
|
331
|
-
"typescript": "^
|
|
332
|
-
"vitest": "
|
|
594
|
+
"typescript": "^7.0.2",
|
|
595
|
+
"vitest": "__VITEST__"
|
|
333
596
|
},
|
|
334
597
|
"engines": {
|
|
335
598
|
"node": ">=22.13"
|
|
@@ -338,16 +601,17 @@ __DEPENDENCIES__
|
|
|
338
601
|
`;
|
|
339
602
|
|
|
340
603
|
/**
|
|
341
|
-
* How the scaffold names `npm run`
|
|
604
|
+
* How the scaffold names `npm run` for one member's script.
|
|
342
605
|
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
606
|
+
* Always with `--prefix`, from the directory commands are run in: the
|
|
607
|
+
* workspace root for a member, the project root for a standalone project. A
|
|
608
|
+
* bare `npm run` there reaches the ROOT manifest and fails, and the regen hint
|
|
609
|
+
* a generated file prints is read in `protocol/` as well, where the script it
|
|
610
|
+
* names does not exist.
|
|
347
611
|
*/
|
|
348
|
-
function npmRun(composition: InitComposition): string {
|
|
612
|
+
function npmRun(composition: InitComposition, member: InitMember): string {
|
|
349
613
|
const workspace = composition.packaging.workspace;
|
|
350
|
-
return workspace === undefined ?
|
|
614
|
+
return `npm --prefix ${workspace === undefined ? member : `${workspace.targetPath}/${member}`} run`;
|
|
351
615
|
}
|
|
352
616
|
|
|
353
617
|
/**
|
|
@@ -359,7 +623,7 @@ function npmRun(composition: InitComposition): string {
|
|
|
359
623
|
* name. Without the second key a scaffolded project has no command line that
|
|
360
624
|
* reaches its own data head at all.
|
|
361
625
|
*
|
|
362
|
-
* Alphabetical, because `
|
|
626
|
+
* Alphabetical, because Oxfmt's `sortPackageJson` sorts `bin` and any other
|
|
363
627
|
* order is rewritten by the scaffolded project's first `format` run.
|
|
364
628
|
*/
|
|
365
629
|
function binBlock(composition: InitComposition): string {
|
|
@@ -370,21 +634,39 @@ function binBlock(composition: InitComposition): string {
|
|
|
370
634
|
return entries.map(([name, target]) => ` "${name}": "${target}"`).join(',\n') + '\n';
|
|
371
635
|
}
|
|
372
636
|
|
|
373
|
-
/**
|
|
637
|
+
/**
|
|
638
|
+
* A member's `lint` script line, over `directories`, or nothing.
|
|
639
|
+
*
|
|
640
|
+
* Emitted only where a root lint config says the repo lints. The absence is
|
|
641
|
+
* the dangerous direction: a task runner runs a script only where one is
|
|
642
|
+
* declared, so a package with no `lint` is SKIPPED rather than reported, and
|
|
643
|
+
* that reads as a clean lint. A wrong invocation is one visible line to edit.
|
|
644
|
+
*/
|
|
645
|
+
function lintScript(member: InitComposition, directories: string): string {
|
|
646
|
+
const workspace = member.packaging.workspace;
|
|
647
|
+
if (workspace?.oxlintConfig !== undefined) {
|
|
648
|
+
const config = `${'../'.repeat(workspace.targetPath.split('/').length)}${workspace.oxlintConfig}`;
|
|
649
|
+
return ` "lint": "oxlint --report-unused-disable-directives --format stylish --config ${config} ${directories} --max-warnings 0",\n`;
|
|
650
|
+
}
|
|
651
|
+
return workspace?.eslintConfig !== undefined ? ` "lint": "eslint ${directories} --max-warnings 0",\n` : '';
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The server's `package.json`, with the name, the private flag and the dependency block all derived. */
|
|
374
655
|
function packageJson(composition: InitComposition): string {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// that reads as a clean lint. A wrong invocation is one visible line to edit.
|
|
381
|
-
const lint = composition.packaging.workspace?.eslintConfig === undefined ? '' : ' "lint": "eslint src test --max-warnings 0",\n';
|
|
656
|
+
const keywords =
|
|
657
|
+
['hydranium', 'langium', 'language-server', composition.projectId]
|
|
658
|
+
.sort()
|
|
659
|
+
.map(keyword => ` ${JSON.stringify(keyword)}`)
|
|
660
|
+
.join(',\n') + '\n';
|
|
382
661
|
return project(PACKAGE_JSON, composition)
|
|
383
|
-
.replace('__PACKAGE_NAME__',
|
|
662
|
+
.replace('__PACKAGE_NAME__', memberPackageName(composition, 'server'))
|
|
663
|
+
.replace('__KEYWORDS__', keywords)
|
|
384
664
|
.replace('__BIN__', binBlock(composition))
|
|
385
|
-
.replace('__LINT__',
|
|
665
|
+
.replace('__LINT__', lintScript(forMember(composition, 'server'), 'src test'))
|
|
666
|
+
.replace('__START_ARGS__', composition.heads.includes('lsp') ? ' --stdio' : ' ../workspace')
|
|
667
|
+
.replace('__VITEST__', pinsVitest(composition) ? '~4.0.18' : '^4.0.0')
|
|
386
668
|
.replace('__PRIVATE__', composition.packaging.private ? ' "private": true,\n' : '')
|
|
387
|
-
.replace('__NPM_RUN__', npmRun(composition))
|
|
669
|
+
.replace('__NPM_RUN__', npmRun(composition, 'server'))
|
|
388
670
|
.replace('__FRAMEWORK_PIN__', frameworkPin(composition))
|
|
389
671
|
.replace('__DEPENDENCIES__', dependencyBlock(composition, ' '));
|
|
390
672
|
}
|
|
@@ -439,6 +721,11 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
|
|
|
439
721
|
['outDir', 'lib'],
|
|
440
722
|
['strict', true],
|
|
441
723
|
['esModuleInterop', true],
|
|
724
|
+
// A server that renders its own user-facing messages loads a catalogue, and
|
|
725
|
+
// a catalogue is JSON. Without this the import does not resolve at all, and
|
|
726
|
+
// without the `include` glob below a composite project rejects it with
|
|
727
|
+
// TS6307 naming neither cause.
|
|
728
|
+
['resolveJsonModule', true],
|
|
442
729
|
['skipLibCheck', true],
|
|
443
730
|
['declaration', true],
|
|
444
731
|
['experimentalDecorators', true],
|
|
@@ -447,6 +734,24 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
|
|
|
447
734
|
['types', ['node']]
|
|
448
735
|
];
|
|
449
736
|
|
|
737
|
+
/** The protocol member's compiler options, pruned against a workspace base like the server's. */
|
|
738
|
+
const PROTOCOL_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
|
|
739
|
+
['target', 'ES2022'],
|
|
740
|
+
['lib', ['ES2022']],
|
|
741
|
+
['module', 'NodeNext'],
|
|
742
|
+
['moduleResolution', 'NodeNext'],
|
|
743
|
+
['rootDir', 'src'],
|
|
744
|
+
['outDir', 'lib'],
|
|
745
|
+
['strict', true],
|
|
746
|
+
['skipLibCheck', true],
|
|
747
|
+
['declaration', true],
|
|
748
|
+
// The server's `tsc -b` builds this project through a reference, which
|
|
749
|
+
// `tsc` refuses unless the referenced project is composite.
|
|
750
|
+
['composite', true],
|
|
751
|
+
// No ambient Node types: a browser client compiles against this package.
|
|
752
|
+
['types', []]
|
|
753
|
+
];
|
|
754
|
+
|
|
450
755
|
/**
|
|
451
756
|
* The options that describe where THIS package's own files live. Never pruned,
|
|
452
757
|
* and emitted first in the workspace case: a base config that supplied them
|
|
@@ -455,9 +760,20 @@ const TSCONFIG_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
|
|
|
455
760
|
*/
|
|
456
761
|
const PACKAGE_LOCAL_OPTIONS: readonly string[] = ['rootDir', 'outDir'];
|
|
457
762
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
460
|
-
|
|
763
|
+
/**
|
|
764
|
+
* The enum-valued options of {@link TSCONFIG_COMPILER_OPTIONS}, whose values
|
|
765
|
+
* `tsc` reads case-insensitively, so `nodenext` and `NodeNext` are one value.
|
|
766
|
+
* An enum-valued option added there belongs here too.
|
|
767
|
+
*/
|
|
768
|
+
const CASE_INSENSITIVE_OPTIONS: readonly string[] = ['target', 'lib', 'module', 'moduleResolution'];
|
|
769
|
+
|
|
770
|
+
/** Compare two option values. Sound here because compiler options are scalars and flat arrays. */
|
|
771
|
+
function optionEquals(key: string, left: JsonValue | undefined, right: JsonValue): boolean {
|
|
772
|
+
const normalise = (value: JsonValue | undefined) => {
|
|
773
|
+
const text = JSON.stringify(value);
|
|
774
|
+
return CASE_INSENSITIVE_OPTIONS.includes(key) ? text?.toLowerCase() : text;
|
|
775
|
+
};
|
|
776
|
+
return normalise(left) === normalise(right);
|
|
461
777
|
}
|
|
462
778
|
|
|
463
779
|
/**
|
|
@@ -467,112 +783,57 @@ function jsonEquals(left: JsonValue | undefined, right: JsonValue): boolean {
|
|
|
467
783
|
* the same key: inheriting `"module": "CommonJS"` where the scaffold needs
|
|
468
784
|
* `NodeNext` would break every emitted import, so a differing base value is a
|
|
469
785
|
* reason to keep the override rather than to trust the parent.
|
|
786
|
+
*
|
|
787
|
+
* `member` is the composition as that member sees it, so `extends` reaches the
|
|
788
|
+
* workspace base from the member's own directory.
|
|
470
789
|
*/
|
|
471
|
-
function tsconfigJson(
|
|
472
|
-
|
|
790
|
+
function tsconfigJson(
|
|
791
|
+
member: InitComposition,
|
|
792
|
+
compilerOptions: ReadonlyArray<readonly [string, JsonValue]>,
|
|
793
|
+
include: string,
|
|
794
|
+
references = ''
|
|
795
|
+
): string {
|
|
796
|
+
const workspace = member.packaging.workspace;
|
|
473
797
|
const base = workspace?.baseTsconfig === undefined ? undefined : (workspace.baseCompilerOptions ?? {});
|
|
474
798
|
const options =
|
|
475
799
|
base === undefined
|
|
476
|
-
?
|
|
800
|
+
? compilerOptions
|
|
477
801
|
: [
|
|
478
|
-
...
|
|
479
|
-
...
|
|
802
|
+
...compilerOptions.filter(([key]) => PACKAGE_LOCAL_OPTIONS.includes(key)),
|
|
803
|
+
...compilerOptions.filter(([key, value]) => !PACKAGE_LOCAL_OPTIONS.includes(key) && !optionEquals(key, base[key], value))
|
|
480
804
|
];
|
|
481
805
|
const extendsLine = workspace?.baseTsconfig === undefined ? '' : ` "extends": "${workspace.baseTsconfig}",\n`;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
// actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
|
|
488
|
-
// vitest compiles through esbuild — a per-file transform with no type
|
|
489
|
-
// information, which cannot tell a re-exported TYPE from a re-exported value.
|
|
490
|
-
// Without the flag `tsc` accepts `export { SomeType } from './x'` that esbuild
|
|
491
|
-
// then emits as a real import of a symbol that does not exist at runtime, so
|
|
492
|
-
// the typecheck passes and the suite fails with a confusing missing-export.
|
|
493
|
-
const TSCONFIG_TEST = `{
|
|
494
|
-
"extends": "./tsconfig.json",
|
|
495
|
-
"compilerOptions": {
|
|
496
|
-
"rootDir": ".",
|
|
497
|
-
"noEmit": true,
|
|
498
|
-
"isolatedModules": true,
|
|
499
|
-
"types": ["node"]
|
|
500
|
-
},
|
|
501
|
-
"include": ["src", "test"]
|
|
806
|
+
// An array spaced as a formatter writes it, which `JSON.stringify` does not.
|
|
807
|
+
const json = (value: JsonValue): string => (Array.isArray(value) ? `[${value.map(json).join(', ')}]` : JSON.stringify(value));
|
|
808
|
+
const body = options.map(([key, value]) => ` "${key}": ${json(value)}`).join(',\n');
|
|
809
|
+
const referencesLine = references === '' ? '' : `,\n "references": ${references}`;
|
|
810
|
+
return `{\n${extendsLine} "compilerOptions": {\n${body}\n },\n "include": ${include}${referencesLine}\n}\n`;
|
|
502
811
|
}
|
|
503
|
-
`;
|
|
504
|
-
|
|
505
|
-
const VITEST_CONFIG = `import { defineConfig } from 'vitest/config';
|
|
506
|
-
|
|
507
|
-
// Vitest transforms TypeScript itself and resolves \`.js\` specifiers to their
|
|
508
|
-
// \`.ts\` source, so tests import from \`../src/\` with the same specifiers the
|
|
509
|
-
// compiled output uses. \`include\` is scoped to \`test/\` so \`lib/\` is never
|
|
510
|
-
// scanned; \`npm test\` runs \`typecheck:test\` first, because the esbuild
|
|
511
|
-
// transform strips types without checking them.
|
|
512
|
-
|
|
513
|
-
export default defineConfig({
|
|
514
|
-
test: {
|
|
515
|
-
environment: 'node',
|
|
516
|
-
include: ['test/**/*.{test,spec}.ts']
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
`;
|
|
520
|
-
|
|
521
|
-
const GITIGNORE = `node_modules/
|
|
522
|
-
lib/
|
|
523
|
-
*.tsbuildinfo
|
|
524
|
-
|
|
525
|
-
# Langium-generated TextMate grammar (rewritten by every \`langium generate\`).
|
|
526
|
-
syntaxes/
|
|
527
|
-
`;
|
|
528
812
|
|
|
529
813
|
/**
|
|
530
|
-
* The
|
|
531
|
-
*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
*/
|
|
542
|
-
const GITIGNORE_WORKSPACE_MEMBER = `# The workspace root already covers \`node_modules/\`, \`lib/\` and \`*.tsbuildinfo\`.
|
|
543
|
-
# It has no reason to know about this one: the TextMate grammar is a Langium
|
|
544
|
-
# artefact, rewritten by every \`langium generate\`, so a root that has never held
|
|
545
|
-
# a Langium package ignores nothing here and the generated file is offered for
|
|
546
|
-
# commit.
|
|
547
|
-
syntaxes/
|
|
548
|
-
`;
|
|
549
|
-
|
|
550
|
-
/** The terminals every grammar needs, shared verbatim by the fragment and the lone-grammar case. */
|
|
551
|
-
const TERMINALS = `hidden terminal WS: /\\s+/;
|
|
552
|
-
terminal ID: /[_a-zA-Z][\\w_]*/;
|
|
553
|
-
hidden terminal SL_COMMENT: /\\/\\/[^\\n\\r]*/;
|
|
554
|
-
hidden terminal ML_COMMENT: /\\/\\*[\\s\\S]*?\\*\\//;
|
|
555
|
-
`;
|
|
814
|
+
* The server's `tsconfig.json`. It references the protocol member, so `tsc -b`
|
|
815
|
+
* compiles protocol first, after `generate` has written its generated modules.
|
|
816
|
+
*/
|
|
817
|
+
function serverTsconfigJson(composition: InitComposition): string {
|
|
818
|
+
return tsconfigJson(
|
|
819
|
+
forMember(composition, 'server'),
|
|
820
|
+
TSCONFIG_COMPILER_OPTIONS,
|
|
821
|
+
'["src", "src/**/*.json"]',
|
|
822
|
+
'[{ "path": "../protocol" }]'
|
|
823
|
+
);
|
|
824
|
+
}
|
|
556
825
|
|
|
557
826
|
/**
|
|
558
|
-
* The
|
|
559
|
-
*
|
|
560
|
-
* The alternative — every grammar declaring its own `WS` / `ID` — lets the token
|
|
561
|
-
* sets drift apart.
|
|
827
|
+
* The terminals every grammar needs: the shared fragment's text after its
|
|
828
|
+
* leading comment run, so a lone grammar declares the same set.
|
|
562
829
|
*/
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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}`;
|
|
830
|
+
function terminals(): string {
|
|
831
|
+
return template('server/src/grammar/common.langium').replace(/^(\/\/.*\n)*\n/, '');
|
|
832
|
+
}
|
|
572
833
|
|
|
573
834
|
/** One starter grammar. `shared` decides whether it imports the fragment or declares its own terminals. */
|
|
574
835
|
function grammarFile(shared: boolean): string {
|
|
575
|
-
const lexis = shared ? "import './common'\n" : `\n${
|
|
836
|
+
const lexis = shared ? "import './common'\n" : `\n${terminals()}`;
|
|
576
837
|
return `grammar __GRAMMAR__
|
|
577
838
|
${shared ? '\n' + lexis : ''}
|
|
578
839
|
// A minimal starter grammar — replace with your own language. \`__NODE_RULE__\` is
|
|
@@ -636,7 +897,10 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
|
|
|
636
897
|
: `const __NAME__SharedModule: Module<__NAME__SharedServices, PartialLangiumSharedServices> = {};`;
|
|
637
898
|
|
|
638
899
|
const serializerImports = composition.grammars
|
|
639
|
-
.
|
|
900
|
+
.flatMap(grammar => [
|
|
901
|
+
`import { ${grammar.grammar}Serializer } from './${grammar.grammarId}-serializer.js';`,
|
|
902
|
+
`import { ${grammar.grammar}ValidationContribution } from './${grammar.grammarId}-validation.js';`
|
|
903
|
+
])
|
|
640
904
|
.join('\n');
|
|
641
905
|
|
|
642
906
|
// Each language gets its own adopter module because `Serializer` is a
|
|
@@ -648,6 +912,11 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
|
|
|
648
912
|
grammar => `const ${grammar.grammar}LanguageModule: Module<__NAME__Services, PartialLangiumServices & DeepPartial<ServerAddedServices>> = {
|
|
649
913
|
serializer: {
|
|
650
914
|
Serializer: services => new ${grammar.grammar}Serializer(services)
|
|
915
|
+
},
|
|
916
|
+
validation: {
|
|
917
|
+
checks: {
|
|
918
|
+
names: () => new ${grammar.grammar}ValidationContribution()
|
|
919
|
+
}
|
|
651
920
|
}
|
|
652
921
|
};`
|
|
653
922
|
)
|
|
@@ -669,11 +938,11 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
|
|
|
669
938
|
|
|
670
939
|
const text = `// DI bootstrap for __NAME__. Composes the framework defaults with adopter
|
|
671
940
|
// overrides via Langium's \`inject()\` (through the framework's
|
|
672
|
-
// \`createIntegrationServices\`). Each adopter module binds
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
// management, build pipeline) boots on the framework defaults;
|
|
676
|
-
// computation
|
|
941
|
+
// \`createIntegrationServices\`). Each adopter module binds its language's
|
|
942
|
+
// \`Serializer\`, which the framework cannot default because a concrete syntax
|
|
943
|
+
// is grammar knowledge, and its validation checks. Everything else (scope,
|
|
944
|
+
// naming, project management, build pipeline) boots on the framework defaults;
|
|
945
|
+
// scope computation and AST extensions go in these same modules.
|
|
677
946
|
//
|
|
678
947
|
// Note which generated symbol comes from which name: the SHARED module is
|
|
679
948
|
// \`__NAME__GeneratedSharedModule\` (from \`projectName\`) and there is one of it,
|
|
@@ -688,7 +957,11 @@ export const __CONFIG_ROOT_CONST___CONFIGURATION_ROOT = '__PROJECT_ID__';
|
|
|
688
957
|
: `
|
|
689
958
|
//
|
|
690
959
|
// A second grammar goes in the \`additionalLanguages\` option of
|
|
691
|
-
// \`createIntegrationServices\`, generated from this same \`langium-config.json
|
|
960
|
+
// \`createIntegrationServices\`, generated from this same \`langium-config.json\`.
|
|
961
|
+
// With several languages, bind \`lsp.configurationRoot\` in
|
|
962
|
+
// \`__NAME__SharedModule\`, or the framework warns and takes the first
|
|
963
|
+
// language's id. Type that module's second \`Module\` argument as
|
|
964
|
+
// \`PartialLangiumSharedServices & { lsp: { configurationRoot: string } }\`.`
|
|
692
965
|
}
|
|
693
966
|
|
|
694
967
|
${importList(
|
|
@@ -708,13 +981,35 @@ export type __NAME__Services = LangiumServices &
|
|
|
708
981
|
LspServerAddedServices & {
|
|
709
982
|
shared: __NAME__SharedServices;
|
|
710
983
|
};
|
|
984
|
+
|
|
985
|
+
/** What a host or a test may vary about this composition. */
|
|
986
|
+
export interface __NAME__Options {
|
|
987
|
+
/**
|
|
988
|
+
* Shared modules layered in after the framework's own bindings.
|
|
989
|
+
*
|
|
990
|
+
* The framework constructs most shared services with no options —
|
|
991
|
+
* \`DocumentBuilder: services => new HydraniumDocumentBuilder(services)\` — so
|
|
992
|
+
* rebinding the slot is the only way to boot one configured differently, and
|
|
993
|
+
* a factory that hard-codes its composition leaves a test nowhere to do it.
|
|
994
|
+
* That is what this is for: pass a module binding \`workspace.DocumentBuilder\`
|
|
995
|
+
* to exercise a builder option, or to substitute a subclass.
|
|
996
|
+
*
|
|
997
|
+
* Composed LAST, after \`__NAME__SharedModule\`, so it wins over every other
|
|
998
|
+
* tier including this file's own bindings — which is what makes it usable for
|
|
999
|
+
* a slot the adopter overrides. Production code should not reach for it.
|
|
1000
|
+
*/
|
|
1001
|
+
readonly extraSharedModules?: ReadonlyArray<Module<__NAME__SharedServices, DeepPartial<__NAME__SharedServices>>>;
|
|
1002
|
+
}
|
|
711
1003
|
${configurationRoot}
|
|
712
1004
|
${sharedModule}
|
|
713
1005
|
|
|
714
1006
|
${languageModules}
|
|
715
1007
|
|
|
716
1008
|
/** Compose the Langium DI tree for __NAME__ — returns the shared + language services. */
|
|
717
|
-
export function create__NAME__Services(
|
|
1009
|
+
export function create__NAME__Services(
|
|
1010
|
+
context: Partial<ServerModuleContext> = EmptyFileSystem,
|
|
1011
|
+
options: __NAME__Options = {}
|
|
1012
|
+
): {
|
|
718
1013
|
shared: __NAME__SharedServices;
|
|
719
1014
|
${returnType}
|
|
720
1015
|
} {
|
|
@@ -724,7 +1019,8 @@ ${returnType}
|
|
|
724
1019
|
sharedModules: {
|
|
725
1020
|
generated: __NAME__GeneratedSharedModule,
|
|
726
1021
|
adopter: __NAME__SharedModule,
|
|
727
|
-
extra: [createLspServerSharedModule(fullContext)]
|
|
1022
|
+
extra: [createLspServerSharedModule(fullContext)],
|
|
1023
|
+
overrides: options.extraSharedModules
|
|
728
1024
|
},
|
|
729
1025
|
languageModules: {
|
|
730
1026
|
generated: ${primary.grammar}GeneratedModule,
|
|
@@ -738,185 +1034,135 @@ ${returnType}
|
|
|
738
1034
|
return project(text, composition);
|
|
739
1035
|
}
|
|
740
1036
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
import { NodeFileSystem } from '@hydranium/core/node';
|
|
748
|
-
import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
|
|
749
|
-
|
|
750
|
-
export function createServices(): ReturnType<typeof create__NAME__Services> {
|
|
751
|
-
return create__NAME__Services({ ...NodeFileSystem });
|
|
1037
|
+
/** One exported constant of the protocol member's `head-ports.ts`. */
|
|
1038
|
+
interface HeadPortConstant {
|
|
1039
|
+
readonly name: string;
|
|
1040
|
+
readonly value: string;
|
|
1041
|
+
readonly doc: string;
|
|
752
1042
|
}
|
|
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
1043
|
|
|
780
1044
|
/**
|
|
781
|
-
*
|
|
1045
|
+
* How a host finds each socket head's port, in emission order: an LSP request
|
|
1046
|
+
* when the LSP head publishes the ports, else the environment variable
|
|
1047
|
+
* `main.ts` reads a port from.
|
|
782
1048
|
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
import type { AstNode } from '@hydranium/langium';
|
|
805
|
-
import { type __ENTRY_RULE__, type __NODE_RULE__, is__ENTRY_RULE__, is__NODE_RULE__ } from './ast.js';
|
|
806
|
-
|
|
807
|
-
export class __GRAMMAR__Serializer extends AbstractSerializer<__ENTRY_RULE__> {
|
|
808
|
-
protected override serializeNode(node: AstNode | Record<string, unknown>): string {
|
|
809
|
-
if (is__ENTRY_RULE__(node)) {
|
|
810
|
-
return node.nodes.map(child => this.emitNode(child)).join('\\n');
|
|
811
|
-
}
|
|
812
|
-
if (is__NODE_RULE__(node)) {
|
|
813
|
-
return this.emitNode(node);
|
|
814
|
-
}
|
|
815
|
-
// Defensive: a rule added to the grammar with no emitter added here.
|
|
816
|
-
throw new Error(\`__GRAMMAR__Serializer: no emitter for $type \${(node as AstNode).$type}\`);
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/** Unreachable — this grammar's one list is emitted by its \`__ENTRY_RULE__\` parent. */
|
|
820
|
-
protected override serializeArray(): string {
|
|
821
|
-
throw new Error('__GRAMMAR__Serializer: arrays are emitted by the per-$type parent, not the generic dispatch.');
|
|
822
|
-
}
|
|
1049
|
+
* Keyed by the PROJECT rather than by a language: one server of each kind per
|
|
1050
|
+
* process serves every registered grammar, so a language-derived name would tie
|
|
1051
|
+
* a project-level endpoint to whichever grammar was scaffolded first.
|
|
1052
|
+
*/
|
|
1053
|
+
function headPortConstants(composition: InitComposition): HeadPortConstant[] {
|
|
1054
|
+
const upper = screamingSnake(composition.projectId);
|
|
1055
|
+
const lsp = composition.heads.includes('lsp');
|
|
1056
|
+
return socketHeads(composition).map(head =>
|
|
1057
|
+
lsp
|
|
1058
|
+
? {
|
|
1059
|
+
name: `${upper}_${head.key}_PORT_COMMAND`,
|
|
1060
|
+
value: `${composition.projectId}/${head.path}/port`,
|
|
1061
|
+
doc: `LSP request the host queries to discover the ${head.label} socket port.`
|
|
1062
|
+
}
|
|
1063
|
+
: {
|
|
1064
|
+
name: `${upper}_${head.key}_PORT_ENV`,
|
|
1065
|
+
value: `${upper}_${head.key}_PORT`,
|
|
1066
|
+
doc: `Environment variable naming the ${head.label} socket port when no \`--${head.path}-port\` flag does.`
|
|
1067
|
+
}
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
823
1070
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1071
|
+
/** The socket heads of the composition, as `head-ports.ts` names them. */
|
|
1072
|
+
function socketHeads(composition: InitComposition) {
|
|
1073
|
+
return [
|
|
1074
|
+
...(composition.heads.includes('data')
|
|
1075
|
+
? [{ key: 'DATA_SERVER', path: 'data-server', command: 'dataServer', label: 'data-server' }]
|
|
1076
|
+
: []),
|
|
1077
|
+
...(composition.heads.includes('glsp') ? [{ key: 'GLSP_SERVER', path: 'glsp-server', command: 'glsp', label: 'GLSP' }] : [])
|
|
1078
|
+
];
|
|
1079
|
+
}
|
|
828
1080
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
* \`serializeTransfer\` carries \`target\` as a plain string rather than a
|
|
833
|
-
* \`Reference\`, and a serializer that reaches for \`$refText\` directly emits
|
|
834
|
-
* the AST correctly and drops every reference on the transfer path.
|
|
835
|
-
*/
|
|
836
|
-
private emitNode(node: __NODE_RULE__): string {
|
|
837
|
-
const target = this.serializeReferenceText(node.target);
|
|
838
|
-
return target === undefined ? \`node \${node.name}\` : \`node \${node.name} -> \${target}\`;
|
|
839
|
-
}
|
|
1081
|
+
/** Whether the composition emits the servers-only extension, which VS Code starts the server through and a Theia app sideloads. */
|
|
1082
|
+
function emitsVscodeServers(composition: InitComposition): boolean {
|
|
1083
|
+
return initMembers(composition.heads, composition.hosts).includes('vscode-servers');
|
|
840
1084
|
}
|
|
841
|
-
`;
|
|
842
1085
|
|
|
843
1086
|
/**
|
|
844
|
-
* The
|
|
845
|
-
*
|
|
846
|
-
* process serves every registered grammar, so a language-derived name would tie
|
|
847
|
-
* a project-level endpoint to whichever grammar was scaffolded first.
|
|
1087
|
+
* The host commands the servers extension answers each socket head's port
|
|
1088
|
+
* under, for a host without a language client of its own.
|
|
848
1089
|
*/
|
|
849
|
-
function
|
|
1090
|
+
function hostCommandConstants(composition: InitComposition): HeadPortConstant[] {
|
|
1091
|
+
if (!emitsVscodeServers(composition)) {
|
|
1092
|
+
return [];
|
|
1093
|
+
}
|
|
850
1094
|
const upper = screamingSnake(composition.projectId);
|
|
851
|
-
return
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1095
|
+
return socketHeads(composition).map(head => ({
|
|
1096
|
+
name: `${upper}_${head.key}_HOST_COMMAND`,
|
|
1097
|
+
value: `${composition.projectId}.port.${head.command}`,
|
|
1098
|
+
doc: `Host command the servers extension answers with the ${head.label} socket port.`
|
|
1099
|
+
}));
|
|
855
1100
|
}
|
|
856
1101
|
|
|
857
1102
|
/**
|
|
858
|
-
* The socket heads'
|
|
1103
|
+
* The socket heads' port commands or variables, in the protocol member.
|
|
859
1104
|
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
* rather than failing.
|
|
1105
|
+
* A host shell has to name the same string the server reads, and reaches it
|
|
1106
|
+
* here without depending on the server. A host that retypes the literal
|
|
1107
|
+
* instead gets no error when it drifts: the framework's port poll defaults to
|
|
1108
|
+
* `findPortAttempts = -1`, so a wrong command retries forever rather than
|
|
1109
|
+
* failing, and a wrong variable is never read.
|
|
866
1110
|
*/
|
|
867
1111
|
function headPortsFile(composition: InitComposition): string {
|
|
868
|
-
const
|
|
869
|
-
const
|
|
870
|
-
const
|
|
871
|
-
const
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1112
|
+
const constants = [...headPortConstants(composition), ...hostCommandConstants(composition)];
|
|
1113
|
+
const blocks = constants.map(constant => `/** ${constant.doc} */\nexport const ${constant.name} = '${constant.value}';`);
|
|
1114
|
+
const socketHeadCount = socketHeads(composition).length;
|
|
1115
|
+
const heads = `socket head${socketHeadCount > 1 ? 's' : ''}`;
|
|
1116
|
+
const opening = composition.heads.includes('lsp')
|
|
1117
|
+
? `// The LSP requests a host queries to discover this server's ${heads}.`
|
|
1118
|
+
: `// The environment variables that name the ports of this server's ${heads}.`;
|
|
1119
|
+
const usage = composition.heads.includes('lsp')
|
|
1120
|
+
? `// The server publishes them and a host shell has to name the same string to
|
|
1121
|
+
// reach the head; both import them from here. A shell that retypes the literal
|
|
1122
|
+
// gets no error when it drifts: the framework's port poll retries indefinitely
|
|
1123
|
+
// by default.`
|
|
1124
|
+
: `// The server reads them and a process that starts it sets them; both import
|
|
1125
|
+
// them from here. A variable retyped with a typo is never read, and the head
|
|
1126
|
+
// then listens on whichever port is free.`;
|
|
1127
|
+
const hostCommands = emitsVscodeServers(composition)
|
|
1128
|
+
? `
|
|
1129
|
+
//
|
|
1130
|
+
// The host commands are the same ports for a host with no language client,
|
|
1131
|
+
// such as a Theia app sideloading the servers extension: it runs the command
|
|
1132
|
+
// and the extension sends the request.`
|
|
1133
|
+
: '';
|
|
1134
|
+
return `${opening}
|
|
884
1135
|
//
|
|
885
|
-
// Both are keyed by the PROJECT, not the language: one server of each kind per
|
|
1136
|
+
// ${socketHeadCount > 1 ? 'Both are' : 'It is'} keyed by the PROJECT, not the language: one server of each kind per
|
|
886
1137
|
// process serves every registered grammar, so a language-derived name would tie
|
|
887
1138
|
// a project-level endpoint to whichever grammar was scaffolded first.
|
|
888
1139
|
//
|
|
889
|
-
|
|
890
|
-
// same string to reach the head, and \`main.ts\` is an executable entry — nothing
|
|
891
|
-
// can import from it. A shell that retypes the literal gets no error when it
|
|
892
|
-
// drifts: the framework's port poll retries indefinitely by default. Import
|
|
893
|
-
// these instead, and assert any host-side copy against them.
|
|
1140
|
+
${usage}${hostCommands}
|
|
894
1141
|
|
|
895
1142
|
${blocks.join('\n\n')}
|
|
896
1143
|
`;
|
|
897
1144
|
}
|
|
898
1145
|
|
|
899
|
-
|
|
900
|
-
|
|
1146
|
+
/**
|
|
1147
|
+
* The server's root entry: the DI factory, the generated AST and each diagram
|
|
1148
|
+
* module, all loadable in a browser worker.
|
|
1149
|
+
*/
|
|
1150
|
+
function indexFile(composition: InitComposition): string {
|
|
1151
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
1152
|
+
const diagramPhrase = diagrams.length === 0 ? '' : diagrams.length === 1 ? ', plus the diagram module' : ', plus the diagram modules';
|
|
1153
|
+
const text = `// Public surface of the __NAME__ language server: the DI factory and the
|
|
1154
|
+
// generated AST${diagramPhrase}.
|
|
901
1155
|
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
1156
|
+
// Everything here loads in a browser worker as well as in Node, so a host can
|
|
1157
|
+
// compose the language wherever it runs it. \`createServices\`, which reads the
|
|
1158
|
+
// real filesystem, is the package's \`./node\` entry instead. The entry points
|
|
1159
|
+
// under \`src/\` are not re-exported either: each opens a transport at module
|
|
1160
|
+
// scope, so importing one starts a server, which is why they are \`bin\` targets.
|
|
905
1161
|
|
|
906
1162
|
export * from './language-server/__PROJECT_ID__-module.js';
|
|
907
1163
|
export * from './language-server/ast.js';
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
/**
|
|
912
|
-
* The public surface, plus the head-port commands when there is a socket head.
|
|
913
|
-
*
|
|
914
|
-
* The commands belong here rather than only in `head-ports.ts` so a host shell
|
|
915
|
-
* reaches them from the package root, the same way it reaches `createServices`.
|
|
916
|
-
*/
|
|
917
|
-
function indexFile(composition: InitComposition): string {
|
|
918
|
-
const base = project(INDEX, composition);
|
|
919
|
-
return portCommandNames(composition).length ? `${base}export * from './head-ports.js';\n` : base;
|
|
1164
|
+
${diagrams.map(grammar => `export * from './glsp/${grammar.grammarId}/diagram-module.js';\n`).join('')}`;
|
|
1165
|
+
return project(text, composition);
|
|
920
1166
|
}
|
|
921
1167
|
|
|
922
1168
|
/**
|
|
@@ -944,12 +1190,14 @@ const SHEBANG = '#!/usr/bin/env node\n';
|
|
|
944
1190
|
*/
|
|
945
1191
|
function mainFile(composition: InitComposition): string {
|
|
946
1192
|
const columns = columnsFor(composition);
|
|
947
|
-
const roots =
|
|
1193
|
+
const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
|
|
948
1194
|
const upper = screamingSnake(composition.projectId);
|
|
1195
|
+
const lsp = composition.heads.includes('lsp');
|
|
949
1196
|
const data = composition.heads.includes('data');
|
|
950
1197
|
const glsp = composition.heads.includes('glsp');
|
|
951
1198
|
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
952
1199
|
const plural = roots.length > 1 ? 's' : '';
|
|
1200
|
+
const portFlags = [...(data ? ['--data-server-port'] : []), ...(glsp ? ['--glsp-server-port'] : [])];
|
|
953
1201
|
|
|
954
1202
|
const headNames = composition.heads.map(head =>
|
|
955
1203
|
head === 'lsp' ? 'the Langium LSP head' : head === 'data' ? 'a socket data-server (model server) head' : 'a GLSP head'
|
|
@@ -963,7 +1211,7 @@ function mainFile(composition: InitComposition): string {
|
|
|
963
1211
|
// `reflect-metadata` must be imported before anything that reads a
|
|
964
1212
|
// decorator's emitted metadata, which inversify does at module scope.
|
|
965
1213
|
const reflectImport = glsp ? "import 'reflect-metadata';\n" : '';
|
|
966
|
-
const coreNodeSymbols = ['NodeFileSystem', ...(data ? ['publishPortOnLspConnection', 'startSocketServer'] : [])];
|
|
1214
|
+
const coreNodeSymbols = ['NodeFileSystem', ...(data ? [...(lsp ? ['publishPortOnLspConnection'] : []), 'startSocketServer'] : [])];
|
|
967
1215
|
const glspImports = glsp
|
|
968
1216
|
? `import { ServerModule } from '@eclipse-glsp/server/node.js';
|
|
969
1217
|
import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';
|
|
@@ -980,46 +1228,59 @@ import { startGlspServer } from '@hydranium/glsp-server/node';
|
|
|
980
1228
|
// \`TransferElement\` structurally, so naming the AST type here compiles fine and
|
|
981
1229
|
// silently tells every typed client that a reference is a resolvable object
|
|
982
1230
|
// rather than a name.
|
|
983
|
-
${importList(roots, '
|
|
1231
|
+
${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
|
|
984
1232
|
`
|
|
985
1233
|
: '';
|
|
986
1234
|
|
|
987
|
-
// The
|
|
988
|
-
// the same string to reach the head, and this file is an executable entry,
|
|
989
|
-
// nothing can import from it.
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1235
|
+
// The names live in the protocol member, not here: a host shell has to name
|
|
1236
|
+
// the same string to reach the head, and this file is an executable entry,
|
|
1237
|
+
// so nothing can import from it.
|
|
1238
|
+
const portNames = headPortConstants(composition).map(constant => constant.name);
|
|
1239
|
+
const portImports = portNames.length ? importList(portNames, memberPackageName(composition, 'protocol'), columns) + '\n' : '';
|
|
1240
|
+
|
|
1241
|
+
const dataBlock = !data
|
|
1242
|
+
? ''
|
|
1243
|
+
: !lsp
|
|
1244
|
+
? `
|
|
1245
|
+
// Data-server head: each accepted client gets its own DataServer.
|
|
1246
|
+
const dataServer = startSocketServer(
|
|
1247
|
+
{ shared, workspace, port: portFrom('data-server-port', ${upper}_DATA_SERVER_PORT_ENV), logTag: 'ModelServer', logger: shared.Logger },
|
|
1248
|
+
dataConnection => {
|
|
1249
|
+
new DataServer<${roots.join(' | ')}>(dataConnection, shared);
|
|
1250
|
+
return { dispose: () => undefined };
|
|
1251
|
+
}
|
|
1252
|
+
);
|
|
1253
|
+
`
|
|
1254
|
+
: `
|
|
996
1255
|
// Data-server head alongside LSP: binds an ephemeral port, published over the LSP
|
|
997
1256
|
// connection for the host to discover. Each accepted client gets its own DataServer.
|
|
998
1257
|
//
|
|
999
1258
|
// Neither the bind nor the publish may be swallowed: either failure leaves the LSP
|
|
1000
1259
|
// head serving text edits while every data client waits on a port command that was
|
|
1001
1260
|
// never registered, and the launcher reports a bind failure only if given a logger.
|
|
1002
|
-
const dataServer = startSocketServer({ port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
|
|
1261
|
+
const dataServer = startSocketServer({ shared, port: 0, logTag: 'ModelServer', logger: shared.Logger }, dataConnection => {
|
|
1003
1262
|
new DataServer<${roots.join(' | ')}>(dataConnection, shared);
|
|
1004
1263
|
return { dispose: () => undefined };
|
|
1005
1264
|
});
|
|
1006
1265
|
dataServer.started
|
|
1007
|
-
.then(
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1266
|
+
.then(
|
|
1267
|
+
() => {
|
|
1268
|
+
const { port } = dataServer;
|
|
1269
|
+
if (port === undefined) {
|
|
1270
|
+
// \`started\` resolves only once the address is resolved, so this is
|
|
1271
|
+
// unreachable; a non-null assertion in its place would publish
|
|
1272
|
+
// \`undefined\`, which the host cannot tell from an unreachable port.
|
|
1273
|
+
throw new Error('the data head started without a resolved port');
|
|
1274
|
+
}
|
|
1275
|
+
publishPortOnLspConnection(shared.lsp.Connection, ${upper}_DATA_SERVER_PORT_COMMAND, port);
|
|
1276
|
+
},
|
|
1277
|
+
// The launcher logged its own bind failure; the rejection is control flow.
|
|
1278
|
+
() => undefined
|
|
1279
|
+
)
|
|
1017
1280
|
.catch((error: unknown) => {
|
|
1018
|
-
|
|
1019
|
-
shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${reason}\`);
|
|
1281
|
+
shared.Logger.error(\`[ModelServer] Could not publish the data-server port: \${describeErrorWithStack(error)}\`);
|
|
1020
1282
|
});
|
|
1021
|
-
|
|
1022
|
-
: '';
|
|
1283
|
+
`;
|
|
1023
1284
|
|
|
1024
1285
|
const glspBlock = glsp
|
|
1025
1286
|
? `
|
|
@@ -1028,49 +1289,115 @@ dataServer.started
|
|
|
1028
1289
|
// grammar a request concerns, so nothing per-language belongs there — each
|
|
1029
1290
|
// diagram module declares its own language instead.
|
|
1030
1291
|
//
|
|
1031
|
-
|
|
1032
|
-
|
|
1292
|
+
${
|
|
1293
|
+
lsp
|
|
1294
|
+
? `// GLSP framework logs route through the LSP connection rather than stdout, which
|
|
1295
|
+
// IS the LSP transport in stdio mode; writing there corrupts the protocol stream.`
|
|
1296
|
+
: `// GLSP framework logs route through the framework's logger to stderr. GLSP's
|
|
1297
|
+
// own startup line goes to stdout, beside the ports line below.`
|
|
1298
|
+
}
|
|
1033
1299
|
const glspServer = startGlspServer({
|
|
1034
1300
|
// The GLSP log threshold lives on the logger, not beside it: the framework
|
|
1035
1301
|
// replaces GLSP's own \`Logger\` binding, so a launcher-level \`logLevel\` would
|
|
1036
1302
|
// be discarded. The logger tracks the framework's process-wide threshold, so
|
|
1037
|
-
// \`HYDRANIUM_LOG_LEVEL\` and the LSP log-level setting govern GLSP output too;
|
|
1303
|
+
// \`HYDRANIUM_LOG_LEVEL\` ${lsp ? 'and the LSP log-level setting govern' : 'governs'} GLSP output too;
|
|
1038
1304
|
// pass \`logLevel\` only to make GLSP quieter than the rest of the server.
|
|
1039
1305
|
createLogger: caller => new GlspClientLogger(shared, { component: caller }),
|
|
1040
1306
|
serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
|
|
1041
1307
|
appModules: [new HydraniumGlspAppModule({ shared })],
|
|
1042
|
-
|
|
1043
|
-
|
|
1308
|
+
${
|
|
1309
|
+
lsp
|
|
1310
|
+
? ` lspConnection: shared.lsp.Connection,
|
|
1311
|
+
portCommand: ${upper}_GLSP_SERVER_PORT_COMMAND`
|
|
1312
|
+
: ` workspace,
|
|
1313
|
+
port: portFrom('glsp-server-port', ${upper}_GLSP_SERVER_PORT_ENV),
|
|
1314
|
+
logger: shared.Logger`
|
|
1315
|
+
}
|
|
1044
1316
|
});
|
|
1045
|
-
void glspServer
|
|
1046
|
-
`
|
|
1317
|
+
${lsp ? 'void glspServer;\n' : ''}`
|
|
1047
1318
|
: '';
|
|
1048
1319
|
|
|
1320
|
+
const lspImports = `// The framework's entry point, NOT Langium's \`@hydranium/langium/lsp\` one. It is
|
|
1321
|
+
// signature-compatible and delegates straight through; what it adds first is
|
|
1322
|
+
// \`assertLspHeadComposed\`, which fails the start if the LSP head's SHARED module
|
|
1323
|
+
// was never composed. Reaching for Langium's is the natural mistake and it is
|
|
1324
|
+
// silent: the server boots, links and completes, while echo suppression, the
|
|
1325
|
+
// didChangeContent debounce and the last-client-close rebuild are all inert.
|
|
1326
|
+
import { startLanguageServer, withHydraniumLspFeatures } from '@hydranium/core/lsp';
|
|
1327
|
+
import { ProposedFeatures, createConnection } from 'vscode-languageserver/node';
|
|
1328
|
+
`;
|
|
1329
|
+
|
|
1330
|
+
const lspStart = `
|
|
1331
|
+
const connection = createConnection(withHydraniumLspFeatures(ProposedFeatures.all));
|
|
1332
|
+
const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
|
|
1333
|
+
startLanguageServer(shared);
|
|
1334
|
+
`;
|
|
1335
|
+
|
|
1336
|
+
// No LSP client names the workspace or asks for the ports here, so the
|
|
1337
|
+
// command line does, and the ports go to stdout once every head listens.
|
|
1338
|
+
const servers = [...(data ? ['dataServer'] : []), ...(glsp ? ['glspServer'] : [])];
|
|
1339
|
+
const socketStart = `
|
|
1340
|
+
const { values, positionals } = parseArgs({
|
|
1341
|
+
options: { ${portFlags.map(flag => `'${flag.slice(2)}': { type: 'string' }`).join(', ')} },
|
|
1342
|
+
allowPositionals: true
|
|
1343
|
+
});
|
|
1344
|
+
// The workspace root: the first argument, else the process cwd.
|
|
1345
|
+
const workspace = positionals[0] ?? process.cwd();
|
|
1346
|
+
|
|
1347
|
+
/** A head's port: its flag, else its environment variable, else 0 for any free port. */
|
|
1348
|
+
function portFrom(flag: keyof typeof values, variable: string): number {
|
|
1349
|
+
const text = values[flag] ?? process.env[variable] ?? '0';
|
|
1350
|
+
const port = Number(text);
|
|
1351
|
+
// Decimal digits only: \`Number\` also reads '' as 0 and takes '1e3' or '0x10'.
|
|
1352
|
+
if (!/^\\d+$/.test(text) || port > 65_535) {
|
|
1353
|
+
throw new Error(\`--\${flag} or \${variable} is '\${text}', not a port\`);
|
|
1354
|
+
}
|
|
1355
|
+
return port;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const { shared } = create__NAME__Services({ ...NodeFileSystem });
|
|
1359
|
+
`;
|
|
1360
|
+
const portsLine = `
|
|
1361
|
+
// One JSON line on stdout once every head listens, for the process that started
|
|
1362
|
+
// this one to read the ports from: the one line that starts with \`{\`. Each
|
|
1363
|
+
// head brings the workspace up before it listens; a head that fails to start
|
|
1364
|
+
// has logged why and ends the process, which would otherwise serve without it.
|
|
1365
|
+
${servers.length === 1 ? `${servers[0]}.started` : `Promise.all([${servers.map(server => `${server}.started`).join(', ')}])`}.then(
|
|
1366
|
+
() => process.stdout.write(\`\${JSON.stringify({ ${servers.map(server => `${server}: ${server}.port`).join(', ')} })}\\n\`),
|
|
1367
|
+
() => process.exit(1)
|
|
1368
|
+
);
|
|
1369
|
+
`;
|
|
1370
|
+
|
|
1371
|
+
const invocation = lsp
|
|
1372
|
+
? `// Invocation: \`node lib/main.js --stdio\`, or the package's \`__PROJECT_ID__\` bin
|
|
1373
|
+
// script.`
|
|
1374
|
+
: `// Invocation: \`node lib/main.js [<workspace-path>] ${portFlags.map(flag => `[${flag} <port>]`).join(' ')}\`,
|
|
1375
|
+
// or the package's \`__PROJECT_ID__\` bin script. Each port falls back to its
|
|
1376
|
+
// environment variable, then to any free port.`;
|
|
1377
|
+
const dataNote = !data
|
|
1378
|
+
? ''
|
|
1379
|
+
: lsp
|
|
1380
|
+
? `
|
|
1381
|
+
//
|
|
1382
|
+
// NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: stdio
|
|
1383
|
+
// here carries LSP, and the data head is a socket whose port is published over
|
|
1384
|
+
// the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
|
|
1385
|
+
: `
|
|
1386
|
+
//
|
|
1387
|
+
// NOT the entry the data-server subcommands of \`hydranium-cli\` speak to: they
|
|
1388
|
+
// drive the data protocol over a child's stdio, and the data head here is a
|
|
1389
|
+
// socket. Those subcommands spawn \`data-server-main.js\`.`;
|
|
1390
|
+
|
|
1049
1391
|
const text = `${SHEBANG}// Standalone entry point: starts ${headSummary} in the same
|
|
1050
1392
|
// process, so every head shares one model store.
|
|
1051
|
-
|
|
1052
|
-
// script.${
|
|
1053
|
-
data
|
|
1054
|
-
? `
|
|
1055
|
-
//
|
|
1056
|
-
// NOT the entry \`hydranium-cli query\` / \`save\` / \`projects\` / \`watch\` speak to:
|
|
1057
|
-
// stdio here carries LSP, and the data head is a socket whose port is published
|
|
1058
|
-
// over the LSP connection. Those subcommands spawn \`data-server-main.js\`.`
|
|
1059
|
-
: ''
|
|
1060
|
-
}
|
|
1393
|
+
${invocation}${dataNote}
|
|
1061
1394
|
//
|
|
1062
1395
|
// Everything here runs at module scope, so this file is an executable rather
|
|
1063
1396
|
// than a library entry — import \`./index.js\` instead to compose the language.
|
|
1064
1397
|
|
|
1065
1398
|
${reflectImport}${glspImports}${importList(coreNodeSymbols, '@hydranium/core/node', columns)}
|
|
1066
|
-
${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}import {
|
|
1067
|
-
|
|
1068
|
-
${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
|
|
1069
|
-
${portCommands === '' ? '' : '\n' + portCommands}
|
|
1070
|
-
const connection = createConnection(ProposedFeatures.all);
|
|
1071
|
-
const { shared } = create__NAME__Services({ connection, ...NodeFileSystem });
|
|
1072
|
-
startLanguageServer(shared);
|
|
1073
|
-
${dataBlock}${glspBlock}`;
|
|
1399
|
+
${data ? "import { DataServer } from '@hydranium/data-server';\n" : ''}${data && lsp ? "import { describeErrorWithStack } from '@hydranium/protocol';\n" : ''}${lsp ? lspImports : "import { parseArgs } from 'node:util';\n"}${diagramImports}${transferImports}import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
|
|
1400
|
+
${portImports === '' ? '' : '\n' + portImports}${lsp ? lspStart : socketStart}${dataBlock}${glspBlock}${lsp ? '' : portsLine}`;
|
|
1074
1401
|
return project(text, composition);
|
|
1075
1402
|
}
|
|
1076
1403
|
|
|
@@ -1078,38 +1405,43 @@ ${dataBlock}${glspBlock}`;
|
|
|
1078
1405
|
* The second executable entry: the data head alone, on stdio.
|
|
1079
1406
|
*
|
|
1080
1407
|
* Emitted with the `data` head because it is the ONLY command line that reaches
|
|
1081
|
-
* that head from outside the editor. `main.ts`
|
|
1082
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* `
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
1087
|
-
* unrecognised method rather than as a missing head.
|
|
1408
|
+
* that head from outside the editor. `main.ts` serves the data head on a
|
|
1409
|
+
* socket, so a client driving the data protocol over a spawned child's
|
|
1410
|
+
* stdin/stdout cannot reach it — which is every data-server subcommand of
|
|
1411
|
+
* `hydranium-cli`. Without this file a scaffolded project has no `--server`
|
|
1412
|
+
* value, and the closest guess, `main.ts`, fails as an unrecognised method or
|
|
1413
|
+
* an unknown flag rather than as a missing head.
|
|
1088
1414
|
*
|
|
1089
1415
|
* A separate process rather than a flag on `main.ts`: only one protocol can own
|
|
1090
1416
|
* stdio, so the two entries are mutually exclusive by construction.
|
|
1091
1417
|
*/
|
|
1092
1418
|
function dataServerMainFile(composition: InitComposition): string {
|
|
1093
1419
|
const columns = columnsFor(composition);
|
|
1094
|
-
const roots =
|
|
1420
|
+
const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
|
|
1095
1421
|
const plural = roots.length > 1 ? 's' : '';
|
|
1096
1422
|
|
|
1097
1423
|
const text = `${SHEBANG}// Standalone entry point: a data-server head on **stdio**, with no LSP head in
|
|
1098
|
-
// the process. This is the transport \`hydranium-cli\` speaks —
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
// language.
|
|
1424
|
+
// the process. This is the transport \`hydranium-cli\` speaks — its data-server
|
|
1425
|
+
// subcommands spawn a server command and drive JSON-RPC over its stdin/stdout —
|
|
1426
|
+
// so it is what makes those subcommands usable against this language.
|
|
1102
1427
|
//
|
|
1103
1428
|
// Invocation: \`node lib/data-server-main.js [<workspace-path>]\`, or the
|
|
1104
1429
|
// package's \`__PROJECT_ID__-data-server\` bin script. The workspace path defaults to
|
|
1105
1430
|
// the process cwd, which is what the CLI's \`--cwd\` sets on the child.
|
|
1106
1431
|
//
|
|
1107
|
-
|
|
1432
|
+
${
|
|
1433
|
+
composition.heads.includes('lsp')
|
|
1434
|
+
? `// Contrast with \`main.ts\`, the editor entry: there the LSP head owns stdio and
|
|
1108
1435
|
// the data head is a socket published over the LSP connection. Here there is no
|
|
1109
1436
|
// LSP connection at all, so the workspace initialization an
|
|
1110
1437
|
// \`initialize\`/\`initialized\` pair would otherwise drive has to happen here —
|
|
1111
1438
|
// which is why this entry uses \`startStdioServer\` rather than wiring a
|
|
1112
|
-
// connection directly
|
|
1439
|
+
// connection directly.`
|
|
1440
|
+
: `// Contrast with \`main.ts\`, where the data head is a socket whose port it
|
|
1441
|
+
// prints. Here it owns stdio, and as there, no LSP connection drives the
|
|
1442
|
+
// workspace initialization, so \`startStdioServer\` brings the workspace up
|
|
1443
|
+
// before it listens.`
|
|
1444
|
+
}
|
|
1113
1445
|
//
|
|
1114
1446
|
// Everything here runs at module scope, so this file is an executable rather
|
|
1115
1447
|
// than a library entry — import \`./index.js\` instead to compose the language.
|
|
@@ -1117,7 +1449,7 @@ function dataServerMainFile(composition: InitComposition): string {
|
|
|
1117
1449
|
${importList(['NodeFileSystem', 'startStdioServer'], '@hydranium/core/node', columns)}
|
|
1118
1450
|
import { DataServer } from '@hydranium/data-server';
|
|
1119
1451
|
// The TRANSFER root${plural}, not the AST one${plural} — same reasoning as \`main.ts\`.
|
|
1120
|
-
${importList(roots, '
|
|
1452
|
+
${importList(roots, memberPackageName(composition, 'protocol'), columns, true)}
|
|
1121
1453
|
import { create__NAME__Services } from './language-server/__PROJECT_ID__-module.js';
|
|
1122
1454
|
|
|
1123
1455
|
const { shared } = create__NAME__Services({ ...NodeFileSystem });
|
|
@@ -1166,7 +1498,7 @@ function servicesTest(composition: InitComposition): string {
|
|
|
1166
1498
|
// is missing from the composition.
|
|
1167
1499
|
|
|
1168
1500
|
import { describe, expect, it } from 'vitest';
|
|
1169
|
-
import { createServices } from '../src/services.js';
|
|
1501
|
+
import { createServices } from '../src/node/services.js';
|
|
1170
1502
|
|
|
1171
1503
|
describe('__NAME__ services', () => {
|
|
1172
1504
|
it('composes the DI tree and registers ${title}', () => {
|
|
@@ -1208,13 +1540,9 @@ const SERIALIZATION_SUITE = `describe('__GRAMMAR__ serialization', () => {
|
|
|
1208
1540
|
|
|
1209
1541
|
it('emits the same text from a transfer model, whose references are plain strings', async () => {
|
|
1210
1542
|
const { __GRAMMAR__ } = createServices();
|
|
1211
|
-
const model
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
{ $type: '__NODE_RULE__', name: 'first', target: 'second' },
|
|
1215
|
-
{ $type: '__NODE_RULE__', name: 'second' }
|
|
1216
|
-
]
|
|
1217
|
-
};
|
|
1543
|
+
const model = transferNode(Transfer__ENTRY_RULE__, {
|
|
1544
|
+
nodes: __TRANSFER_NODES__
|
|
1545
|
+
});
|
|
1218
1546
|
|
|
1219
1547
|
expect(await __GRAMMAR__.serializer.Serializer.serializeTransfer(model)).toBe(source);
|
|
1220
1548
|
});
|
|
@@ -1233,11 +1561,24 @@ const SERIALIZATION_SUITE = `describe('__GRAMMAR__ serialization', () => {
|
|
|
1233
1561
|
function serializationTest(composition: InitComposition): string {
|
|
1234
1562
|
const columns = columnsFor(composition);
|
|
1235
1563
|
const astTypes = composition.grammars.map(grammar => grammar.entryRule);
|
|
1236
|
-
const
|
|
1564
|
+
const transferConstants = composition.grammars.flatMap(grammar => [
|
|
1565
|
+
`${grammar.entryRule} as Transfer${grammar.entryRule}`,
|
|
1566
|
+
grammar.nodeRule
|
|
1567
|
+
]);
|
|
1568
|
+
const suite = (grammar: InitGrammarNames): string => {
|
|
1569
|
+
const nodes = [
|
|
1570
|
+
`transferNode(${grammar.nodeRule}, { name: 'first', target: 'second' })`,
|
|
1571
|
+
`transferNode(${grammar.nodeRule}, { name: 'second' })`
|
|
1572
|
+
];
|
|
1573
|
+
const single = `[${nodes.join(', ')}]`;
|
|
1574
|
+
const literal =
|
|
1575
|
+
` nodes: ${single}`.length <= columns ? single : `[\n${nodes.map(node => ` ${node}`).join(',\n')}\n ]`;
|
|
1576
|
+
return render(SERIALIZATION_SUITE, composition, grammar).replace('__TRANSFER_NODES__', literal);
|
|
1577
|
+
};
|
|
1237
1578
|
const text = `// Round-trips each grammar through its serializer: parse the source, serialize
|
|
1238
1579
|
// the model, compare the text. The serializer is the parser's inverse, so this
|
|
1239
|
-
// is the assertion that keeps \`
|
|
1240
|
-
// language server can read back.
|
|
1580
|
+
// is the assertion that keeps a client session's \`update\` / \`save\` writing
|
|
1581
|
+
// files the language server can read back.
|
|
1241
1582
|
//
|
|
1242
1583
|
// The transfer case is the one that catches the mistake worth catching.
|
|
1243
1584
|
// \`ModelService.modelToText\` short-circuits only a RAW STRING, so a typed model
|
|
@@ -1247,10 +1588,11 @@ function serializationTest(composition: InitComposition): string {
|
|
|
1247
1588
|
import { parseHelper } from '@hydranium/core/testing';
|
|
1248
1589
|
import { describe, expect, it } from 'vitest';
|
|
1249
1590
|
${importList(astTypes, '../src/language-server/ast.js', columns, true)}
|
|
1250
|
-
${importList(
|
|
1251
|
-
import { createServices } from '../src/services.js';
|
|
1591
|
+
${importList(transferConstants, memberPackageName(composition, 'protocol'), columns)}
|
|
1592
|
+
import { createServices } from '../src/node/services.js';
|
|
1593
|
+
import { transferNode } from './generated-hydranium/transfer-builder.js';
|
|
1252
1594
|
|
|
1253
|
-
${composition.grammars.map(
|
|
1595
|
+
${composition.grammars.map(suite).join('\n\n')}
|
|
1254
1596
|
`;
|
|
1255
1597
|
return text;
|
|
1256
1598
|
}
|
|
@@ -1330,14 +1672,9 @@ const LINKING_SUITE = `describe('__GRAMMAR__ linking', () => {
|
|
|
1330
1672
|
});`;
|
|
1331
1673
|
|
|
1332
1674
|
/**
|
|
1333
|
-
* The validation tier
|
|
1334
|
-
* adopter
|
|
1335
|
-
*
|
|
1336
|
-
* The scaffold binds no `validation.checks`, so a test over an adopter check
|
|
1337
|
-
* would have to invent one and would then assert the test's own fixture rather
|
|
1338
|
-
* than the language. What a scaffolded project really guarantees on day one is
|
|
1339
|
-
* that a dangling reference is reported, which is a linker diagnostic — and
|
|
1340
|
-
* that is the tier a new adopter breaks third.
|
|
1675
|
+
* The validation tier: the linker's diagnostic for a dangling reference, the
|
|
1676
|
+
* tier a new adopter breaks third, and the scaffold's one check, a warning on
|
|
1677
|
+
* a repeated name.
|
|
1341
1678
|
*/
|
|
1342
1679
|
const VALIDATING_SUITE = `describe('__GRAMMAR__ validation', () => {
|
|
1343
1680
|
it('reports nothing for a well-formed document', async () => {
|
|
@@ -1364,9 +1701,24 @@ const VALIDATING_SUITE = `describe('__GRAMMAR__ validation', () => {
|
|
|
1364
1701
|
expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error);
|
|
1365
1702
|
expect(diagnostics[0].message).toContain('absent');
|
|
1366
1703
|
});
|
|
1367
|
-
});`;
|
|
1368
1704
|
|
|
1369
|
-
|
|
1705
|
+
it('warns on each node named like one before it', async () => {
|
|
1706
|
+
const { shared, __GRAMMAR__ } = createServices();
|
|
1707
|
+
|
|
1708
|
+
const document = await parseHelper<__ENTRY_RULE__>(__GRAMMAR__)('node first\\nnode second\\nnode first', {
|
|
1709
|
+
documentUri: 'file:///duplicate.__EXTENSION__'
|
|
1710
|
+
});
|
|
1711
|
+
await shared.workspace.DocumentBuilder.build([document], { validation: true });
|
|
1712
|
+
|
|
1713
|
+
const diagnostics = document.diagnostics ?? [];
|
|
1714
|
+
expect(diagnostics).toHaveLength(1);
|
|
1715
|
+
expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Warning);
|
|
1716
|
+
expect(diagnostics[0].message).toBe("A node named 'first' is already declared.");
|
|
1717
|
+
expect(diagnostics[0].range.start.line).toBe(2);
|
|
1718
|
+
});
|
|
1719
|
+
});`;
|
|
1720
|
+
|
|
1721
|
+
/** Assemble one grammar-derived test file: a fixed preamble plus one suite per grammar. */
|
|
1370
1722
|
function grammarTest(composition: InitComposition, header: string, imports: readonly string[], suite: string): string {
|
|
1371
1723
|
const columns = columnsFor(composition);
|
|
1372
1724
|
const astTypes = composition.grammars.map(grammar => grammar.entryRule);
|
|
@@ -1375,7 +1727,7 @@ function grammarTest(composition: InitComposition, header: string, imports: read
|
|
|
1375
1727
|
import { parseHelper } from '@hydranium/core/testing';
|
|
1376
1728
|
import { describe, expect, it } from 'vitest';
|
|
1377
1729
|
${imports.join('\n')}${imports.length ? '\n' : ''}${importList(astTypes, '../src/language-server/ast.js', columns, true)}
|
|
1378
|
-
import { createServices } from '../src/services.js';
|
|
1730
|
+
import { createServices } from '../src/node/services.js';
|
|
1379
1731
|
|
|
1380
1732
|
${composition.grammars.map(grammar => render(suite, composition, grammar)).join('\n\n')}
|
|
1381
1733
|
`;
|
|
@@ -1396,16 +1748,16 @@ const LINKING_HEADER = `// Tier two: does my cross-reference resolve.
|
|
|
1396
1748
|
|
|
1397
1749
|
const VALIDATING_HEADER = `// Tier three: does a broken model get reported.
|
|
1398
1750
|
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
//
|
|
1402
|
-
// language module and assert it here alongside these.`;
|
|
1751
|
+
// A dangling reference is the FRAMEWORK's own linker diagnostic; a repeated
|
|
1752
|
+
// name is reported by the scaffold's own validation check. Bind a further
|
|
1753
|
+
// check in your language module and assert it here alongside these.`;
|
|
1403
1754
|
|
|
1404
1755
|
/** The README, assembled from lines to avoid escaping the Markdown code fences. */
|
|
1405
1756
|
function readme(composition: InitComposition): string {
|
|
1406
1757
|
const multi = composition.grammars.length > 1;
|
|
1407
1758
|
const data = composition.heads.includes('data');
|
|
1408
1759
|
const primaryExtension = composition.grammars[0].extensions[0];
|
|
1760
|
+
const diagramCount = composition.grammars.filter(grammar => grammar.diagram).length;
|
|
1409
1761
|
const grammarBullets = composition.grammars.map(
|
|
1410
1762
|
grammar => `- \`src/grammar/${grammar.grammarId}.langium\` — the \`${grammar.grammar}\` grammar (.${grammar.extensions.join(', .')})`
|
|
1411
1763
|
);
|
|
@@ -1416,24 +1768,11 @@ function readme(composition: InitComposition): string {
|
|
|
1416
1768
|
'',
|
|
1417
1769
|
'## Getting started',
|
|
1418
1770
|
'',
|
|
1419
|
-
// Conditional on the pin this scaffold actually carries — see
|
|
1420
|
-
// `UNPUBLISHED_FRAMEWORK_VERSION`.
|
|
1421
|
-
...(composition.frameworkVersion === UNPUBLISHED_FRAMEWORK_VERSION
|
|
1422
|
-
? [
|
|
1423
|
-
'> **Pre-publish note.** `@hydranium/*` is not on npm yet, so the `0.0.0`',
|
|
1424
|
-
'> pins below are placeholders and `npm install` will fail with a 404 until',
|
|
1425
|
-
'> the framework is released. Until then, supply the packages from a local',
|
|
1426
|
-
'> framework checkout with [yalc](https://github.com/wclr/yalc) — a plain',
|
|
1427
|
-
'> `file:` path or `npm link` is not enough, because the framework packages',
|
|
1428
|
-
'> depend on each other by version and npm would try to fetch those from the',
|
|
1429
|
-
'> registry too.',
|
|
1430
|
-
''
|
|
1431
|
-
]
|
|
1432
|
-
: []),
|
|
1433
1771
|
'```bash',
|
|
1434
1772
|
'npm install',
|
|
1435
1773
|
'npm run langium:generate # generate the AST from the grammar',
|
|
1436
1774
|
'npm run build # generate + compile to lib/',
|
|
1775
|
+
'npm run watch # regenerate and recompile on every grammar or source change',
|
|
1437
1776
|
'npm test # typecheck the tests, then run them',
|
|
1438
1777
|
'```',
|
|
1439
1778
|
'',
|
|
@@ -1444,25 +1783,26 @@ function readme(composition: InitComposition): string {
|
|
|
1444
1783
|
// nowhere else.
|
|
1445
1784
|
'```bash',
|
|
1446
1785
|
'# Grammar / AST reflection',
|
|
1447
|
-
'npx hydranium-cli reflect --services ./lib/services.js',
|
|
1786
|
+
'npx hydranium-cli reflect --services ./lib/node/services.js',
|
|
1448
1787
|
'',
|
|
1449
1788
|
'# Grammar-convention lint (CI gate)',
|
|
1450
|
-
'npx hydranium-cli lint-grammar --services ./lib/services.js',
|
|
1789
|
+
'npx hydranium-cli lint-grammar --services ./lib/node/services.js',
|
|
1451
1790
|
'',
|
|
1452
1791
|
'# Validate a workspace of model files (non-zero exit on errors)',
|
|
1453
|
-
'npx hydranium-cli validate --services ./lib/services.js
|
|
1792
|
+
'npx hydranium-cli validate --services ./lib/node/services.js ../workspace',
|
|
1454
1793
|
...(data
|
|
1455
1794
|
? [
|
|
1456
1795
|
'',
|
|
1457
1796
|
'# Data-head operations. `--server` is a command line the CLI spawns and then',
|
|
1458
1797
|
"# drives the data protocol over the child's stdin/stdout, so it has to name",
|
|
1459
|
-
|
|
1460
|
-
|
|
1798
|
+
...(composition.heads.includes('lsp')
|
|
1799
|
+
? ['# `data-server-main.js` — `main.js` gives stdio to LSP and answers these', '# methods with "Unhandled method".']
|
|
1800
|
+
: ['# `data-server-main.js` — `main.js` serves the data head on a socket.']),
|
|
1461
1801
|
'#',
|
|
1462
1802
|
'# The workspace goes to the ENTRY, not to `--cwd`: `--cwd` re-roots the child,',
|
|
1463
1803
|
'# so a relative entry path is refused by name (an absolute one is fine).',
|
|
1464
|
-
'npx hydranium-cli projects --server "node ./lib/data-server-main.js
|
|
1465
|
-
`npx hydranium-cli query --server "node ./lib/data-server-main.js
|
|
1804
|
+
'npx hydranium-cli projects --server "node ./lib/data-server-main.js ../workspace"',
|
|
1805
|
+
`npx hydranium-cli query --server "node ./lib/data-server-main.js ../workspace" --uri <file:// URI of ../workspace/nodes.${primaryExtension}>`
|
|
1466
1806
|
]
|
|
1467
1807
|
: []),
|
|
1468
1808
|
'```',
|
|
@@ -1472,12 +1812,12 @@ function readme(composition: InitComposition): string {
|
|
|
1472
1812
|
...grammarBullets,
|
|
1473
1813
|
...(multi ? ['- `src/grammar/common.langium` — shared terminals, imported by each grammar (not a language)'] : []),
|
|
1474
1814
|
'- `src/language-server/__PROJECT_ID__-module.ts` — `create__NAME__Services` DI wiring',
|
|
1475
|
-
...composition.grammars.
|
|
1476
|
-
grammar
|
|
1477
|
-
|
|
1478
|
-
),
|
|
1479
|
-
|
|
1480
|
-
|
|
1815
|
+
...composition.grammars.flatMap(grammar => [
|
|
1816
|
+
`- \`src/language-server/${grammar.grammarId}-serializer.ts\` — emits \`${grammar.grammar}\` back to text (the framework defaults this to a throw)`,
|
|
1817
|
+
`- \`src/language-server/${grammar.grammarId}-validation.ts\` — the \`${grammar.grammar}\` checks: a warning on a repeated name`
|
|
1818
|
+
]),
|
|
1819
|
+
"- `src/node/services.ts` — zero-arg `createServices()` for the headless CLI, the package's `./node` entry",
|
|
1820
|
+
`- \`src/index.ts\` — the package root, loadable in a browser worker: DI factory + generated AST${diagramCount === 0 ? '' : diagramCount === 1 ? ' + diagram module' : ' + diagram modules'}`,
|
|
1481
1821
|
`- \`src/main.ts\` — starts ${composition.heads.join(' + ')}, the \`__PROJECT_ID__\` bin entry`,
|
|
1482
1822
|
...(data
|
|
1483
1823
|
? [
|
|
@@ -1489,8 +1829,9 @@ function readme(composition: InitComposition): string {
|
|
|
1489
1829
|
.filter(grammar => grammar.diagram)
|
|
1490
1830
|
.map(
|
|
1491
1831
|
grammar =>
|
|
1492
|
-
`- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram:
|
|
1832
|
+
`- \`src/glsp/${grammar.grammarId}/\` — the \`${grammar.grammar}\` diagram: state, storage, submission handler, GModel factory, configuration, create-node handler, DI module`
|
|
1493
1833
|
),
|
|
1834
|
+
`- \`../protocol/\` — the \`${memberPackageName(composition, 'protocol')}\` package: what this server and its clients both import (language ids, ${headPortConstants(composition).length === 0 ? '' : composition.heads.includes('lsp') ? 'port commands, ' : 'port variables, '}${composition.grammars.some(grammar => grammar.diagram) ? 'diagram ids, ' : ''}the transfer model), written partly by \`npm run generate\``,
|
|
1494
1835
|
'- `test/services.test.ts` — the DI tree composes; grows as your language does',
|
|
1495
1836
|
'- `test/parsing.test.ts` / `linking.test.ts` / `validating.test.ts` — the three tiers a new language breaks first',
|
|
1496
1837
|
'- `test/serialization.test.ts` — each grammar round-trips through its serializer',
|
|
@@ -1543,6 +1884,21 @@ function readme(composition: InitComposition): string {
|
|
|
1543
1884
|
'atomic set and depends on a single physical copy, so a floating range can',
|
|
1544
1885
|
'silently resolve a second one.',
|
|
1545
1886
|
'',
|
|
1887
|
+
'`vscode-languageserver` is pinned to `10.1.2`, and',
|
|
1888
|
+
'`vscode-languageserver-protocol` and `vscode-jsonrpc` to the `3.18.4` and',
|
|
1889
|
+
'`9.0.3` it pins, so Langium, the LSP connection and the framework share one',
|
|
1890
|
+
'copy of each. With two, an error a Langium handler returns, such as a request',
|
|
1891
|
+
'for a document the server does not have, reaches the client as a result, and a',
|
|
1892
|
+
'framework error your own LSP handler throws loses its code.',
|
|
1893
|
+
'',
|
|
1894
|
+
...(pinsVitest(composition)
|
|
1895
|
+
? [
|
|
1896
|
+
'`vitest` is held below 4.1, whose peer set crashes the npm every Node 22',
|
|
1897
|
+
'release bundles. Raise it once every npm that installs this project is',
|
|
1898
|
+
'11.6 or later.',
|
|
1899
|
+
''
|
|
1900
|
+
]
|
|
1901
|
+
: []),
|
|
1546
1902
|
'## If your repo gates license headers',
|
|
1547
1903
|
'',
|
|
1548
1904
|
'The emitted `.ts` files carry no copyright header — the scaffold cannot know',
|
|
@@ -1562,184 +1918,39 @@ function readme(composition: InitComposition): string {
|
|
|
1562
1918
|
/**
|
|
1563
1919
|
* The GLSP files for ONE grammar's diagram.
|
|
1564
1920
|
*
|
|
1565
|
-
* Per grammar rather than per project because
|
|
1566
|
-
* grammar
|
|
1567
|
-
*
|
|
1568
|
-
* grammar with no diagram can sit on the same server.
|
|
1921
|
+
* Per grammar rather than per project because the state, storage and GModel
|
|
1922
|
+
* factory are typed over that grammar's model, and a grammar with no diagram
|
|
1923
|
+
* can sit on the same server.
|
|
1569
1924
|
*
|
|
1570
1925
|
* The index and the computed-bounds handler are framework classes used
|
|
1571
1926
|
* unmodified; storage and the submission handler are thin typed subclasses,
|
|
1572
1927
|
* which is where an adopter's own load/save and log formatting land.
|
|
1573
1928
|
*
|
|
1574
|
-
* The set is EDITABLE rather than read-only:
|
|
1575
|
-
* on the module. GLSP's own `DiagramModule` binds no handler that
|
|
1576
|
-
* source model, so without
|
|
1577
|
-
* client it may change nothing — which reads as a broken diagram
|
|
1578
|
-
* deliberate viewer.
|
|
1929
|
+
* The set is EDITABLE rather than read-only: a create-node and a delete handler,
|
|
1930
|
+
* registered on the module. GLSP's own `DiagramModule` binds no handler that
|
|
1931
|
+
* mutates a source model, so without them the head renders and answers while
|
|
1932
|
+
* telling the client it may change nothing — which reads as a broken diagram
|
|
1933
|
+
* rather than as a deliberate viewer.
|
|
1579
1934
|
*/
|
|
1580
1935
|
function glspFiles(composition: InitComposition, grammar: InitGrammarNames): InitFile[] {
|
|
1581
1936
|
const columns = columnsFor(composition);
|
|
1582
|
-
const
|
|
1583
|
-
const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
//
|
|
1588
|
-
// **Authoritative half of a client/server contract.** Every id here has to be
|
|
1589
|
-
// registered on the client too: sprotty's registries are exact-key maps with no
|
|
1590
|
-
// prefix fallback, so an id the client does not know yields an element with none
|
|
1591
|
-
// of a node's features, rendered by \`MissingView\` with only a
|
|
1592
|
-
// \`no registered view for type '…'\` console warning. Nothing fails server-side.
|
|
1593
|
-
//
|
|
1594
|
-
// The ids stay namespaced under the GLSP defaults (\`node:\` / \`edge:\`) so a
|
|
1595
|
-
// reader can tell a node id from an edge id at a glance.
|
|
1596
|
-
|
|
1597
|
-
import { DefaultTypes } from '@eclipse-glsp/server';
|
|
1598
|
-
|
|
1599
|
-
/**
|
|
1600
|
-
* The GLSP diagram type — the string GLSP routes every per-diagram-type request
|
|
1601
|
-
* by. Mirrored by the client; a mismatch silently DROPS the request rather than
|
|
1602
|
-
* reporting an unknown diagram type. Same value as the language id, so the two
|
|
1603
|
-
* cannot drift apart as a diagram gains element types.
|
|
1604
|
-
*/
|
|
1605
|
-
export const ${upper}_DIAGRAM_TYPE = '__LANGUAGE_ID__';
|
|
1606
|
-
|
|
1607
|
-
/** A \`__NODE_RULE__\` — the starter grammar's one named, referenceable node. */
|
|
1608
|
-
export const ${upper}_NODE_TYPE = \`\${DefaultTypes.NODE}:__GRAMMAR_ID__-node\`;
|
|
1609
|
-
|
|
1610
|
-
/** A resolved \`target\` reference, drawn as a connection between two nodes. */
|
|
1611
|
-
export const ${upper}_EDGE_TYPE = \`\${DefaultTypes.EDGE}:__GRAMMAR_ID__-target\`;
|
|
1612
|
-
`;
|
|
1613
|
-
|
|
1614
|
-
const state = `// GLSP state for the __GRAMMAR__ diagram.
|
|
1615
|
-
//
|
|
1616
|
-
// \`FullTextHydraniumGlspState\` is the simplest of the framework source-model
|
|
1617
|
-
// strategies (the others project a structured transfer model, over one document
|
|
1618
|
-
// or several): the source model is the whole document text, serialised through
|
|
1619
|
-
// the per-URI \`Serializer\` and round-tripped by re-parsing. Both seams resolve
|
|
1620
|
-
// through shared services, so narrowing the root type is all an adopter adds.
|
|
1621
|
-
//
|
|
1622
|
-
// It cannot field-merge — every concurrent edit on a whole-document model is a
|
|
1623
|
-
// same-document collision, so undo / redo degrade to drop-on-divergence. Move to
|
|
1624
|
-
// \`ReconcilingTransferHydraniumGlspState\` when you need field-level undo.
|
|
1625
|
-
|
|
1626
|
-
import { FullTextHydraniumGlspState } from '@hydranium/glsp-server';
|
|
1627
|
-
import { injectable } from 'inversify';
|
|
1628
|
-
import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
|
|
1629
|
-
|
|
1630
|
-
@injectable()
|
|
1631
|
-
export class __GRAMMAR__GlspState extends FullTextHydraniumGlspState<__ENTRY_RULE__> {}
|
|
1632
|
-
`;
|
|
1633
|
-
|
|
1634
|
-
const storage = `// Source-model storage for the __GRAMMAR__ diagram, inheriting both framework
|
|
1635
|
-
// defaults: \`loadSourceModel\` (open + settle + \`setSourceRoot\`) and
|
|
1636
|
-
// \`saveSourceModel\` (through \`ModelService.save\` → the per-URI \`Serializer\` →
|
|
1637
|
-
// the multi-client text store → \`WritableFileSystemProvider\`).
|
|
1638
|
-
//
|
|
1639
|
-
// The subclass exists so a bespoke load or save has a stable place to land.
|
|
1640
|
-
|
|
1641
|
-
import { type FullTextSourceModel, HydraniumGlspStorage } from '@hydranium/glsp-server';
|
|
1642
|
-
import { injectable } from 'inversify';
|
|
1643
|
-
import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
|
|
1644
|
-
|
|
1645
|
-
@injectable()
|
|
1646
|
-
export class __GRAMMAR__GlspStorage extends HydraniumGlspStorage<__ENTRY_RULE__, FullTextSourceModel> {}
|
|
1647
|
-
`;
|
|
1648
|
-
|
|
1649
|
-
const submission = `// Submission handler for the __GRAMMAR__ diagram.
|
|
1650
|
-
//
|
|
1651
|
-
// Inherits the framework's \`readyEvent = IntegrityService.SettledState\`, which
|
|
1652
|
-
// is load-bearing rather than incidental: the GModel factory resolves
|
|
1653
|
-
// \`target.ref\`, so it needs a fully-linked AST. Without the gate those reads
|
|
1654
|
-
// fire mid-build and warn about resolution before scopes are computed.
|
|
1655
|
-
//
|
|
1656
|
-
// Only \`formatSourceRoot\` is overridden, so the submit log names the model and
|
|
1657
|
-
// its node count instead of a bare \`$type\`.
|
|
1658
|
-
|
|
1659
|
-
import { ModelState } from '@eclipse-glsp/server';
|
|
1660
|
-
import { type FullTextSourceModel, HydraniumGlspSubmissionHandler } from '@hydranium/glsp-server';
|
|
1661
|
-
import { inject, injectable } from 'inversify';
|
|
1662
|
-
import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
|
|
1663
|
-
import type { __GRAMMAR__GlspState } from './state.js';
|
|
1664
|
-
|
|
1665
|
-
@injectable()
|
|
1666
|
-
export class __GRAMMAR__SubmissionHandler extends HydraniumGlspSubmissionHandler<__ENTRY_RULE__, FullTextSourceModel> {
|
|
1667
|
-
@inject(ModelState) declare protected modelState: __GRAMMAR__GlspState;
|
|
1668
|
-
|
|
1669
|
-
protected override formatSourceRoot(root: __ENTRY_RULE__ | undefined): string {
|
|
1670
|
-
return root ? \`__ENTRY_RULE__ nodes=\${root.nodes.length}\` : 'none';
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
`;
|
|
1674
|
-
|
|
1675
|
-
const factory = `// AST → GModel for the __GRAMMAR__ diagram: one node per \`__NODE_RULE__\`, one
|
|
1676
|
-
// edge per resolved \`target\` reference.
|
|
1677
|
-
//
|
|
1678
|
-
// Ids come from the index rather than being composed here, so the id strategy
|
|
1679
|
-
// stays in one place. Nodes are emitted before edges, because a GLSP edge
|
|
1680
|
-
// pointing at an id that does not exist fails client-side with a far less
|
|
1681
|
-
// obvious error than a missing edge. An unresolved reference is SKIPPED rather
|
|
1682
|
-
// than treated as an error: a dangling reference is ordinary editing state, and
|
|
1683
|
-
// the LSP head already reports it as a diagnostic.
|
|
1684
|
-
|
|
1685
|
-
import { DefaultTypes, GEdge, GGraph, GLabel, type GModelFactory, GNode, ModelState } from '@eclipse-glsp/server';
|
|
1686
|
-
import { inject, injectable } from 'inversify';
|
|
1687
|
-
import type { __ENTRY_RULE__ } from '../../language-server/ast.js';
|
|
1688
|
-
import { ${upper}_EDGE_TYPE, ${upper}_NODE_TYPE } from './types.js';
|
|
1689
|
-
import type { __GRAMMAR__GlspState } from './state.js';
|
|
1690
|
-
|
|
1691
|
-
@injectable()
|
|
1692
|
-
export class __GRAMMAR__GModelFactory implements GModelFactory {
|
|
1693
|
-
@inject(ModelState) protected readonly modelState!: __GRAMMAR__GlspState;
|
|
1694
|
-
|
|
1695
|
-
createModel(): void {
|
|
1696
|
-
const root = this.modelState.sourceRoot;
|
|
1697
|
-
const graph = GGraph.builder().id(this.modelState.sourceUri).build();
|
|
1698
|
-
if (root) {
|
|
1699
|
-
this.buildGraph(root, graph);
|
|
1700
|
-
}
|
|
1701
|
-
this.modelState.updateRoot(graph);
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
protected buildGraph(root: __ENTRY_RULE__, graph: GGraph): void {
|
|
1705
|
-
for (const node of root.nodes) {
|
|
1706
|
-
const id = this.modelState.index.createId(node);
|
|
1707
|
-
graph.children.push(
|
|
1708
|
-
GNode.builder()
|
|
1709
|
-
.id(id)
|
|
1710
|
-
.type(${upper}_NODE_TYPE)
|
|
1711
|
-
.add(GLabel.builder().id(\`\${id}_name\`).text(node.name).type(DefaultTypes.LABEL).build())
|
|
1712
|
-
.build()
|
|
1713
|
-
);
|
|
1714
|
-
}
|
|
1715
|
-
for (const node of root.nodes) {
|
|
1716
|
-
const target = node.target?.ref;
|
|
1717
|
-
if (!target) {
|
|
1718
|
-
continue;
|
|
1719
|
-
}
|
|
1720
|
-
const sourceId = this.modelState.index.createId(node);
|
|
1721
|
-
graph.children.push(
|
|
1722
|
-
GEdge.builder()
|
|
1723
|
-
.id(\`\${sourceId}_target\`)
|
|
1724
|
-
.type(${upper}_EDGE_TYPE)
|
|
1725
|
-
.sourceId(sourceId)
|
|
1726
|
-
.targetId(this.modelState.index.createId(target))
|
|
1727
|
-
.build()
|
|
1728
|
-
);
|
|
1729
|
-
}
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
`;
|
|
1937
|
+
const fixed = (file: string): InitFile => grammarTemplate(`server/src/glsp/__GRAMMAR_ID__/${file}`, composition, grammar);
|
|
1938
|
+
const computed = (file: string, text: string): InitFile => ({
|
|
1939
|
+
path: `server/src/glsp/${grammar.grammarId}/${file}`,
|
|
1940
|
+
content: render(text, composition, grammar)
|
|
1941
|
+
});
|
|
1733
1942
|
|
|
1734
1943
|
const configuration = `// Diagram configuration for the __GRAMMAR__ diagram.
|
|
1735
1944
|
//
|
|
1736
|
-
// **
|
|
1737
|
-
// __GRAMMAR__CreateNodeOperationHandler
|
|
1738
|
-
//
|
|
1739
|
-
//
|
|
1740
|
-
//
|
|
1741
|
-
//
|
|
1742
|
-
//
|
|
1945
|
+
// **A hint is \`true\` only where a handler backs it.** The starter
|
|
1946
|
+
// __GRAMMAR__CreateNodeOperationHandler offers creation through the tool palette,
|
|
1947
|
+
// which GLSP assembles from the create handlers' trigger actions — there is no
|
|
1948
|
+
// \`creatable\` hint. __GRAMMAR__DeleteOperationHandler backs \`deletable\` on a
|
|
1949
|
+
// node and an edge; a node of another document stays undeletable, since this
|
|
1950
|
+
// diagram does not own it. Nothing backs reparent / reposition / resize, so
|
|
1951
|
+
// declaring one would offer a gesture whose operation the server rejects: a worse
|
|
1952
|
+
// failure than the tool being absent. Turn a hint on in the same change that adds
|
|
1953
|
+
// its handler.
|
|
1743
1954
|
//
|
|
1744
1955
|
// \`needsClientLayout\` is \`true\` and \`layoutKind\` is \`NONE\`: the starter grammar
|
|
1745
1956
|
// persists no bounds, so the client measures and places everything. That is also
|
|
@@ -1754,7 +1965,7 @@ ${importList(
|
|
|
1754
1965
|
columns
|
|
1755
1966
|
)}
|
|
1756
1967
|
import { injectable } from 'inversify';
|
|
1757
|
-
import {
|
|
1968
|
+
import { __GRAMMAR_ID_CONST___EDGE_TYPE, __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE, __GRAMMAR_ID_CONST___NODE_TYPE } from '__PROTOCOL_PACKAGE__';
|
|
1758
1969
|
|
|
1759
1970
|
@injectable()
|
|
1760
1971
|
export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
|
|
@@ -1766,7 +1977,14 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
|
|
|
1766
1977
|
|
|
1767
1978
|
readonly shapeTypeHints: ShapeTypeHint[] = [
|
|
1768
1979
|
{
|
|
1769
|
-
elementTypeId:
|
|
1980
|
+
elementTypeId: __GRAMMAR_ID_CONST___NODE_TYPE,
|
|
1981
|
+
deletable: true,
|
|
1982
|
+
reparentable: false,
|
|
1983
|
+
repositionable: false,
|
|
1984
|
+
resizable: false
|
|
1985
|
+
},
|
|
1986
|
+
{
|
|
1987
|
+
elementTypeId: __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE,
|
|
1770
1988
|
deletable: false,
|
|
1771
1989
|
reparentable: false,
|
|
1772
1990
|
repositionable: false,
|
|
@@ -1776,125 +1994,41 @@ export class __GRAMMAR__DiagramConfiguration implements DiagramConfiguration {
|
|
|
1776
1994
|
|
|
1777
1995
|
readonly edgeTypeHints: EdgeTypeHint[] = [
|
|
1778
1996
|
{
|
|
1779
|
-
elementTypeId:
|
|
1780
|
-
deletable:
|
|
1997
|
+
elementTypeId: __GRAMMAR_ID_CONST___EDGE_TYPE,
|
|
1998
|
+
deletable: true,
|
|
1781
1999
|
repositionable: false,
|
|
1782
2000
|
routable: false,
|
|
1783
|
-
sourceElementTypeIds: [
|
|
1784
|
-
targetElementTypeIds: [
|
|
2001
|
+
sourceElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE],
|
|
2002
|
+
targetElementTypeIds: [__GRAMMAR_ID_CONST___NODE_TYPE, __GRAMMAR_ID_CONST___EXTERNAL_NODE_TYPE]
|
|
1785
2003
|
}
|
|
1786
2004
|
];
|
|
1787
2005
|
}
|
|
1788
|
-
`;
|
|
1789
|
-
|
|
1790
|
-
const createHandler = `// The starter operation handler for the __GRAMMAR__ diagram: the tool-palette
|
|
1791
|
-
// entry that creates a \`__NODE_RULE__\`.
|
|
1792
|
-
//
|
|
1793
|
-
// **Deleting this file and its \`configureOperationHandlers\` registration in
|
|
1794
|
-
// \`diagram-module.ts\` gives a read-only viewer**, and nothing else has to change
|
|
1795
|
-
// — creation is offered through the palette rather than through a type hint, so
|
|
1796
|
-
// every hint in \`diagram-configuration.ts\` is already \`false\`. The scaffold emits
|
|
1797
|
-
// the editable direction because that asymmetry runs one way: editable →
|
|
1798
|
-
// read-only is a deletion the compiler checks, while read-only → editable is
|
|
1799
|
-
// authoring against a seam you have not used yet.
|
|
1800
|
-
//
|
|
1801
|
-
// **It composes TEXT rather than mutating the AST.** The source model of
|
|
1802
|
-
// \`FullTextHydraniumGlspState\` is the document text, and reading it back through
|
|
1803
|
-
// \`state.sourceModel\` serialises the AST through the per-URI \`Serializer\`, which
|
|
1804
|
-
// this scaffold does not bind — so that getter throws until you do. Appending a
|
|
1805
|
-
// declaration to the text the parser last read needs no serializer, which is what
|
|
1806
|
-
// makes a scaffolded diagram editable on day one. Bind a \`Serializer\` at
|
|
1807
|
-
// \`services.serializer.Serializer\` and this becomes a
|
|
1808
|
-
// \`HydraniumGlspRecordingCommand\` over \`state.sourceModel\` instead — the same
|
|
1809
|
-
// binding the diagram's own save action needs.
|
|
1810
|
-
//
|
|
1811
|
-
// **The drop location is discarded.** \`needsClientLayout\` is \`true\` and the
|
|
1812
|
-
// starter grammar persists no bounds, so there is nowhere to put a coordinate: the
|
|
1813
|
-
// node is appended at the end of the document and the client places it.
|
|
1814
|
-
//
|
|
1815
|
-
// Like \`types.ts\`, \`gmodel-factory.ts\` and \`diagram-configuration.ts\`, this file
|
|
1816
|
-
// knows the starter grammar's concrete syntax — the \`node\` keyword below is that
|
|
1817
|
-
// grammar's. Replacing the grammar means replacing these four together.
|
|
1818
|
-
|
|
1819
|
-
import { type Command, type CreateNodeOperation, JsonCreateNodeOperationHandler, type MaybePromise } from '@eclipse-glsp/server';
|
|
1820
|
-
import { findNextUnique } from '@hydranium/protocol';
|
|
1821
|
-
import { injectable } from 'inversify';
|
|
1822
|
-
import { ${upper}_NODE_TYPE } from './types.js';
|
|
1823
|
-
import type { __GRAMMAR__GlspState } from './state.js';
|
|
1824
|
-
|
|
1825
|
-
/** Proposed name for a new node, uniquified against the ones the document already has. */
|
|
1826
|
-
const NODE_NAME_STEM = 'Node';
|
|
1827
|
-
|
|
1828
|
-
@injectable()
|
|
1829
|
-
export class __GRAMMAR__CreateNodeOperationHandler extends JsonCreateNodeOperationHandler {
|
|
1830
|
-
declare protected modelState: __GRAMMAR__GlspState;
|
|
1831
|
-
|
|
1832
|
-
/** The palette's word for the thing it creates, so a noun rather than an action. */
|
|
1833
|
-
override readonly label = '__NODE_RULE__';
|
|
1834
|
-
elementTypeIds = [${upper}_NODE_TYPE];
|
|
1835
|
-
|
|
1836
|
-
override createCommand(operation: CreateNodeOperation): MaybePromise<Command | undefined> {
|
|
1837
|
-
if (!this.elementTypeIds.includes(operation.elementTypeId)) {
|
|
1838
|
-
return undefined;
|
|
1839
|
-
}
|
|
1840
|
-
const state = this.modelState;
|
|
1841
|
-
const before = this.documentText();
|
|
1842
|
-
const after = this.withNode(
|
|
1843
|
-
before,
|
|
1844
|
-
findNextUnique(
|
|
1845
|
-
NODE_NAME_STEM,
|
|
1846
|
-
state.sourceRoot.nodes.map(node => node.name)
|
|
1847
|
-
)
|
|
1848
|
-
);
|
|
1849
|
-
// Whole-document undo, which is all a full-text source model can offer: it
|
|
1850
|
-
// has exactly one field, so there is nothing to merge a concurrent edit into.
|
|
1851
|
-
return {
|
|
1852
|
-
execute: () => state.updateSourceModel({ text: after }),
|
|
1853
|
-
undo: () => state.updateSourceModel({ text: before }),
|
|
1854
|
-
redo: () => state.updateSourceModel({ text: after })
|
|
1855
|
-
};
|
|
1856
|
-
}
|
|
1857
|
-
|
|
1858
|
-
/** The text the captured source root was parsed from — the baseline an edit appends to. */
|
|
1859
|
-
protected documentText(): string {
|
|
1860
|
-
return this.modelState.sourceRoot.$document?.textDocument.getText() ?? '';
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
/** \`text\` with one more node declaration, under exactly one trailing newline. */
|
|
1864
|
-
protected withNode(text: string, name: string): string {
|
|
1865
|
-
const body = text.trimEnd();
|
|
1866
|
-
const declaration = \`node \${name}\`;
|
|
1867
|
-
return body.length === 0 ? \`\${declaration}\\n\` : \`\${body}\\n\${declaration}\\n\`;
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
2006
|
`;
|
|
1871
2007
|
|
|
1872
2008
|
const module = `// GLSP diagram module for __GRAMMAR__ — the DI wiring of one diagram type.
|
|
1873
2009
|
//
|
|
1874
|
-
// \`declareLanguage\` is the multi-grammar seam: it binds the grammar this diagram
|
|
1875
|
-
// edits on the SESSION container, so \`modelState.diagramLanguage\` and the
|
|
1876
|
-
// per-language lookups resolve to __LANGUAGE_ID__. That is what stops one
|
|
1877
|
-
// diagram type from fighting over a process-wide binding when a project holds
|
|
1878
|
-
// several grammars — a grammar with no diagram is registered on the same server.
|
|
1879
|
-
//
|
|
1880
2010
|
// \`configureActionHandlers\` REBINDS rather than adds: GLSP's own
|
|
1881
2011
|
// \`DiagramModule\` already registers \`ComputedBoundsActionHandler\`, and two
|
|
1882
2012
|
// handlers for one action would both run.
|
|
1883
2013
|
//
|
|
1884
2014
|
// \`configureOperationHandlers\` ADDS: GLSP's default pair
|
|
1885
2015
|
// (\`CompoundOperationHandler\` and \`LayoutOperationHandler\`) mutates no source
|
|
1886
|
-
// model, so the starter create
|
|
2016
|
+
// model, so the starter create and delete handlers are what make this diagram
|
|
2017
|
+
// editable at all.
|
|
1887
2018
|
|
|
1888
2019
|
${importList(
|
|
1889
2020
|
[
|
|
1890
2021
|
'ActionHandlerConstructor',
|
|
1891
2022
|
'BindingTarget',
|
|
2023
|
+
'ComputedBoundsAction',
|
|
1892
2024
|
'DiagramConfiguration',
|
|
1893
2025
|
'GModelFactory',
|
|
1894
2026
|
'GModelIndex',
|
|
2027
|
+
'GModelRoot',
|
|
1895
2028
|
'InstanceMultiBinding',
|
|
1896
2029
|
'ModelState',
|
|
1897
2030
|
'ModelSubmissionHandler',
|
|
2031
|
+
'ModelValidator',
|
|
1898
2032
|
'OperationHandlerConstructor',
|
|
1899
2033
|
'SourceModelStorage'
|
|
1900
2034
|
],
|
|
@@ -1903,27 +2037,36 @@ ${importList(
|
|
|
1903
2037
|
true
|
|
1904
2038
|
)}
|
|
1905
2039
|
import { ComputedBoundsActionHandler } from '@eclipse-glsp/server';
|
|
1906
|
-
import type { LanguageMetaData } from '@hydranium/langium';
|
|
1907
2040
|
${importList(
|
|
1908
|
-
['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex'],
|
|
2041
|
+
['HydraniumGlspComputedBoundsActionHandler', 'AbstractHydraniumGlspDiagramModule', 'HydraniumGlspIndex', 'HydraniumGlspModelValidator'],
|
|
1909
2042
|
'@hydranium/glsp-server',
|
|
1910
2043
|
columns
|
|
1911
2044
|
)}
|
|
1912
|
-
import { __GRAMMAR__LanguageMetaData } from '../../language-server/generated/module.js';
|
|
1913
2045
|
import { __GRAMMAR__CreateNodeOperationHandler } from './create-node-operation-handler.js';
|
|
2046
|
+
import { __GRAMMAR__DeleteOperationHandler } from './delete-operation-handler.js';
|
|
1914
2047
|
import { __GRAMMAR__DiagramConfiguration } from './diagram-configuration.js';
|
|
1915
2048
|
import { __GRAMMAR__GModelFactory } from './gmodel-factory.js';
|
|
1916
2049
|
import { __GRAMMAR__GlspState } from './state.js';
|
|
1917
2050
|
import { __GRAMMAR__GlspStorage } from './storage.js';
|
|
1918
2051
|
import { __GRAMMAR__SubmissionHandler } from './submission-handler.js';
|
|
1919
|
-
import {
|
|
1920
|
-
|
|
1921
|
-
export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
|
|
1922
|
-
readonly diagramType = ${upper}_DIAGRAM_TYPE;
|
|
2052
|
+
import { __GRAMMAR_ID_CONST___DIAGRAM_TYPE } from '__PROTOCOL_PACKAGE__';
|
|
2053
|
+
import { injectable } from 'inversify';
|
|
1923
2054
|
|
|
1924
|
-
|
|
1925
|
-
|
|
2055
|
+
/**
|
|
2056
|
+
* The framework handler, keeping none of the edge routes the client reports:
|
|
2057
|
+
* it routes an edge before it sizes the nodes, so a self-reference's loop would
|
|
2058
|
+
* stay drawn inside its node. The starter's edges have no routing points, and
|
|
2059
|
+
* the client routes them afresh from the sized nodes.
|
|
2060
|
+
*/
|
|
2061
|
+
@injectable()
|
|
2062
|
+
export class __GRAMMAR__ComputedBoundsActionHandler extends HydraniumGlspComputedBoundsActionHandler {
|
|
2063
|
+
protected override applyBounds(root: GModelRoot, action: ComputedBoundsAction): void {
|
|
2064
|
+
super.applyBounds(root, { ...action, routes: undefined });
|
|
1926
2065
|
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule {
|
|
2069
|
+
readonly diagramType = __GRAMMAR_ID_CONST___DIAGRAM_TYPE;
|
|
1927
2070
|
|
|
1928
2071
|
protected override bindModelState(): BindingTarget<ModelState> {
|
|
1929
2072
|
return { service: __GRAMMAR__GlspState };
|
|
@@ -1950,81 +2093,107 @@ export class __GRAMMAR__DiagramModule extends AbstractHydraniumGlspDiagramModule
|
|
|
1950
2093
|
return { service: HydraniumGlspIndex };
|
|
1951
2094
|
}
|
|
1952
2095
|
|
|
2096
|
+
/**
|
|
2097
|
+
* The language's diagnostics as diagram markers, following each edit. GLSP
|
|
2098
|
+
* binds no validator by default, and without one no marker ever shows.
|
|
2099
|
+
*/
|
|
2100
|
+
protected override bindModelValidator(): BindingTarget<ModelValidator> {
|
|
2101
|
+
return HydraniumGlspModelValidator;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1953
2104
|
protected override configureActionHandlers(binding: InstanceMultiBinding<ActionHandlerConstructor>): void {
|
|
1954
2105
|
super.configureActionHandlers(binding);
|
|
1955
|
-
binding.rebind(ComputedBoundsActionHandler,
|
|
2106
|
+
binding.rebind(ComputedBoundsActionHandler, __GRAMMAR__ComputedBoundsActionHandler);
|
|
1956
2107
|
}
|
|
1957
2108
|
|
|
1958
2109
|
protected override configureOperationHandlers(binding: InstanceMultiBinding<OperationHandlerConstructor>): void {
|
|
1959
2110
|
super.configureOperationHandlers(binding);
|
|
1960
2111
|
binding.add(__GRAMMAR__CreateNodeOperationHandler);
|
|
2112
|
+
binding.add(__GRAMMAR__DeleteOperationHandler);
|
|
1961
2113
|
}
|
|
1962
2114
|
}
|
|
1963
2115
|
`;
|
|
1964
2116
|
|
|
1965
2117
|
return [
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2118
|
+
fixed('state.ts'),
|
|
2119
|
+
fixed('storage.ts'),
|
|
2120
|
+
fixed('submission-handler.ts'),
|
|
2121
|
+
fixed('gmodel-factory.ts'),
|
|
2122
|
+
computed('diagram-configuration.ts', configuration),
|
|
2123
|
+
fixed('create-node-operation-handler.ts'),
|
|
2124
|
+
fixed('delete-operation-handler.ts'),
|
|
2125
|
+
computed('diagram-module.ts', module),
|
|
2126
|
+
grammarTemplate('server/test/__GRAMMAR_ID__-diagram.test.ts', composition, grammar)
|
|
1974
2127
|
];
|
|
1975
2128
|
}
|
|
1976
2129
|
|
|
1977
2130
|
/**
|
|
1978
|
-
*
|
|
2131
|
+
* The server member: the grammar, the language and its heads.
|
|
1979
2132
|
*
|
|
1980
2133
|
* Grammar-count-dependent emissions live here rather than in a template string:
|
|
1981
2134
|
* one `.langium` file per grammar, and the shared `common.langium` fragment only
|
|
1982
2135
|
* when there is more than one grammar to share it.
|
|
1983
2136
|
*/
|
|
1984
|
-
|
|
2137
|
+
function serverFiles(composition: InitComposition): InitFile[] {
|
|
1985
2138
|
const multi = composition.grammars.length > 1;
|
|
1986
2139
|
const grammarFiles: InitFile[] = composition.grammars.map(grammar => ({
|
|
1987
|
-
path: `src/grammar/${grammar.grammarId}.langium`,
|
|
2140
|
+
path: `server/src/grammar/${grammar.grammarId}.langium`,
|
|
1988
2141
|
content: render(grammarFile(multi), composition, grammar)
|
|
1989
2142
|
}));
|
|
2143
|
+
// The shared lexical fragment, only for several grammars: each declaring its
|
|
2144
|
+
// own `WS` / `ID` lets the token sets drift apart.
|
|
1990
2145
|
if (multi) {
|
|
1991
|
-
grammarFiles.unshift(
|
|
2146
|
+
grammarFiles.unshift(projectTemplate('server/src/grammar/common.langium', composition));
|
|
1992
2147
|
}
|
|
1993
2148
|
|
|
1994
2149
|
const diagramFiles = composition.grammars.filter(grammar => grammar.diagram).flatMap(grammar => glspFiles(composition, grammar));
|
|
1995
2150
|
|
|
1996
2151
|
return [
|
|
1997
|
-
{ path: 'package.json', content: packageJson(composition) },
|
|
1998
|
-
{ path: 'langium-config.json', content: langiumConfig(composition) },
|
|
1999
|
-
{ path: 'tsconfig.json', content:
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2152
|
+
{ path: 'server/package.json', content: packageJson(composition) },
|
|
2153
|
+
{ path: 'server/langium-config.json', content: langiumConfig(composition) },
|
|
2154
|
+
{ path: 'server/tsconfig.json', content: serverTsconfigJson(composition) },
|
|
2155
|
+
// `isolatedModules` is what makes this check agree with the transform that
|
|
2156
|
+
// actually runs the tests. `npm test` is `typecheck:test && vitest run`, and
|
|
2157
|
+
// vitest compiles each file on its own — a transform with no type
|
|
2158
|
+
// information, which cannot tell a re-exported TYPE from a re-exported value.
|
|
2159
|
+
// Without the flag `tsc` accepts `export { SomeType } from './x'` that the
|
|
2160
|
+
// transform then emits as a real import of a symbol that does not exist at
|
|
2161
|
+
// runtime, so the typecheck passes and the suite fails with a confusing
|
|
2162
|
+
// missing-export.
|
|
2163
|
+
projectTemplate('server/tsconfig.test.json', composition),
|
|
2164
|
+
// Found by name, unlike `tsconfig.test.json`. Under vite 8, vitest's transform
|
|
2165
|
+
// applies a tsconfig only to the files its own `include` lists, so without this
|
|
2166
|
+
// a test declaring a decorated class fails to load.
|
|
2167
|
+
projectTemplate('server/test/tsconfig.json', composition),
|
|
2168
|
+
projectTemplate('server/vitest.config.ts', composition),
|
|
2169
|
+
// Written for a workspace member too: its root may not ignore `lib/` (a `yo
|
|
2170
|
+
// langium` root ignores `out/`), and a rule the root repeats costs nothing.
|
|
2171
|
+
ignoreFile('server'),
|
|
2172
|
+
{ path: 'server/README.md', content: readme(composition) },
|
|
2007
2173
|
...grammarFiles,
|
|
2008
|
-
{ path: `src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2174
|
+
{ path: `server/src/language-server/${composition.projectId}-module.ts`, content: moduleFile(composition) },
|
|
2175
|
+
grammarTemplate('server/src/language-server/ast.ts', composition, composition.grammars[0]),
|
|
2176
|
+
// Per grammar, because `services.serializer.Serializer` is a per-language slot
|
|
2177
|
+
// and a serializer is grammar-shaped by definition. That is also why the
|
|
2178
|
+
// framework refuses to default it: its `UnboundSerializer` throws, naming the
|
|
2179
|
+
// binding to add, rather than guessing a syntax.
|
|
2180
|
+
...composition.grammars.flatMap(grammar => [
|
|
2181
|
+
grammarTemplate('server/src/language-server/__GRAMMAR_ID__-serializer.ts', composition, grammar),
|
|
2182
|
+
grammarTemplate('server/src/language-server/__GRAMMAR_ID__-validation.ts', composition, grammar)
|
|
2183
|
+
]),
|
|
2184
|
+
{ path: 'server/src/index.ts', content: indexFile(composition) },
|
|
2185
|
+
projectTemplate('server/src/node/services.ts', composition),
|
|
2186
|
+
{ path: 'server/src/main.ts', content: mainFile(composition) },
|
|
2187
|
+
...(composition.heads.includes('data') ? [{ path: 'server/src/data-server-main.ts', content: dataServerMainFile(composition) }] : []),
|
|
2019
2188
|
...diagramFiles,
|
|
2020
|
-
{ path: 'test/services.test.ts', content: servicesTest(composition) },
|
|
2021
|
-
{ path: 'test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
|
|
2189
|
+
{ path: 'server/test/services.test.ts', content: servicesTest(composition) },
|
|
2190
|
+
{ path: 'server/test/parsing.test.ts', content: grammarTest(composition, PARSING_HEADER, [], PARSING_SUITE) },
|
|
2022
2191
|
{
|
|
2023
|
-
path: 'test/linking.test.ts',
|
|
2192
|
+
path: 'server/test/linking.test.ts',
|
|
2024
2193
|
content: grammarTest(composition, LINKING_HEADER, ["import { AstUtils } from '@hydranium/langium';"], LINKING_SUITE)
|
|
2025
2194
|
},
|
|
2026
2195
|
{
|
|
2027
|
-
path: 'test/validating.test.ts',
|
|
2196
|
+
path: 'server/test/validating.test.ts',
|
|
2028
2197
|
content: grammarTest(
|
|
2029
2198
|
composition,
|
|
2030
2199
|
VALIDATING_HEADER,
|
|
@@ -2032,6 +2201,2260 @@ export function buildInitTemplates(composition: InitComposition): InitFile[] {
|
|
|
2032
2201
|
VALIDATING_SUITE
|
|
2033
2202
|
)
|
|
2034
2203
|
},
|
|
2035
|
-
{ path: 'test/serialization.test.ts', content: serializationTest(composition) }
|
|
2204
|
+
{ path: 'server/test/serialization.test.ts', content: serializationTest(composition) }
|
|
2205
|
+
];
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* The protocol member's `package.json`.
|
|
2210
|
+
*
|
|
2211
|
+
* Its `build` runs the server's `generate` first, because the server writes
|
|
2212
|
+
* this package's `generated-hydranium/` modules from the grammar: a runner that
|
|
2213
|
+
* builds in dependency order reaches this package before the server, and would
|
|
2214
|
+
* otherwise compile modules that do not exist yet. A full build therefore
|
|
2215
|
+
* generates twice; the second pass rewrites nothing.
|
|
2216
|
+
*/
|
|
2217
|
+
function protocolPackageJson(composition: InitComposition): string {
|
|
2218
|
+
const member = forMember(composition, 'protocol');
|
|
2219
|
+
const dependencies: Record<string, string> = {};
|
|
2220
|
+
for (const head of composition.heads) {
|
|
2221
|
+
Object.assign(dependencies, PROTOCOL_THIRD_PARTY_DEPENDENCIES[head]);
|
|
2222
|
+
}
|
|
2223
|
+
const dependencyLines =
|
|
2224
|
+
Object.keys(dependencies).length === 0 ? '' : ` "dependencies": {\n${sortedBlock(dependencies, ' ')}\n },\n`;
|
|
2225
|
+
return `{
|
|
2226
|
+
"name": "${memberPackageName(composition, 'protocol')}",
|
|
2227
|
+
"version": "0.0.0",
|
|
2228
|
+
${composition.packaging.private ? ' "private": true,\n' : ''} "description": "What the ${composition.name} language server and its clients share: language ids, wire types and diagram ids.",
|
|
2229
|
+
"license": "UNLICENSED",
|
|
2230
|
+
"author": "",
|
|
2231
|
+
"files": [
|
|
2232
|
+
"lib",
|
|
2233
|
+
"src"
|
|
2234
|
+
],
|
|
2235
|
+
"type": "module",
|
|
2236
|
+
"main": "lib/index.js",
|
|
2237
|
+
"types": "lib/index.d.ts",
|
|
2238
|
+
"scripts": {
|
|
2239
|
+
"build": "npm --prefix ../server run generate && tsc -b",
|
|
2240
|
+
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
2241
|
+
${lintScript(member, 'src')} "watch": "tsc -b --watch"
|
|
2242
|
+
},
|
|
2243
|
+
${dependencyLines} "devDependencies": {
|
|
2244
|
+
"rimraf": "^5.0.0",
|
|
2245
|
+
"typescript": "^7.0.2"
|
|
2246
|
+
},
|
|
2247
|
+
"engines": {
|
|
2248
|
+
"node": ">=22.13"
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
`;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
/** The protocol member's root entry, one re-export per module. */
|
|
2255
|
+
function protocolIndexFile(composition: InitComposition): string {
|
|
2256
|
+
const modules = [
|
|
2257
|
+
'./generated-hydranium/languages.js',
|
|
2258
|
+
'./generated-hydranium/transfer-model.js',
|
|
2259
|
+
...(headPortConstants(composition).length ? ['./head-ports.js'] : []),
|
|
2260
|
+
...composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-types.js`)
|
|
2261
|
+
];
|
|
2262
|
+
const text = `// What the __NAME__ server and its clients both import. A client depends on
|
|
2263
|
+
// this package instead of the server, so it never installs Langium to reach a
|
|
2264
|
+
// constant or a wire type.
|
|
2265
|
+
//
|
|
2266
|
+
// The server's \`generate\` writes \`generated-hydranium/\` from the grammar;
|
|
2267
|
+
// commit those modules with the grammar change that produced them. An interface
|
|
2268
|
+
// narrowed here hides the generated constant of the same name; re-export that
|
|
2269
|
+
// constant beside it, and declare the type map under its own name with the
|
|
2270
|
+
// narrowed entry, or the builder and the GLSP state keep the generated one.
|
|
2271
|
+
|
|
2272
|
+
${modules.map(module => `export * from '${module}';`).join('\n')}
|
|
2273
|
+
`;
|
|
2274
|
+
return project(text, composition);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
/** The protocol member's README. */
|
|
2278
|
+
function protocolReadme(composition: InitComposition): string {
|
|
2279
|
+
const lines = [
|
|
2280
|
+
`# ${memberPackageName(composition, 'protocol')}`,
|
|
2281
|
+
'',
|
|
2282
|
+
'What the __NAME__ language server and its clients both import, so a client',
|
|
2283
|
+
'never depends on the server or installs Langium.',
|
|
2284
|
+
'',
|
|
2285
|
+
'- `src/generated-hydranium/languages.ts` — each language id and its file extensions',
|
|
2286
|
+
'- `src/generated-hydranium/transfer-model.ts` — the wire types of the model, references as plain strings',
|
|
2287
|
+
...(headPortConstants(composition).length === 0
|
|
2288
|
+
? []
|
|
2289
|
+
: composition.heads.includes('lsp')
|
|
2290
|
+
? ['- `src/head-ports.ts` — the LSP requests a host sends to find the socket heads']
|
|
2291
|
+
: ["- `src/head-ports.ts` — the environment variables naming the socket heads' ports"]),
|
|
2292
|
+
...composition.grammars
|
|
2293
|
+
.filter(grammar => grammar.diagram)
|
|
2294
|
+
.map(grammar => `- \`src/${grammar.grammarId}-diagram-types.ts\` — the \`${grammar.grammar}\` diagram's type ids`),
|
|
2295
|
+
'',
|
|
2296
|
+
"The server's `npm run generate` writes `src/generated-hydranium/` from the",
|
|
2297
|
+
'grammar, and `npm run build` here runs it first. Commit the generated',
|
|
2298
|
+
'modules with the grammar change that produced them.',
|
|
2299
|
+
''
|
|
2300
|
+
];
|
|
2301
|
+
return project(lines.join('\n'), composition);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** The protocol member: everything the server and its clients both import. */
|
|
2305
|
+
function protocolFiles(composition: InitComposition): InitFile[] {
|
|
2306
|
+
return [
|
|
2307
|
+
{ path: 'protocol/package.json', content: protocolPackageJson(composition) },
|
|
2308
|
+
{ path: 'protocol/tsconfig.json', content: tsconfigJson(forMember(composition, 'protocol'), PROTOCOL_COMPILER_OPTIONS, '["src"]') },
|
|
2309
|
+
ignoreFile('protocol'),
|
|
2310
|
+
{ path: 'protocol/README.md', content: protocolReadme(composition) },
|
|
2311
|
+
{ path: 'protocol/src/index.ts', content: protocolIndexFile(composition) },
|
|
2312
|
+
...(headPortConstants(composition).length ? [{ path: 'protocol/src/head-ports.ts', content: headPortsFile(composition) }] : []),
|
|
2313
|
+
...composition.grammars
|
|
2314
|
+
.filter(grammar => grammar.diagram)
|
|
2315
|
+
.map(grammar => grammarTemplate('protocol/src/__GRAMMAR_ID__-diagram-types.ts', composition, grammar))
|
|
2316
|
+
];
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* The starter model files every host and the CLI open, two per language: one
|
|
2321
|
+
* declaring nodes, one referencing a node in the other file.
|
|
2322
|
+
*
|
|
2323
|
+
* Named by extension alone, since `init` refuses two grammars sharing one.
|
|
2324
|
+
*/
|
|
2325
|
+
function workspaceFiles(composition: InitComposition): InitFile[] {
|
|
2326
|
+
return composition.grammars.flatMap(grammar => [
|
|
2327
|
+
grammarTemplate('workspace/nodes.__EXTENSION__', composition, grammar),
|
|
2328
|
+
grammarTemplate('workspace/references.__EXTENSION__', composition, grammar)
|
|
2329
|
+
]);
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
/** The client member's compiler options: the protocol's, with the DOM its diagram renders into. */
|
|
2333
|
+
const CLIENT_COMPILER_OPTIONS: ReadonlyArray<readonly [string, JsonValue]> = [
|
|
2334
|
+
['target', 'ES2022'],
|
|
2335
|
+
['lib', ['ES2022', 'DOM']],
|
|
2336
|
+
['module', 'NodeNext'],
|
|
2337
|
+
['moduleResolution', 'NodeNext'],
|
|
2338
|
+
['rootDir', 'src'],
|
|
2339
|
+
['outDir', 'lib'],
|
|
2340
|
+
['strict', true],
|
|
2341
|
+
['skipLibCheck', true],
|
|
2342
|
+
['declaration', true],
|
|
2343
|
+
// For inversify's `@injectable`, which a GLSP view needs to be bound.
|
|
2344
|
+
['experimentalDecorators', true],
|
|
2345
|
+
['types', []],
|
|
2346
|
+
// A base that sets it fails every `import` in this CommonJS member.
|
|
2347
|
+
['verbatimModuleSyntax', false]
|
|
2348
|
+
];
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* The client member's `package.json`.
|
|
2352
|
+
*
|
|
2353
|
+
* CommonJS, unlike its siblings, as GLSP's client is: inversify ships an ES
|
|
2354
|
+
* build and a CommonJS one, so an ES module here would load the first while
|
|
2355
|
+
* GLSP's client loads the second, giving a page two containers' worth of
|
|
2356
|
+
* classes. Node from 22.12 and `tsc` under `nodenext` both let it require the
|
|
2357
|
+
* ES protocol member.
|
|
2358
|
+
*/
|
|
2359
|
+
function clientPackageJson(composition: InitComposition): string {
|
|
2360
|
+
const dependencies: Record<string, string> = {
|
|
2361
|
+
[memberPackageName(composition, 'protocol')]: MEMBER_PIN,
|
|
2362
|
+
...CLIENT_THIRD_PARTY_DEPENDENCIES
|
|
2363
|
+
};
|
|
2364
|
+
for (const framework of CLIENT_FRAMEWORK_DEPENDENCIES) {
|
|
2365
|
+
dependencies[framework] = frameworkPin(composition);
|
|
2366
|
+
}
|
|
2367
|
+
return `{
|
|
2368
|
+
"name": "${memberPackageName(composition, 'client')}",
|
|
2369
|
+
"version": "0.0.0",
|
|
2370
|
+
${composition.packaging.private ? ' "private": true,\n' : ''} "description": "The host-neutral client side of the ${composition.name} language: what every host of it mounts.",
|
|
2371
|
+
"license": "UNLICENSED",
|
|
2372
|
+
"author": "",
|
|
2373
|
+
"files": [
|
|
2374
|
+
"lib",
|
|
2375
|
+
"src"
|
|
2376
|
+
],
|
|
2377
|
+
"main": "lib/index.js",
|
|
2378
|
+
"types": "lib/index.d.ts",
|
|
2379
|
+
"scripts": {
|
|
2380
|
+
"build": "tsc -b",
|
|
2381
|
+
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
2382
|
+
${lintScript(forMember(composition, 'client'), 'src')} "watch": "tsc --watch"
|
|
2383
|
+
},
|
|
2384
|
+
"dependencies": {
|
|
2385
|
+
${sortedBlock(dependencies, ' ')}
|
|
2386
|
+
},
|
|
2387
|
+
"devDependencies": {
|
|
2388
|
+
"rimraf": "^5.0.0",
|
|
2389
|
+
"typescript": "^7.0.2"
|
|
2390
|
+
},
|
|
2391
|
+
"engines": {
|
|
2392
|
+
"node": ">=22.13"
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
`;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/** The client member's README. */
|
|
2399
|
+
function clientReadme(composition: InitComposition): string {
|
|
2400
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
2401
|
+
const lines = [
|
|
2402
|
+
`# ${memberPackageName(composition, 'client')}`,
|
|
2403
|
+
'',
|
|
2404
|
+
'The host-neutral client side of the __NAME__ language: what every host of it',
|
|
2405
|
+
'mounts, whatever that host is. It reads the element ids from the protocol',
|
|
2406
|
+
'package and never depends on the server.',
|
|
2407
|
+
'',
|
|
2408
|
+
...diagrams.map(
|
|
2409
|
+
grammar =>
|
|
2410
|
+
`- \`src/${grammar.grammarId}-diagram-module.ts\` — the \`${grammar.grammar}\` diagram: a registration per element type, its container module, and \`initialize${grammar.grammar}DiagramContainer\``
|
|
2411
|
+
),
|
|
2412
|
+
'- `src/index.ts` — the package root, re-exporting each module',
|
|
2413
|
+
'',
|
|
2414
|
+
'A host builds a diagram container with the initializer, passing its own',
|
|
2415
|
+
'modules: they go first and the diagram definition last, since GLSP gives an',
|
|
2416
|
+
'element type to whichever module registered it last.',
|
|
2417
|
+
''
|
|
2418
|
+
];
|
|
2419
|
+
return project(lines.join('\n'), composition);
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** The client member's root entry, one re-export per diagram module. */
|
|
2423
|
+
function clientIndexFile(composition: InitComposition): string {
|
|
2424
|
+
const modules = composition.grammars.filter(grammar => grammar.diagram).map(grammar => `./${grammar.grammarId}-diagram-module.js`);
|
|
2425
|
+
return `// What every host of the ${composition.name} language mounts.
|
|
2426
|
+
|
|
2427
|
+
${modules.map(module => `export * from '${module}';`).join('\n')}
|
|
2428
|
+
`;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
/**
|
|
2432
|
+
* The client member: the host-neutral client side of the language, emitted
|
|
2433
|
+
* when there is a diagram to define. Each host mounts it rather than defining
|
|
2434
|
+
* the diagram again.
|
|
2435
|
+
*/
|
|
2436
|
+
function clientFiles(composition: InitComposition): InitFile[] {
|
|
2437
|
+
return [
|
|
2438
|
+
{ path: 'client/package.json', content: clientPackageJson(composition) },
|
|
2439
|
+
{
|
|
2440
|
+
path: 'client/tsconfig.json',
|
|
2441
|
+
content: tsconfigJson(forMember(composition, 'client'), CLIENT_COMPILER_OPTIONS, '["src"]', '[{ "path": "../protocol" }]')
|
|
2442
|
+
},
|
|
2443
|
+
ignoreFile('client'),
|
|
2444
|
+
{ path: 'client/README.md', content: clientReadme(composition) },
|
|
2445
|
+
{ path: 'client/src/index.ts', content: clientIndexFile(composition) },
|
|
2446
|
+
...composition.grammars
|
|
2447
|
+
.filter(grammar => grammar.diagram)
|
|
2448
|
+
.map(grammar => grammarTemplate('client/src/__GRAMMAR_ID__-diagram-module.ts', composition, grammar))
|
|
2449
|
+
];
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* The browser host's `package.json`. Always private: it is an application,
|
|
2454
|
+
* which nothing installs as a dependency.
|
|
2455
|
+
*/
|
|
2456
|
+
function hostBrowserPackageJson(composition: InitComposition): string {
|
|
2457
|
+
const dependencies: Record<string, string> = {
|
|
2458
|
+
[memberPackageName(composition, 'protocol')]: MEMBER_PIN,
|
|
2459
|
+
[memberPackageName(composition, 'server')]: MEMBER_PIN,
|
|
2460
|
+
...(composition.heads.includes('glsp') ? { [memberPackageName(composition, 'client')]: MEMBER_PIN } : {}),
|
|
2461
|
+
// A page with no editor saves through the diagram's own action dispatcher.
|
|
2462
|
+
...(composition.heads.includes('lsp') ? {} : { '@eclipse-glsp/client': CLIENT_THIRD_PARTY_DEPENDENCIES['@eclipse-glsp/client'] })
|
|
2463
|
+
};
|
|
2464
|
+
for (const head of ['base', ...composition.heads] as const) {
|
|
2465
|
+
Object.assign(dependencies, HOST_BROWSER_THIRD_PARTY_DEPENDENCIES[head]);
|
|
2466
|
+
for (const framework of HOST_BROWSER_FRAMEWORK_DEPENDENCIES[head]) {
|
|
2467
|
+
dependencies[framework] = frameworkPin(composition);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return `{
|
|
2471
|
+
"name": "${memberPackageName(composition, 'host-browser')}",
|
|
2472
|
+
"version": "0.0.0",
|
|
2473
|
+
"private": true,
|
|
2474
|
+
"description": "${composition.name} in a browser page, with every head in a web worker and no backend.",
|
|
2475
|
+
"license": "UNLICENSED",
|
|
2476
|
+
"author": "",
|
|
2477
|
+
"type": "module",
|
|
2478
|
+
"scripts": {
|
|
2479
|
+
"build": "npm run typecheck && node esbuild.mjs",
|
|
2480
|
+
"clean": "rimraf out test-results playwright-report",
|
|
2481
|
+
${lintScript(forMember(composition, 'host-browser'), 'src test')} "start": "node esbuild.mjs --serve",
|
|
2482
|
+
"test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
|
|
2483
|
+
"test:e2e:install": "playwright install chromium",
|
|
2484
|
+
"typecheck": "tsc --noEmit -p tsconfig.worker.json && tsc --noEmit -p tsconfig.page.json && tsc --noEmit -p tsconfig.e2e.json"
|
|
2485
|
+
},
|
|
2486
|
+
"dependencies": {
|
|
2487
|
+
${sortedBlock(dependencies, ' ')}
|
|
2488
|
+
},
|
|
2489
|
+
"devDependencies": {
|
|
2490
|
+
${sortedBlock(HOST_BROWSER_DEV_DEPENDENCIES, ' ')}
|
|
2491
|
+
},
|
|
2492
|
+
"engines": {
|
|
2493
|
+
"node": ">=22.13"
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
`;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** The workspace URI of a grammar's first starter file, as a template literal over `WORKSPACE_ROOT_URI`. */
|
|
2500
|
+
function starterUri(grammar: InitGrammarNames): string {
|
|
2501
|
+
return `\`\${WORKSPACE_ROOT_URI}/nodes.${grammar.extensions[0]}\``;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
/**
|
|
2505
|
+
* The worker hosting the heads: every head over the one store, with the
|
|
2506
|
+
* workspace seeded from `../workspace` and saved files kept in IndexedDB.
|
|
2507
|
+
*/
|
|
2508
|
+
function hostWorkerFile(composition: InitComposition): string {
|
|
2509
|
+
const columns = columnsFor(composition);
|
|
2510
|
+
const lsp = composition.heads.includes('lsp');
|
|
2511
|
+
const data = composition.heads.includes('data');
|
|
2512
|
+
const glsp = composition.heads.includes('glsp');
|
|
2513
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
2514
|
+
const roots = composition.grammars.map(grammar => grammar.entryRule).sort();
|
|
2515
|
+
const serverSymbols = [`create${composition.name}Services`, ...diagrams.map(grammar => `${grammar.grammar}DiagramModule`)].sort(
|
|
2516
|
+
(left, right) => left.localeCompare(right)
|
|
2517
|
+
);
|
|
2518
|
+
|
|
2519
|
+
const imports = [
|
|
2520
|
+
...(glsp
|
|
2521
|
+
? [
|
|
2522
|
+
'// First, before any class carrying a decorator loads: GLSP reads the metadata',
|
|
2523
|
+
'// its decorators emit as each class is defined.',
|
|
2524
|
+
"import 'reflect-metadata';",
|
|
2525
|
+
"import { ServerModule } from '@eclipse-glsp/server/browser.js';"
|
|
2526
|
+
]
|
|
2527
|
+
: []),
|
|
2528
|
+
"import { persistentFileSystem } from '@hydranium/core';",
|
|
2529
|
+
importList(
|
|
2530
|
+
['hostHeadsInWorker', 'IndexedDbFileSystemStore', ...(lsp ? ['startLanguageServerInWorker'] : [])],
|
|
2531
|
+
'@hydranium/core/browser',
|
|
2532
|
+
columns
|
|
2533
|
+
),
|
|
2534
|
+
...(data
|
|
2535
|
+
? [
|
|
2536
|
+
"import { DataServer } from '@hydranium/data-server';",
|
|
2537
|
+
"import { startDataServerInWorker } from '@hydranium/data-server/browser';"
|
|
2538
|
+
]
|
|
2539
|
+
: []),
|
|
2540
|
+
...(glsp
|
|
2541
|
+
? [
|
|
2542
|
+
"import { GlspClientLogger, HydraniumGlspAppModule } from '@hydranium/glsp-server';",
|
|
2543
|
+
"import { startGlspServerInWorker } from '@hydranium/glsp-server/browser';"
|
|
2544
|
+
]
|
|
2545
|
+
: []),
|
|
2546
|
+
...(data ? [importList(roots, memberPackageName(composition, 'protocol'), columns, true)] : []),
|
|
2547
|
+
importList(serverSymbols, memberPackageName(composition, 'server'), columns),
|
|
2548
|
+
"import { WORKSPACE_ROOT_URI } from './workspace.js';"
|
|
2549
|
+
];
|
|
2550
|
+
|
|
2551
|
+
const services = `const shared = create${composition.name}Services({ ...fileSystem }).shared;`;
|
|
2552
|
+
const lspCall = `startLanguageServerInWorker(ports.lsp, connection => create${composition.name}Services({ connection, ...fileSystem }).shared);`;
|
|
2553
|
+
const startServices = !lsp
|
|
2554
|
+
? services
|
|
2555
|
+
: ` const shared = ${lspCall}`.length <= columns
|
|
2556
|
+
? `const shared = ${lspCall}`
|
|
2557
|
+
: `const shared = startLanguageServerInWorker(
|
|
2558
|
+
ports.lsp,
|
|
2559
|
+
connection => create${composition.name}Services({ connection, ...fileSystem }).shared
|
|
2560
|
+
);`;
|
|
2561
|
+
const dataServer = `connection => new DataServer<${roots.join(' | ')}>(connection, shared)`;
|
|
2562
|
+
const dataCall = `startDataServerInWorker(ports.data, shared, ${dataServer});`;
|
|
2563
|
+
const dataHead = data
|
|
2564
|
+
? `
|
|
2565
|
+
// The data head serves from the ${lsp ? "LSP head's" : "services'"} store, on its own port.
|
|
2566
|
+
${
|
|
2567
|
+
` ${dataCall}`.length <= columns
|
|
2568
|
+
? dataCall
|
|
2569
|
+
: `startDataServerInWorker(
|
|
2570
|
+
ports.data,
|
|
2571
|
+
shared,
|
|
2572
|
+
${dataServer}
|
|
2573
|
+
);`
|
|
2574
|
+
}`
|
|
2575
|
+
: '';
|
|
2576
|
+
const glspHead = glsp
|
|
2577
|
+
? `
|
|
2578
|
+
const glsp = startGlspServerInWorker({
|
|
2579
|
+
context: ports.glsp,
|
|
2580
|
+
createLogger: caller => new GlspClientLogger(shared, { component: caller }),
|
|
2581
|
+
serverModule: new ServerModule()${diagrams.map(grammar => `.configureDiagramModule(new ${grammar.grammar}DiagramModule())`).join('')},
|
|
2582
|
+
appModules: [new HydraniumGlspAppModule({ shared })]
|
|
2583
|
+
});
|
|
2584
|
+
// The GLSP head reports a failed start through \`started\`, which the
|
|
2585
|
+
// ready message waits for.
|
|
2586
|
+
return { shared, started: [glsp.started] };`
|
|
2587
|
+
: `
|
|
2588
|
+
return shared;`;
|
|
2589
|
+
|
|
2590
|
+
const text = `// The worker hosting the heads: every head over one store, in the browser and
|
|
2591
|
+
// with no backend. The page reaches each head over a \`MessagePort\` of its own.
|
|
2592
|
+
|
|
2593
|
+
${imports.join('\n')}
|
|
2594
|
+
|
|
2595
|
+
/** The files of \`../workspace\`, read by \`esbuild.mjs\` and keyed by their path below the root. */
|
|
2596
|
+
declare const __WORKSPACE_SEED__: Record<string, string>;
|
|
2597
|
+
/** This project's directory, hashed by \`esbuild.mjs\`, so that no other project on the origin opens its store. */
|
|
2598
|
+
declare const __DIRECTORY_KEY__: string;
|
|
2599
|
+
|
|
2600
|
+
// A saved file is kept in IndexedDB and laid over the seed on the next load;
|
|
2601
|
+
// the heads start once the stored files are restored.
|
|
2602
|
+
const store = new IndexedDbFileSystemStore(\`__PROJECT_ID__-workspace-\${__DIRECTORY_KEY__}\`);
|
|
2603
|
+
|
|
2604
|
+
hostHeadsInWorker(
|
|
2605
|
+
persistentFileSystem({ store, seed: __WORKSPACE_SEED__, rootUri: WORKSPACE_ROOT_URI }),
|
|
2606
|
+
(ports, fileSystem) => {
|
|
2607
|
+
${lsp ? '// The LSP head composes the services, as the Node entry does around stdio.\n ' : ''}${startServices}${dataHead}${glspHead}
|
|
2608
|
+
},
|
|
2609
|
+
${
|
|
2610
|
+
lsp
|
|
2611
|
+
? '// The ready message then carries the workspace files the page opens.'
|
|
2612
|
+
: '// With no LSP head the workspace comes up from this root, and the ready\n // message then carries its files.'
|
|
2613
|
+
}
|
|
2614
|
+
{ rootUri: WORKSPACE_ROOT_URI }
|
|
2615
|
+
);
|
|
2616
|
+
`;
|
|
2617
|
+
return project(text, composition);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* The page, and the one place deciding which head's part of it runs: each
|
|
2622
|
+
* part is a module of its own, and this calls the ones the heads serve.
|
|
2623
|
+
*/
|
|
2624
|
+
function hostPageFile(composition: InitComposition): string {
|
|
2625
|
+
const lsp = composition.heads.includes('lsp');
|
|
2626
|
+
const data = composition.heads.includes('data');
|
|
2627
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
2628
|
+
// A host takes lsp or glsp, and glsp marks a diagram grammar, so without lsp there is one.
|
|
2629
|
+
const diagramLanguage = lsp || diagram === undefined ? undefined : `${screamingSnake(diagram.grammarId)}_LANGUAGE`;
|
|
2630
|
+
const shownLanguage = diagramLanguage ?? 'ALL_LANGUAGES';
|
|
2631
|
+
const parts = [
|
|
2632
|
+
// Every part follows the file the page shows, so the switch comes first. With
|
|
2633
|
+
// no editor it lists only the diagram's files, the only ones the page shows.
|
|
2634
|
+
{
|
|
2635
|
+
module: './shown-file.js',
|
|
2636
|
+
imports: 'startShownFile',
|
|
2637
|
+
call: `const shownFile = await startShownFile(heads, ${diagramLanguage === undefined ? shownLanguage : `[${diagramLanguage}]`});`
|
|
2638
|
+
},
|
|
2639
|
+
// Save is armed with the editor, so it works before the other panes load;
|
|
2640
|
+
// with a diagram it waits for the edits of the one shown, once mounted.
|
|
2641
|
+
...(lsp
|
|
2642
|
+
? [
|
|
2643
|
+
{
|
|
2644
|
+
module: './editor.js',
|
|
2645
|
+
imports: 'armEditorSave, startEditor',
|
|
2646
|
+
call: [
|
|
2647
|
+
'const editor = await startEditor(heads, shownFile);',
|
|
2648
|
+
...(diagram === undefined
|
|
2649
|
+
? ['armEditorSave(editor);']
|
|
2650
|
+
: [
|
|
2651
|
+
'let shownDiagram: Awaited<ReturnType<typeof mountDiagram>> | undefined = undefined;',
|
|
2652
|
+
'armEditorSave(editor, () => settleDiagramEdits(shownDiagram?.()));'
|
|
2653
|
+
])
|
|
2654
|
+
].join('\n ')
|
|
2655
|
+
}
|
|
2656
|
+
]
|
|
2657
|
+
: []),
|
|
2658
|
+
...(data
|
|
2659
|
+
? [
|
|
2660
|
+
{
|
|
2661
|
+
module: './data-status.js',
|
|
2662
|
+
imports: 'showDataStatus',
|
|
2663
|
+
call: `await showDataStatus(heads.data, ${starterUri(composition.grammars[0])});`
|
|
2664
|
+
}
|
|
2665
|
+
]
|
|
2666
|
+
: []),
|
|
2667
|
+
...(diagram === undefined
|
|
2668
|
+
? []
|
|
2669
|
+
: [
|
|
2670
|
+
{
|
|
2671
|
+
module: './diagram.js',
|
|
2672
|
+
imports: lsp ? 'mountDiagram, settleDiagramEdits' : 'mountDiagram',
|
|
2673
|
+
call: `${lsp ? 'shownDiagram' : 'const shownDiagram'} = await mountDiagram(heads.glsp, shownFile, logger, ${lsp ? 'settleDiagramEdits' : 'saveBeforeLeaving'});`
|
|
2674
|
+
}
|
|
2675
|
+
]),
|
|
2676
|
+
// Without the editor, the diagram is what the page saves through.
|
|
2677
|
+
...(lsp ? [] : [{ module: './diagram-save.js', imports: 'armDiagramSave, saveBeforeLeaving', call: 'armDiagramSave(shownDiagram);' }])
|
|
2678
|
+
];
|
|
2679
|
+
const imports = [
|
|
2680
|
+
...parts.map(part => `import { ${part.imports} } from '${part.module}';`),
|
|
2681
|
+
"import { appendLog, setStatus } from './log.js';",
|
|
2682
|
+
...(data ? ["import { WORKSPACE_ROOT_URI } from './workspace.js';"] : [])
|
|
2683
|
+
].sort((left, right) => left.slice(left.indexOf("'")).localeCompare(right.slice(right.indexOf("'"))));
|
|
2684
|
+
|
|
2685
|
+
return `// The page: it starts the worker, hands each head a channel of its own, and
|
|
2686
|
+
// fills the pane of each head the server runs, one after the other.
|
|
2687
|
+
|
|
2688
|
+
import { connectWorkerHeads } from '@hydranium/client-browser';
|
|
2689
|
+
import { ConsoleLogger, describeError } from '@hydranium/protocol';
|
|
2690
|
+
import { ${shownLanguage} } from '${memberPackageName(composition, 'protocol')}';
|
|
2691
|
+
${imports.join('\n')}
|
|
2692
|
+
|
|
2693
|
+
/** Where the worker bundle is served, substituted by \`esbuild.mjs\`. */
|
|
2694
|
+
declare const __WORKER_BUNDLE_URL__: string;
|
|
2695
|
+
|
|
2696
|
+
async function main(): Promise<void> {
|
|
2697
|
+
// The page's end of each head's connection logs to the browser console.
|
|
2698
|
+
const logger = new ConsoleLogger();
|
|
2699
|
+
const heads = connectWorkerHeads(new Worker(__WORKER_BUNDLE_URL__), { logger });
|
|
2700
|
+
heads.onWorkerError(report => appendLog(\`worker error during \${report.phase}: \${report.message}\`));
|
|
2701
|
+
setStatus('starting…');
|
|
2702
|
+
${parts.map(part => ` ${part.call}`).join('\n')}
|
|
2703
|
+
setStatus('ready');
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
main().catch((error: unknown) => setStatus(\`failed: \${describeError(error)}\`));
|
|
2707
|
+
`;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* The smoke test: what each head shows on the page, and an edit that survives
|
|
2712
|
+
* a reload, typed in the editor or, with no LSP head, made in the diagram. With
|
|
2713
|
+
* a diagram, also a diagram edit the reload guard and Save catch before the
|
|
2714
|
+
* server has answered it.
|
|
2715
|
+
*/
|
|
2716
|
+
function hostSmokeSpec(composition: InitComposition): string {
|
|
2717
|
+
const lsp = composition.heads.includes('lsp');
|
|
2718
|
+
const data = composition.heads.includes('data');
|
|
2719
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
2720
|
+
const primary = composition.grammars[0];
|
|
2721
|
+
const checks = [
|
|
2722
|
+
...(lsp ? [" await expect(editor).toContainText('node First');"] : []),
|
|
2723
|
+
...(data
|
|
2724
|
+
? [
|
|
2725
|
+
' // The data head reads the document out of the same store.',
|
|
2726
|
+
` await expect(page.locator('#data-status')).toHaveText('data head: nodes.${primary.extensions[0]} holds a ${primary.entryRule}');`
|
|
2727
|
+
]
|
|
2728
|
+
: []),
|
|
2729
|
+
...(diagram === undefined
|
|
2730
|
+
? []
|
|
2731
|
+
: [
|
|
2732
|
+
' // The diagram follows the switch: two nodes in one file, and in the other its',
|
|
2733
|
+
' // node and the target it references there, drawn as external.',
|
|
2734
|
+
" const first = await page.locator('#file').inputValue();",
|
|
2735
|
+
` await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');`,
|
|
2736
|
+
" await expect(page.locator('#diagram .sprotty-node')).toHaveCount(2);",
|
|
2737
|
+
` await page.locator('#file').selectOption('references.${diagram.extensions[0]}');`,
|
|
2738
|
+
` await expect(page.locator('#diagram .external')).toHaveText('Second (nodes.${diagram.extensions[0]})');`,
|
|
2739
|
+
" await page.locator('#file').selectOption(first);"
|
|
2740
|
+
])
|
|
2741
|
+
];
|
|
2742
|
+
const edit = lsp
|
|
2743
|
+
? ` await editor.click();
|
|
2744
|
+
await page.keyboard.press('ControlOrMeta+End');
|
|
2745
|
+
await page.keyboard.type('\\nnode Added');
|
|
2746
|
+
await page.locator('#save').click();
|
|
2747
|
+
await expect(page.locator('#status')).toHaveText('saved 1 file(s)');
|
|
2748
|
+
|
|
2749
|
+
await page.reload();
|
|
2750
|
+
await expect(page.locator('#status')).toHaveText('ready');
|
|
2751
|
+
await expect(editor).toContainText('node Added');`
|
|
2752
|
+
: ` // A node from the palette, dropped low and left of centre: the starter nodes
|
|
2753
|
+
// sit in the upper band and the palette in the upper right.
|
|
2754
|
+
const canvas = page.locator('#diagram');
|
|
2755
|
+
const create = async (): Promise<void> => {
|
|
2756
|
+
await page.locator('#diagram .tool-button', { hasText: '${diagram?.nodeRule}' }).click();
|
|
2757
|
+
const size = await canvas.evaluate(element => ({ width: element.clientWidth, height: element.clientHeight }));
|
|
2758
|
+
await canvas.click({ position: { x: size.width * 0.4, y: size.height * 0.85 } });
|
|
2759
|
+
};
|
|
2760
|
+
await create();
|
|
2761
|
+
await expect(nodes).toHaveCount(3);
|
|
2762
|
+
await page.locator('#save').click();
|
|
2763
|
+
await expect(page.locator('#status')).toHaveText('saved the diagram');
|
|
2764
|
+
// A second, left unsaved: the switch saves the diagram it leaves.
|
|
2765
|
+
await create();
|
|
2766
|
+
await expect(nodes).toHaveCount(4);
|
|
2767
|
+
await page.locator('#file').selectOption('references.${diagram?.extensions[0]}');
|
|
2768
|
+
await expect(page.locator('#diagram .external')).toBeVisible();
|
|
2769
|
+
|
|
2770
|
+
await page.reload();
|
|
2771
|
+
await expect(page.locator('#status')).toHaveText('ready');
|
|
2772
|
+
await expect(nodes).toHaveCount(4);`;
|
|
2773
|
+
const guardTest =
|
|
2774
|
+
diagram === undefined
|
|
2775
|
+
? ''
|
|
2776
|
+
: `
|
|
2777
|
+
test('asks before a reload drops a diagram edit the server has not answered, and saves it with Save at once', async ({ page }) => {
|
|
2778
|
+
const nodes = page.locator('#diagram .sprotty-node');
|
|
2779
|
+
const askGuard = (): Promise<boolean> =>
|
|
2780
|
+
page.evaluate(() => {
|
|
2781
|
+
const event = new Event('beforeunload', { cancelable: true });
|
|
2782
|
+
window.dispatchEvent(event);
|
|
2783
|
+
return event.defaultPrevented;
|
|
2784
|
+
});
|
|
2785
|
+
await page.goto('/');
|
|
2786
|
+
await expect(page.locator('#status')).toHaveText('ready');
|
|
2787
|
+
await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');
|
|
2788
|
+
await expect(nodes).toHaveCount(2);
|
|
2789
|
+
|
|
2790
|
+
const canvas = page.locator('#diagram');
|
|
2791
|
+
await page.locator('#diagram .tool-button', { hasText: '${diagram.nodeRule}' }).click();
|
|
2792
|
+
const size = await canvas.evaluate(element => ({ width: element.clientWidth, height: element.clientHeight }));
|
|
2793
|
+
// Asked and clicked right after the click's own handlers, in the same turn
|
|
2794
|
+
// of the page, so the server's answer cannot have marked anything unsaved.
|
|
2795
|
+
await page.evaluate(() => {
|
|
2796
|
+
window.addEventListener(
|
|
2797
|
+
'mouseup',
|
|
2798
|
+
() =>
|
|
2799
|
+
queueMicrotask(() => {
|
|
2800
|
+
const event = new Event('beforeunload', { cancelable: true });
|
|
2801
|
+
window.dispatchEvent(event);
|
|
2802
|
+
document.getElementById('save')?.click();
|
|
2803
|
+
Reflect.set(window, 'guardedOnCreate', event.defaultPrevented);
|
|
2804
|
+
}),
|
|
2805
|
+
{ once: true }
|
|
2806
|
+
);
|
|
2807
|
+
});
|
|
2808
|
+
await canvas.click({ position: { x: size.width * 0.4, y: size.height * 0.85 } });
|
|
2809
|
+
expect(await page.evaluate(() => Reflect.get(window, 'guardedOnCreate'))).toBe(true);
|
|
2810
|
+
await expect(page.locator('#status')).toHaveText('${lsp ? 'saved 1 file(s)' : 'saved the diagram'}');
|
|
2811
|
+
expect(await askGuard()).toBe(false);
|
|
2812
|
+
|
|
2813
|
+
await page.reload();
|
|
2814
|
+
await expect(page.locator('#status')).toHaveText('ready');
|
|
2815
|
+
await page.locator('#file').selectOption('nodes.${diagram.extensions[0]}');
|
|
2816
|
+
await expect(nodes).toHaveCount(3);
|
|
2817
|
+
});
|
|
2818
|
+
`;
|
|
2819
|
+
const locators = [
|
|
2820
|
+
...(lsp ? [" const editor = page.locator('#editor .view-lines');"] : []),
|
|
2821
|
+
...(lsp ? [] : [" const nodes = page.locator('#diagram .sprotty-node');"])
|
|
2822
|
+
];
|
|
2823
|
+
return `// The page loads with every head answering, and an edit saved through the
|
|
2824
|
+
// ${lsp ? 'LSP head' : 'GLSP head'} is still there after a reload; any console error fails that
|
|
2825
|
+
// test. The other asks before a reload drops a diagram edit the server has not
|
|
2826
|
+
// answered yet, and keeps it when Save is clicked at once.
|
|
2827
|
+
|
|
2828
|
+
import { expect, test } from '@playwright/test';
|
|
2829
|
+
|
|
2830
|
+
test('loads every head and keeps a saved ${lsp ? 'edit' : 'diagram edit'} across a reload', async ({ page }) => {
|
|
2831
|
+
const errors: string[] = [];
|
|
2832
|
+
page.on('pageerror', error => errors.push(error.message));
|
|
2833
|
+
page.on('console', message => {
|
|
2834
|
+
if (message.type() === 'error') {
|
|
2835
|
+
errors.push(message.text());
|
|
2836
|
+
}
|
|
2837
|
+
});
|
|
2838
|
+
${locators.join('\n')}
|
|
2839
|
+
|
|
2840
|
+
await page.goto('/');
|
|
2841
|
+
await expect(page.locator('#status')).toHaveText('ready');
|
|
2842
|
+
${checks.join('\n')}
|
|
2843
|
+
|
|
2844
|
+
${edit}
|
|
2845
|
+
expect(errors).toEqual([]);
|
|
2846
|
+
});
|
|
2847
|
+
${guardTest}`;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
/** The browser host's README. */
|
|
2851
|
+
function hostBrowserReadme(composition: InitComposition): string {
|
|
2852
|
+
const lsp = composition.heads.includes('lsp');
|
|
2853
|
+
const data = composition.heads.includes('data');
|
|
2854
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
2855
|
+
const lines = [
|
|
2856
|
+
`# ${memberPackageName(composition, 'host-browser')}`,
|
|
2857
|
+
'',
|
|
2858
|
+
'__NAME__ in a browser page: every head runs in a web worker, with no backend',
|
|
2859
|
+
`and no Node runtime. The page saves through the ${lsp ? 'LSP head' : 'GLSP head'}, and the worker`,
|
|
2860
|
+
'keeps each saved file in IndexedDB, laid over `../workspace` on the next',
|
|
2861
|
+
'load.',
|
|
2862
|
+
'',
|
|
2863
|
+
'```bash',
|
|
2864
|
+
'npm run build # type-check and bundle into out/',
|
|
2865
|
+
'npm start # serve the page at the URL it prints, rebundled on every source change',
|
|
2866
|
+
'npm run test:e2e:install # download the Chromium the smoke test drives, once',
|
|
2867
|
+
'npm run test:e2e # the smoke test, which serves the page itself',
|
|
2868
|
+
'```',
|
|
2869
|
+
'',
|
|
2870
|
+
'`PORT` moves the page to another port, for `npm start` and the smoke test alike;',
|
|
2871
|
+
'stop `npm start` before the smoke test, or give one of them another port.',
|
|
2872
|
+
'`npm start` reads `../workspace` once, so restart it after editing a file',
|
|
2873
|
+
'there.',
|
|
2874
|
+
'',
|
|
2875
|
+
'## Layout',
|
|
2876
|
+
'',
|
|
2877
|
+
'- `index.html` — the page, with a pane for each head that stays hidden until its part fills it',
|
|
2878
|
+
'- `hydranium-mark.svg` — the tab icon',
|
|
2879
|
+
`- \`esbuild.mjs\` — bundles the page${lsp ? ', the worker and Monaco’s worker' : ' and the worker'}, reading \`../workspace\` in as the seed`,
|
|
2880
|
+
'- `src/worker.ts` — the worker: the heads over one store, seeded and persisted',
|
|
2881
|
+
'- `src/page.ts` — starts the worker and runs the part of the page each head serves',
|
|
2882
|
+
'- `src/shown-file.ts` — the file switch above the panes, which every part follows',
|
|
2883
|
+
...(lsp ? ['- `src/editor.ts` — the LSP part: a Monaco editor and Save'] : []),
|
|
2884
|
+
...(data ? ['- `src/data-status.ts` — the data part: one document read through the data head'] : []),
|
|
2885
|
+
...(diagram === undefined
|
|
2886
|
+
? []
|
|
2887
|
+
: [
|
|
2888
|
+
`- \`src/diagram.ts\` — the GLSP part: the \`${diagram.grammar}\` diagram of the file the page shows, defined in the client package`
|
|
2889
|
+
]),
|
|
2890
|
+
...(lsp ? [] : ['- `src/diagram-save.ts` — Save with no editor: the diagram saved through the GLSP head']),
|
|
2891
|
+
'- `src/log.ts` — the status line and the log area',
|
|
2892
|
+
'- `src/workspace.ts` — the workspace root URI both ends name',
|
|
2893
|
+
...(lsp ? ['- `src/monaco-editor-worker.ts` — Monaco’s own worker, which hosts no head'] : []),
|
|
2894
|
+
'- `src/posix-path-shim.ts` — the `path` function the framework imports, for the bundle',
|
|
2895
|
+
'- `test/smoke.spec.mts` — the page loads, each head answers, and a saved edit survives a reload',
|
|
2896
|
+
''
|
|
2897
|
+
];
|
|
2898
|
+
return project(lines.join('\n'), composition);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/** The page with the line under its log view saying what shows there, which the LSP head decides. */
|
|
2902
|
+
function withLogHint(page: InitFile, lsp: boolean): InitFile {
|
|
2903
|
+
const hint = lsp
|
|
2904
|
+
? 'What the server logs over the LSP head, and the errors the page reports.'
|
|
2905
|
+
: "The errors the page reports. The server logs to the worker's console, in the browser's developer tools.";
|
|
2906
|
+
return { ...page, content: page.content.replace('__LOG_HINT__', hint) };
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/** The browser host: a page and a worker, bundled by esbuild and served as static files. */
|
|
2910
|
+
function hostBrowserFiles(composition: InitComposition): InitFile[] {
|
|
2911
|
+
const fixed = (file: string): InitFile => projectTemplate(`host-browser/${file}`, composition);
|
|
2912
|
+
const lsp = composition.heads.includes('lsp');
|
|
2913
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
2914
|
+
return [
|
|
2915
|
+
{ path: 'host-browser/package.json', content: hostBrowserPackageJson(composition) },
|
|
2916
|
+
ignoreFile('host-browser'),
|
|
2917
|
+
{ path: 'host-browser/README.md', content: hostBrowserReadme(composition) },
|
|
2918
|
+
withLogHint(fixed('index.html'), lsp),
|
|
2919
|
+
fixed('hydranium-mark.svg'),
|
|
2920
|
+
fixed('esbuild.mjs'),
|
|
2921
|
+
fixed('playwright.config.mts'),
|
|
2922
|
+
fixed('tsconfig.worker.json'),
|
|
2923
|
+
fixed('tsconfig.page.json'),
|
|
2924
|
+
fixed('tsconfig.e2e.json'),
|
|
2925
|
+
{ path: 'host-browser/src/worker.ts', content: hostWorkerFile(composition) },
|
|
2926
|
+
{ path: 'host-browser/src/page.ts', content: hostPageFile(composition) },
|
|
2927
|
+
fixed('src/shown-file.ts'),
|
|
2928
|
+
...(lsp ? [fixed('src/editor.ts')] : []),
|
|
2929
|
+
...(composition.heads.includes('data') ? [fixed('src/data-status.ts')] : []),
|
|
2930
|
+
...(diagram === undefined ? [] : [grammarTemplate('host-browser/src/diagram.ts', composition, diagram)]),
|
|
2931
|
+
...(lsp ? [] : [fixed('src/diagram-save.ts')]),
|
|
2932
|
+
fixed('src/log.ts'),
|
|
2933
|
+
fixed('src/workspace.ts'),
|
|
2934
|
+
...(lsp ? [fixed('src/monaco-editor-worker.ts'), fixed('src/monaco-editor-core-worker.d.ts')] : []),
|
|
2935
|
+
fixed('src/posix-path-shim.ts'),
|
|
2936
|
+
{ path: 'host-browser/test/smoke.spec.mts', content: hostSmokeSpec(composition) }
|
|
2937
|
+
];
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** A JSON value as it sits at `indent` in a manifest npm writes. */
|
|
2941
|
+
function jsonAt(value: unknown, indent: string): string {
|
|
2942
|
+
return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* The languages and grammars a VS Code extension contributes. Each extension
|
|
2947
|
+
* declares its own, since either may be installed without the other; the
|
|
2948
|
+
* grammars are the server's, copied in by `copy:syntaxes`.
|
|
2949
|
+
*/
|
|
2950
|
+
function vscodeLanguageContributions(composition: InitComposition): Record<string, unknown> {
|
|
2951
|
+
return {
|
|
2952
|
+
grammars: composition.grammars.map(grammar => ({
|
|
2953
|
+
language: grammar.languageId,
|
|
2954
|
+
scopeName: `source.${grammar.languageId}`,
|
|
2955
|
+
path: `./syntaxes/${grammar.languageId}.tmLanguage.json`
|
|
2956
|
+
})),
|
|
2957
|
+
languages: composition.grammars.map(grammar => ({
|
|
2958
|
+
id: grammar.languageId,
|
|
2959
|
+
aliases: [grammar.grammar],
|
|
2960
|
+
extensions: grammar.extensions.map(extension => `.${extension}`)
|
|
2961
|
+
}))
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
/** VS Code finds the grammars only inside the extension, so the build copies the server's. */
|
|
2966
|
+
const COPY_SYNTAXES_SCRIPT = "require('node:fs').cpSync('../server/syntaxes', 'syntaxes', { recursive: true })";
|
|
2967
|
+
const COPY_SYNTAXES = `node -e \\"${COPY_SYNTAXES_SCRIPT}\\"`;
|
|
2968
|
+
|
|
2969
|
+
/** `copy:syntaxes`, then again each time the server's `langium:watch` rewrites a grammar. */
|
|
2970
|
+
const WATCH_SYNTAXES = `node -e \\"const copy = () => ${COPY_SYNTAXES_SCRIPT}; copy(); require('node:fs').watch('../server/syntaxes', copy)\\"`;
|
|
2971
|
+
|
|
2972
|
+
/**
|
|
2973
|
+
* The servers-only extension's `package.json`. Always private, like a host:
|
|
2974
|
+
* it is installed into an application, never as a dependency.
|
|
2975
|
+
*
|
|
2976
|
+
* `onStartupFinished` rather than `onLanguage`, since a Theia backend asks for
|
|
2977
|
+
* a port before any document is open.
|
|
2978
|
+
*/
|
|
2979
|
+
function vscodeServersPackageJson(composition: InitComposition): string {
|
|
2980
|
+
const dependencies: Record<string, string> = {
|
|
2981
|
+
[memberPackageName(composition, 'protocol')]: MEMBER_PIN,
|
|
2982
|
+
[memberPackageName(composition, 'server')]: MEMBER_PIN,
|
|
2983
|
+
...VSCODE_LANGUAGE_CLIENT,
|
|
2984
|
+
'@hydranium/client-vscode': frameworkPin(composition)
|
|
2985
|
+
};
|
|
2986
|
+
return `{
|
|
2987
|
+
"name": "${memberPackageName(composition, 'vscode-servers')}",
|
|
2988
|
+
"displayName": "${composition.name} Servers",
|
|
2989
|
+
"version": "0.0.0",
|
|
2990
|
+
"private": true,
|
|
2991
|
+
"description": "Starts the ${composition.name} server${socketHeads(composition).length > 0 ? " and publishes its socket heads' ports as host commands" : ''}, with no editor of its own.",
|
|
2992
|
+
"license": "UNLICENSED",
|
|
2993
|
+
"author": "",
|
|
2994
|
+
"publisher": "${composition.projectId}",
|
|
2995
|
+
"main": "./out/extension.js",
|
|
2996
|
+
"scripts": {
|
|
2997
|
+
"build": "npm run copy:syntaxes && tsc",
|
|
2998
|
+
"clean": "rimraf out syntaxes tsconfig.tsbuildinfo",
|
|
2999
|
+
"copy:syntaxes": "${COPY_SYNTAXES}",
|
|
3000
|
+
${lintScript(forMember(composition, 'vscode-servers'), 'src')} "watch": "concurrently --kill-others --names syntaxes,tsc \\"npm run watch:syntaxes\\" \\"tsc --watch\\"",
|
|
3001
|
+
"watch:syntaxes": "${WATCH_SYNTAXES}"
|
|
3002
|
+
},
|
|
3003
|
+
"dependencies": {
|
|
3004
|
+
${sortedBlock(dependencies, ' ')}
|
|
3005
|
+
},
|
|
3006
|
+
"devDependencies": {
|
|
3007
|
+
${sortedBlock({ '@types/node': '^22.0.0', '@types/vscode': VSCODE_ENGINE, concurrently: '^9.0.0', rimraf: '^5.0.0', typescript: '^7.0.2' }, ' ')}
|
|
3008
|
+
},
|
|
3009
|
+
"contributes": ${jsonAt(vscodeLanguageContributions(composition), ' ')},
|
|
3010
|
+
"activationEvents": [
|
|
3011
|
+
"onStartupFinished"
|
|
3012
|
+
],
|
|
3013
|
+
"engines": {
|
|
3014
|
+
"vscode": "${VSCODE_ENGINE}"
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
`;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/**
|
|
3021
|
+
* The servers-only extension's activation: the shared launch, and with a socket
|
|
3022
|
+
* head the port commands, which only this extension registers.
|
|
3023
|
+
*/
|
|
3024
|
+
function vscodeServersExtensionFile(composition: InitComposition): string {
|
|
3025
|
+
const upper = screamingSnake(composition.projectId);
|
|
3026
|
+
const heads = socketHeads(composition).map(head => head.key);
|
|
3027
|
+
const protocolSymbols = heads.flatMap(head => [`${upper}_${head}_HOST_COMMAND`, `${upper}_${head}_PORT_COMMAND`]);
|
|
3028
|
+
const imports =
|
|
3029
|
+
heads.length === 0
|
|
3030
|
+
? ''
|
|
3031
|
+
: `import { registerPortCommands } from '@hydranium/client-vscode/node';
|
|
3032
|
+
${importList(protocolSymbols, memberPackageName(composition, 'protocol'), columnsFor(composition))}
|
|
3033
|
+
`;
|
|
3034
|
+
const portCommands =
|
|
3035
|
+
heads.length === 0
|
|
3036
|
+
? ''
|
|
3037
|
+
: `
|
|
3038
|
+
|
|
3039
|
+
// Each socket head's port as a host command, for a host with no language
|
|
3040
|
+
// client of its own. Not in the shared launch: VS Code throws on a second
|
|
3041
|
+
// registration of one id, so the VS Code extension beside this one would fail
|
|
3042
|
+
// to activate. A pass-through: awaiting anything else in it would hang a
|
|
3043
|
+
// Theia backend on a user dismissing a notification.
|
|
3044
|
+
registerPortCommands(context, client, {
|
|
3045
|
+
${heads.map(head => ` [${upper}_${head}_HOST_COMMAND]: ${upper}_${head}_PORT_COMMAND`).join(',\n')}
|
|
3046
|
+
});`;
|
|
3047
|
+
return `// The servers-only extension: it starts the ${composition.name} server and contributes no
|
|
3048
|
+
// editor, view or command. A Theia app sideloads this one rather than the VS
|
|
3049
|
+
// Code extension, whose diagram editors would give its files a second editor.
|
|
3050
|
+
|
|
3051
|
+
${imports}import type { ExtensionContext } from 'vscode';
|
|
3052
|
+
import type { LanguageClient } from 'vscode-languageclient/node';
|
|
3053
|
+
import { start${composition.name}LanguageClient } from './language-client.js';
|
|
3054
|
+
|
|
3055
|
+
let client: LanguageClient | undefined;
|
|
3056
|
+
|
|
3057
|
+
export async function activate(context: ExtensionContext): Promise<void> {
|
|
3058
|
+
client = await start${composition.name}LanguageClient(context);${portCommands}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
export async function deactivate(): Promise<void> {
|
|
3062
|
+
await client?.stop();
|
|
3063
|
+
client = undefined;
|
|
3064
|
+
}
|
|
3065
|
+
`;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
/**
|
|
3069
|
+
* The server launch both extensions share: one launch, so VS Code and a Theia
|
|
3070
|
+
* app sideloading the servers extension hand the server the same documents.
|
|
3071
|
+
*/
|
|
3072
|
+
function vscodeLanguageClientFile(composition: InitComposition): string {
|
|
3073
|
+
return `// Starts the ${composition.name} server for VS Code, and for a Theia app that sideloads
|
|
3074
|
+
// this extension: one launch, so both hand the server the same documents.
|
|
3075
|
+
|
|
3076
|
+
import { textDocumentContentMiddleware } from '@hydranium/client-vscode/node';
|
|
3077
|
+
import { ALL_LANGUAGES } from '${memberPackageName(composition, 'protocol')}';
|
|
3078
|
+
import { type ExtensionContext, workspace } from 'vscode';
|
|
3079
|
+
import { LanguageClient, TransportKind } from 'vscode-languageclient/node';
|
|
3080
|
+
|
|
3081
|
+
/** Fork the server, start its language client and return it once started. */
|
|
3082
|
+
export async function start${composition.name}LanguageClient(context: ExtensionContext): Promise<LanguageClient> {
|
|
3083
|
+
// Resolved through the package, so it works from the workspace and from an
|
|
3084
|
+
// installed extension alike. Over IPC, which keeps the server's stdout free.
|
|
3085
|
+
const serverModule = require.resolve('${memberPackageName(composition, 'server')}/lib/main.js');
|
|
3086
|
+
const extensions = ALL_LANGUAGES.flatMap(language => language.fileExtensions).join(',');
|
|
3087
|
+
const client: LanguageClient = new LanguageClient(
|
|
3088
|
+
'${composition.projectId}',
|
|
3089
|
+
'${composition.name}',
|
|
3090
|
+
{ module: serverModule, transport: TransportKind.ipc },
|
|
3091
|
+
{
|
|
3092
|
+
documentSelector: ALL_LANGUAGES.map(({ languageId }) => ({ scheme: 'file', language: languageId })),
|
|
3093
|
+
// Changes on disk the editor does not hold, such as a file another tool writes.
|
|
3094
|
+
synchronize: { fileEvents: workspace.createFileSystemWatcher(\`**/*{\${extensions}}\`) },
|
|
3095
|
+
// The text of a document only the server holds, such as a built-in library.
|
|
3096
|
+
middleware: textDocumentContentMiddleware(() => client)
|
|
3097
|
+
}
|
|
3098
|
+
);
|
|
3099
|
+
context.subscriptions.push(client);
|
|
3100
|
+
await client.start();
|
|
3101
|
+
|
|
3102
|
+
return client;
|
|
3103
|
+
}
|
|
3104
|
+
`;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/** The folders of the Theia applications the composition emits, as code spans. */
|
|
3108
|
+
function theiaApplicationPaths(composition: InitComposition): string[] {
|
|
3109
|
+
return composition.hosts.filter(isTheiaHost).map(host => `\`../host-${host}\``);
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
/** The servers-only extension's README. */
|
|
3113
|
+
function vscodeServersReadme(composition: InitComposition): string {
|
|
3114
|
+
const vscode = composition.hosts.includes('vscode');
|
|
3115
|
+
const theiaApps = theiaApplicationPaths(composition);
|
|
3116
|
+
const intro = [
|
|
3117
|
+
'A VS Code extension that starts the __NAME__ server and contributes no editor, view or command: the languages, their grammars and the server.',
|
|
3118
|
+
`With a socket head, it also answers each head’s port under a host command, which a host without a language client of its own runs${
|
|
3119
|
+
theiaApps.length > 0
|
|
3120
|
+
? theiaApps.length === 1
|
|
3121
|
+
? `: the Theia application in ${theiaApps[0]}, which sideloads this extension.`
|
|
3122
|
+
: `: the Theia applications in ${listPhrase(theiaApps)}, which sideload this extension.`
|
|
3123
|
+
: ', such as a Theia app sideloading this extension.'
|
|
3124
|
+
}`,
|
|
3125
|
+
...(vscode
|
|
3126
|
+
? [
|
|
3127
|
+
'The VS Code extension in `../host-vscode` starts the server through the same function.',
|
|
3128
|
+
'Both work side by side, but each starts its own server and language client, so diagnostics may show twice: install one.'
|
|
3129
|
+
]
|
|
3130
|
+
: [])
|
|
3131
|
+
];
|
|
3132
|
+
const lines = [
|
|
3133
|
+
`# ${memberPackageName(composition, 'vscode-servers')}`,
|
|
3134
|
+
'',
|
|
3135
|
+
...wrapProse(intro.join(' ')),
|
|
3136
|
+
'',
|
|
3137
|
+
'```bash',
|
|
3138
|
+
'npm run build # copy the server’s grammars and compile into out/; build the server first',
|
|
3139
|
+
'npm run watch # the same on every change',
|
|
3140
|
+
'```',
|
|
3141
|
+
'',
|
|
3142
|
+
'- `src/extension.ts` — activation, which starts the server and, with a socket head, registers its port commands',
|
|
3143
|
+
`- \`src/language-client.ts\` — the server launch${vscode ? ', shared with `../host-vscode`' : ''}`,
|
|
3144
|
+
''
|
|
3145
|
+
];
|
|
3146
|
+
return project(lines.join('\n'), composition);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
/** The servers-only extension: the server's launch, without an editor. */
|
|
3150
|
+
function vscodeServersFiles(composition: InitComposition): InitFile[] {
|
|
3151
|
+
return [
|
|
3152
|
+
{ path: 'vscode-servers/package.json', content: vscodeServersPackageJson(composition) },
|
|
3153
|
+
ignoreFile('vscode-servers'),
|
|
3154
|
+
{ path: 'vscode-servers/README.md', content: vscodeServersReadme(composition) },
|
|
3155
|
+
projectTemplate('vscode-servers/tsconfig.json', composition),
|
|
3156
|
+
{ path: 'vscode-servers/src/extension.ts', content: vscodeServersExtensionFile(composition) },
|
|
3157
|
+
{ path: 'vscode-servers/src/language-client.ts', content: vscodeLanguageClientFile(composition) }
|
|
3158
|
+
];
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
/**
|
|
3162
|
+
* The custom-editor view type of a grammar's diagram, as the editor module
|
|
3163
|
+
* declares it. VS Code keys custom editors by view type alone, across every
|
|
3164
|
+
* installed extension, so it carries the language id, which `init` qualifies
|
|
3165
|
+
* by the project wherever the grammar is named apart from it.
|
|
3166
|
+
*/
|
|
3167
|
+
function diagramViewType(grammar: InitGrammarNames): string {
|
|
3168
|
+
return `${grammar.languageId}.diagram`;
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
/**
|
|
3172
|
+
* The VS Code extension's `package.json`. Always private, as every host is.
|
|
3173
|
+
*
|
|
3174
|
+
* A diagram file opens in its diagram by default; `Reopen Editor With` reaches
|
|
3175
|
+
* the text.
|
|
3176
|
+
*/
|
|
3177
|
+
function hostVscodePackageJson(composition: InitComposition): string {
|
|
3178
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3179
|
+
const dependencies: Record<string, string> = {
|
|
3180
|
+
[memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
|
|
3181
|
+
...VSCODE_LANGUAGE_CLIENT
|
|
3182
|
+
};
|
|
3183
|
+
if (diagrams.length > 0) {
|
|
3184
|
+
dependencies[memberPackageName(composition, 'client')] = MEMBER_PIN;
|
|
3185
|
+
dependencies[memberPackageName(composition, 'protocol')] = MEMBER_PIN;
|
|
3186
|
+
Object.assign(dependencies, HOST_VSCODE_GLSP_THIRD_PARTY_DEPENDENCIES);
|
|
3187
|
+
for (const framework of HOST_VSCODE_GLSP_FRAMEWORK_DEPENDENCIES) {
|
|
3188
|
+
dependencies[framework] = frameworkPin(composition);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
if (composition.heads.includes('data')) {
|
|
3192
|
+
dependencies[memberPackageName(composition, 'protocol')] = MEMBER_PIN;
|
|
3193
|
+
Object.assign(dependencies, HOST_VSCODE_DATA_THIRD_PARTY_DEPENDENCIES);
|
|
3194
|
+
for (const framework of HOST_VSCODE_DATA_FRAMEWORK_DEPENDENCIES) {
|
|
3195
|
+
dependencies[framework] = frameworkPin(composition);
|
|
3196
|
+
}
|
|
3197
|
+
}
|
|
3198
|
+
const devDependencies = { ...HOST_VSCODE_DEV_DEPENDENCIES, ...(diagrams.length > 0 ? { esbuild: '^0.28.0' } : {}) };
|
|
3199
|
+
const watches: Record<string, string> = {
|
|
3200
|
+
syntaxes: 'npm run watch:syntaxes',
|
|
3201
|
+
tsc: 'tsc --watch',
|
|
3202
|
+
...(diagrams.length > 0 ? { webview: 'node esbuild.mjs --watch' } : {})
|
|
3203
|
+
};
|
|
3204
|
+
const watchNames = Object.keys(watches).join(',');
|
|
3205
|
+
const watchCommands = Object.values(watches)
|
|
3206
|
+
.map(command => `\\"${command}\\"`)
|
|
3207
|
+
.join(' ');
|
|
3208
|
+
const contributes = {
|
|
3209
|
+
...(composition.heads.includes('data')
|
|
3210
|
+
? { commands: [{ command: `${composition.projectId}.showModel`, title: 'Show Model', category: composition.name }] }
|
|
3211
|
+
: {}),
|
|
3212
|
+
...(diagrams.length > 0
|
|
3213
|
+
? {
|
|
3214
|
+
customEditors: diagrams.map(grammar => ({
|
|
3215
|
+
viewType: diagramViewType(grammar),
|
|
3216
|
+
displayName: `${grammar.grammar} Diagram`,
|
|
3217
|
+
selector: grammar.extensions.map(extension => ({ filenamePattern: `*.${extension}` })),
|
|
3218
|
+
priority: 'default'
|
|
3219
|
+
}))
|
|
3220
|
+
}
|
|
3221
|
+
: {}),
|
|
3222
|
+
...vscodeLanguageContributions(composition)
|
|
3223
|
+
};
|
|
3224
|
+
const typecheck = [...(diagrams.length > 0 ? ['tsconfig.webview.json'] : []), 'tsconfig.e2e.json']
|
|
3225
|
+
.map(project => `tsc --noEmit -p ${project}`)
|
|
3226
|
+
.join(' && ');
|
|
3227
|
+
return `{
|
|
3228
|
+
"name": "${memberPackageName(composition, 'host-vscode')}",
|
|
3229
|
+
"displayName": "${composition.name}",
|
|
3230
|
+
"version": "0.0.0",
|
|
3231
|
+
"private": true,
|
|
3232
|
+
"description": "${composition.name} in VS Code: ${hostVscodeParts(composition)}.",
|
|
3233
|
+
"license": "UNLICENSED",
|
|
3234
|
+
"author": "",
|
|
3235
|
+
"publisher": "${composition.projectId}",
|
|
3236
|
+
"main": "./out/extension.js",
|
|
3237
|
+
"scripts": {
|
|
3238
|
+
"build": "npm run copy:syntaxes && npm run typecheck && tsc${diagrams.length > 0 ? ' && node esbuild.mjs' : ''}",
|
|
3239
|
+
"clean": "rimraf out syntaxes tsconfig.tsbuildinfo test-results playwright-report",
|
|
3240
|
+
"copy:syntaxes": "${COPY_SYNTAXES}",
|
|
3241
|
+
${lintScript(forMember(composition, 'host-vscode'), 'src test')} "start": "node start.mjs",
|
|
3242
|
+
"test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
|
|
3243
|
+
"test:e2e:install": "node --experimental-strip-types test/install-vscode.mts",
|
|
3244
|
+
"typecheck": "${typecheck}",
|
|
3245
|
+
"watch": "concurrently --kill-others --names ${watchNames} ${watchCommands}",
|
|
3246
|
+
"watch:syntaxes": "${WATCH_SYNTAXES}"
|
|
3247
|
+
},
|
|
3248
|
+
"dependencies": {
|
|
3249
|
+
${sortedBlock(dependencies, ' ')}
|
|
3250
|
+
},
|
|
3251
|
+
"devDependencies": {
|
|
3252
|
+
${sortedBlock(devDependencies, ' ')}
|
|
3253
|
+
},
|
|
3254
|
+
"contributes": ${jsonAt(contributes, ' ')},
|
|
3255
|
+
"activationEvents": ${jsonAt(
|
|
3256
|
+
composition.grammars.map(grammar => `onLanguage:${grammar.languageId}`),
|
|
3257
|
+
' '
|
|
3258
|
+
)},
|
|
3259
|
+
"engines": {
|
|
3260
|
+
"vscode": "${VSCODE_ENGINE}"
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
`;
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
/** What the VS Code extension brings, as one phrase; `started` follows the language client. */
|
|
3267
|
+
function hostVscodeParts(composition: InitComposition, started = ''): string {
|
|
3268
|
+
const parts = [
|
|
3269
|
+
`the language client${started}`,
|
|
3270
|
+
...(composition.heads.includes('data') ? ['a Show Model command reading a file through the data head'] : []),
|
|
3271
|
+
...(composition.grammars.some(grammar => grammar.diagram) ? ['a custom editor for each diagram'] : [])
|
|
3272
|
+
];
|
|
3273
|
+
return listPhrase(parts);
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
/** `parts` as one English list: `a`, `a and b`, `a, b, and c`. */
|
|
3277
|
+
function listPhrase(parts: readonly string[]): string {
|
|
3278
|
+
return parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')}${parts.length > 2 ? ',' : ''} and ${parts.at(-1)}`;
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
/**
|
|
3282
|
+
* The VS Code extension's entry: the shared server launch, then the Show Model
|
|
3283
|
+
* command over the data head and a diagram editor per diagram grammar.
|
|
3284
|
+
*/
|
|
3285
|
+
function hostVscodeExtensionFile(composition: InitComposition): string {
|
|
3286
|
+
const columns = columnsFor(composition);
|
|
3287
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3288
|
+
const data = composition.heads.includes('data');
|
|
3289
|
+
const start = `start${composition.name}LanguageClient`;
|
|
3290
|
+
const servers = `${memberPackageName(composition, 'vscode-servers')}/out/language-client.js`;
|
|
3291
|
+
const upper = screamingSnake(composition.projectId);
|
|
3292
|
+
const editors = diagrams.map(grammar => ({
|
|
3293
|
+
module: `./${grammar.grammarId}-diagram-editor.js`,
|
|
3294
|
+
viewType: `${screamingSnake(grammar.grammarId)}_DIAGRAM_VIEW_TYPE`,
|
|
3295
|
+
provider: `${grammar.grammar}DiagramEditorProvider`,
|
|
3296
|
+
local: `${grammar.grammar.charAt(0).toLowerCase()}${grammar.grammar.slice(1)}Editor`
|
|
3297
|
+
}));
|
|
3298
|
+
const registrations = editors.map(
|
|
3299
|
+
editor => ` const ${editor.local} = new ${editor.provider}(glspConnector, context.extensionUri);
|
|
3300
|
+
context.subscriptions.push(
|
|
3301
|
+
window.registerCustomEditorProvider(${editor.viewType}, ${editor.local}, {
|
|
3302
|
+
// A hidden tab keeps its diagram session rather than repeating the handshake.
|
|
3303
|
+
webviewOptions: { retainContextWhenHidden: true },
|
|
3304
|
+
// Two views of one document would be two GLSP clients racing on one model.
|
|
3305
|
+
supportsMultipleEditorsPerDocument: false
|
|
3306
|
+
})
|
|
3307
|
+
);`
|
|
3308
|
+
);
|
|
3309
|
+
const sockets = data || diagrams.length > 0;
|
|
3310
|
+
const protocolSymbols = [
|
|
3311
|
+
...(data ? [`${upper}_DATA_SERVER_PORT_COMMAND`] : []),
|
|
3312
|
+
...(diagrams.length > 0 ? [`${upper}_GLSP_SERVER_PORT_COMMAND`] : []),
|
|
3313
|
+
...(data ? [`type ${composition.name}Element`] : [])
|
|
3314
|
+
];
|
|
3315
|
+
const imports = [
|
|
3316
|
+
...(sockets ? ["import { findPort } from '@hydranium/client-vscode/node';"] : []),
|
|
3317
|
+
...(data ? ["import { SocketDataPort } from '@hydranium/data-client-vscode/node';"] : []),
|
|
3318
|
+
...(diagrams.length > 0
|
|
3319
|
+
? ["import { HydraniumGlspVscodeConnector, HydraniumGlspVscodeServer } from '@hydranium/glsp-client-vscode/node';"]
|
|
3320
|
+
: []),
|
|
3321
|
+
...(sockets
|
|
3322
|
+
? [
|
|
3323
|
+
importList(
|
|
3324
|
+
[
|
|
3325
|
+
'ConsoleLogger',
|
|
3326
|
+
...(data ? ['DataConnectionWithEvents'] : []),
|
|
3327
|
+
...(diagrams.length > 0 ? ['describeError'] : []),
|
|
3328
|
+
...(data ? ['renderFrameworkMessage'] : [])
|
|
3329
|
+
],
|
|
3330
|
+
'@hydranium/protocol',
|
|
3331
|
+
columns
|
|
3332
|
+
)
|
|
3333
|
+
]
|
|
3334
|
+
: []),
|
|
3335
|
+
...(sockets ? [importList(protocolSymbols, memberPackageName(composition, 'protocol'), columns)] : []),
|
|
3336
|
+
...(sockets
|
|
3337
|
+
? [`import { ${[...(data ? ['commands'] : []), 'type ExtensionContext', 'window'].join(', ')} } from 'vscode';`]
|
|
3338
|
+
: ["import type { ExtensionContext } from 'vscode';"]),
|
|
3339
|
+
"import type { LanguageClient } from 'vscode-languageclient/node';",
|
|
3340
|
+
`import { ${start} } from '${servers}';`,
|
|
3341
|
+
...editors.map(editor => importList([editor.viewType, editor.provider], editor.module, columns)),
|
|
3342
|
+
...(data ? ["import { showModelCommand } from './show-model-command.js';"] : [])
|
|
3343
|
+
];
|
|
3344
|
+
const dataBlock = data
|
|
3345
|
+
? `
|
|
3346
|
+
|
|
3347
|
+
// The data head, read by the Show Model command. Nothing connects until it runs.
|
|
3348
|
+
const dataPort = new SocketDataPort({
|
|
3349
|
+
findPort: () => findPort(running, ${upper}_DATA_SERVER_PORT_COMMAND),
|
|
3350
|
+
logger: new ConsoleLogger().with('data head'),
|
|
3351
|
+
// A failed read shown to the user, not only logged; the message carries the detail.
|
|
3352
|
+
reportError: (_error, reported) => void window.showErrorMessage(renderFrameworkMessage(reported))
|
|
3353
|
+
});
|
|
3354
|
+
const dataConnection = new DataConnectionWithEvents<${composition.name}Element>(dataPort);
|
|
3355
|
+
context.subscriptions.push(dataConnection, dataPort, commands.registerCommand('${composition.projectId}.showModel', showModelCommand(dataConnection)));`
|
|
3356
|
+
: '';
|
|
3357
|
+
const glspBlock =
|
|
3358
|
+
diagrams.length > 0
|
|
3359
|
+
? `
|
|
3360
|
+
|
|
3361
|
+
// The GLSP head, and the connector bridging it to every diagram webview. No
|
|
3362
|
+
// \`messenger\` option: the connector's own keeps messages flowing to a hidden tab.
|
|
3363
|
+
const glspServer = new HydraniumGlspVscodeServer({
|
|
3364
|
+
clientId: '${composition.projectId}-glsp',
|
|
3365
|
+
clientName: '${composition.name}',
|
|
3366
|
+
findPort: () => findPort(running, ${upper}_GLSP_SERVER_PORT_COMMAND),
|
|
3367
|
+
logger: new ConsoleLogger().with('GLSP head')
|
|
3368
|
+
});
|
|
3369
|
+
const glspConnector = new HydraniumGlspVscodeConnector({ server: glspServer, logging: false });
|
|
3370
|
+
context.subscriptions.push(glspServer, glspConnector);
|
|
3371
|
+
// Not awaited, so a session that opens no diagram does not wait on the port.
|
|
3372
|
+
// \`start()\` resolves either way; a failure is reported off \`onReady\`.
|
|
3373
|
+
void glspServer.start();
|
|
3374
|
+
void glspServer.onReady.catch((error: unknown) => {
|
|
3375
|
+
void window.showErrorMessage(\`${composition.name} diagram unavailable: \${describeError(error)}\`);
|
|
3376
|
+
});
|
|
3377
|
+
${registrations.join('\n')}`
|
|
3378
|
+
: '';
|
|
3379
|
+
const opening = wrapComment(
|
|
3380
|
+
`The ${composition.name} VS Code extension: ${hostVscodeParts(composition, ', started the way the servers extension starts it')}.${sockets ? ' Node extension host only: the server is forked and its socket heads reached over TCP.' : ''}`
|
|
3381
|
+
);
|
|
3382
|
+
const activation = sockets
|
|
3383
|
+
? ` const running = await ${start}(context);
|
|
3384
|
+
client = running;${dataBlock}${glspBlock}`
|
|
3385
|
+
: ` client = await ${start}(context);`;
|
|
3386
|
+
return `${opening}
|
|
3387
|
+
|
|
3388
|
+
${imports.join('\n')}
|
|
3389
|
+
|
|
3390
|
+
let client: LanguageClient | undefined;
|
|
3391
|
+
|
|
3392
|
+
export async function activate(context: ExtensionContext): Promise<void> {
|
|
3393
|
+
${activation}
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
export async function deactivate(): Promise<void> {
|
|
3397
|
+
await client?.stop();
|
|
3398
|
+
client = undefined;
|
|
3399
|
+
}
|
|
3400
|
+
`;
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
/** A smoke test's title: what it checks of each head the composition has. */
|
|
3404
|
+
function hostVscodeSmokeTitle(composition: InitComposition): string {
|
|
3405
|
+
return listPhrase([
|
|
3406
|
+
...(composition.heads.includes('data') ? ['reads the model'] : []),
|
|
3407
|
+
...(composition.grammars.some(grammar => grammar.diagram) ? ['draws the diagram'] : []),
|
|
3408
|
+
'reports a broken reference'
|
|
3409
|
+
]);
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
/**
|
|
3413
|
+
* The smoke test: VS Code opens the starter workspace with the extension
|
|
3414
|
+
* loaded, Show Model names the model with the data head, each diagram file
|
|
3415
|
+
* draws, and the server reports a broken reference.
|
|
3416
|
+
*/
|
|
3417
|
+
function hostVscodeSmokeSpec(composition: InitComposition): string {
|
|
3418
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
3419
|
+
const file = `nodes.${(diagram ?? composition.grammars[0]).extensions[0]}`;
|
|
3420
|
+
const diagramSteps = diagram
|
|
3421
|
+
? `
|
|
3422
|
+
// The diagram file opens in its diagram, which draws the two starter nodes.
|
|
3423
|
+
const webview = page.frameLocator('iframe.webview.ready').frameLocator('#active-frame');
|
|
3424
|
+
// In the editor's container: sprotty measures shapes in a hidden copy beside it.
|
|
3425
|
+
await expect(webview.locator('.hydranium-diagram-container .sprotty-node')).toHaveCount(2);
|
|
3426
|
+
// Then as text. The row is clicked, since Enter runs the best fuzzy match,
|
|
3427
|
+
// and its name ends in its keybinding, if it has one.
|
|
3428
|
+
await page.keyboard.press('ControlOrMeta+Shift+P');
|
|
3429
|
+
await page.locator('.quick-input-widget input').fill('>View: Reopen Editor with Text Editor');
|
|
3430
|
+
await page
|
|
3431
|
+
.locator('.quick-input-list')
|
|
3432
|
+
.getByRole('option', { name: /^View: Reopen Editor with Text Editor(,|$)/ })
|
|
3433
|
+
.click();
|
|
3434
|
+
`
|
|
3435
|
+
: '';
|
|
3436
|
+
const primary = diagram ?? composition.grammars[0];
|
|
3437
|
+
const showModelSteps = composition.heads.includes('data')
|
|
3438
|
+
? ` // The data head reads the open file.
|
|
3439
|
+
await page.keyboard.press('ControlOrMeta+Shift+P');
|
|
3440
|
+
await page.locator('.quick-input-widget input').fill('>${composition.name}: Show Model');
|
|
3441
|
+
await page
|
|
3442
|
+
.locator('.quick-input-list')
|
|
3443
|
+
.getByRole('option', { name: /^${composition.name}: Show Model(,|$)/ })
|
|
3444
|
+
.click();
|
|
3445
|
+
await expect(page.locator('.notification-toast').filter({ hasText: '${file} holds a ${primary.entryRule}' })).toBeVisible();
|
|
3446
|
+
`
|
|
3447
|
+
: '';
|
|
3448
|
+
const opening = wrapComment(
|
|
3449
|
+
`VS Code opens a copy of the starter workspace with this extension loaded, ${listPhrase([
|
|
3450
|
+
...(composition.heads.includes('data') ? ['the data head reads the file'] : []),
|
|
3451
|
+
...(diagram ? ['the diagram file draws in its diagram editor'] : []),
|
|
3452
|
+
'the server reports a reference that resolves to nothing'
|
|
3453
|
+
])}.`
|
|
3454
|
+
);
|
|
3455
|
+
return `${opening}
|
|
3456
|
+
|
|
3457
|
+
import { _electron, expect, test, type ElectronApplication, type Page } from '@playwright/test';
|
|
3458
|
+
import { downloadAndUnzipVSCode } from '@vscode/test-electron';
|
|
3459
|
+
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3460
|
+
import { tmpdir } from 'node:os';
|
|
3461
|
+
import { join } from 'node:path';
|
|
3462
|
+
import { fileURLToPath } from 'node:url';
|
|
3463
|
+
import { VSCODE_CACHE, VSCODE_VERSION } from './vscode-version.mts';
|
|
3464
|
+
|
|
3465
|
+
const extensionRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
3466
|
+
|
|
3467
|
+
test('${hostVscodeSmokeTitle(composition)}', async () => {
|
|
3468
|
+
// A copy, since the server writes its repairs to the folder it opens, and a
|
|
3469
|
+
// fresh profile, since a reused one restores the last run's editors.
|
|
3470
|
+
const scratch = mkdtempSync(join(tmpdir(), '${composition.projectId}-vscode-'));
|
|
3471
|
+
const workspace = join(scratch, 'workspace');
|
|
3472
|
+
cpSync(fileURLToPath(new URL('../../workspace', import.meta.url)), workspace, { recursive: true });
|
|
3473
|
+
let app: ElectronApplication | undefined;
|
|
3474
|
+
try {
|
|
3475
|
+
app = await _electron.launch({
|
|
3476
|
+
executablePath: await downloadAndUnzipVSCode({ version: VSCODE_VERSION, cachePath: VSCODE_CACHE }),
|
|
3477
|
+
args: [
|
|
3478
|
+
'--no-sandbox',
|
|
3479
|
+
'--disable-gpu-sandbox',
|
|
3480
|
+
'--disable-updates',
|
|
3481
|
+
'--skip-welcome',
|
|
3482
|
+
'--skip-release-notes',
|
|
3483
|
+
'--disable-workspace-trust',
|
|
3484
|
+
'--disable-extensions',
|
|
3485
|
+
\`--extensionDevelopmentPath=\${extensionRoot}\`,
|
|
3486
|
+
\`--user-data-dir=\${join(scratch, 'user-data')}\`,
|
|
3487
|
+
\`--extensions-dir=\${join(scratch, 'extensions')}\`,
|
|
3488
|
+
workspace
|
|
3489
|
+
]
|
|
3490
|
+
});
|
|
3491
|
+
const page = await app.firstWindow();
|
|
3492
|
+
await expect(page.locator('.monaco-workbench')).toBeVisible();
|
|
3493
|
+
await quickOpen(page, '${file}');
|
|
3494
|
+
${showModelSteps}${diagramSteps}
|
|
3495
|
+
const editor = page.locator('.monaco-editor .view-lines').first();
|
|
3496
|
+
await expect(editor).toContainText('node First');
|
|
3497
|
+
await editor.click();
|
|
3498
|
+
await page.keyboard.press('ControlOrMeta+End');
|
|
3499
|
+
// In one edit: typed key by key, the half-typed reference is reported on the way.
|
|
3500
|
+
await page.keyboard.insertText('\\nnode Broken -> Missing');
|
|
3501
|
+
await expect(page.locator('.monaco-editor .squiggly-error').first()).toBeVisible();
|
|
3502
|
+
} finally {
|
|
3503
|
+
await app?.close();
|
|
3504
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
3505
|
+
}
|
|
3506
|
+
});
|
|
3507
|
+
|
|
3508
|
+
/** Open \`file\` through Quick Open, so it gets the editor a user would. */
|
|
3509
|
+
async function quickOpen(page: Page, file: string): Promise<void> {
|
|
3510
|
+
await page.keyboard.press('ControlOrMeta+P');
|
|
3511
|
+
const input = page.locator('.quick-input-widget input');
|
|
3512
|
+
await input.fill(file);
|
|
3513
|
+
await expect(page.locator('.quick-input-list .monaco-list-row').first()).toContainText(file);
|
|
3514
|
+
await input.press('Enter');
|
|
3515
|
+
await expect(page.locator('.quick-input-widget')).toBeHidden();
|
|
3516
|
+
}
|
|
3517
|
+
`;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
/**
|
|
3521
|
+
* `text` broken at spaces into lines of at most 80 columns, the width
|
|
3522
|
+
* `check:docs` holds Markdown to. A link or code span stays whole, and runs
|
|
3523
|
+
* long on a line of its own.
|
|
3524
|
+
*/
|
|
3525
|
+
function wrapProse(text: string, width = 80): string[] {
|
|
3526
|
+
const lines: string[] = [];
|
|
3527
|
+
for (const piece of text.match(/!?\[[^\]]*\]\([^)]*\)\S*|`+[^`]*`+\S*|\S+/g) ?? []) {
|
|
3528
|
+
const last = lines.at(-1);
|
|
3529
|
+
if (last !== undefined && last.length + 1 + piece.length <= width) {
|
|
3530
|
+
lines[lines.length - 1] = `${last} ${piece}`;
|
|
3531
|
+
} else {
|
|
3532
|
+
lines.push(piece);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
return lines;
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
/** `text` as a `//` comment of lines at most 80 columns wide. */
|
|
3539
|
+
function wrapComment(text: string): string {
|
|
3540
|
+
return wrapProse(text, 77)
|
|
3541
|
+
.map(line => `// ${line}`)
|
|
3542
|
+
.join('\n');
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
/** The VS Code extension's README. */
|
|
3546
|
+
function hostVscodeReadme(composition: InitComposition): string {
|
|
3547
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3548
|
+
const data = composition.heads.includes('data');
|
|
3549
|
+
const intro = [
|
|
3550
|
+
diagrams.length > 0
|
|
3551
|
+
? `${composition.name} in VS Code: the language client, and a custom editor drawing each diagram file.`
|
|
3552
|
+
: `${composition.name} in VS Code: the language client.`,
|
|
3553
|
+
'The server is started by `../vscode-servers`, whose launch this extension calls, so the two never hand the server different documents.',
|
|
3554
|
+
socketHeads(composition).length > 0
|
|
3555
|
+
? 'It needs a Node extension host: the server is forked, and its socket heads are reached over TCP.'
|
|
3556
|
+
: 'It needs a Node extension host, since the server is forked.'
|
|
3557
|
+
];
|
|
3558
|
+
const dataNote = `\`${composition.name}: Show Model\`, in the Command Palette, reads the active file through the data head over a socket from the extension host, and names its root type. A webview reaches the data head too: see the Host in VS Code guide.`;
|
|
3559
|
+
const lines = [
|
|
3560
|
+
`# ${memberPackageName(composition, 'host-vscode')}`,
|
|
3561
|
+
'',
|
|
3562
|
+
...wrapProse(intro.join(' ')),
|
|
3563
|
+
...(data ? ['', ...wrapProse(dataNote)] : []),
|
|
3564
|
+
'',
|
|
3565
|
+
'```bash',
|
|
3566
|
+
`npm run build # copy the server’s grammars, type-check and compile${diagrams.length > 0 ? ', then bundle' : ''}`,
|
|
3567
|
+
`npm run watch # copy the grammars and recompile${diagrams.length > 0 ? ' and rebundle' : ''} on every change; Developer: Reload Window picks it up`,
|
|
3568
|
+
'npm start # VS Code with this extension, on ../workspace; needs `code` on the PATH',
|
|
3569
|
+
'npm run test:e2e:install # download the VS Code the smoke test drives, once',
|
|
3570
|
+
'npm run test:e2e # the smoke test; under `xvfb-run -a` on Linux without a display',
|
|
3571
|
+
'```',
|
|
3572
|
+
'',
|
|
3573
|
+
'## Layout',
|
|
3574
|
+
'',
|
|
3575
|
+
`- \`src/extension.ts\` — activation: ${listPhrase([
|
|
3576
|
+
'the server, through `../vscode-servers`',
|
|
3577
|
+
...(data ? ['the Show Model command'] : []),
|
|
3578
|
+
...(diagrams.length > 0 ? ['the diagram editors'] : [])
|
|
3579
|
+
])}`,
|
|
3580
|
+
...(data ? ['- `src/show-model-command.ts` — Show Model: the active file, read through the data head'] : []),
|
|
3581
|
+
...diagrams.flatMap(grammar => [
|
|
3582
|
+
`- \`src/${grammar.grammarId}-diagram-editor.ts\` — the \`${grammar.grammar}\` diagram’s custom editor; its view type is the one \`package.json\` declares`,
|
|
3583
|
+
`- \`src/webview/${grammar.grammarId}-diagram.ts\` — the webview it loads, over the client package’s diagram`
|
|
3584
|
+
]),
|
|
3585
|
+
...(diagrams.length > 0
|
|
3586
|
+
? ['- `src/webview/diagram.css` — the webview’s size and colours', '- `esbuild.mjs` — bundles each webview into `out/webview/`']
|
|
3587
|
+
: []),
|
|
3588
|
+
'- `start.mjs` — `npm start`: VS Code with this extension, on `../workspace`',
|
|
3589
|
+
'- `test/smoke.spec.mts` — VS Code opens the workspace, ' +
|
|
3590
|
+
(composition.heads.includes('data') ? 'Show Model names the model, ' : '') +
|
|
3591
|
+
(diagrams.length > 0 ? 'the diagram draws, ' : '') +
|
|
3592
|
+
'and the server reports a broken reference',
|
|
3593
|
+
'- `test/vscode-version.mts` — the VS Code release the smoke test runs',
|
|
3594
|
+
''
|
|
3595
|
+
];
|
|
3596
|
+
return project(lines.join('\n'), composition);
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/** The VS Code extension: the language client and a diagram editor per diagram grammar. */
|
|
3600
|
+
function hostVscodeFiles(composition: InitComposition): InitFile[] {
|
|
3601
|
+
const fixed = (file: string): InitFile => projectTemplate(`host-vscode/${file}`, composition);
|
|
3602
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3603
|
+
return [
|
|
3604
|
+
{ path: 'host-vscode/package.json', content: hostVscodePackageJson(composition) },
|
|
3605
|
+
ignoreFile('host-vscode'),
|
|
3606
|
+
{ path: 'host-vscode/README.md', content: hostVscodeReadme(composition) },
|
|
3607
|
+
fixed('tsconfig.json'),
|
|
3608
|
+
fixed('tsconfig.e2e.json'),
|
|
3609
|
+
fixed('playwright.config.mts'),
|
|
3610
|
+
fixed('start.mjs'),
|
|
3611
|
+
{ path: 'host-vscode/src/extension.ts', content: hostVscodeExtensionFile(composition) },
|
|
3612
|
+
...(composition.heads.includes('data') ? [fixed('src/show-model-command.ts')] : []),
|
|
3613
|
+
...(diagrams.length > 0 ? [fixed('tsconfig.webview.json'), fixed('esbuild.mjs'), fixed('src/webview/diagram.css')] : []),
|
|
3614
|
+
...diagrams.flatMap(grammar => [
|
|
3615
|
+
grammarTemplate('host-vscode/src/__GRAMMAR_ID__-diagram-editor.ts', composition, grammar),
|
|
3616
|
+
grammarTemplate('host-vscode/src/webview/__GRAMMAR_ID__-diagram.ts', composition, grammar)
|
|
3617
|
+
]),
|
|
3618
|
+
{ path: 'host-vscode/test/smoke.spec.mts', content: hostVscodeSmokeSpec(composition) },
|
|
3619
|
+
fixed('test/vscode-version.mts'),
|
|
3620
|
+
fixed('test/install-vscode.mts')
|
|
3621
|
+
];
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
/**
|
|
3625
|
+
* The Theia release of every Theia member, the one the framework's own
|
|
3626
|
+
* Theia examples take, so a scaffold builds against what the repo tests: the
|
|
3627
|
+
* Theia community release. Exact, as Theia's members pin each other, so a
|
|
3628
|
+
* workspace root declares it too and GLSP's open Theia peer resolves to it.
|
|
3629
|
+
*/
|
|
3630
|
+
const THEIA_VERSION = '1.74.3';
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* What each head adds to the Theia extension; `base` is the connection hardening
|
|
3634
|
+
* and editor sync every one gets. With the third-party entries below, they name
|
|
3635
|
+
* the peers of the framework's Theia packages, so an installer that adds no peers
|
|
3636
|
+
* still finds them.
|
|
3637
|
+
*/
|
|
3638
|
+
const THEIA_FRAMEWORK_DEPENDENCIES: Record<InitHead | 'base', readonly string[]> = {
|
|
3639
|
+
base: ['@hydranium/client-theia', '@hydranium/protocol'],
|
|
3640
|
+
lsp: [],
|
|
3641
|
+
data: ['@hydranium/data-client-theia'],
|
|
3642
|
+
glsp: ['@hydranium/glsp-client', '@hydranium/glsp-client-theia']
|
|
3643
|
+
};
|
|
3644
|
+
const THEIA_THIRD_PARTY_DEPENDENCIES: Record<InitHead | 'base', Readonly<Record<string, string>>> = {
|
|
3645
|
+
base: {
|
|
3646
|
+
'@theia/core': THEIA_VERSION,
|
|
3647
|
+
'@theia/editor': THEIA_VERSION,
|
|
3648
|
+
'@theia/filesystem': THEIA_VERSION,
|
|
3649
|
+
'@theia/output': THEIA_VERSION,
|
|
3650
|
+
inversify: '^6.1.3',
|
|
3651
|
+
'vscode-jsonrpc': '9.0.3'
|
|
3652
|
+
},
|
|
3653
|
+
lsp: {},
|
|
3654
|
+
data: { '@theia/workspace': THEIA_VERSION },
|
|
3655
|
+
glsp: {
|
|
3656
|
+
'@eclipse-glsp/client': '2.8.0',
|
|
3657
|
+
'@eclipse-glsp/theia-integration': '2.8.0',
|
|
3658
|
+
'@theia/process': THEIA_VERSION,
|
|
3659
|
+
'@theia/workspace': THEIA_VERSION
|
|
3660
|
+
}
|
|
3661
|
+
};
|
|
3662
|
+
|
|
3663
|
+
/** The Theia extension's third-party pins for `heads`, for its manifest and a workspace root's. */
|
|
3664
|
+
function theiaThirdPartyDependencies(heads: readonly InitHead[]): Array<[string, string]> {
|
|
3665
|
+
return (['base', ...heads] as const).flatMap(head => Object.entries(THEIA_THIRD_PARTY_DEPENDENCIES[head]));
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
/**
|
|
3669
|
+
* The Theia packages the application assembles: the workbench a user expects,
|
|
3670
|
+
* and `plugin-ext` with its VS Code API to sideload the servers extension.
|
|
3671
|
+
*/
|
|
3672
|
+
const HOST_THEIA_BROWSER_DEPENDENCIES: readonly string[] = [
|
|
3673
|
+
'@theia/core',
|
|
3674
|
+
'@theia/editor',
|
|
3675
|
+
'@theia/filesystem',
|
|
3676
|
+
'@theia/markers',
|
|
3677
|
+
'@theia/messages',
|
|
3678
|
+
'@theia/monaco',
|
|
3679
|
+
'@theia/navigator',
|
|
3680
|
+
'@theia/output',
|
|
3681
|
+
'@theia/plugin-ext',
|
|
3682
|
+
'@theia/plugin-ext-vscode',
|
|
3683
|
+
'@theia/preferences',
|
|
3684
|
+
'@theia/process',
|
|
3685
|
+
'@theia/workspace'
|
|
3686
|
+
];
|
|
3687
|
+
|
|
3688
|
+
/** The Theia tooling the application builds with. */
|
|
3689
|
+
const HOST_THEIA_BROWSER_DEV_DEPENDENCIES: readonly string[] = ['@theia/cli'];
|
|
3690
|
+
|
|
3691
|
+
/** Every Theia package the application declares, for a workspace root's pins. */
|
|
3692
|
+
function hostTheiaBrowserTheiaDependencies(): Array<[string, string]> {
|
|
3693
|
+
return [...HOST_THEIA_BROWSER_DEPENDENCIES, ...HOST_THEIA_BROWSER_DEV_DEPENDENCIES].map(name => [name, THEIA_VERSION]);
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3696
|
+
/** The Electron application's Theia packages: the browser application's, and Theia's Electron integration. */
|
|
3697
|
+
const HOST_THEIA_ELECTRON_DEPENDENCIES: readonly string[] = [...HOST_THEIA_BROWSER_DEPENDENCIES, '@theia/electron'].sort();
|
|
3698
|
+
|
|
3699
|
+
/** The Electron release `@theia/electron` peers on, exactly. */
|
|
3700
|
+
const ELECTRON_VERSION = '42.11.16';
|
|
3701
|
+
|
|
3702
|
+
/** Every package the Electron application pins, for a workspace root's pins. */
|
|
3703
|
+
function hostTheiaElectronPins(): Array<[string, string]> {
|
|
3704
|
+
return [
|
|
3705
|
+
...[...HOST_THEIA_ELECTRON_DEPENDENCIES, ...HOST_THEIA_BROWSER_DEV_DEPENDENCIES].map((name): [string, string] => [
|
|
3706
|
+
name,
|
|
3707
|
+
THEIA_VERSION
|
|
3708
|
+
]),
|
|
3709
|
+
['electron', ELECTRON_VERSION]
|
|
3710
|
+
];
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/** The Theia extension's `theiaExtensions` entries: one per head it forwards, and one per diagram. */
|
|
3714
|
+
function theiaExtensionEntries(composition: InitComposition): Array<Record<string, string>> {
|
|
3715
|
+
const id = composition.projectId;
|
|
3716
|
+
return [
|
|
3717
|
+
{
|
|
3718
|
+
frontendPreload: `lib/browser/${id}-connection-preload-module`,
|
|
3719
|
+
frontend: `lib/browser/${id}-frontend-module`,
|
|
3720
|
+
backend: `lib/node/${id}-connection-backend-module`
|
|
3721
|
+
},
|
|
3722
|
+
...(composition.heads.includes('data') ? [{ backend: `lib/node/${id}-data-backend-module` }] : []),
|
|
3723
|
+
...(composition.heads.includes('glsp') ? [{ backend: `lib/node/${id}-glsp-backend-module` }] : []),
|
|
3724
|
+
...composition.grammars
|
|
3725
|
+
.filter(grammar => grammar.diagram)
|
|
3726
|
+
.map(grammar => ({ frontend: `lib/browser/${grammar.grammarId}-diagram-frontend-module` }))
|
|
3727
|
+
];
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
/** The Theia extension's `package.json`. Private unless `--public`, as the client member is. */
|
|
3731
|
+
function theiaPackageJson(composition: InitComposition): string {
|
|
3732
|
+
const dependencies: Record<string, string> = {
|
|
3733
|
+
// The socket heads' host commands, and the data head's model type.
|
|
3734
|
+
...(socketHeads(composition).length > 0 ? { [memberPackageName(composition, 'protocol')]: MEMBER_PIN } : {}),
|
|
3735
|
+
...(composition.heads.includes('glsp') ? { [memberPackageName(composition, 'client')]: MEMBER_PIN } : {}),
|
|
3736
|
+
...Object.fromEntries(theiaThirdPartyDependencies(composition.heads))
|
|
3737
|
+
};
|
|
3738
|
+
for (const head of ['base', ...composition.heads] as const) {
|
|
3739
|
+
for (const framework of THEIA_FRAMEWORK_DEPENDENCIES[head]) {
|
|
3740
|
+
dependencies[framework] = frameworkPin(composition);
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
return `{
|
|
3744
|
+
"name": "${memberPackageName(composition, 'theia')}",
|
|
3745
|
+
"version": "0.0.0",
|
|
3746
|
+
${composition.packaging.private ? ' "private": true,\n' : ''} "description": "The ${composition.name} Theia extension: ${theiaParts(composition)}.",
|
|
3747
|
+
"license": "UNLICENSED",
|
|
3748
|
+
"author": "",
|
|
3749
|
+
"files": [
|
|
3750
|
+
"lib",
|
|
3751
|
+
"src",
|
|
3752
|
+
"style"
|
|
3753
|
+
],
|
|
3754
|
+
"scripts": {
|
|
3755
|
+
"build": "tsc",
|
|
3756
|
+
"clean": "rimraf lib tsconfig.tsbuildinfo",
|
|
3757
|
+
${lintScript(forMember(composition, 'theia'), 'src')} "watch": "tsc --watch"
|
|
3758
|
+
},
|
|
3759
|
+
"dependencies": {
|
|
3760
|
+
${sortedBlock(dependencies, ' ')}
|
|
3761
|
+
},
|
|
3762
|
+
"devDependencies": {
|
|
3763
|
+
${sortedBlock({ '@types/node': '^22.0.0', rimraf: '^5.0.0', typescript: '^7.0.2' }, ' ')}
|
|
3764
|
+
},
|
|
3765
|
+
"engines": {
|
|
3766
|
+
"node": ">=22.13"
|
|
3767
|
+
},
|
|
3768
|
+
"theiaExtensions": ${jsonAt(theiaExtensionEntries(composition), ' ')}
|
|
3769
|
+
}
|
|
3770
|
+
`;
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
/** What the Theia extension brings, as one phrase. */
|
|
3774
|
+
function theiaParts(composition: InitComposition): string {
|
|
3775
|
+
return listPhrase([
|
|
3776
|
+
'reconnect hardening',
|
|
3777
|
+
...(composition.heads.includes('data') ? ['a Show Model command reading a file through the data head'] : []),
|
|
3778
|
+
...(composition.grammars.some(grammar => grammar.diagram) ? ['a diagram editor for each diagram'] : [])
|
|
3779
|
+
]);
|
|
3780
|
+
}
|
|
3781
|
+
|
|
3782
|
+
/** The extension's one frontend module: the logger, the connection diagnostics and editor sync, and each head's client. */
|
|
3783
|
+
function theiaFrontendModuleFile(composition: InitComposition): string {
|
|
3784
|
+
const id = composition.projectId;
|
|
3785
|
+
const name = composition.name;
|
|
3786
|
+
const data = composition.heads.includes('data');
|
|
3787
|
+
const glsp = composition.heads.includes('glsp');
|
|
3788
|
+
const imports = [
|
|
3789
|
+
...(glsp ? ["import { GLSPClientContribution } from '@eclipse-glsp/theia-integration';"] : []),
|
|
3790
|
+
"import { bindChannelLogger, bindConnectionDiagnostics, bindEditorDiskSync } from '@hydranium/client-theia/browser';",
|
|
3791
|
+
...(data
|
|
3792
|
+
? [
|
|
3793
|
+
"import { bindDataConnection } from '@hydranium/data-client-theia/browser';",
|
|
3794
|
+
"import { CommandContribution } from '@theia/core';"
|
|
3795
|
+
]
|
|
3796
|
+
: []),
|
|
3797
|
+
"import { ContainerModule } from '@theia/core/shared/inversify';",
|
|
3798
|
+
...(data
|
|
3799
|
+
? [
|
|
3800
|
+
`import { ${name}DataConnection } from './${id}-data-connection.js';`,
|
|
3801
|
+
`import { ${name}DataPort } from './${id}-data-port.js';`
|
|
3802
|
+
]
|
|
3803
|
+
: []),
|
|
3804
|
+
...(glsp ? [`import { ${name}GlspClientContribution } from './${id}-glsp-client-contribution.js';`] : []),
|
|
3805
|
+
...(data ? [`import { ${name}ShowModelCommand } from './${id}-show-model-command.js';`] : [])
|
|
3806
|
+
];
|
|
3807
|
+
const dataBlock = data
|
|
3808
|
+
? `
|
|
3809
|
+
|
|
3810
|
+
// The data head: one connection, opened when the command first runs.
|
|
3811
|
+
bind(${name}DataPort).toSelf().inSingletonScope();
|
|
3812
|
+
bindDataConnection(bind, isBound, ${name}DataConnection);
|
|
3813
|
+
bind(${name}ShowModelCommand).toSelf().inSingletonScope();
|
|
3814
|
+
bind(CommandContribution).toService(${name}ShowModelCommand);`
|
|
3815
|
+
: '';
|
|
3816
|
+
const glspBlock = glsp
|
|
3817
|
+
? `
|
|
3818
|
+
|
|
3819
|
+
// One GLSP client for every diagram, so each diagram module skips binding its own.
|
|
3820
|
+
bind(${name}GlspClientContribution).toSelf().inSingletonScope();
|
|
3821
|
+
bind(GLSPClientContribution).toService(${name}GlspClientContribution);`
|
|
3822
|
+
: '';
|
|
3823
|
+
return `${wrapComment(
|
|
3824
|
+
`The ${name} Theia extension's frontend: ${listPhrase([
|
|
3825
|
+
'the connection’s lifecycle in an Output channel',
|
|
3826
|
+
'a warning when an outage outlasts the offline buffer',
|
|
3827
|
+
'editor disk sync',
|
|
3828
|
+
...(data ? ['the data head’s connection and the Show Model command'] : []),
|
|
3829
|
+
...(glsp ? ['the GLSP client the diagrams share'] : [])
|
|
3830
|
+
])}.`
|
|
3831
|
+
)}
|
|
3832
|
+
|
|
3833
|
+
${imports.join('\n')}
|
|
3834
|
+
|
|
3835
|
+
export default new ContainerModule((bind, _unbind, isBound, rebind) => {
|
|
3836
|
+
bindChannelLogger(bind, { channelName: '${name} Connection' });
|
|
3837
|
+
bindConnectionDiagnostics(bind);
|
|
3838
|
+
// Keeps an editor from writing over a file a head saved while it held the same text unsaved.
|
|
3839
|
+
bindEditorDiskSync(bind, isBound, rebind);${dataBlock}${glspBlock}
|
|
3840
|
+
});
|
|
3841
|
+
`;
|
|
3842
|
+
}
|
|
3843
|
+
|
|
3844
|
+
/** The Theia extension's README. */
|
|
3845
|
+
function theiaReadme(composition: InitComposition): string {
|
|
3846
|
+
const id = composition.projectId;
|
|
3847
|
+
const data = composition.heads.includes('data');
|
|
3848
|
+
const glsp = composition.heads.includes('glsp');
|
|
3849
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3850
|
+
const theiaApps = theiaApplicationPaths(composition);
|
|
3851
|
+
const intro = [
|
|
3852
|
+
`The ${composition.name} Theia extension: ${theiaParts(composition)}.`,
|
|
3853
|
+
socketHeads(composition).length > 0
|
|
3854
|
+
? 'The language client is not here: the application sideloads `../vscode-servers`, which starts the server and answers each socket head’s port under a host command, and the backend modules forward the page’s channels to those ports.'
|
|
3855
|
+
: 'The language client is not here: the application sideloads `../vscode-servers`, which starts the server.',
|
|
3856
|
+
theiaApps.length === 1 ? `The application in ${theiaApps[0]} runs it.` : `The applications in ${listPhrase(theiaApps)} run it.`
|
|
3857
|
+
];
|
|
3858
|
+
const before = [...(socketHeads(composition).length > 0 ? ['protocol'] : []), ...(glsp ? ['client'] : [])];
|
|
3859
|
+
const lines = [
|
|
3860
|
+
`# ${memberPackageName(composition, 'theia')}`,
|
|
3861
|
+
'',
|
|
3862
|
+
...wrapProse(intro.join(' ')),
|
|
3863
|
+
'',
|
|
3864
|
+
'```bash',
|
|
3865
|
+
'npm run build # compile into lib/' +
|
|
3866
|
+
(before.length > 0 ? `; build the ${before.join(' and ')} package${before.length > 1 ? 's' : ''} first` : ''),
|
|
3867
|
+
'```',
|
|
3868
|
+
'',
|
|
3869
|
+
'## Layout',
|
|
3870
|
+
'',
|
|
3871
|
+
`- \`src/browser/${id}-connection-preload-module.ts\` and \`src/node/${id}-connection-backend-module.ts\` — reconnect hardening, sized by the application’s \`connectionBufferBytes\``,
|
|
3872
|
+
`- \`src/browser/${id}-frontend-module.ts\` — the frontend bindings`,
|
|
3873
|
+
...(data
|
|
3874
|
+
? [
|
|
3875
|
+
`- \`src/browser/${id}-data-port.ts\` and \`${id}-data-connection.ts\` — the data head’s connection`,
|
|
3876
|
+
`- \`src/browser/${id}-show-model-command.ts\` — \`${composition.name}: Show Model\`, reading the current file through the data head`,
|
|
3877
|
+
`- \`src/node/${id}-data-backend-module.ts\` — forwards the data channel to the data head’s port`
|
|
3878
|
+
]
|
|
3879
|
+
: []),
|
|
3880
|
+
...(glsp
|
|
3881
|
+
? [
|
|
3882
|
+
`- \`src/common/${id}-glsp-contribution.ts\` — the id the GLSP client and its forwarder share`,
|
|
3883
|
+
`- \`src/browser/${id}-glsp-client-contribution.ts\` — the GLSP client every diagram shares`,
|
|
3884
|
+
`- \`src/node/${id}-glsp-backend-module.ts\` — forwards the GLSP channel to the GLSP head’s port`
|
|
3885
|
+
]
|
|
3886
|
+
: []),
|
|
3887
|
+
...diagrams.map(
|
|
3888
|
+
grammar =>
|
|
3889
|
+
`- \`src/browser/${grammar.grammarId}-diagram-frontend-module.ts\` — the \`${grammar.grammar}\` diagram editor, over the client package’s diagram`
|
|
3890
|
+
),
|
|
3891
|
+
...(diagrams.length > 0 ? ['- `style/diagram.css` — the diagrams’ colours, from the Theia theme'] : []),
|
|
3892
|
+
''
|
|
3893
|
+
];
|
|
3894
|
+
return project(lines.join('\n'), composition);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
/** The Theia extension: what a Theia application runs of the language, beside the sideloaded servers extension. */
|
|
3898
|
+
function theiaFiles(composition: InitComposition): InitFile[] {
|
|
3899
|
+
const fixed = (file: string): InitFile => ({
|
|
3900
|
+
path: project(`theia/${file}`, composition),
|
|
3901
|
+
content: project(template(`theia/${file}`), composition)
|
|
3902
|
+
});
|
|
3903
|
+
const id = composition.projectId;
|
|
3904
|
+
const data = composition.heads.includes('data');
|
|
3905
|
+
const glsp = composition.heads.includes('glsp');
|
|
3906
|
+
const diagrams = composition.grammars.filter(grammar => grammar.diagram);
|
|
3907
|
+
return [
|
|
3908
|
+
{ path: 'theia/package.json', content: theiaPackageJson(composition) },
|
|
3909
|
+
ignoreFile('theia'),
|
|
3910
|
+
{ path: 'theia/README.md', content: theiaReadme(composition) },
|
|
3911
|
+
fixed('tsconfig.json'),
|
|
3912
|
+
fixed('src/browser/__PROJECT_ID__-connection-preload-module.ts'),
|
|
3913
|
+
{ path: `theia/src/browser/${id}-frontend-module.ts`, content: theiaFrontendModuleFile(composition) },
|
|
3914
|
+
fixed('src/node/__PROJECT_ID__-connection-backend-module.ts'),
|
|
3915
|
+
...(data
|
|
3916
|
+
? [
|
|
3917
|
+
fixed('src/browser/__PROJECT_ID__-data-port.ts'),
|
|
3918
|
+
fixed('src/browser/__PROJECT_ID__-data-connection.ts'),
|
|
3919
|
+
fixed('src/browser/__PROJECT_ID__-show-model-command.ts'),
|
|
3920
|
+
fixed('src/node/__PROJECT_ID__-data-backend-module.ts')
|
|
3921
|
+
]
|
|
3922
|
+
: []),
|
|
3923
|
+
...(glsp
|
|
3924
|
+
? [
|
|
3925
|
+
fixed('src/common/__PROJECT_ID__-glsp-contribution.ts'),
|
|
3926
|
+
fixed('src/browser/__PROJECT_ID__-glsp-client-contribution.ts'),
|
|
3927
|
+
fixed('src/node/__PROJECT_ID__-glsp-backend-module.ts')
|
|
3928
|
+
]
|
|
3929
|
+
: []),
|
|
3930
|
+
...diagrams.map(grammar => grammarTemplate('theia/src/browser/__GRAMMAR_ID__-diagram-frontend-module.ts', composition, grammar)),
|
|
3931
|
+
...(diagrams.length > 0 ? [fixed('style/diagram.css')] : [])
|
|
3932
|
+
];
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
/**
|
|
3936
|
+
* The Theia application's `package.json`. Always private, as every host is.
|
|
3937
|
+
*
|
|
3938
|
+
* Theia's default `frontendConnectionTimeout` of 0 ends the session on any
|
|
3939
|
+
* dropped socket, and the page reloads; a minute lets the reconnect hardening
|
|
3940
|
+
* resume it.
|
|
3941
|
+
*/
|
|
3942
|
+
function hostTheiaBrowserPackageJson(composition: InitComposition): string {
|
|
3943
|
+
const dependencies: Record<string, string> = {
|
|
3944
|
+
[memberPackageName(composition, 'theia')]: MEMBER_PIN,
|
|
3945
|
+
...Object.fromEntries(HOST_THEIA_BROWSER_DEPENDENCIES.map(name => [name, THEIA_VERSION]))
|
|
3946
|
+
};
|
|
3947
|
+
const devDependencies: Record<string, string> = {
|
|
3948
|
+
// Built before this application, whose start links it into plugins/.
|
|
3949
|
+
[memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
|
|
3950
|
+
'@playwright/test': '^1.55.0',
|
|
3951
|
+
...Object.fromEntries(HOST_THEIA_BROWSER_DEV_DEPENDENCIES.map(name => [name, THEIA_VERSION])),
|
|
3952
|
+
'@types/node': '^22.0.0',
|
|
3953
|
+
rimraf: '^5.0.0',
|
|
3954
|
+
typescript: '^7.0.2'
|
|
3955
|
+
};
|
|
3956
|
+
const theia = {
|
|
3957
|
+
target: 'browser',
|
|
3958
|
+
frontend: {
|
|
3959
|
+
config: {
|
|
3960
|
+
applicationName: composition.name,
|
|
3961
|
+
connectionBufferBytes: 102400,
|
|
3962
|
+
preferences: { 'security.workspace.trust.enabled': false }
|
|
3963
|
+
}
|
|
3964
|
+
},
|
|
3965
|
+
backend: { config: { connectionBufferBytes: 102400, frontendConnectionTimeout: 60000 } }
|
|
3966
|
+
};
|
|
3967
|
+
return `{
|
|
3968
|
+
"name": "${memberPackageName(composition, 'host-theia-browser')}",
|
|
3969
|
+
"version": "0.0.0",
|
|
3970
|
+
"private": true,
|
|
3971
|
+
"description": "${composition.name} in a Theia browser application: the Theia extension, and the servers extension sideloaded for the language client.",
|
|
3972
|
+
"license": "UNLICENSED",
|
|
3973
|
+
"author": "",
|
|
3974
|
+
"scripts": {
|
|
3975
|
+
"build": "theia build --mode development && npm run typecheck",
|
|
3976
|
+
"clean": "rimraf lib src-gen plugins gen-esbuild.browser.mjs gen-esbuild.node.mjs smoke-workspace test-results playwright-report",
|
|
3977
|
+
${lintScript(forMember(composition, 'host-theia-browser'), 'test')} "start": "node start.mjs",
|
|
3978
|
+
"test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
|
|
3979
|
+
"test:e2e:install": "playwright install chromium",
|
|
3980
|
+
"typecheck": "tsc --noEmit -p tsconfig.e2e.json",
|
|
3981
|
+
"watch": "theia build --watch --mode development"
|
|
3982
|
+
},
|
|
3983
|
+
"dependencies": {
|
|
3984
|
+
${sortedBlock(dependencies, ' ')}
|
|
3985
|
+
},
|
|
3986
|
+
"devDependencies": {
|
|
3987
|
+
${sortedBlock(devDependencies, ' ')}
|
|
3988
|
+
},
|
|
3989
|
+
"engines": {
|
|
3990
|
+
"node": ">=22.13"
|
|
3991
|
+
},
|
|
3992
|
+
"theia": ${jsonAt(theia, ' ')}
|
|
3993
|
+
}
|
|
3994
|
+
`;
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
/**
|
|
3998
|
+
* The Theia smoke test: the application opens a copy of the starter
|
|
3999
|
+
* workspace, and each head answers. The steps are the same in both
|
|
4000
|
+
* applications; the browser one's page is Chromium on the server the
|
|
4001
|
+
* configuration starts, the Electron one's the window the test launches.
|
|
4002
|
+
*/
|
|
4003
|
+
function theiaSmokeSpec(composition: InitComposition, target: 'browser' | 'electron'): string {
|
|
4004
|
+
const data = composition.heads.includes('data');
|
|
4005
|
+
const diagram = composition.grammars.find(grammar => grammar.diagram);
|
|
4006
|
+
const primary = diagram ?? composition.grammars[0];
|
|
4007
|
+
const file = `nodes.${primary.extensions[0]}`;
|
|
4008
|
+
const showModelSteps = data
|
|
4009
|
+
? `
|
|
4010
|
+
// The data head reads the open file, with focus in the Explorer beside it.
|
|
4011
|
+
await page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' }).click();
|
|
4012
|
+
await expect(page.locator('.theia-FileTree:focus-within')).toHaveCount(1);
|
|
4013
|
+
await runCommand(page, '${composition.name}: Show Model');
|
|
4014
|
+
// \`first()\`: Theia lists a notification in its toast and in the notification centre.
|
|
4015
|
+
await expect(
|
|
4016
|
+
page.locator('.theia-notification-message').filter({ hasText: '${file} holds a ${primary.entryRule}' }).first()
|
|
4017
|
+
).toBeVisible();
|
|
4018
|
+
`
|
|
4019
|
+
: '';
|
|
4020
|
+
const diagramSteps = diagram
|
|
4021
|
+
? `
|
|
4022
|
+
// The diagram file opens in its diagram, which draws the two starter nodes.
|
|
4023
|
+
// In the editor's copy: sprotty measures shapes in a hidden one on the page.
|
|
4024
|
+
await expect(page.locator('.sprotty:not(.sprotty-hidden) .sprotty-node')).toHaveCount(2);
|
|
4025
|
+
`
|
|
4026
|
+
: '';
|
|
4027
|
+
const textSteps = diagram
|
|
4028
|
+
? ` // Then as text, through the Explorer's Open With.
|
|
4029
|
+
await page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' }).click({ button: 'right' });
|
|
4030
|
+
await page.locator('.lm-Menu-item', { hasText: 'Open With...' }).click();
|
|
4031
|
+
await page.locator('.quick-input-list .monaco-list-row', { hasText: 'Text Editor' }).click();
|
|
4032
|
+
`
|
|
4033
|
+
: '';
|
|
4034
|
+
return `${wrapComment(
|
|
4035
|
+
`Theia opens a copy of the starter workspace with this application's extensions, ${listPhrase([
|
|
4036
|
+
...(diagram ? ['the diagram file draws in its diagram editor'] : []),
|
|
4037
|
+
...(data ? ['the data head reads the file'] : []),
|
|
4038
|
+
'the server reports a reference that resolves to nothing'
|
|
4039
|
+
])}.`
|
|
4040
|
+
)}
|
|
4041
|
+
|
|
4042
|
+
${target === 'browser' ? "import { expect, test, type Page } from '@playwright/test';\n" : theiaElectronSmokeFixture(composition)}
|
|
4043
|
+
test('${listPhrase([...(diagram ? ['draws the diagram'] : []), ...(data ? ['reads the model'] : []), 'reports a broken reference'])}', async ({ page }) => {
|
|
4044
|
+
${target === 'browser' ? " await page.goto('/');\n" : ''} // Up once the loading screen is gone; a key pressed before then is lost.
|
|
4045
|
+
await expect(page.locator('.theia-preload')).toHaveCount(0);
|
|
4046
|
+
await page.keyboard.press('ControlOrMeta+Shift+E');
|
|
4047
|
+
await expect(page.locator('.theia-FileTree .theia-TreeNode', { hasText: '${file}' })).toBeVisible();
|
|
4048
|
+
await quickOpen(page, '${file}');
|
|
4049
|
+
${diagramSteps}${showModelSteps}
|
|
4050
|
+
${textSteps} const editor = page.locator('.monaco-editor .view-lines').first();
|
|
4051
|
+
await expect(editor).toContainText('node First');
|
|
4052
|
+
await editor.click();
|
|
4053
|
+
await page.keyboard.press('ControlOrMeta+End');
|
|
4054
|
+
// In one edit: typed key by key, the half-typed reference is reported on the way.
|
|
4055
|
+
await page.keyboard.insertText('\\nnode Broken -> Missing');
|
|
4056
|
+
await expect(page.locator('.monaco-editor .squiggly-error').first()).toBeVisible();
|
|
4057
|
+
});
|
|
4058
|
+
|
|
4059
|
+
/** Open \`file\` through Quick Open, so it gets the editor a user would. */
|
|
4060
|
+
async function quickOpen(page: Page, file: string): Promise<void> {
|
|
4061
|
+
await page.keyboard.press('ControlOrMeta+P');
|
|
4062
|
+
const input = page.locator('.quick-input-widget input');
|
|
4063
|
+
await input.fill(file);
|
|
4064
|
+
await expect(page.locator('.quick-input-list .monaco-list-row').first()).toContainText(file);
|
|
4065
|
+
await input.press('Enter');
|
|
4066
|
+
await expect(page.locator('.quick-input-widget')).toBeHidden();
|
|
4067
|
+
}
|
|
4068
|
+
${
|
|
4069
|
+
data
|
|
4070
|
+
? `
|
|
4071
|
+
/** Run the command whose palette entry reads \`label\`, checked before Enter, since the match is fuzzy. */
|
|
4072
|
+
async function runCommand(page: Page, label: string): Promise<void> {
|
|
4073
|
+
await page.keyboard.press('F1');
|
|
4074
|
+
const input = page.locator('.quick-input-widget input');
|
|
4075
|
+
await input.fill(\`>\${label}\`);
|
|
4076
|
+
await expect(page.locator('.quick-input-list .monaco-list-row.focused')).toContainText(label);
|
|
4077
|
+
await input.press('Enter');
|
|
4078
|
+
await expect(page.locator('.quick-input-widget')).toBeHidden();
|
|
4079
|
+
}
|
|
4080
|
+
`
|
|
4081
|
+
: ''
|
|
4082
|
+
}`;
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
/** The Theia application's README. */
|
|
4086
|
+
function hostTheiaBrowserReadme(composition: InitComposition): string {
|
|
4087
|
+
const intro = [
|
|
4088
|
+
`${composition.name} in a Theia browser application: the Theia extension in \`../theia\`, and \`../vscode-servers\` sideloaded through \`@theia/plugin-ext\`, which starts the server and its language client.`,
|
|
4089
|
+
'The backend runs the server, so this is a Node application; Theia serves the page.',
|
|
4090
|
+
...(composition.grammars.some(grammar => grammar.diagram)
|
|
4091
|
+
? ['The servers extension contributes no editor, so a diagram file opens in the Theia diagram editor alone.']
|
|
4092
|
+
: [])
|
|
4093
|
+
];
|
|
4094
|
+
const lines = [
|
|
4095
|
+
`# ${memberPackageName(composition, 'host-theia-browser')}`,
|
|
4096
|
+
'',
|
|
4097
|
+
...wrapProse(intro.join(' ')),
|
|
4098
|
+
'',
|
|
4099
|
+
'```bash',
|
|
4100
|
+
'npm run build # bundle and type-check; build the members first',
|
|
4101
|
+
'npm run watch # rebundle whenever the members’ builds change; reload the page to pick it up',
|
|
4102
|
+
'npm start # Theia at http://localhost:3000 on ../workspace; THEIA_PORT picks another port',
|
|
4103
|
+
'npm run test:e2e:install # download the Chromium the smoke test drives, once',
|
|
4104
|
+
'npm run test:e2e # the smoke test, which starts the application itself',
|
|
4105
|
+
'```',
|
|
4106
|
+
'',
|
|
4107
|
+
...wrapProse(
|
|
4108
|
+
'The `theia` block of `package.json` sets `frontendConnectionTimeout`, without which Theia ends the session on any dropped socket and the page reloads, and `connectionBufferBytes`, the messages kept while the socket is down; the Theia extension’s connection modules read both.'
|
|
4109
|
+
),
|
|
4110
|
+
'',
|
|
4111
|
+
'## Layout',
|
|
4112
|
+
'',
|
|
4113
|
+
'- `esbuild.mjs` — the bundling `theia build` runs. It keeps this file, so it is the place to adjust the bundle',
|
|
4114
|
+
'- `start.mjs` — `npm start`: links `../vscode-servers` into `plugins/`, then starts the backend on `THEIA_PORT`, on a folder given as its argument or `../workspace`',
|
|
4115
|
+
'- `playwright.config.mts` — the smoke tier, on port 4938 or `THEIA_PORT`, on a copy of `../workspace` in `smoke-workspace/`',
|
|
4116
|
+
'- `test/smoke.spec.mts` — Theia opens the workspace and each head answers',
|
|
4117
|
+
''
|
|
4118
|
+
];
|
|
4119
|
+
return project(lines.join('\n'), composition);
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
/** The Theia browser application: the Theia extension and the sideloaded servers extension. */
|
|
4123
|
+
function hostTheiaBrowserFiles(composition: InitComposition): InitFile[] {
|
|
4124
|
+
const fixed = (file: string): InitFile => projectTemplate(`host-theia-browser/${file}`, composition);
|
|
4125
|
+
return [
|
|
4126
|
+
{ path: 'host-theia-browser/package.json', content: hostTheiaBrowserPackageJson(composition) },
|
|
4127
|
+
ignoreFile('host-theia-browser'),
|
|
4128
|
+
{ path: 'host-theia-browser/README.md', content: hostTheiaBrowserReadme(composition) },
|
|
4129
|
+
fixed('esbuild.mjs'),
|
|
4130
|
+
fixed('start.mjs'),
|
|
4131
|
+
fixed('playwright.config.mts'),
|
|
4132
|
+
fixed('tsconfig.e2e.json'),
|
|
4133
|
+
{ path: 'host-theia-browser/test/smoke.spec.mts', content: theiaSmokeSpec(composition, 'browser') }
|
|
4134
|
+
];
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
/**
|
|
4138
|
+
* The Electron smoke test's imports and its `page`: the application's window,
|
|
4139
|
+
* launched by the test on a scratch copy of the workspace.
|
|
4140
|
+
*/
|
|
4141
|
+
function theiaElectronSmokeFixture(composition: InitComposition): string {
|
|
4142
|
+
return `import { expect, test as base, type ElectronApplication, type Page } from '@playwright/test';
|
|
4143
|
+
import { once } from 'node:events';
|
|
4144
|
+
import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs';
|
|
4145
|
+
import { tmpdir } from 'node:os';
|
|
4146
|
+
import { join } from 'node:path';
|
|
4147
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4148
|
+
|
|
4149
|
+
/**
|
|
4150
|
+
* The application in Electron, on a copy of the starter workspace, since the
|
|
4151
|
+
* server writes its repairs to the folder it opens, with a profile of its own:
|
|
4152
|
+
* Theia quits at once while another window holds one. Its settings are its own
|
|
4153
|
+
* too, so a run never writes \`~/.theia/settings.json\`.
|
|
4154
|
+
*/
|
|
4155
|
+
const test = base.extend<{ page: Page }>({
|
|
4156
|
+
page: async ({ playwright }, use) => {
|
|
4157
|
+
const scratch = mkdtempSync(join(tmpdir(), '${composition.projectId}-theia-electron-'));
|
|
4158
|
+
const workspace = join(scratch, 'workspace');
|
|
4159
|
+
// The servers extension, sideloaded from a plugins folder as \`npm start\` does.
|
|
4160
|
+
const plugins = join(scratch, 'plugins');
|
|
4161
|
+
const link = join(plugins, '${composition.projectId}-vscode-servers');
|
|
4162
|
+
let app: ElectronApplication | undefined;
|
|
4163
|
+
try {
|
|
4164
|
+
cpSync(fileURLToPath(new URL('../../workspace', import.meta.url)), workspace, { recursive: true });
|
|
4165
|
+
mkdirSync(plugins);
|
|
4166
|
+
symlinkSync(fileURLToPath(new URL('../../vscode-servers', import.meta.url)), link, 'junction');
|
|
4167
|
+
// Playwright resolves Electron itself; given its path, Theia opens this
|
|
4168
|
+
// folder instead of the workspace.
|
|
4169
|
+
app = await playwright._electron.launch({
|
|
4170
|
+
args: [
|
|
4171
|
+
fileURLToPath(new URL('..', import.meta.url)),
|
|
4172
|
+
'--no-sandbox',
|
|
4173
|
+
\`--user-data-dir=\${join(scratch, 'user-data')}\`,
|
|
4174
|
+
// In URI form, since Theia reads the argument as a URI.
|
|
4175
|
+
\`--plugins=local-dir:\${pathToFileURL(plugins).pathname}\`,
|
|
4176
|
+
workspace
|
|
4177
|
+
],
|
|
4178
|
+
env: {
|
|
4179
|
+
...process.env,
|
|
4180
|
+
THEIA_CONFIG_DIR: join(scratch, 'config'),
|
|
4181
|
+
// Menus drawn in the page, where the test can click them.
|
|
4182
|
+
THEIA_ELECTRON_DISABLE_NATIVE_ELEMENTS: '1'
|
|
4183
|
+
}
|
|
4184
|
+
});
|
|
4185
|
+
await use(await app.firstWindow());
|
|
4186
|
+
} finally {
|
|
4187
|
+
try {
|
|
4188
|
+
// Exited, not closed: closing asks whether to save the edited file. An
|
|
4189
|
+
// app that is already gone is left alone, so the test's own error stands.
|
|
4190
|
+
const child = app?.process();
|
|
4191
|
+
if (app && child?.exitCode === null && child.signalCode === null) {
|
|
4192
|
+
const exited = once(child, 'exit');
|
|
4193
|
+
await app.evaluate(({ app: electron }) => electron.exit(0));
|
|
4194
|
+
await exited;
|
|
4195
|
+
}
|
|
4196
|
+
} finally {
|
|
4197
|
+
// Unlinked first, so the remove never reaches \`../vscode-servers\` through it.
|
|
4198
|
+
rmSync(link, { force: true });
|
|
4199
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
});
|
|
4204
|
+
`;
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
/**
|
|
4208
|
+
* The Theia Electron application's `package.json`: the browser application's
|
|
4209
|
+
* `theia` block with the Electron target. Always private, as every host is.
|
|
4210
|
+
*/
|
|
4211
|
+
function hostTheiaElectronPackageJson(composition: InitComposition): string {
|
|
4212
|
+
const dependencies: Record<string, string> = {
|
|
4213
|
+
[memberPackageName(composition, 'theia')]: MEMBER_PIN,
|
|
4214
|
+
...Object.fromEntries(HOST_THEIA_ELECTRON_DEPENDENCIES.map(name => [name, THEIA_VERSION]))
|
|
4215
|
+
};
|
|
4216
|
+
const devDependencies: Record<string, string> = {
|
|
4217
|
+
// Built before this application, whose start links it into plugins/.
|
|
4218
|
+
[memberPackageName(composition, 'vscode-servers')]: MEMBER_PIN,
|
|
4219
|
+
'@playwright/test': '^1.55.0',
|
|
4220
|
+
...Object.fromEntries(HOST_THEIA_BROWSER_DEV_DEPENDENCIES.map(name => [name, THEIA_VERSION])),
|
|
4221
|
+
'@types/node': '^22.0.0',
|
|
4222
|
+
electron: ELECTRON_VERSION,
|
|
4223
|
+
rimraf: '^5.0.0',
|
|
4224
|
+
typescript: '^7.0.2'
|
|
4225
|
+
};
|
|
4226
|
+
const theia = {
|
|
4227
|
+
target: 'electron',
|
|
4228
|
+
frontend: {
|
|
4229
|
+
config: {
|
|
4230
|
+
applicationName: composition.name,
|
|
4231
|
+
connectionBufferBytes: 102400,
|
|
4232
|
+
preferences: { 'security.workspace.trust.enabled': false }
|
|
4233
|
+
}
|
|
4234
|
+
},
|
|
4235
|
+
backend: { config: { connectionBufferBytes: 102400, frontendConnectionTimeout: 60000 } }
|
|
4236
|
+
};
|
|
4237
|
+
return `{
|
|
4238
|
+
"name": "${memberPackageName(composition, 'host-theia-electron')}",
|
|
4239
|
+
"version": "0.0.0",
|
|
4240
|
+
"private": true,
|
|
4241
|
+
"description": "${composition.name} in a Theia Electron application: the Theia extension, and the servers extension sideloaded for the language client.",
|
|
4242
|
+
"license": "UNLICENSED",
|
|
4243
|
+
"author": "",
|
|
4244
|
+
"main": "lib/backend/electron-main.js",
|
|
4245
|
+
"scripts": {
|
|
4246
|
+
"build": "theia build --mode development && npm run typecheck",
|
|
4247
|
+
"clean": "rimraf lib src-gen plugins gen-esbuild.browser.mjs gen-esbuild.electron.mjs gen-esbuild.node.mjs test-results playwright-report",
|
|
4248
|
+
${lintScript(forMember(composition, 'host-theia-electron'), 'test')} "start": "node start.mjs",
|
|
4249
|
+
"test:e2e": "playwright test --tsconfig tsconfig.e2e.json",
|
|
4250
|
+
"typecheck": "tsc --noEmit -p tsconfig.e2e.json",
|
|
4251
|
+
"watch": "theia build --watch --mode development"
|
|
4252
|
+
},
|
|
4253
|
+
"dependencies": {
|
|
4254
|
+
${sortedBlock(dependencies, ' ')}
|
|
4255
|
+
},
|
|
4256
|
+
"devDependencies": {
|
|
4257
|
+
${sortedBlock(devDependencies, ' ')}
|
|
4258
|
+
},
|
|
4259
|
+
"engines": {
|
|
4260
|
+
"node": ">=22.13"
|
|
4261
|
+
},
|
|
4262
|
+
"theia": ${jsonAt(theia, ' ')}
|
|
4263
|
+
}
|
|
4264
|
+
`;
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
/** The Theia Electron application's README. */
|
|
4268
|
+
function hostTheiaElectronReadme(composition: InitComposition): string {
|
|
4269
|
+
const intro = [
|
|
4270
|
+
`${composition.name} in a Theia Electron application: the Theia extension in \`../theia\`, and \`../vscode-servers\` sideloaded through \`@theia/plugin-ext\`, which starts the server and its language client.`,
|
|
4271
|
+
'Electron runs the window, and a backend process it starts runs the server.',
|
|
4272
|
+
...(composition.grammars.some(grammar => grammar.diagram)
|
|
4273
|
+
? ['The servers extension contributes no editor, so a diagram file opens in the Theia diagram editor alone.']
|
|
4274
|
+
: [])
|
|
4275
|
+
];
|
|
4276
|
+
const lines = [
|
|
4277
|
+
`# ${memberPackageName(composition, 'host-theia-electron')}`,
|
|
4278
|
+
'',
|
|
4279
|
+
...wrapProse(intro.join(' ')),
|
|
4280
|
+
'',
|
|
4281
|
+
'```bash',
|
|
4282
|
+
'npm run build # bundle and type-check, and download Electron; build the members first',
|
|
4283
|
+
'npm run watch # rebundle whenever the members’ builds change; reload the window to pick it up',
|
|
4284
|
+
'npm start # the application on ../workspace',
|
|
4285
|
+
'npm run test:e2e # the smoke test, which launches the application itself',
|
|
4286
|
+
'```',
|
|
4287
|
+
'',
|
|
4288
|
+
...wrapProse(
|
|
4289
|
+
'There is no `theia rebuild:electron` step: Theia’s native modules are N-API builds, which load in Electron as `npm install` built them for Node.'
|
|
4290
|
+
),
|
|
4291
|
+
'',
|
|
4292
|
+
...wrapProse(
|
|
4293
|
+
'`npm run watch` reports a `theia-source-map-paths` JSON error as it starts: `esbuild.mjs` starts the browser and electron builds together, and they share `lib/frontend`. Later rebuilds are clean.'
|
|
4294
|
+
),
|
|
4295
|
+
'',
|
|
4296
|
+
'## Layout',
|
|
4297
|
+
'',
|
|
4298
|
+
'- `esbuild.mjs` — the bundling `theia build` runs. It keeps this file, so it is the place to adjust the bundle',
|
|
4299
|
+
'- `start.mjs` — `npm start`: links `../vscode-servers` into `plugins/`, then starts the application on a folder given as its argument or `../workspace`',
|
|
4300
|
+
'- `playwright.config.mts` — the smoke tier',
|
|
4301
|
+
'- `test/smoke.spec.mts` — Electron opens a copy of the workspace with a profile of its own, and each head answers',
|
|
4302
|
+
''
|
|
4303
|
+
];
|
|
4304
|
+
return project(lines.join('\n'), composition);
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
/** The Theia Electron application: the browser application's extensions in a desktop window. */
|
|
4308
|
+
function hostTheiaElectronFiles(composition: InitComposition): InitFile[] {
|
|
4309
|
+
const fixed = (file: string): InitFile => projectTemplate(`host-theia-electron/${file}`, composition);
|
|
4310
|
+
return [
|
|
4311
|
+
{ path: 'host-theia-electron/package.json', content: hostTheiaElectronPackageJson(composition) },
|
|
4312
|
+
ignoreFile('host-theia-electron'),
|
|
4313
|
+
{ path: 'host-theia-electron/README.md', content: hostTheiaElectronReadme(composition) },
|
|
4314
|
+
fixed('esbuild.mjs'),
|
|
4315
|
+
fixed('start.mjs'),
|
|
4316
|
+
fixed('playwright.config.mts'),
|
|
4317
|
+
fixed('tsconfig.e2e.json'),
|
|
4318
|
+
{ path: 'host-theia-electron/test/smoke.spec.mts', content: theiaSmokeSpec(composition, 'electron') }
|
|
4319
|
+
];
|
|
4320
|
+
}
|
|
4321
|
+
|
|
4322
|
+
/**
|
|
4323
|
+
* The formatter and linter a standalone root runs. Exact, because a formatter
|
|
4324
|
+
* release that changes its output would fail the project's `format:check`.
|
|
4325
|
+
*/
|
|
4326
|
+
const ROOT_STYLE_DEV_DEPENDENCIES: Readonly<Record<string, string>> = { oxfmt: '0.71.0', oxlint: '1.82.0' };
|
|
4327
|
+
|
|
4328
|
+
/**
|
|
4329
|
+
* The standalone root's formatter config: the style every template is written
|
|
4330
|
+
* in, at the width the emitted sources wrap at. Generated code is ignored
|
|
4331
|
+
* because each run of its generator rewrites it in that generator's style.
|
|
4332
|
+
*/
|
|
4333
|
+
function oxfmtConfig(composition: InitComposition): string {
|
|
4334
|
+
return `{
|
|
4335
|
+
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
4336
|
+
"arrowParens": "avoid",
|
|
4337
|
+
"endOfLine": "lf",
|
|
4338
|
+
"ignorePatterns": ["**/generated", "**/generated-hydranium", "**/syntaxes", "**/*.md"],
|
|
4339
|
+
"jsxSingleQuote": true,
|
|
4340
|
+
"printWidth": ${columnsFor(composition)},
|
|
4341
|
+
"singleQuote": true,
|
|
4342
|
+
"sortPackageJson": true,
|
|
4343
|
+
"tabWidth": 3,
|
|
4344
|
+
"trailingComma": "none",
|
|
4345
|
+
"overrides": [
|
|
4346
|
+
{
|
|
4347
|
+
"files": ["*.json", "*.yml", "*.yaml"],
|
|
4348
|
+
"options": {
|
|
4349
|
+
"printWidth": 100,
|
|
4350
|
+
"tabWidth": 2
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
]
|
|
4354
|
+
}
|
|
4355
|
+
`;
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
/**
|
|
4359
|
+
* A standalone project's root: the members as npm workspaces.
|
|
4360
|
+
*
|
|
4361
|
+
* Always private: it is never published, whatever `--public` makes of the
|
|
4362
|
+
* members. npm runs a script across the members in the order listed here, not
|
|
4363
|
+
* by their dependencies; listing protocol first is safe because its `build`
|
|
4364
|
+
* runs the server's `generate` before compiling.
|
|
4365
|
+
*
|
|
4366
|
+
* It declares the exact pins `--monorepo` prints for a workspace root, for the
|
|
4367
|
+
* same reason: a package ranging over one, such as a framework package's peer,
|
|
4368
|
+
* otherwise takes the top of the tree with the newest match, and each member's
|
|
4369
|
+
* exact copy nests beneath it.
|
|
4370
|
+
*
|
|
4371
|
+
* `watch` keeps every non-host member built; `watch:<host>` adds one host, so
|
|
4372
|
+
* only the bundler of the host being worked on runs.
|
|
4373
|
+
*
|
|
4374
|
+
* `format` and `lint` run at the root over every member, which is why the
|
|
4375
|
+
* members get no `lint` of their own here.
|
|
4376
|
+
*/
|
|
4377
|
+
function rootFiles(composition: InitComposition): InitFile[] {
|
|
4378
|
+
const devDependencies = {
|
|
4379
|
+
...Object.fromEntries(workspaceRootPins(composition)),
|
|
4380
|
+
concurrently: '^9.0.0',
|
|
4381
|
+
...ROOT_STYLE_DEV_DEPENDENCIES
|
|
4382
|
+
};
|
|
4383
|
+
const members = initMembers(composition.heads, composition.hosts);
|
|
4384
|
+
// Only the server's watch is `tsc -b`, so protocol is built by one process
|
|
4385
|
+
// rather than two writing the same `lib/`; a host's watch joins only in its
|
|
4386
|
+
// own `watch:<host>`.
|
|
4387
|
+
const watched = members.filter(member => member !== 'protocol' && !member.startsWith('host-'));
|
|
4388
|
+
const parallel = (commands: Readonly<Record<string, string>>): string =>
|
|
4389
|
+
`concurrently --kill-others --names ${Object.keys(commands).join(',')} ${Object.values(commands)
|
|
4390
|
+
.map(command => `\\"${command}\\"`)
|
|
4391
|
+
.join(' ')}`;
|
|
4392
|
+
const scripts: Record<string, string> = {
|
|
4393
|
+
build: 'npm run build --workspaces',
|
|
4394
|
+
clean: 'npm run clean --workspaces --if-present',
|
|
4395
|
+
format: 'oxfmt',
|
|
4396
|
+
'format:check': 'oxfmt --check',
|
|
4397
|
+
lint: 'oxlint --report-unused-disable-directives --max-warnings 0',
|
|
4398
|
+
...Object.fromEntries(composition.hosts.map(host => [`start:${host}`, `npm --prefix host-${host} start`])),
|
|
4399
|
+
test: 'npm test --workspaces --if-present',
|
|
4400
|
+
watch: parallel(Object.fromEntries(watched.map(member => [member, `npm --prefix ${member} run watch`]))),
|
|
4401
|
+
// The browser host's `start` is its watch: it rebundles the page as it serves it.
|
|
4402
|
+
...Object.fromEntries(
|
|
4403
|
+
composition.hosts.map(host => [
|
|
4404
|
+
`watch:${host}`,
|
|
4405
|
+
parallel({
|
|
4406
|
+
watch: 'npm run watch',
|
|
4407
|
+
[`host-${host}`]: `npm --prefix host-${host} ${host === 'browser' ? 'start' : 'run watch'}`
|
|
4408
|
+
})
|
|
4409
|
+
])
|
|
4410
|
+
)
|
|
4411
|
+
};
|
|
4412
|
+
const content = `{
|
|
4413
|
+
"name": "${composition.projectId}-root",
|
|
4414
|
+
"version": "0.0.0",
|
|
4415
|
+
"private": true,
|
|
4416
|
+
"workspaces": [
|
|
4417
|
+
${members.map(member => ` "${member}"`).join(',\n')}
|
|
4418
|
+
],
|
|
4419
|
+
"scripts": {
|
|
4420
|
+
${Object.entries(scripts)
|
|
4421
|
+
.map(([name, command]) => ` "${name}": "${command}"`)
|
|
4422
|
+
.join(',\n')}
|
|
4423
|
+
},
|
|
4424
|
+
"devDependencies": {
|
|
4425
|
+
${sortedBlock(devDependencies, ' ')}
|
|
4426
|
+
},
|
|
4427
|
+
"engines": {
|
|
4428
|
+
"node": ">=22.13"
|
|
4429
|
+
}
|
|
4430
|
+
}
|
|
4431
|
+
`;
|
|
4432
|
+
return [
|
|
4433
|
+
{ path: 'package.json', content },
|
|
4434
|
+
ignoreFile(''),
|
|
4435
|
+
{ path: '.editorconfig', content: template('editorconfig') },
|
|
4436
|
+
{ path: '.oxfmtrc.json', content: oxfmtConfig(composition) },
|
|
4437
|
+
{ path: '.oxlintrc.json', content: template('oxlintrc.json') }
|
|
4438
|
+
];
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/**
|
|
4442
|
+
* Every file the scaffold emits, fully rendered, at its path in the project
|
|
4443
|
+
* directory. A workspace member's project gets no root of its own: it joins the
|
|
4444
|
+
* surrounding workspace, whose manifest `init` prints the changes for.
|
|
4445
|
+
*/
|
|
4446
|
+
export function buildInitTemplates(composition: InitComposition): InitFile[] {
|
|
4447
|
+
return [
|
|
4448
|
+
...(composition.packaging.workspace === undefined ? rootFiles(composition) : []),
|
|
4449
|
+
...protocolFiles(composition),
|
|
4450
|
+
...serverFiles(composition),
|
|
4451
|
+
...(composition.heads.includes('glsp') ? clientFiles(composition) : []),
|
|
4452
|
+
...(emitsVscodeServers(composition) ? vscodeServersFiles(composition) : []),
|
|
4453
|
+
...(composition.hosts.some(isTheiaHost) ? theiaFiles(composition) : []),
|
|
4454
|
+
...(composition.hosts.includes('browser') ? hostBrowserFiles(composition) : []),
|
|
4455
|
+
...(composition.hosts.includes('vscode') ? hostVscodeFiles(composition) : []),
|
|
4456
|
+
...(composition.hosts.includes('theia-browser') ? hostTheiaBrowserFiles(composition) : []),
|
|
4457
|
+
...(composition.hosts.includes('theia-electron') ? hostTheiaElectronFiles(composition) : []),
|
|
4458
|
+
...workspaceFiles(composition)
|
|
2036
4459
|
];
|
|
2037
4460
|
}
|