@zubra/language-server 5.4.0 → 5.5.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 +67 -0
  2. package/package.json +1 -1
  3. package/server.js +130 -4
package/backend.js CHANGED
@@ -55,9 +55,52 @@ function wasmBackend(wasm) {
55
55
  const response = wasm.request({ command: 'format', path: absolute, files: { [absolute]: text }, ...options });
56
56
  return response.ok ? { ok: true, output: response.output } : { ok: false, error: response.error ?? '' };
57
57
  },
58
+ formatRange(path, text, range, options) {
59
+ const absolute = resolve(path);
60
+ const response = wasm.request({ command: 'formatRange', path: absolute, files: { [absolute]: text }, ...range, ...options });
61
+ return rangeEdit(response?.ok ? { ok: true, output: response.output } : { ok: false, error: response?.error ?? '' });
62
+ },
63
+ refactor(path, text, extraction) {
64
+ const absolute = resolve(path);
65
+ const files = wasm.sources(absolute);
66
+ files[absolute] = text;
67
+ for (const specifier of importsOf(text)) wasm.sources(resolve(dirname(absolute), specifier), files);
68
+ return refactored(wasm.request({ command: 'refactor', action: 'extract', path: absolute, files, ...extraction }));
69
+ },
58
70
  };
59
71
  }
60
72
 
73
+ /**
74
+ * A `formatRange` answer as { ok, edit } (edit: null when nothing changes) or { ok: false, error,
75
+ * unsupported? }. A compiler from before the command compiles the file instead: its answer isn't
76
+ * the command's.
77
+ */
78
+ function rangeEdit(answer) {
79
+ if (!answer.ok) return { ok: false, error: answer.error, unsupported: /unknown command|usage: zubra/.test(answer.error) };
80
+ try {
81
+ const parsed = JSON.parse(answer.output);
82
+ if (parsed && typeof parsed === 'object' && 'edit' in parsed) return { ok: true, edit: parsed.edit };
83
+ } catch {
84
+ // Not the command's answer.
85
+ }
86
+ return { ok: false, error: 'this compiler does not know range formatting', unsupported: true };
87
+ }
88
+
89
+ /** A `refactor` response as { ok, result } or { ok: false, reason, unsupported? }. */
90
+ function refactored(response) {
91
+ if (response?.ok) {
92
+ // A compiler from before the command doesn't know it and compiles the file instead.
93
+ try {
94
+ const result = JSON.parse(response.output);
95
+ if (typeof result?.fileSource === 'string' && Array.isArray(result.edits)) return { ok: true, result };
96
+ } catch {
97
+ // Not the command's answer.
98
+ }
99
+ return { ok: false, unsupported: true, reason: 'this compiler does not know the refactor command' };
100
+ }
101
+ return { ok: false, reason: response?.diagnostics?.[0]?.message ?? response?.error ?? 'the compiler did not answer' };
102
+ }
103
+
61
104
  function cliBackend(compiler) {
62
105
  const run = (args, input) => spawnSync(compiler, args, { encoding: 'utf8', input });
63
106
  return {
@@ -81,6 +124,30 @@ function cliBackend(compiler) {
81
124
  if (result.error) return { ok: false, error: result.error.message };
82
125
  return result.status === 0 ? { ok: true, output: result.stdout } : { ok: false, error: result.stderr };
83
126
  },
127
+ formatRange(path, text, range, { indentWidth, useTabs, printWidth } = {}) {
128
+ const absolute = resolve(path);
129
+ const request = { command: 'formatRange', path: absolute, files: { [absolute]: text }, ...range, indentWidth, useTabs, printWidth };
130
+ const result = run(['request'], JSON.stringify(request));
131
+ if (result.error) return { ok: false, error: result.error.message };
132
+ try {
133
+ const response = JSON.parse(result.stdout);
134
+ return rangeEdit(response.ok ? { ok: true, output: response.output } : { ok: false, error: response.error ?? '' });
135
+ } catch {
136
+ return { ok: false, error: result.stderr, unsupported: true };
137
+ }
138
+ },
139
+ refactor(path, text, extraction) {
140
+ const absolute = resolve(path);
141
+ const files = { [absolute]: text };
142
+ for (const specifier of importsOf(text)) {
143
+ const imported = resolve(dirname(absolute), specifier);
144
+ const source = readText(imported);
145
+ if (source !== null) files[imported] = source;
146
+ }
147
+ const result = run(['request'], JSON.stringify({ command: 'refactor', action: 'extract', path: absolute, files, ...extraction }));
148
+ if (result.error) return { ok: false, reason: result.error.message };
149
+ try { return refactored(JSON.parse(result.stdout)); } catch { return { ok: false, reason: result.stderr || 'the compiler did not answer', unsupported: true }; }
150
+ },
84
151
  };
85
152
  }
