@atcute/lex-cli 2.6.0 → 2.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.
@@ -187,40 +187,44 @@ export const runPull = async (args: PullCommand): Promise<void> => {
187
187
  const outdir = path.resolve(config.root, pullConfig.outdir);
188
188
  const formatter = await createFormatter(config.formatter, config.root);
189
189
 
190
- const seen = new Map<string, SourceLocation>();
191
- const collected: PulledLexicon[] = [];
192
- const sourceRevisions: SourceRevision[] = [];
190
+ try {
191
+ const seen = new Map<string, SourceLocation>();
192
+ const collected: PulledLexicon[] = [];
193
+ const sourceRevisions: SourceRevision[] = [];
193
194
 
194
- for (const source of pullConfig.sources) {
195
- const result = await pullSource(source);
195
+ for (const source of pullConfig.sources) {
196
+ const result = await pullSource(source);
196
197
 
197
- sourceRevisions.push({ source, rev: result.rev });
198
+ sourceRevisions.push({ source, rev: result.rev });
198
199
 
199
- for (const [nsid, entry] of result.pulled) {
200
- const existing = seen.get(nsid);
200
+ for (const [nsid, entry] of result.pulled) {
201
+ const existing = seen.get(nsid);
201
202
 
202
- if (existing) {
203
- console.error(pc.bold(pc.red(`duplicate lexicon "${nsid}"`)));
204
- console.error(`- found ${entry.location.relativePath} from ${entry.location.sourceDescription}`);
205
- console.error(` at ${entry.location.absolutePath}`);
206
- console.error(`- already found ${existing.relativePath} from ${existing.sourceDescription}`);
207
- console.error(` at ${existing.absolutePath}`);
208
- process.exit(1);
209
- }
203
+ if (existing) {
204
+ console.error(pc.bold(pc.red(`duplicate lexicon "${nsid}"`)));
205
+ console.error(`- found ${entry.location.relativePath} from ${entry.location.sourceDescription}`);
206
+ console.error(` at ${entry.location.absolutePath}`);
207
+ console.error(`- already found ${existing.relativePath} from ${existing.sourceDescription}`);
208
+ console.error(` at ${existing.absolutePath}`);
209
+ process.exit(1);
210
+ }
210
211
 
211
- seen.set(nsid, entry.location);
212
- collected.push(entry);
212
+ seen.set(nsid, entry.location);
213
+ collected.push(entry);
214
+ }
213
215
  }
214
- }
215
216
 
216
- if (pullConfig.clean) {
217
- await fs.rm(outdir, { recursive: true, force: true });
218
- }
217
+ if (pullConfig.clean) {
218
+ await fs.rm(outdir, { recursive: true, force: true });
219
+ }
219
220
 
220
- await fs.mkdir(outdir, { recursive: true });
221
+ await fs.mkdir(outdir, { recursive: true });
221
222
 
222
- await Promise.all([
223
- ...collected.map((entry) => writeLexicon(outdir, entry.nsid, entry.doc, formatter)),
224
- writeSourceReadme(outdir, sourceRevisions, formatter),
225
- ]);
223
+ await Promise.all([
224
+ ...collected.map((entry) => writeLexicon(outdir, entry.nsid, entry.doc, formatter)),
225
+ writeSourceReadme(outdir, sourceRevisions, formatter),
226
+ ]);
227
+ } finally {
228
+ await formatter.dispose();
229
+ }
226
230
  };
package/src/config.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import * as fs from 'node:fs/promises';
2
- import { availableParallelism } from 'node:os';
3
2
  import * as path from 'node:path';
4
3
  import * as url from 'node:url';
5
4
 
@@ -72,7 +71,11 @@ const formatterConfigSchema = v.union(
72
71
  concurrency: v
73
72
  .number()
74
73
  .assert((value) => Number.isInteger(value) && value > 0, `must be a positive integer`)
75
- .optional(() => availableParallelism()),
74
+ .optional(() => 1),
75
+ }),
76
+ v.object({
77
+ type: v.literal('lsp'),
78
+ command: v.string().assert((value) => value.length > 0, `must not be empty`),
76
79
  }),
