@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.
- package/LICENSE +21 -0
- package/package.json +29 -0
- 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
|
+
}
|