@8bitscript/language-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +29 -0
  3. package/src/server.mjs +187 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 8BitScript contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@8bitscript/language-server",
3
+ "version": "0.1.0",
4
+ "description": "Internal: the 8BitScript language server, served to editors over LSP.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=26"
9
+ },
10
+ "main": "./src/server.mjs",
11
+ "exports": {
12
+ ".": "./src/server.mjs",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src"
17
+ ],
18
+ "dependencies": {
19
+ "@8bitscript/compiler": "0.1.0",
20
+ "vscode-languageserver": "10.1.1",
21
+ "vscode-languageserver-textdocument": "1.0.14"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "node --test"
28
+ }
29
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,187 @@
1
+ // The 8BitScript language server.
2
+ //
3
+ // It contains no language knowledge of its own. Every diagnostic it publishes
4
+ // comes from @8bitscript/compiler — the same call `8bs check` makes — so the
5
+ // squiggle in the editor and the error in CI can never disagree.
6
+ //
7
+ // Despite the package names, `vscode-languageserver` is an editor-agnostic LSP
8
+ // implementation. This server speaks the protocol over stdio, so any client
9
+ // that speaks LSP can drive it: `8bs lsp --stdio` is all an editor needs.
10
+ import { existsSync, statSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ import {
15
+ createConnection,
16
+ ProposedFeatures,
17
+ TextDocuments,
18
+ TextDocumentSyncKind,
19
+ DiagnosticSeverity,
20
+ MarkupKind,
21
+ CompletionItemKind,
22
+ } from 'vscode-languageserver/node';
23
+ import { TextDocument } from 'vscode-languageserver-textdocument';
24
+
25
+ import { analyze, getHoverInfo, getCompletions } from '@8bitscript/compiler';
26
+
27
+ // What the compiler calls a completion item, in LSP's vocabulary. The
28
+ // compiler says what kind of thing a name is (a type, a compile-time
29
+ // function, a unit constant); this file only translates.
30
+ const COMPLETION_KIND = {
31
+ type: CompletionItemKind.TypeParameter,
32
+ function: CompletionItemKind.Function,
33
+ constant: CompletionItemKind.Constant,
34
+ };
35
+
36
+ const SEVERITY = {
37
+ error: DiagnosticSeverity.Error,
38
+ warning: DiagnosticSeverity.Warning,
39
+ };
40
+
41
+ /**
42
+ * Walk upward from `dir` looking for 8bs.config.ts, so a document opened
43
+ * from src/ (or deeper) still finds its project's config. Stops at the
44
+ * filesystem root.
45
+ */
46
+ function findConfigPath(dir) {
47
+ let current = dir;
48
+ for (;;) {
49
+ const candidate = join(current, '8bs.config.ts');
50
+ if (existsSync(candidate)) return candidate;
51
+ const parent = dirname(current);
52
+ if (parent === current) return null;
53
+ current = parent;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The project's `frameRate` (8bs.config.ts, default 60) for the document at
59
+ * `filePath` — so `#frames(...)` diagnostics in the editor agree with what
60
+ * `8bs build`/`8bs check` would actually report, the same invariant this
61
+ * file's header comment already promises for every other diagnostic.
62
+ *
63
+ * Mirrors packages/cli/src/config.mjs's resolveFrameRate, duplicated rather
64
+ * than imported: `@8bitscript/cli` already depends on this package (`8bs lsp
65
+ * --stdio`), so importing it back here would cycle.
66
+ *
67
+ * The `?t=<mtime>` on the dynamic import is a cache-buster: Node's ESM
68
+ * loader otherwise caches a resolved file URL for the life of the process,
69
+ * so an edited 8bs.config.ts would need a server restart to take effect
70
+ * without it.
71
+ */
72
+ async function frameRateFor(filePath) {
73
+ const configPath = findConfigPath(dirname(filePath));
74
+ if (!configPath) return 60;
75
+ try {
76
+ const { mtimeMs } = statSync(configPath);
77
+ const module = await import(`${pathToFileURL(configPath).href}?t=${mtimeMs}`);
78
+ const frameRate = module.default?.frameRate;
79
+ return Number.isInteger(frameRate) && frameRate > 0 ? frameRate : 60;
80
+ } catch {
81
+ return 60;
82
+ }
83
+ }
84
+
85
+ export function start() {
86
+ const connection = createConnection(ProposedFeatures.all);
87
+ const documents = new TextDocuments(TextDocument);
88
+
89
+ connection.onInitialize(() => ({
90
+ capabilities: {
91
+ // Full sync: files on this hardware are small, and incremental sync buys
92
+ // nothing until the compiler can reuse a previous parse.
93
+ textDocumentSync: TextDocumentSyncKind.Full,
94
+ hoverProvider: true,
95
+ completionProvider: { triggerCharacters: [':', '<'] },
96
+ },
97
+ serverInfo: { name: '8BitScript Language Server', version: '0.1.0' },
98
+ }));
99
+
100
+ /**
101
+ * Analyse one document and publish the result.
102
+ *
103
+ * Import resolution needs a real path on disk, so it is enabled only for
104
+ * `file:` documents. An untitled buffer still gets every lexical and checker
105
+ * diagnostic; it just cannot be asked whether its imports exist.
106
+ */
107
+ const validate = async (document) => {
108
+ const text = document.getText();
109
+ const { version } = document;
110
+ let path = null;
111
+ if (document.uri.startsWith('file://')) {
112
+ try {
113
+ path = fileURLToPath(document.uri);
114
+ } catch {
115
+ path = null;
116
+ }
117
+ }
118
+ const frameRate = path ? await frameRateFor(path) : 60;
119
+ // Two things can happen while frameRateFor() awaits the filesystem: a
120
+ // newer edit can land (the TextDocument is mutated in place, not
121
+ // replaced, so `document.version` — not object identity — is what
122
+ // reveals that), or the document can close (onDidClose already
123
+ // published empty diagnostics for it; publishing again here would
124
+ // resurrect them). Either way, an out-of-order publish would be wrong.
125
+ if (document.version !== version || documents.get(document.uri) !== document) return;
126
+ const diagnostics = analyze(text, path ?? document.uri, {
127
+ resolveImports: path !== null,
128
+ frameRate,
129
+ }).map((d) => ({
130
+ severity: SEVERITY[d.severity] ?? DiagnosticSeverity.Error,
131
+ range: {
132
+ start: document.positionAt(d.start),
133
+ end: document.positionAt(d.start + d.length),
134
+ },
135
+ code: d.code,
136
+ source: '8bs',
137
+ message: d.message,
138
+ }));
139
+ connection.sendDiagnostics({ uri: document.uri, diagnostics });
140
+ };
141
+
142
+ documents.onDidChangeContent((event) => validate(event.document));
143
+ documents.onDidClose((event) =>
144
+ connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] }),
145
+ );
146
+
147
+ // Both handlers below are protocol glue only: the compiler decides what a
148
+ // position means (a built-in type, `volatile`, `@address`, ...), this file
149
+ // just converts its answer to LSP shapes. No language knowledge lives here.
150
+ connection.onHover((params) => {
151
+ const document = documents.get(params.textDocument.uri);
152
+ if (!document) return null;
153
+
154
+ const offset = document.offsetAt(params.position);
155
+ const info = getHoverInfo(document.getText(), offset);
156
+ if (!info) return null;
157
+
158
+ return {
159
+ contents: { kind: MarkupKind.Markdown, value: info.markdown },
160
+ range: {
161
+ start: document.positionAt(info.start),
162
+ end: document.positionAt(info.start + info.length),
163
+ },
164
+ };
165
+ });
166
+
167
+ connection.onCompletion((params) => {
168
+ const document = documents.get(params.textDocument.uri);
169
+ if (!document) return [];
170
+
171
+ const offset = document.offsetAt(params.position);
172
+ return getCompletions(document.getText(), offset).map((item) => ({
173
+ label: item.label,
174
+ kind: COMPLETION_KIND[item.kind] ?? CompletionItemKind.TypeParameter,
175
+ detail: item.detail,
176
+ documentation: { kind: MarkupKind.Markdown, value: item.documentation },
177
+ // Canonical names sort ahead of short aliases within the same list.
178
+ sortText: `${item.sortRank}${item.label}`,
179
+ // Present only when what gets typed differs from the label — the
180
+ // `#` of a `#frames` already in the buffer, say.
181
+ ...(item.insertText ? { insertText: item.insertText } : {}),
182
+ }));
183
+ });
184
+
185
+ documents.listen(connection);
186
+ connection.listen();
187
+ }