86
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zubra/language-server",
3
- "version": "5.4.0",
3
+ "version": "5.5.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
@@ -1,31 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  // zubra language server: diagnostics from the compiler as you type, completion of directives,
3
3
  // imported components, their props and events, hover documentation from their manifests, and
4
- // go to definition for imported components, and formatting (`zubra fmt`). Speaks LSP over stdio
4
+ // go to definition for imported components, formatting (`zubra fmt`), and the Extract to component
5
+ // refactor (a code action, and the `zubra.applyExtractComponent` command that takes a name). Speaks LSP over stdio
5
6
  // (`--stdio`).
6
7
  //
7
8
  // initializationOptions: { compiler?: "/path/to/zubra", printWidth?: number } — otherwise
8
9
  // @zubra/compiler (WebAssembly) when installed, else `zubra` on PATH. printWidth is the width a
9
10
  // tag is fitted into when formatting (default 100); indentation comes from the editor.
10
- import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind, DiagnosticSeverity, CompletionItemKind, InsertTextFormat, MarkupKind } from 'vscode-languageserver/node';
11
+ import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind, DiagnosticSeverity, CompletionItemKind, InsertTextFormat, MarkupKind, CodeActionKind } from 'vscode-languageserver/node';
11
12
  import { TextDocument } from 'vscode-languageserver-textdocument';
12
13
  import { fileURLToPath, pathToFileURL } from 'node:url';
13
14
  import { dirname, resolve } from 'node:path';
14
- import { statSync } from 'node:fs';
15
+ import { existsSync, statSync } from 'node:fs';
15
16
  import { createBackend, importsOf, readText } from './backend.js';
16
- import { completions, documentation, interfaceOf, offsetAt, positionAt, tagAt } from './analysis.js';
17
+ import { completions, documentation, hostOf, interfaceOf, offsetAt, positionAt, tagAt } from './analysis.js';
17
18
 
19
+ const EXTRACT_COMMAND = 'zubra.applyExtractComponent'; // not the editor command's name: the client registers the server's commands itself
18
20
  const connection = createConnection(ProposedFeatures.all);
19
21
  const documents = new TextDocuments(TextDocument);
20
22
  let backend;
21
23
  let printWidth;
22
24
  let toldAboutVersion = false;
25
+ let toldAboutRangeVersion = false;
23
26
  const manifests = new Map(); // path → { mtime, component }
24
27
 