77
80
  );
78
81
 
package/src/formatter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { availableParallelism } from 'node:os';
3
2
 
4
3
  import type { FormatterConfig } from './config.ts';
4
+ import { createLspClient } from './lsp-client.ts';
5
5
 
6
6
  /** formats source code */
7
7
  export interface Formatter {
@@ -12,6 +12,8 @@ export interface Formatter {
12
12
  * @returns formatted code
13
13
  */
14
14
  format(code: string, filepath: string): Promise<string>;
15
+ /** releases any resources held by the formatter */
16
+ dispose(): Promise<void>;
15
17
  }
16
18
 
17
19
  const inferPrettierParser = (filepath: string): string => {
@@ -74,72 +76,82 @@ class Semaphore {
74
76
  * @returns a formatter instance
75
77
  */
76
78
  export const createFormatter = async (config: FormatterConfig, root: string): Promise<Formatter> => {
77
- let inner: Formatter;
78
- let concurrency: number;
79
-
80
- if (config.type === 'prettier') {
81
- const prettier = await import('prettier');
82
- const prettierConfig = await prettier.resolveConfig(root, { editorconfig: true });
83
-
84
- // prettier is in-process and CPU-bound, so concurrency only helps
85
- // avoid buffering all files in memory at once
86
- concurrency = availableParallelism();
87
- inner = {
88
- async format(code, filepath) {
89
- return prettier.format(code, { ...prettierConfig, parser: inferPrettierParser(filepath) });
90
- },
91
- };
92
- } else {
93
- // the template uses {filepath} as a placeholder, which is passed as a
94
- // positional argument to sh to avoid shell injection via filenames
95
- const shellCmd = config.command.replaceAll('{filepath}', '"$1"');
96
-
97
- concurrency = config.concurrency;
98
- inner = {
99
- format(code, filepath) {
100
- return new Promise<string>((resolve, reject) => {
101
- const child = spawn('sh', ['-c', shellCmd, 'sh', filepath], {
102
- stdio: ['pipe', 'pipe', 'pipe'],
103
- });
104
-
105
- const stdoutChunks: Buffer[] = [];
106
- const stderrChunks: Buffer[] = [];
107
-
108
- child.stdout.on('data', (chunk: Buffer) => {
109
- stdoutChunks.push(chunk);
110
- });
111
-
112
- child.stderr.on('data', (chunk: Buffer) => {
113
- stderrChunks.push(chunk);
114
- });
115
-
116
- child.on('error', reject);
117
-
118
- child.on('close', (exitCode: number | null) => {
119
- if (exitCode !== 0) {
120
- const stderr = Buffer.concat(stderrChunks).toString();
121
- reject(new Error(`formatter exited with code ${exitCode}:\n${stderr}`));
122
- } else {
123
- resolve(Buffer.concat(stdoutChunks).toString());
124
- }
125
- });
126
-
127
- child.stdin.end(code);
128
- });
129
- },
130
- };
79
+ switch (config.type) {
80
+ case 'prettier': {
81
+ const prettier = await import('prettier');
82
+ const prettierConfig = await prettier.resolveConfig(root, { editorconfig: true });
83
+
84
+ return {
85
+ async format(code, filepath) {
86
+ return prettier.format(code, { ...prettierConfig, parser: inferPrettierParser(filepath) });
87
+ },
88
+ async dispose() {},
89
+ };
90
+ }
91
+ case 'command': {
92
+ // the template uses {filepath} as a placeholder, which is passed as a
93
+ // positional argument to sh to avoid shell injection via filenames
94
+ const shellCmd = config.command.replaceAll('{filepath}', '"$1"');
95
+ const semaphore = new Semaphore(config.concurrency);
96
+
97
+ return {
98
+ async format(code, filepath) {
99
+ const lock = await semaphore.acquire();
100
+
101
+ try {
102
+ return await new Promise<string>((resolve, reject) => {
103
+ const child = spawn('sh', ['-c', shellCmd, 'sh', filepath], {
104
+ stdio: ['pipe', 'pipe', 'pipe'],
105
+ });
106
+
107
+ const stdoutChunks: Buffer[] = [];
108
+ const stderrChunks: Buffer[] = [];
109
+
110
+ child.stdout.on('data', (chunk: Buffer) => {
111
+ stdoutChunks.push(chunk);
112
+ });
113
+
114
+ child.stderr.on('data', (chunk: Buffer) => {
115
+ stderrChunks.push(chunk);
116
+ });
117
+
118
+ child.on('error', reject);
119
+
120
+ child.on('close', (exitCode: number | null) => {
121
+ if (exitCode !== 0) {
122
+ const stderr = Buffer.concat(stderrChunks).toString();
123
+ reject(new Error(`formatter exited with code ${exitCode}:\n${stderr}`));
124
+ } else {
125
+ resolve(Buffer.concat(stdoutChunks).toString());
126
+ }
127
+ });
128
+
129
+ child.stdin.end(code);
130
+ });
131
+ } finally {
132
+ lock.release();
133
+ }
134
+ },
135
+ async dispose() {},
136
+ };
137
+ }
138
+ case 'lsp': {
139
+ const client = await createLspClient(config.command, root);
140
+ const semaphore = new Semaphore(1);
141
+
142
+ return {
143
+ async format(code, filepath) {
144
+ const lock = await semaphore.acquire();
145
+ try {
146
+ return await client.formatDocument(code, filepath);
147
+ } finally {
148
+ lock.release();
149
+ }
150
+ },
151
+ async dispose() {
152
+ await client.dispose();
153
+ },
154
+ };
155
+ }
131
156
  }
132
-
133
- const semaphore = new Semaphore(concurrency);
134
-
135
- return {
136
- async format(code, filepath) {
137
- const lock = await semaphore.acquire();
138
- try {
139
- return await inner.format(code, filepath);
140
- } finally {
141
- lock.release();
142
- }
143
- },
144
- };
145
157
  };
@@ -0,0 +1,292 @@
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
+ // drain stderr so a chatty server doesn't block on a full pipe buffer
116
+ child.stderr.resume();
117
+
118
+ // #region JSON-RPC framing
119
+
120
+ const pending = new Map<number, PromiseWithResolvers<unknown>>();
121
+ let nextId = 1;
122
+ let exited = false;
123
+
124
+ const sendMessage = (message: Record<string, unknown>): void => {
125
+ if (exited) {
126
+ return;
127
+ }
128
+
129
+ const json = JSON.stringify(message);
130
+ const byteLength = getUtf8Length(json);
131
+
132
+ child.stdin.write(`Content-Length: ${byteLength}\r\n\r\n${json}`);
133
+ };
134
+
135
+ const sendRequest = (method: string, params?: unknown): Promise<unknown> => {
136
+ if (exited) {
137
+ return Promise.reject(new Error(`LSP server has already exited`));
138
+ }
139
+
140
+ const id = nextId++;
141
+ const deferred = Promise.withResolvers<unknown>();
142
+
143
+ pending.set(id, deferred);
144
+ sendMessage({ jsonrpc: '2.0', id, method, params });
145
+
146
+ return deferred.promise;
147
+ };
148
+
149
+ const sendNotification = (method: string, params?: unknown): void => {
150
+ sendMessage({ jsonrpc: '2.0', method, params });
151
+ };
152
+
153
+ // incremental message parser
154
+ const HEADER_SEPARATOR = Buffer.from('\r\n\r\n');
155
+
156
+ let buffer: Buffer = Buffer.alloc(0);
157
+ let contentLength = -1;
158
+
159
+ const processBuffer = (): void => {
160
+ while (true) {
161
+ if (contentLength === -1) {
162
+ const separatorIndex = buffer.indexOf(HEADER_SEPARATOR);
163
+ if (separatorIndex === -1) {
164
+ break;
165
+ }
166
+
167
+ const header = buffer.toString('utf8', 0, separatorIndex);
168
+ const match = header.match(/Content-Length:\s*(\d+)/i);
169
+
170
+ buffer = buffer.subarray(separatorIndex + 4);
171
+
172
+ if (!match) {
173
+ continue;
174
+ }
175
+
176
+ contentLength = parseInt(match[1], 10);
177
+ }
178
+
179
+ if (buffer.length < contentLength) {
180
+ break;
181
+ }
182
+
183
+ const body = buffer.toString('utf8', 0, contentLength);
184
+ buffer = buffer.subarray(contentLength);
185
+ contentLength = -1;
186
+
187
+ let message: JsonRpcMessage;
188
+ try {
189
+ message = JSON.parse(body);
190
+ } catch {
191
+ continue;
192
+ }
193
+
194
+ if (message.id != null) {
195
+ const entry = pending.get(message.id);
196
+ if (entry) {
197
+ pending.delete(message.id);
198
+
199
+ if (message.error) {
200
+ entry.reject(new Error(`LSP error ${message.error.code}: ${message.error.message}`));
201
+ } else {
202
+ entry.resolve(message.result);
203
+ }
204
+ } else if (message.method != null) {
205
+ // server-initiated request — reply with MethodNotFound so it doesn't hang
206
+ sendMessage({
207
+ jsonrpc: '2.0',
208
+ id: message.id,
209
+ error: { code: -32601, message: `method not found` },
210
+ });
211
+ }
212
+ }
213
+ }
214
+ };
215
+
216
+ child.stdout.on('data', (chunk: Buffer) => {
217
+ buffer = buffer.length > 0 ? Buffer.concat([buffer, chunk]) : chunk;
218
+ processBuffer();
219
+ });
220
+
221
+ const rejectPending = (error: Error): void => {
222
+ for (const [, entry] of pending) {
223
+ entry.reject(error);
224
+ }
225
+ pending.clear();
226
+ };
227
+
228
+ child.on('error', (err) => {
229
+ exited = true;
230
+ rejectPending(err);
231
+ });
232
+
233
+ child.on('close', (exitCode) => {
234
+ exited = true;
235
+ rejectPending(new Error(`LSP server exited unexpectedly with code ${exitCode}`));
236
+ });
237
+
238
+ // #endregion
239
+
240
+ // #region initialize handshake
241
+
242
+ const rootUri = url.pathToFileURL(root).href;
243
+
244
+ await sendRequest('initialize', {
245
+ processId: process.pid,
246
+ clientInfo: { name: 'lex-cli' },
247
+ rootUri,
248
+ capabilities: {},
249
+ });
250
+
251
+ sendNotification('initialized');
252
+
253
+ // #endregion
254
+
255
+ return {
256
+ async formatDocument(code, filepath) {
257
+ const uri = url.pathToFileURL(filepath).href;
258
+ const languageId = inferLanguageId(filepath);
259
+
260
+ sendNotification('textDocument/didOpen', {
261
+ textDocument: { uri, languageId, version: 1, text: code },
262
+ });
263
+
264
+ const edits = (await sendRequest('textDocument/formatting', {
265
+ textDocument: { uri },
266
+ options: { tabSize: 2, insertSpaces: false },
267
+ })) as TextEdit[] | null;
268
+
269
+ sendNotification('textDocument/didClose', {
270
+ textDocument: { uri },
271
+ });
272
+
273
+ if (!edits || edits.length === 0) {
274
+ return code;
275
+ }
276
+
277
+ return applyTextEdits(code, edits);
278
+ },
279
+
280
+ async dispose() {
281
+ if (!exited) {
282
+ await sendRequest('shutdown', null);
283
+ sendNotification('exit');
284
+ }
285
+
286
+ if (!exited) {
287
+ child.kill();
288
+ await new Promise<void>((resolve) => child.on('close', resolve));
289
+ }
290
+ },
291
+ };
292
+ };