@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.
Files changed (3) hide show
  1. package/backend.js +123 -23
  2. package/package.json +1 -1
  3. 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 workspace (or beside the server), otherwise the `zubra` CLI (or the one in `compiler`). Both give the same diagnostics,
3
- // and both format (`format` answers { ok, output } or { ok: false, error }).
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 workspace: the nearest node_modules above `root` that has it.
11
- * The server can't rely on its own location for this: in the VS Code extension it sits inside the
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 = dirname(dir) === dir ? null : dirname(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
- export async function createBackend({ compiler, root } = {}) {
24
- if (!compiler) {
25
- for (const specifier of [...compilerEntries(root).map((entry) => pathToFileURL(entry).href), '@zubra/compiler']) {
26
- try {
27
- const wasm = await import(specifier);
28
- if (wasm.available()) return wasmBackend(wasm);
29
- } catch {
30
- // Not installed there, or its WebAssembly isn't built.
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
- const chosen = compiler ?? process.env.ZUBRA_COMPILER;
35
- if (chosen) return cliBackend(chosen);
36
- return cliBackend('zubra');
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
- function cliBackend(compiler) {
105
- const run = (args, input) => spawnSync(compiler, args, { encoding: 'utf8', input });
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 [{ file: path, line: 1, column: 1, severity: 'error', message: `zubra: couldn't run the compiler \`${compiler}\` (${result.error.code ?? result.error.message}); install @zubra/compiler or the zubra CLI`, help: null }];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/language-server",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "Language server for .zb components: diagnostics, completion, hover and go to definition",
5
5
  "license": "ISC",
6
6
  "repository": {
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, else `zubra` on PATH. printWidth is the width a
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({ ...params.initializationOptions, root: folder ? fileURLToPath(folder) : undefined });
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
- const text = document.getText();
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) => {