@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/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @kupcad/editor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }