@zubra/language-server 5.4.1 → 5.6.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 +67 -0
- package/package.json +1 -1
- 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
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,
|
|
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();
|