@zubra/language-server 5.6.0 → 5.7.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/backend.js +123 -23
- package/package.json +1 -1
- package/server.js +14 -4
package/backend.js
CHANGED
|
@@ -1,39 +1,108 @@
|
|
|
1
1
|
// How the language server reaches the compiler: the WebAssembly build (@zubra/compiler) when it's
|
|
2
|
-
// installed in the
|
|
3
|
-
//
|
|
2
|
+
// installed in the project (the nearest one above the file being edited, or above the workspace),
|
|
3
|
+
// otherwise a CLI: the one named, ZUBRA_COMPILER, a cargo build in a zubra checkout, or `zubra` on
|
|
4
|
+
// PATH. Both give the same diagnostics, and both format (`format` answers { ok, output } or
|
|
5
|
+
// { ok: false, error }).
|
|
4
6
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve, win32 } from 'node:path';
|
|
7
9
|
import { pathToFileURL } from 'node:url';
|
|
8
10
|
|
|
11
|
+
const parent = (dir) => (dirname(dir) === dir ? null : dirname(dir));
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
|
-
* Where @zubra/compiler is installed for a
|
|
11
|
-
* The server can't rely on its own location for this: in the VS Code extension it
|
|
12
|
-
* extension's folder, and the compiler is in the project being edited.
|
|
14
|
+
* Where @zubra/compiler is installed for a folder: every node_modules above `root` that has it,
|
|
15
|
+
* nearest first. The server can't rely on its own location for this: in the VS Code extension it
|
|
16
|
+
* sits inside the extension's folder, and the compiler is in the project being edited.
|
|
13
17
|
*/
|
|
14
18
|
export function compilerEntries(root) {
|
|
15
19
|
const found = [];
|
|
16
|
-
for (let dir = root && resolve(root); dir; dir =
|
|
20
|
+
for (let dir = root && resolve(root); dir; dir = parent(dir)) {
|
|
17
21
|
const entry = join(dir, 'node_modules/@zubra/compiler/index.js');
|
|
18
22
|
if (existsSync(entry)) found.push(entry);
|
|
19
23
|
}
|
|
20
24
|
return found;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A CLI that `cargo build` made in a zubra checkout at or above `root` (target/debug or
|
|
29
|
+
* target/release, the newer of the two), as @zubra/server finds it. Null when there's none.
|
|
30
|
+
*/
|
|
31
|
+
export function checkoutCompiler(root, { exe = process.platform === 'win32' ? '.exe' : '' } = {}) {
|
|
32
|
+
for (let dir = root && resolve(root); dir; dir = parent(dir)) {
|
|
33
|
+
const built = ['debug', 'release'].map((profile) => join(dir, 'target', profile, `zubra${exe}`)).filter((cli) => existsSync(cli));
|
|
34
|
+
if (built.length) return built.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The file to run for `command`. On Windows, spawning without a shell neither searches PATHEXT nor
|
|
41
|
+
* runs a `.cmd`, so `zubra` installed from npm (a `zubra.cmd` shim) was never found: look it up
|
|
42
|
+
* here. Elsewhere the command is run as given.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveCommand(command, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
|
|
45
|
+
if (platform !== 'win32' || win32.extname(command)) return command;
|
|
46
|
+
const extensions = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
47
|
+
const folders = /[\\/]/.test(command) ? [''] : (env.PATH ?? env.Path ?? '').split(';').filter(Boolean);
|
|
48
|
+
for (const folder of folders) {
|
|
49
|
+
for (const extension of extensions) {
|
|
50
|
+
const candidate = folder ? win32.join(folder, command + extension) : command + extension;
|
|
51
|
+
if (exists(candidate)) return candidate;
|
|
32
52
|
}
|
|
33
53
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
54
|
+
return command;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The backend for a workspace. Without a compiler named, a file below the workspace that has its
|
|
59
|
+
* own @zubra/compiler (an app with its own node_modules) is answered by that one once `prepare`
|
|
60
|
+
* has loaded it; everything else by the workspace's. `onUnavailable(message)` is told once when
|
|
61
|
+
* the CLI can't be run, instead of every file getting an error that isn't about the file.
|
|
62
|
+
*/
|
|
63
|
+
export async function createBackend({ compiler, root, onUnavailable = (message) => console.error(message) } = {}) {
|
|
64
|
+
if (compiler) return cliBackend(() => resolveCommand(compiler), onUnavailable);
|
|
65
|
+
const loaded = new Map(); // entry → its backend, or null when its WebAssembly isn't built
|
|
66
|
+
const load = async (entry) => {
|
|
67
|
+
if (!loaded.has(entry)) loaded.set(entry, await wasmFrom(pathToFileURL(entry).href));
|
|
68
|
+
return loaded.get(entry);
|
|
69
|
+
};
|
|
70
|
+
let fallback = null;
|
|
71
|
+
for (const entry of compilerEntries(root)) if ((fallback = await load(entry))) break;
|
|
72
|
+
fallback ??= await wasmFrom('@zubra/compiler');
|
|
73
|
+
fallback ??= cliBackend(() => {
|
|
74
|
+
if (process.env.ZUBRA_COMPILER) return resolveCommand(process.env.ZUBRA_COMPILER);
|
|
75
|
+
return checkoutCompiler(root) ?? resolveCommand('zubra');
|
|
76
|
+
}, onUnavailable);
|
|
77
|
+
const nearest = (path) => {
|
|
78
|
+
for (const entry of compilerEntries(dirname(resolve(path)))) {
|
|
79
|
+
const backend = loaded.get(entry);
|
|
80
|
+
if (backend) return backend;
|
|
81
|
+
}
|
|
82
|
+
return fallback;
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
name: fallback.name,
|
|
86
|
+
/** Loads the @zubra/compiler nearest `path`, if there's one: call before working on a file. */
|
|
87
|
+
async prepare(path) {
|
|
88
|
+
for (const entry of compilerEntries(dirname(resolve(path)))) if (await load(entry)) return;
|
|
89
|
+
},
|
|
90
|
+
check: (path, ...rest) => nearest(path).check(path, ...rest),
|
|
91
|
+
manifest: (path, ...rest) => nearest(path).manifest(path, ...rest),
|
|
92
|
+
format: (path, ...rest) => nearest(path).format(path, ...rest),
|
|
93
|
+
formatRange: (path, ...rest) => nearest(path).formatRange(path, ...rest),
|
|
94
|
+
refactor: (path, ...rest) => nearest(path).refactor(path, ...rest),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function wasmFrom(specifier) {
|
|
99
|
+
try {
|
|
100
|
+
const wasm = await import(specifier);
|
|
101
|
+
if (wasm.available()) return wasmBackend(wasm);
|
|
102
|
+
} catch {
|
|
103
|
+
// Not installed there, or its WebAssembly isn't built.
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
37
106
|
}
|
|
38
107
|
|
|
39
108
|
function wasmBackend(wasm) {
|
|
@@ -101,13 +170,44 @@ function refactored(response) {
|
|
|
101
170
|
return { ok: false, reason: response?.diagnostics?.[0]?.message ?? response?.error ?? 'the compiler did not answer' };
|
|
102
171
|
}
|
|
103
172
|
|
|
104
|
-
|
|
105
|
-
|
|
173
|
+
/** How to run a CLI: a `.cmd` or `.bat` (an npm shim on Windows) only runs through the shell. */
|
|
174
|
+
function runner(command) {
|
|
175
|
+
if (/\.(cmd|bat)$/i.test(command)) {
|
|
176
|
+
const quote = (text) => `"${String(text).replace(/"/g, '""')}"`;
|
|
177
|
+
return (args, input) => spawnSync(quote(command), args.map(quote), { encoding: 'utf8', input, shell: true, windowsHide: true });
|
|
178
|
+
}
|
|
179
|
+
return (args, input) => spawnSync(command, args, { encoding: 'utf8', input, windowsHide: true });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The CLI `locate()` names. It's looked for again when it can't be run, so building or installing
|
|
184
|
+
* it works without restarting the editor. That it can't be run is said once, through
|
|
185
|
+
* `onUnavailable`, and isn't a diagnostic: it's about the setup, not the file.
|
|
186
|
+
*/
|
|
187
|
+
function cliBackend(locate, onUnavailable) {
|
|
188
|
+
let command = locate();
|
|
189
|
+
let told = false;
|
|
190
|
+
const run = (args, input) => {
|
|
191
|
+
let result = runner(command)(args, input);
|
|
192
|
+
if (result.error) {
|
|
193
|
+
const again = locate();
|
|
194
|
+
if (again !== command) {
|
|
195
|
+
command = again;
|
|
196
|
+
result = runner(command)(args, input);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!result.error) told = false;
|
|
200
|
+
else if (!told) {
|
|
201
|
+
told = true;
|
|
202
|
+
onUnavailable?.(`zubra: couldn't run the compiler \`${command}\` (${result.error.code ?? result.error.message}), so .zb files aren't checked or formatted. Install @zubra/compiler in the project (npm i -D @zubra/compiler), run \`cargo build\` in a zubra checkout, or set the zubra.compiler setting to the CLI's path.`);
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
};
|
|
106
206
|
return {
|
|
107
207
|
name: 'cli',
|
|
108
208
|
check(path, text) {
|
|
109
209
|
const result = run(['check', path, '--stdin', '--format', 'json'], text);
|
|
110
|
-
if (result.error) return [
|
|
210
|
+
if (result.error) return [];
|
|
111
211
|
try { return JSON.parse(result.stdout); } catch { return []; }
|
|
112
212
|
},
|
|
113
213
|
manifest(path) {
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// (`--stdio`).
|
|
7
7
|
//
|
|
8
8
|
// initializationOptions: { compiler?: "/path/to/zubra", printWidth?: number } — otherwise
|
|
9
|
-
// @zubra/compiler (WebAssembly) when installed
|
|
9
|
+
// @zubra/compiler (WebAssembly) when installed (the nearest above the file), else ZUBRA_COMPILER,
|
|
10
|
+
// a cargo build in a zubra checkout, or `zubra` on PATH. A compiler that can't be run is shown once
|
|
11
|
+
// as a warning, not as an error in every file. printWidth is the width a
|
|
10
12
|
// tag is fitted into when formatting (default 100); indentation comes from the editor.
|
|
11
13
|
import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind, DiagnosticSeverity, CompletionItemKind, InsertTextFormat, MarkupKind, CodeActionKind } from 'vscode-languageserver/node';
|
|
12
14
|
import { TextDocument } from 'vscode-languageserver-textdocument';
|
|
@@ -31,7 +33,11 @@ const client = { createsFiles: false, appliesEdits: false, actionLiterals: false
|
|
|
31
33
|
|
|
32
34
|
connection.onInitialize(async (params) => {
|
|
33
35
|
const folder = params.workspaceFolders?.[0]?.uri ?? params.rootUri;
|
|
34
|
-
backend = await createBackend({
|
|
36
|
+
backend = await createBackend({
|
|
37
|
+
...params.initializationOptions,
|
|
38
|
+
root: folder ? fileURLToPath(folder) : undefined,
|
|
39
|
+
onUnavailable: (message) => connection.window.showWarningMessage(message),
|
|
40
|
+
});
|
|
35
41
|
printWidth = Number(params.initializationOptions?.printWidth) || undefined;
|
|
36
42
|
const edits = params.capabilities?.workspace?.workspaceEdit;
|
|
37
43
|
const actions = params.capabilities?.textDocument?.codeAction;
|
|
@@ -82,9 +88,13 @@ function componentsOf(uri, text) {
|
|
|
82
88
|
const timers = new Map();
|
|
83
89
|
function validate(document) {
|
|
84
90
|
clearTimeout(timers.get(document.uri));
|
|
85
|
-
timers.set(document.uri, setTimeout(() => {
|
|
91
|
+
timers.set(document.uri, setTimeout(async () => {
|
|
86
92
|
const path = pathOf(document.uri);
|
|
87
|
-
|
|
93
|
+
await backend.prepare?.(path);
|
|
94
|
+
// The newest text, after loading: it may have changed meanwhile, or closed.
|
|
95
|
+
const current = documents.get(document.uri);
|
|
96
|
+
if (!current) return;
|
|
97
|
+
const text = current.getText();
|
|
88
98
|
const diagnostics = backend.check(path, text)
|
|
89
99
|
.filter((diagnostic) => resolve(diagnostic.file) === path || diagnostic.file === path)
|
|
90
100
|
.map((diagnostic) => {
|