28
+ // What the editor said it can do, for Extract to component: the edit creates a file, which not
29
+ // every client applies, and a client that doesn't know disabled actions would show one as usable.
30
+ const client = { createsFiles: false, appliesEdits: false, actionLiterals: false, disabledActions: false };
31
+
25
32
  connection.onInitialize(async (params) => {
26
33
  const folder = params.workspaceFolders?.[0]?.uri ?? params.rootUri;
27
34
  backend = await createBackend({ ...params.initializationOptions, root: folder ? fileURLToPath(folder) : undefined });
28
35
  printWidth = Number(params.initializationOptions?.printWidth) || undefined;
36
+ const edits = params.capabilities?.workspace?.workspaceEdit;
37
+ const actions = params.capabilities?.textDocument?.codeAction;
38
+ client.createsFiles = edits?.documentChanges === true && (edits.resourceOperations ?? []).includes('create');
39
+ client.appliesEdits = params.capabilities?.workspace?.applyEdit === true;
40
+ client.actionLiterals = !!actions?.codeActionLiteralSupport;
41
+ client.disabledActions = actions?.disabledSupport === true;
29
42
  return {
30
43
  capabilities: {
31
44
  textDocumentSync: TextDocumentSyncKind.Incremental,
@@ -33,6 +46,10 @@ connection.onInitialize(async (params) => {
33
46
  hoverProvider: true,
34
47
  definitionProvider: true,
35
48
  documentFormattingProvider: true,
49
+ documentRangeFormattingProvider: true,
50
+ // Only to an editor that can take the edit: a new file and changes to this one, together.
51
+ ...(client.createsFiles && client.actionLiterals ? { codeActionProvider: { codeActionKinds: [CodeActionKind.RefactorExtract] } } : {}),
52
+ ...(client.createsFiles && client.appliesEdits ? { executeCommandProvider: { commands: [EXTRACT_COMMAND] } } : {}),
36
53
  },
37
54
  serverInfo: { name: 'zubra-language-server', version: '1.2.0' },
38
55
  };
@@ -40,6 +57,10 @@ connection.onInitialize(async (params) => {
40
57
 
41
58
  const pathOf = (uri) => fileURLToPath(uri);
42
59
 
60
+ // Byte offsets (the compiler's) and string indexes (LSP's UTF-16 positions).
61
+ const byteOffset = (text, index) => Buffer.byteLength(text.slice(0, index));
62
+ const indexOfByte = (text, bytes) => Buffer.from(text).subarray(0, bytes).toString().length;
63
+
43
64
  /** The components a document imports: { tag, …interface, path }. */
44
65
  function componentsOf(uri, text) {
45
66
  const base = dirname(pathOf(uri));
@@ -148,5 +169,110 @@ connection.onDocumentFormatting(({ textDocument, options }) => {
148
169
  return [{ range: { start: { line: 0, character: 0 }, end: positionAt(text, text.length) }, newText: result.output }];
149
170
  });
150
171
 
172
+ // Format Selection (and format on paste): the lines the selection touches, as `zubra fmt` would
173
+ // write them in the whole file. One edit, or none when they're already formatted.
174
+ connection.onDocumentRangeFormatting(({ textDocument, range, options }) => {
175
+ const document = documents.get(textDocument.uri);
176
+ if (!document) return [];
177
+ const text = document.getText();
178
+ const answer = backend.formatRange?.(
179
+ pathOf(textDocument.uri),
180
+ text,
181
+ { start: byteOffset(text, offsetAt(text, range.start)), end: byteOffset(text, offsetAt(text, range.end)) },
182
+ { indentWidth: options.tabSize, useTabs: options.insertSpaces === false, printWidth },
183
+ );
184
+ if (!answer?.ok) {
185
+ if (answer?.unsupported && !toldAboutRangeVersion) {
186
+ toldAboutRangeVersion = true;
187
+ connection.window.showInformationMessage('Formatting a selection needs a newer Zubra: update @zubra/compiler (or the zubra CLI).');
188
+ }
189
+ return [];
190
+ }
191
+ if (!answer.edit) return [];
192
+ return [{
193
+ range: { start: positionAt(text, indexOfByte(text, answer.edit.start)), end: positionAt(text, indexOfByte(text, answer.edit.end)) },
194
+ newText: answer.edit.text,
195
+ }];
196
+ });
197
+
198
+ // ---- Extract to component -------------------------------------------------------------------
199
+ // The compiler (`refactor`, `action: "extract"`) decides what can be extracted and how; offsets
200
+ // there are UTF-8 bytes, LSP's are UTF-16 (`byteOffset` and `indexOfByte`, above).
201
+
202
+ /** A name for the new component that isn't taken beside the document. */
203
+ function defaultTag(uri, text) {
204
+ const base = `${hostOf(text)?.tag ?? 'my'}-part`;
205
+ const directory = dirname(pathOf(uri));
206
+ let tag = base;
207
+ for (let n = 2; existsSync(resolve(directory, `${tag}.zb`)); n++) tag = `${base}-${n}`;
208
+ return tag;
209
+ }
210
+
211
+ /** The workspace edit for extracting `range` of the document as `tag`, or why it can't be. */
212
+ function extraction(document, range, tag) {
213
+ const text = document.getText();
214
+ const start = byteOffset(text, offsetAt(text, range.start));
215
+ const end = byteOffset(text, offsetAt(text, range.end));
216
+ const made = backend.refactor?.(pathOf(document.uri), text, { start, end, tag });
217
+ if (!made) return { reason: 'This compiler cannot extract components: update @zubra/compiler (or the zubra CLI).' };
218
+ if (!made.ok) {
219
+ return { reason: made.unsupported ? 'Extracting a component needs a newer Zubra: update @zubra/compiler (or the zubra CLI).' : made.reason };
220
+ }
221
+ const { fileName, fileSource, edits } = made.result;
222
+ const newPath = resolve(dirname(pathOf(document.uri)), fileName);
223
+ // The edit creates the file and would fail on one that's there, after the name was chosen.
224
+ if (existsSync(newPath)) return { reason: `${fileName} already exists beside this file: choose another name.` };
225
+ const newUri = pathToFileURL(newPath).href;
226
+ return {
227
+ edit: {
228
+ documentChanges: [
229
+ { kind: 'create', uri: newUri, options: { ignoreIfExists: false } },
230
+ { textDocument: { uri: newUri, version: null }, edits: [{ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, newText: fileSource }] },
231
+ {
232
+ textDocument: { uri: document.uri, version: document.version },
233
+ edits: edits.map((edit) => ({
234
+ range: { start: positionAt(text, indexOfByte(text, edit.start)), end: positionAt(text, indexOfByte(text, edit.end)) },
235
+ newText: edit.text,
236
+ })),
237
+ },
238
+ ],
239
+ },
240
+ tag: made.result.tag,
241
+ };
242
+ }
243
+
244
+ // The code action: offered on a non-empty selection, with a default name; when the compiler
245
+ // refuses, it's shown disabled with the reason, so the rule is discoverable.
246
+ connection.onCodeAction(({ textDocument, range, context }) => {
247
+ const document = documents.get(textDocument.uri);
248
+ if (!document || !client.createsFiles || !client.actionLiterals) return [];
249
+ if (context.only && !context.only.some((kind) => CodeActionKind.RefactorExtract.startsWith(kind))) return [];
250
+ if (range.start.line === range.end.line && range.start.character === range.end.character) return [];
251
+ const made = extraction(document, range, defaultTag(textDocument.uri, document.getText()));
252
+ if (!made.edit) {
253
+ // An editor that doesn't know `disabled` would offer this as an action that does nothing.
254
+ if (!client.disabledActions) return [];
255
+ return [{ title: 'Extract to component', kind: CodeActionKind.RefactorExtract, disabled: { reason: made.reason } }];
256
+ }
257
+ return [{ title: `Extract to component <${made.tag}>`, kind: CodeActionKind.RefactorExtract, edit: made.edit }];
258
+ });
259
+
260
+ // For editors that ask for the name first (the VS Code extension): [uri, range, tag].
261
+ connection.onExecuteCommand(async ({ command, arguments: args }) => {
262
+ if (command !== EXTRACT_COMMAND) return null;
263
+ const [uri, range, tag] = args ?? [];
264
+ const document = documents.get(uri);
265
+ if (!document || !range || !tag) return { error: 'Extract to component needs a document, a selection and a name.' };
266
+ if (!client.createsFiles || !client.appliesEdits) return { error: 'This editor did not say it can apply an edit that creates a file, which extracting a component needs.' };
267
+ const made = extraction(document, range, tag);
268
+ if (!made.edit) {
269
+ connection.window.showErrorMessage(made.reason);
270
+ return { error: made.reason };
271
+ }
272
+ const applied = await connection.workspace.applyEdit(made.edit);
273
+ if (!applied.applied) connection.window.showErrorMessage(`Couldn't extract: ${applied.failureReason ?? 'the editor refused the edit'}`);
274
+ return { applied: applied.applied, tag: made.tag };
275
+ });
276
+
151
277
  documents.listen(connection);
152
278
  connection.listen();