@atcute/lex-cli 2.6.0 → 2.6.1
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/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +17 -12
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +20 -15
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +29 -24
- package/dist/commands/pull.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -2
- package/dist/config.js.map +1 -1
- package/dist/formatter.d.ts +2 -0
- package/dist/formatter.d.ts.map +1 -1
- package/dist/formatter.js +71 -60
- package/dist/formatter.js.map +1 -1
- package/dist/lsp-client.d.ts +20 -0
- package/dist/lsp-client.d.ts.map +1 -0
- package/dist/lsp-client.js +194 -0
- package/dist/lsp-client.js.map +1 -0
- package/package.json +5 -4
- package/src/commands/export.ts +18 -14
- package/src/commands/generate.ts +25 -20
- package/src/commands/pull.ts +31 -27
- package/src/config.ts +5 -2
- package/src/formatter.ts +80 -68
- package/src/lsp-client.ts +283 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import * as url from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { getUtf8Length } from '@atcute/uint8array';
|
|
5
|
+
|
|
6
|
+
// #region types
|
|
7
|
+
|
|
8
|
+
interface Position {
|
|
9
|
+
line: number;
|
|
10
|
+
character: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Range {
|
|
14
|
+
start: Position;
|
|
15
|
+
end: Position;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TextEdit {
|
|
19
|
+
range: Range;
|
|
20
|
+
newText: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JsonRpcMessage {
|
|
24
|
+
jsonrpc: '2.0';
|
|
25
|
+
id?: number;
|
|
26
|
+
method?: string;
|
|
27
|
+
params?: unknown;
|
|
28
|
+
result?: unknown;
|
|
29
|
+
error?: { code: number; message: string; data?: unknown };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// #endregion
|
|
33
|
+
|
|
34
|
+
// #region text edits
|
|
35
|
+
|
|
36
|
+
const applyTextEdits = (code: string, edits: TextEdit[]): string => {
|
|
37
|
+
if (edits.length === 0) {
|
|
38
|
+
return code;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// build line start offsets
|
|
42
|
+
const lineStarts = [0];
|
|
43
|
+
for (let i = 0; i < code.length; i++) {
|
|
44
|
+
if (code[i] === '\n') {
|
|
45
|
+
lineStarts.push(i + 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const positionToOffset = (pos: Position): number => {
|
|
50
|
+
return (lineStarts[pos.line] ?? code.length) + pos.character;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// sort edits in reverse document order so earlier positions stay valid
|
|
54
|
+
const sorted = edits.toSorted((a, b) => {
|
|
55
|
+
const lineDiff = b.range.start.line - a.range.start.line;
|
|
56
|
+
if (lineDiff !== 0) {
|
|
57
|
+
return lineDiff;
|
|
58
|
+
}
|
|
59
|
+
return b.range.start.character - a.range.start.character;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
let result = code;
|
|
63
|
+
for (const edit of sorted) {
|
|
64
|
+
const start = positionToOffset(edit.range.start);
|
|
65
|
+
const end = positionToOffset(edit.range.end);
|
|
66
|
+
result = result.slice(0, start) + edit.newText + result.slice(end);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// #endregion
|
|
73
|
+
|
|
74
|
+
const inferLanguageId = (filepath: string): string => {
|
|
75
|
+
if (filepath.endsWith('.ts') || filepath.endsWith('.tsx')) {
|
|
76
|
+
return 'typescript';
|
|
77
|
+
}
|
|
78
|
+
if (filepath.endsWith('.json')) {
|
|
79
|
+
return 'json';
|
|
80
|
+
}
|
|
81
|
+
if (filepath.endsWith('.md') || filepath.endsWith('.markdown')) {
|
|
82
|
+
return 'markdown';
|
|
83
|
+
}
|
|
84
|
+
return 'typescript';
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** LSP client for formatting documents */
|
|
88
|
+
export interface LspClient {
|
|
89
|
+
/**
|
|
90
|
+
* formats a document via LSP textDocument/formatting
|
|
91
|
+
* @param code source code to format
|
|
92
|
+
* @param filepath filepath for language detection and URI
|
|
93
|
+
* @returns formatted code
|
|
94
|
+
*/
|
|
95
|
+
formatDocument(code: string, filepath: string): Promise<string>;
|
|
96
|
+
/** shuts down the LSP server */
|
|
97
|
+
dispose(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* creates an LSP client that communicates with a formatter over stdio
|
|
102
|
+
* @param command shell command to spawn the LSP server
|
|
103
|
+
* @param root project root for LSP rootUri
|
|
104
|
+
* @returns an initialized LSP client ready for formatting
|
|
105
|
+
*/
|
|
106
|
+
export const createLspClient = async (command: string, root: string): Promise<LspClient> => {
|
|
107
|
+
const child = spawn('sh', ['-c', command], {
|
|
108
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// prevent EPIPE crash when child exits mid-write; actual errors
|
|
112
|
+
// are handled by the close/error handlers below
|
|
113
|
+
child.stdin.on('error', () => {});
|
|
114
|
+
|
|
115
|
+
// #region JSON-RPC framing
|
|
116
|
+
|
|
117
|
+
const pending = new Map<number, PromiseWithResolvers<unknown>>();
|
|
118
|
+
let nextId = 1;
|
|
119
|
+
let exited = false;
|
|
120
|
+
|
|
121
|
+
const sendMessage = (message: Record<string, unknown>): void => {
|
|
122
|
+
if (exited) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const json = JSON.stringify(message);
|
|
127
|
+
const byteLength = getUtf8Length(json);
|
|
128
|
+
|
|
129
|
+
child.stdin.write(`Content-Length: ${byteLength}\r\n\r\n${json}`);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const sendRequest = (method: string, params?: unknown): Promise<unknown> => {
|
|
133
|
+
if (exited) {
|
|
134
|
+
return Promise.reject(new Error(`LSP server has already exited`));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const id = nextId++;
|
|
138
|
+
const deferred = Promise.withResolvers<unknown>();
|
|
139
|
+
|
|
140
|
+
pending.set(id, deferred);
|
|
141
|
+
sendMessage({ jsonrpc: '2.0', id, method, params });
|
|
142
|
+
|
|
143
|
+
return deferred.promise;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const sendNotification = (method: string, params?: unknown): void => {
|
|
147
|
+
sendMessage({ jsonrpc: '2.0', method, params });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// incremental message parser
|
|
151
|
+
const HEADER_SEPARATOR = Buffer.from('\r\n\r\n');
|
|
152
|
+
|
|
153
|
+
let buffer: Buffer = Buffer.alloc(0);
|
|
154
|
+
let contentLength = -1;
|
|
155
|
+
|
|
156
|
+
const processBuffer = (): void => {
|
|
157
|
+
while (true) {
|
|
158
|
+
if (contentLength === -1) {
|
|
159
|
+
const separatorIndex = buffer.indexOf(HEADER_SEPARATOR);
|
|
160
|
+
if (separatorIndex === -1) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const header = buffer.toString('utf8', 0, separatorIndex);
|
|
165
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
166
|
+
|
|
167
|
+
buffer = buffer.subarray(separatorIndex + 4);
|
|
168
|
+
|
|
169
|
+
if (!match) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
contentLength = parseInt(match[1], 10);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (buffer.length < contentLength) {
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const body = buffer.toString('utf8', 0, contentLength);
|
|
181
|
+
buffer = buffer.subarray(contentLength);
|
|
182
|
+
contentLength = -1;
|
|
183
|
+
|
|
184
|
+
let message: JsonRpcMessage;
|
|
185
|
+
try {
|
|
186
|
+
message = JSON.parse(body);
|
|
187
|
+
} catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// dispatch responses to pending requests, ignore everything else
|
|
192
|
+
if (message.id != null) {
|
|
193
|
+
const entry = pending.get(message.id);
|
|
194
|
+
if (entry) {
|
|
195
|
+
pending.delete(message.id);
|
|
196
|
+
|
|
197
|
+
if (message.error) {
|
|
198
|
+
entry.reject(new Error(`LSP error ${message.error.code}: ${message.error.message}`));
|
|
199
|
+
} else {
|
|
200
|
+
entry.resolve(message.result);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
208
|
+
buffer = buffer.length > 0 ? Buffer.concat([buffer, chunk]) : chunk;
|
|
209
|
+
processBuffer();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const rejectPending = (error: Error): void => {
|
|
213
|
+
for (const [, entry] of pending) {
|
|
214
|
+
entry.reject(error);
|
|
215
|
+
}
|
|
216
|
+
pending.clear();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
child.on('error', (err) => {
|
|
220
|
+
exited = true;
|
|
221
|
+
rejectPending(err);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
child.on('close', (exitCode) => {
|
|
225
|
+
exited = true;
|
|
226
|
+
rejectPending(new Error(`LSP server exited unexpectedly with code ${exitCode}`));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// #endregion
|
|
230
|
+
|
|
231
|
+
// #region initialize handshake
|
|
232
|
+
|
|
233
|
+
const rootUri = url.pathToFileURL(root).href;
|
|
234
|
+
|
|
235
|
+
await sendRequest('initialize', {
|
|
236
|
+
processId: process.pid,
|
|
237
|
+
clientInfo: { name: 'lex-cli' },
|
|
238
|
+
rootUri,
|
|
239
|
+
capabilities: {},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
sendNotification('initialized');
|
|
243
|
+
|
|
244
|
+
// #endregion
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
async formatDocument(code, filepath) {
|
|
248
|
+
const uri = url.pathToFileURL(filepath).href;
|
|
249
|
+
const languageId = inferLanguageId(filepath);
|
|
250
|
+
|
|
251
|
+
sendNotification('textDocument/didOpen', {
|
|
252
|
+
textDocument: { uri, languageId, version: 1, text: code },
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const edits = (await sendRequest('textDocument/formatting', {
|
|
256
|
+
textDocument: { uri },
|
|
257
|
+
options: { tabSize: 2, insertSpaces: false },
|
|
258
|
+
})) as TextEdit[] | null;
|
|
259
|
+
|
|
260
|
+
sendNotification('textDocument/didClose', {
|
|
261
|
+
textDocument: { uri },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!edits || edits.length === 0) {
|
|
265
|
+
return code;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return applyTextEdits(code, edits);
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async dispose() {
|
|
272
|
+
if (!exited) {
|
|
273
|
+
await sendRequest('shutdown', null);
|
|
274
|
+
sendNotification('exit');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!exited) {
|
|
278
|
+
child.kill();
|
|
279
|
+
await new Promise<void>((resolve) => child.on('close', resolve));
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
};
|