@kupcad/editor 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/README.md +76 -2
- package/package.json +60 -3
- package/src/Editor.svelte +74 -0
- package/src/editor.js +184 -0
- package/src/findings.js +74 -0
- package/src/index.js +17 -0
- package/src/language-server.js +163 -0
- package/src/language.js +131 -0
- package/src/svelte.js +4 -0
- package/src/theme.js +71 -0
- package/src/vocabulary.json +301 -0
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kupcad/editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CodeMirror 6 for a KupCAD (`.kup`) script, for the web hosts (the playground, the desktop app,
|
|
4
|
+
later the PWA; VS Code has its own editor): the language's highlighting, its blocks indented,
|
|
5
|
+
its words completed, and the core's findings marked where they are, or a language server's
|
|
6
|
+
features. A class any host can own,
|
|
7
|
+
and a Svelte component around it.
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { Editor } from '@kupcad/editor';
|
|
11
|
+
|
|
12
|
+
const editor = new Editor(element, {
|
|
13
|
+
value: source,
|
|
14
|
+
onchange: (value) => run(value), // the user's changes; a host's own (setValue) aren't told
|
|
15
|
+
label: 'bracket.kup', // what the script is, for a screen reader
|
|
16
|
+
});
|
|
17
|
+
editor.setFindings(await kupcad.check(editor.value)); // underlined, in the gutter, a fix one click away
|
|
18
|
+
editor.setFindings([{ severity: 'error', message, line, column }]); // a place a run named: a point
|
|
19
|
+
editor.reveal(line, column); // the cursor there, in view, the focus in the editor
|
|
20
|
+
editor.setValue(other);
|
|
21
|
+
const kept = editor.snapshot(); // plain JSON: the script, its selection and its undo history
|
|
22
|
+
editor.dispose();
|
|
23
|
+
// Another script shown meanwhile, this one back as it was left, undo and all; `value` put in
|
|
24
|
+
// place of the snapshot's text where the file changed since (itself undoable)
|
|
25
|
+
const again = new Editor(element, { value: source, restore: kept });
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```svelte
|
|
29
|
+
<script>
|
|
30
|
+
import { Editor } from '@kupcad/editor/svelte';
|
|
31
|
+
let source = $state('cube(10)\n');
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<Editor bind:value={source} {findings} bind:editor />
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
* **The language** (`language.js`): the literals are Ruby's (strings with interpolation, the
|
|
38
|
+
single-quoted ones, `%w` and `%q`, heredocs, symbols, comments), so CodeMirror's Ruby mode
|
|
39
|
+
reads them and indents the blocks; the words are the core's. `src/vocabulary.json` is the
|
|
40
|
+
lexer's keywords, every global function (`cube`, `param`, `raise`) and every method (a
|
|
41
|
+
Solid's, a value's, a class's), which `zig build grammar` writes from the core beside the VS
|
|
42
|
+
Code grammar, and a core test fails while it is behind. So `raise` is a function and `import`
|
|
43
|
+
a keyword, as KupCAD has them, not as Ruby does; `in` is a keyword after `for` alone. The same
|
|
44
|
+
words are completed: a word with the keywords and functions, a name after a dot with the
|
|
45
|
+
methods. `kupcad()` gives the language to a host's own CodeMirror.
|
|
46
|
+
* **Findings** (`findings.js`): the core places them by 1-based lines and columns in UTF-8 bytes
|
|
47
|
+
(`@kupcad/protocol`'s `characterAt` reads them); `check`'s have an end, and a fix becomes an
|
|
48
|
+
action in its tooltip, made only on the text it was found in. A place a run named (its error, a
|
|
49
|
+
warning) is a point. Marked in place of the last ones given, they move with the edits made
|
|
50
|
+
until the next ones come.
|
|
51
|
+
* **A language server** (`language-server.js`, `@codemirror/lsp-client`): for a host that runs
|
|
52
|
+
`kupcad lsp` (the desktop app), over any stream of bytes, its child process's stdin and
|
|
53
|
+
stdout. `streamTransport(stream, onError)` frames each message as LSP frames them on stdio
|
|
54
|
+
(`Content-Length`, then the JSON) and reads the server's frames back however its chunks are
|
|
55
|
+
cut (`MessageReader`); a stream that isn't framed so is `onError`'s, the messages before the
|
|
56
|
+
fault read and reading started over with the next chunk. `languageServerClient({ rootUri, notifications })` is the client with
|
|
57
|
+
every feature the package has, a server's own notifications (`kupcad/viewportReady`) handled
|
|
58
|
+
by the host. An editor given `languageServer: { client, uri }` opens its script on the server
|
|
59
|
+
(`kupcad`, its language) and takes the server's diagnostics, completion (in place of the
|
|
60
|
+
vocabulary's: the server knows the script's own names), hovers, signatures, formatting,
|
|
61
|
+
renaming and definitions; its host gives it no findings then.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const client = languageServerClient({ rootUri: 'file:///work' });
|
|
65
|
+
client.connect(streamTransport({ write, read }, (error) => log(error.message)));
|
|
66
|
+
new Editor(element, { value, languageServer: { client, uri: 'file:///work/part.kup' } });
|
|
67
|
+
```
|
|
68
|
+
* **The look** (`theme.js`): the `--kc-*` tokens of `@kupcad/ui` (the page's own colours where
|
|
69
|
+
they are unset), and the code's colours by `--kc-code-keyword`, `-string`, `-comment`,
|
|
70
|
+
`-constant`, `-function` and `-definition` (GitHub's, light or dark, where unset);
|
|
71
|
+
`--kc-code-font` and `--kc-code-font-size`.
|
|
72
|
+
|
|
73
|
+
Tests: the highlighting, indentation and completion on the parsed text, the findings placed (the
|
|
74
|
+
real core's `check`, through `@kupcad/wasm`) and their fixes made, a snapshot restored (its undo
|
|
75
|
+
history, its selection, a text changed since, one it can't read), the editor and its Svelte
|
|
76
|
+
component under jsdom, the LSP frames cut at every byte, and an editor opening its script on a
|
|
77
|
+
stand-in server whose diagnostics it marks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kupcad/editor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "KupCAD's code editor for the web: CodeMirror 6 with the language's highlighting and the core's findings marked, and a Svelte component around it.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kupcad/kupcad.git",
|
|
9
|
+
"directory": "packages/kupcad-editor"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.js",
|
|
14
|
+
"!src/**/*.test-helper.*"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.js",
|
|
19
|
+
"./svelte": {
|
|
20
|
+
"svelte": "./src/svelte.js",
|
|
21
|
+
"default": "./src/svelte.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@codemirror/autocomplete": "^6.20.4",
|
|
29
|
+
"@codemirror/commands": "^6.11.1",
|
|
30
|
+
"@codemirror/language": "^6.13.1",
|
|
31
|
+
"@codemirror/legacy-modes": "^6.5.5",
|
|
32
|
+
"@codemirror/lint": "^6.9.7",
|
|
33
|
+
"@codemirror/lsp-client": "^6.3.0",
|
|
34
|
+
"@codemirror/search": "^6.7.2",
|
|
35
|
+
"@codemirror/state": "^6.7.6",
|
|
36
|
+
"@codemirror/view": "^6.43.15",
|
|
37
|
+
"@kupcad/protocol": "0.1.1",
|
|
38
|
+
"@lezer/highlight": "^1.2.5"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@kupcad/wasm": "0.1.1",
|
|
42
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
43
|
+
"@testing-library/svelte": "^5.4.2",
|
|
44
|
+
"@types/node": "^26.6.5",
|
|
45
|
+
"jsdom": "^30.1.3",
|
|
46
|
+
"svelte": "^5.57.2",
|
|
47
|
+
"svelte-check": "^4.7.7",
|
|
48
|
+
"vite": "^8.3.5",
|
|
49
|
+
"vitest": "^5.0.5"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"svelte": "^5.57.2"
|
|
53
|
+
},
|
|
54
|
+
"peerDependenciesMeta": {
|
|
55
|
+
"svelte": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"check": "svelte-check --tsconfig ./jsconfig.json --fail-on-warnings",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
}
|
|
6
63
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The editor for Svelte: CodeMirror on a KupCAD script, filling its box. `value` is bindable (the
|
|
4
|
+
user's changes set it, a host's reach the editor), and so is `editor`, the Editor itself, for
|
|
5
|
+
what isn't state: `reveal(line, column)`, `focus()`. `findings` are marked as given; with a
|
|
6
|
+
`languageServer` (set when it mounts) the server's are, and no findings are given.
|
|
7
|
+
-->
|
|
8
|
+
<script>
|
|
9
|
+
import { onMount } from 'svelte';
|
|
10
|
+
import { Editor } from './editor.js';
|
|
11
|
+
|
|
12
|
+
/** @import { Mark } from './findings.js' */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} Props
|
|
16
|
+
* @property {string} [value] the script; bindable
|
|
17
|
+
* @property {readonly Mark[]} [findings] what to mark in it: `check`'s, a run's error
|
|
18
|
+
* @property {Editor | null} [editor] the Editor, once mounted; bindable
|
|
19
|
+
* @property {string} [label] what the script is, for a screen reader
|
|
20
|
+
* @property {(value: string) => void} [onchange] the user changed the script
|
|
21
|
+
* @property {import('./editor.js').EditorOptions['languageServer']} [languageServer] the
|
|
22
|
+
* server the script is known to, by its URI
|
|
23
|
+
* @property {unknown} [restore] an earlier editor's `snapshot()` of the script, its undo
|
|
24
|
+
* history with it (read when it mounts)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {Props} */
|
|
28
|
+
let {
|
|
29
|
+
value = $bindable(''),
|
|
30
|
+
findings = [],
|
|
31
|
+
editor = $bindable(null),
|
|
32
|
+
label = 'The script',
|
|
33
|
+
onchange,
|
|
34
|
+
languageServer,
|
|
35
|
+
restore,
|
|
36
|
+
} = $props();
|
|
37
|
+
|
|
38
|
+
/** @type {HTMLDivElement} */
|
|
39
|
+
let root;
|
|
40
|
+
|
|
41
|
+
onMount(() => {
|
|
42
|
+
const made = new Editor(root, {
|
|
43
|
+
value,
|
|
44
|
+
label,
|
|
45
|
+
languageServer,
|
|
46
|
+
restore,
|
|
47
|
+
onchange: (next) => {
|
|
48
|
+
value = next;
|
|
49
|
+
onchange?.(next);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
editor = made;
|
|
53
|
+
return () => {
|
|
54
|
+
made.dispose();
|
|
55
|
+
editor = null;
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
$effect(() => {
|
|
60
|
+
editor?.setValue(value);
|
|
61
|
+
});
|
|
62
|
+
$effect(() => {
|
|
63
|
+
editor?.setFindings(findings);
|
|
64
|
+
});
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div class="kc-editor" bind:this={root}></div>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.kc-editor {
|
|
71
|
+
height: 100%;
|
|
72
|
+
min-height: 0;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The editor: CodeMirror 6 for a KupCAD script, for any host; Editor.svelte wraps it for Svelte.
|
|
2
|
+
// It highlights the language and completes its words (language.js), indents its blocks, and
|
|
3
|
+
// marks the findings its host gives it (findings.js: the core's `check`, a run's error), each
|
|
4
|
+
// underlined and in the gutter, its fix an action. With a language server
|
|
5
|
+
// (language-server.js), the server completes, marks and answers instead: hovers, signatures,
|
|
6
|
+
// formatting, renaming, definitions. It tells its host of each change the user makes to the
|
|
7
|
+
// script; one the host makes (setValue) it doesn't. A host that shows another script in its
|
|
8
|
+
// place keeps this one's snapshot(), its undo history with it, and restores it (`restore`).
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
autocompletion,
|
|
12
|
+
closeBrackets,
|
|
13
|
+
closeBracketsKeymap,
|
|
14
|
+
completionKeymap,
|
|
15
|
+
} from '@codemirror/autocomplete';
|
|
16
|
+
import {
|
|
17
|
+
defaultKeymap,
|
|
18
|
+
history,
|
|
19
|
+
historyField,
|
|
20
|
+
historyKeymap,
|
|
21
|
+
indentWithTab,
|
|
22
|
+
} from '@codemirror/commands';
|
|
23
|
+
import {
|
|
24
|
+
bracketMatching,
|
|
25
|
+
indentOnInput,
|
|
26
|
+
indentUnit,
|
|
27
|
+
syntaxHighlighting,
|
|
28
|
+
} from '@codemirror/language';
|
|
29
|
+
import { lintGutter, lintKeymap, setDiagnostics } from '@codemirror/lint';
|
|
30
|
+
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
|
|
31
|
+
import { Annotation, EditorState } from '@codemirror/state';
|
|
32
|
+
import {
|
|
33
|
+
drawSelection,
|
|
34
|
+
EditorView,
|
|
35
|
+
highlightActiveLine,
|
|
36
|
+
highlightActiveLineGutter,
|
|
37
|
+
keymap,
|
|
38
|
+
lineNumbers,
|
|
39
|
+
} from '@codemirror/view';
|
|
40
|
+
import { diagnosticsOf, positionOf } from './findings.js';
|
|
41
|
+
import { kupcad } from './language.js';
|
|
42
|
+
import { editorTheme, highlightStyle } from './theme.js';
|
|
43
|
+
|
|
44
|
+
/** @import { LSPClient } from '@codemirror/lsp-client' */
|
|
45
|
+
/** @import { Mark } from './findings.js' */
|
|
46
|
+
|
|
47
|
+
/** A change the host made (setValue): not the user's, so not told back. */
|
|
48
|
+
const fromHost = Annotation.define();
|
|
49
|
+
/**
|
|
50
|
+
* The state a `snapshot()` was of, on `extensions`; null for none, or one it can't read.
|
|
51
|
+
* @param {unknown} snapshot
|
|
52
|
+
* @param {import('@codemirror/state').Extension[]} extensions
|
|
53
|
+
*/
|
|
54
|
+
function restoredState(snapshot, extensions) {
|
|
55
|
+
if (snapshot === undefined || snapshot === null) return null;
|
|
56
|
+
try {
|
|
57
|
+
return EditorState.fromJSON(snapshot, { extensions }, { history: historyField });
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Two spaces a level, as `kupcad fmt` indents. */
|
|
63
|
+
const INDENT = ' ';
|
|
64
|
+
/** The language, as a language server is told a document's (and VS Code names it). */
|
|
65
|
+
export const LANGUAGE_ID = 'kupcad';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} EditorOptions
|
|
69
|
+
* @property {string} [value] the script it starts with
|
|
70
|
+
* @property {(value: string) => void} [onchange] the user changed the script: it now is `value`
|
|
71
|
+
* @property {string} [label] what the script is, for a screen reader
|
|
72
|
+
* @property {{ client: LSPClient, uri: string }} [languageServer] the server the script is known
|
|
73
|
+
* to, by its URI: it completes and marks the script then, and the host gives no findings
|
|
74
|
+
* @property {unknown} [restore] a `snapshot()` of an editor of this script: it starts as that one
|
|
75
|
+
* was (its undo history, its selection), `value` put in place of its text where they differ;
|
|
76
|
+
* one it can't read is passed over
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
export class Editor {
|
|
80
|
+
/** @type {EditorView} */
|
|
81
|
+
#view;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {HTMLElement} parent where it goes
|
|
85
|
+
* @param {EditorOptions} [options]
|
|
86
|
+
*/
|
|
87
|
+
constructor(
|
|
88
|
+
parent,
|
|
89
|
+
{ value = '', onchange, label = 'The script', languageServer, restore } = {},
|
|
90
|
+
) {
|
|
91
|
+
const extensions = [
|
|
92
|
+
lineNumbers(),
|
|
93
|
+
highlightActiveLineGutter(),
|
|
94
|
+
history(),
|
|
95
|
+
drawSelection(),
|
|
96
|
+
indentOnInput(),
|
|
97
|
+
indentUnit.of(INDENT),
|
|
98
|
+
EditorState.tabSize.of(INDENT.length),
|
|
99
|
+
bracketMatching(),
|
|
100
|
+
closeBrackets(),
|
|
101
|
+
autocompletion(),
|
|
102
|
+
highlightActiveLine(),
|
|
103
|
+
highlightSelectionMatches(),
|
|
104
|
+
lintGutter(),
|
|
105
|
+
kupcad({ complete: !languageServer }),
|
|
106
|
+
syntaxHighlighting(highlightStyle),
|
|
107
|
+
editorTheme,
|
|
108
|
+
keymap.of([
|
|
109
|
+
...closeBracketsKeymap,
|
|
110
|
+
...defaultKeymap,
|
|
111
|
+
...searchKeymap,
|
|
112
|
+
...historyKeymap,
|
|
113
|
+
...completionKeymap,
|
|
114
|
+
...lintKeymap,
|
|
115
|
+
indentWithTab,
|
|
116
|
+
]),
|
|
117
|
+
languageServer ? languageServer.client.plugin(languageServer.uri, LANGUAGE_ID) : [],
|
|
118
|
+
EditorView.contentAttributes.of({ 'aria-label': label }),
|
|
119
|
+
EditorView.updateListener.of((update) => {
|
|
120
|
+
const user = !update.transactions.some((tr) => tr.annotation(fromHost));
|
|
121
|
+
if (update.docChanged && user) onchange?.(update.state.doc.toString());
|
|
122
|
+
}),
|
|
123
|
+
];
|
|
124
|
+
this.#view = new EditorView({
|
|
125
|
+
parent,
|
|
126
|
+
state: restoredState(restore, extensions) ?? EditorState.create({ doc: value, extensions }),
|
|
127
|
+
});
|
|
128
|
+
// The text the host gives now, where the snapshot's is another (the file changed since)
|
|
129
|
+
this.setValue(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The editor as it is now, its undo history with it, for `restore`: plain JSON. */
|
|
133
|
+
snapshot() {
|
|
134
|
+
return this.#view.state.toJSON({ history: historyField });
|
|
135
|
+
}
|
|
136
|
+
/** The script as it is now. */
|
|
137
|
+
get value() {
|
|
138
|
+
return this.#view.state.doc.toString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Puts `value` in place of the script, unless it is the script already; the host isn't told.
|
|
143
|
+
* @param {string} value
|
|
144
|
+
*/
|
|
145
|
+
setValue(value) {
|
|
146
|
+
if (value === this.value) return;
|
|
147
|
+
this.#view.dispatch({
|
|
148
|
+
changes: { from: 0, to: this.#view.state.doc.length, insert: value },
|
|
149
|
+
annotations: fromHost.of(true),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Marks `findings` in place of the ones marked, found in the script as it is now.
|
|
155
|
+
* @param {readonly Mark[]} findings
|
|
156
|
+
*/
|
|
157
|
+
setFindings(findings) {
|
|
158
|
+
const { state } = this.#view;
|
|
159
|
+
this.#view.dispatch(setDiagnostics(state, diagnosticsOf(state.doc, findings)));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Puts the cursor at a place the core names (a 1-based line and column, in UTF-8 bytes), in
|
|
164
|
+
* view, and gives the editor the focus.
|
|
165
|
+
* @param {number} line
|
|
166
|
+
* @param {number} column
|
|
167
|
+
*/
|
|
168
|
+
reveal(line, column) {
|
|
169
|
+
const at = positionOf(this.#view.state.doc, line, column);
|
|
170
|
+
this.#view.dispatch({
|
|
171
|
+
selection: { anchor: at },
|
|
172
|
+
effects: EditorView.scrollIntoView(at, { y: 'center' }),
|
|
173
|
+
});
|
|
174
|
+
this.#view.focus();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
focus() {
|
|
178
|
+
this.#view.focus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
dispose() {
|
|
182
|
+
this.#view.destroy();
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/findings.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The core's findings as CodeMirror's diagnostics: `check`'s, and a place a run named (its error,
|
|
2
|
+
// a warning) given as one. Each is underlined where it is and marked in the gutter, and a fix
|
|
3
|
+
// the core offers is an action in its tooltip. The core places them by 1-based lines and
|
|
4
|
+
// columns in UTF-8 bytes (@kupcad/protocol's characterAt reads them).
|
|
5
|
+
|
|
6
|
+
import { characterAt } from '@kupcad/protocol';
|
|
7
|
+
|
|
8
|
+
/** @import { Diagnostic } from '@codemirror/lint' */
|
|
9
|
+
/** @import { Text } from '@codemirror/state' */
|
|
10
|
+
/** @import { EditorView } from '@codemirror/view' */
|
|
11
|
+
/** @import { Finding, Fix } from '@kupcad/protocol/types' */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What the editor marks: a finding of `check`, or a place a run named, which has no end (the
|
|
15
|
+
* mark is a point there).
|
|
16
|
+
* @typedef {Pick<Finding, 'severity' | 'message' | 'line' | 'column'> & Partial<Pick<Finding, 'endLine' | 'endColumn' | 'code' | 'fix'>>} Mark
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where a place the core names (a 1-based line and column in UTF-8 bytes) is in `doc`: a line
|
|
21
|
+
* past its last is its last, a column past a line's end its end.
|
|
22
|
+
* @param {Text} doc
|
|
23
|
+
* @param {number} line
|
|
24
|
+
* @param {number} column
|
|
25
|
+
*/
|
|
26
|
+
export function positionOf(doc, line, column) {
|
|
27
|
+
const at = doc.line(Math.min(Math.max(line, 1), doc.lines));
|
|
28
|
+
return at.from + characterAt(at.text, column);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The marks as diagnostics in `doc`, the text they were found in.
|
|
33
|
+
* @param {Text} doc
|
|
34
|
+
* @param {readonly Mark[]} marks
|
|
35
|
+
* @returns {Diagnostic[]}
|
|
36
|
+
*/
|
|
37
|
+
export function diagnosticsOf(doc, marks) {
|
|
38
|
+
return marks.map((mark) => {
|
|
39
|
+
const from = positionOf(doc, mark.line, mark.column);
|
|
40
|
+
const end =
|
|
41
|
+
mark.endLine === undefined ? from : positionOf(doc, mark.endLine, mark.endColumn ?? 1);
|
|
42
|
+
/** @type {Diagnostic} */
|
|
43
|
+
const diagnostic = {
|
|
44
|
+
from,
|
|
45
|
+
to: Math.max(from, end),
|
|
46
|
+
severity: mark.severity,
|
|
47
|
+
message: mark.message,
|
|
48
|
+
};
|
|
49
|
+
if (mark.code) diagnostic.source = mark.code;
|
|
50
|
+
if (mark.fix) diagnostic.actions = [actionOf(mark.fix, doc)];
|
|
51
|
+
return diagnostic;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fix as an action: its edits made, on the text it was found in; once the text is another,
|
|
57
|
+
* nothing (its places are the old text's: the next check offers it again, placed in the new).
|
|
58
|
+
* @param {Fix} fix
|
|
59
|
+
* @param {Text} doc
|
|
60
|
+
*/
|
|
61
|
+
function actionOf(fix, doc) {
|
|
62
|
+
const changes = fix.edits.map((edit) => ({
|
|
63
|
+
from: positionOf(doc, edit.line, edit.column),
|
|
64
|
+
to: positionOf(doc, edit.endLine, edit.endColumn),
|
|
65
|
+
insert: edit.text,
|
|
66
|
+
}));
|
|
67
|
+
return {
|
|
68
|
+
name: fix.title,
|
|
69
|
+
/** @param {EditorView} view */
|
|
70
|
+
apply(view) {
|
|
71
|
+
if (view.state.doc.eq(doc)) view.dispatch({ changes });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @kupcad/editor: CodeMirror 6 for a KupCAD script, for any host (Editor), and for Svelte
|
|
2
|
+
// (`@kupcad/editor/svelte`); the language alone, for a host's own CodeMirror (kupcad()); and a
|
|
3
|
+
// client of `kupcad lsp` over a byte stream for it (language-server.js).
|
|
4
|
+
|
|
5
|
+
export { Editor, LANGUAGE_ID } from './editor.js';
|
|
6
|
+
export { kupcad } from './language.js';
|
|
7
|
+
export {
|
|
8
|
+
FramingError,
|
|
9
|
+
frameMessage,
|
|
10
|
+
languageServerClient,
|
|
11
|
+
MessageReader,
|
|
12
|
+
streamTransport,
|
|
13
|
+
} from './language-server.js';
|
|
14
|
+
|
|
15
|
+
/** @typedef {import('./findings.js').Mark} Mark */
|
|
16
|
+
/** @typedef {import('./language-server.js').ByteStream} ByteStream */
|
|
17
|
+
/** @typedef {import('@codemirror/lsp-client').LSPClient} LSPClient */
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// The editor's language server (@codemirror/lsp-client): `kupcad lsp` over a stream of bytes, a
|
|
2
|
+
// host's child process (the desktop app's), its messages framed as LSP frames them on stdio, a
|
|
3
|
+
// `Content-Length` header before each JSON body. The client gives the editor the server's
|
|
4
|
+
// diagnostics, completion, hover, signature help, formatting, renaming and definitions.
|
|
5
|
+
|
|
6
|
+
import { LSPClient, languageServerExtensions } from '@codemirror/lsp-client';
|
|
7
|
+
|
|
8
|
+
/** @import { Transport } from '@codemirror/lsp-client' */
|
|
9
|
+
|
|
10
|
+
const HEADER_END = new TextEncoder().encode('\r\n\r\n');
|
|
11
|
+
const CONTENT_LENGTH = /^content-length:\s*(\d+)\s*$/im;
|
|
12
|
+
/** A header block longer than this is no LSP header: the stream isn't the server's. */
|
|
13
|
+
const MAX_HEADER_BYTES = 1024;
|
|
14
|
+
|
|
15
|
+
/** A stream that isn't framed as LSP frames its messages. */
|
|
16
|
+
export class FramingError extends Error {
|
|
17
|
+
/** @param {string} message */
|
|
18
|
+
constructor(message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'FramingError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `message` (JSON) as LSP frames it: its length in UTF-8 bytes, then it.
|
|
26
|
+
* @param {string} message
|
|
27
|
+
* @returns {Uint8Array}
|
|
28
|
+
*/
|
|
29
|
+
export function frameMessage(message) {
|
|
30
|
+
const body = new TextEncoder().encode(message);
|
|
31
|
+
const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`);
|
|
32
|
+
const framed = new Uint8Array(header.length + body.length);
|
|
33
|
+
framed.set(header);
|
|
34
|
+
framed.set(body, header.length);
|
|
35
|
+
return framed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The messages in a stream of LSP frames, as its chunks arrive, cut wherever they are. A stream
|
|
40
|
+
* that isn't framed so (a header with no length, or none where one should be) is `onError`'s:
|
|
41
|
+
* the messages before the fault are read, what follows it is dropped, and reading starts over
|
|
42
|
+
* with the next chunk.
|
|
43
|
+
*/
|
|
44
|
+
export class MessageReader {
|
|
45
|
+
#pending = new Uint8Array(0);
|
|
46
|
+
/** @type {(error: FramingError) => void} */
|
|
47
|
+
#onError;
|
|
48
|
+
|
|
49
|
+
/** @param {(error: FramingError) => void} onError */
|
|
50
|
+
constructor(onError) {
|
|
51
|
+
this.#onError = onError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The messages `chunk` completes, in order; what it starts waits for the next.
|
|
56
|
+
* @param {Uint8Array} chunk
|
|
57
|
+
* @returns {string[]}
|
|
58
|
+
*/
|
|
59
|
+
push(chunk) {
|
|
60
|
+
const joined = new Uint8Array(this.#pending.length + chunk.length);
|
|
61
|
+
joined.set(this.#pending);
|
|
62
|
+
joined.set(chunk, this.#pending.length);
|
|
63
|
+
/** @type {string[]} */
|
|
64
|
+
const messages = [];
|
|
65
|
+
let at = 0;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const headerEnd = indexOf(joined, HEADER_END, at);
|
|
68
|
+
if (headerEnd === -1) {
|
|
69
|
+
if (joined.length - at > MAX_HEADER_BYTES) {
|
|
70
|
+
return this.#unframed(messages, `no LSP header in the first ${MAX_HEADER_BYTES} bytes`);
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
const header = new TextDecoder().decode(joined.subarray(at, headerEnd));
|
|
75
|
+
const length = CONTENT_LENGTH.exec(header)?.[1];
|
|
76
|
+
if (length === undefined) {
|
|
77
|
+
return this.#unframed(messages, `an LSP header with no length: ${header}`);
|
|
78
|
+
}
|
|
79
|
+
const bodyStart = headerEnd + HEADER_END.length;
|
|
80
|
+
const bodyEnd = bodyStart + Number(length);
|
|
81
|
+
if (bodyEnd > joined.length) break;
|
|
82
|
+
messages.push(new TextDecoder().decode(joined.subarray(bodyStart, bodyEnd)));
|
|
83
|
+
at = bodyEnd;
|
|
84
|
+
}
|
|
85
|
+
this.#pending = joined.slice(at);
|
|
86
|
+
return messages;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `messages`, read before a fault in the framing: what follows it is dropped.
|
|
91
|
+
* @param {string[]} messages
|
|
92
|
+
* @param {string} why
|
|
93
|
+
*/
|
|
94
|
+
#unframed(messages, why) {
|
|
95
|
+
this.#pending = new Uint8Array(0);
|
|
96
|
+
this.#onError(new FramingError(why));
|
|
97
|
+
return messages;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where `needle` first is in `bytes` from `from`, or -1.
|
|
103
|
+
* @param {Uint8Array} bytes
|
|
104
|
+
* @param {Uint8Array} needle
|
|
105
|
+
* @param {number} from
|
|
106
|
+
*/
|
|
107
|
+
function indexOf(bytes, needle, from) {
|
|
108
|
+
outer: for (let i = from; i + needle.length <= bytes.length; i++) {
|
|
109
|
+
for (let j = 0; j < needle.length; j++) if (bytes[i + j] !== needle[j]) continue outer;
|
|
110
|
+
return i;
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A byte stream to a language server: what is written to it, and what it sends.
|
|
117
|
+
* @typedef {object} ByteStream
|
|
118
|
+
* @property {(bytes: Uint8Array) => void} write
|
|
119
|
+
* @property {(onChunk: (chunk: Uint8Array) => void) => void} read calls `onChunk` with each chunk
|
|
120
|
+
* the server sends, from now on
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The client's transport over `stream`: each message framed on the way out, the frames read
|
|
125
|
+
* back into messages on the way in. A stream that isn't framed as LSP frames is `onError`'s.
|
|
126
|
+
* @param {ByteStream} stream
|
|
127
|
+
* @param {(error: FramingError) => void} onError
|
|
128
|
+
* @returns {Transport}
|
|
129
|
+
*/
|
|
130
|
+
export function streamTransport(stream, onError) {
|
|
131
|
+
/** @type {Set<(message: string) => void>} */
|
|
132
|
+
const handlers = new Set();
|
|
133
|
+
const reader = new MessageReader(onError);
|
|
134
|
+
stream.read((chunk) => {
|
|
135
|
+
for (const message of reader.push(chunk)) for (const handler of handlers) handler(message);
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
send: (message) => stream.write(frameMessage(message)),
|
|
139
|
+
subscribe: (handler) => void handlers.add(handler),
|
|
140
|
+
unsubscribe: (handler) => void handlers.delete(handler),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @typedef {object} LanguageServerOptions
|
|
146
|
+
* @property {string} rootUri the folder the server reads the scripts' imports from
|
|
147
|
+
* @property {Record<string, (client: LSPClient, params: any) => boolean>} [notifications] the
|
|
148
|
+
* server's notifications of its own (`kupcad/viewportReady`), by method
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A client of the language server, with every feature the editor takes from it: connect it
|
|
153
|
+
* (`client.connect(streamTransport(...))`), then give each editor `client.plugin(uri)`.
|
|
154
|
+
* @param {LanguageServerOptions} options
|
|
155
|
+
* @returns {LSPClient}
|
|
156
|
+
*/
|
|
157
|
+
export function languageServerClient({ rootUri, notifications = {} }) {
|
|
158
|
+
return new LSPClient({
|
|
159
|
+
rootUri,
|
|
160
|
+
extensions: languageServerExtensions(),
|
|
161
|
+
notificationHandlers: notifications,
|
|
162
|
+
});
|
|
163
|
+
}
|