@atcute/lex-cli 2.5.3 → 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.
Files changed (56) hide show
  1. package/dist/codegen.d.ts +1 -7
  2. package/dist/codegen.d.ts.map +1 -1
  3. package/dist/codegen.js +7 -16
  4. package/dist/codegen.js.map +1 -1
  5. package/dist/commands/export.d.ts +2 -6
  6. package/dist/commands/export.d.ts.map +1 -1
  7. package/dist/commands/export.js +21 -28
  8. package/dist/commands/export.js.map +1 -1
  9. package/dist/commands/generate.d.ts +2 -6
  10. package/dist/commands/generate.d.ts.map +1 -1
  11. package/dist/commands/generate.js +22 -15
  12. package/dist/commands/generate.js.map +1 -1
  13. package/dist/commands/pull.d.ts +2 -6
  14. package/dist/commands/pull.d.ts.map +1 -1
  15. package/dist/commands/pull.js +37 -37
  16. package/dist/commands/pull.js.map +1 -1
  17. package/dist/config.d.ts +23 -2
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +21 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/formatter.d.ts +21 -0
  22. package/dist/formatter.d.ts.map +1 -0
  23. package/dist/formatter.js +122 -0
  24. package/dist/formatter.js.map +1 -0
  25. package/dist/git.d.ts.map +1 -1
  26. package/dist/git.js.map +1 -1
  27. package/dist/index.d.ts +2 -66
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/lexicon-loader.d.ts.map +1 -1
  31. package/dist/lexicon-loader.js +9 -1
  32. package/dist/lexicon-loader.js.map +1 -1
  33. package/dist/lexicon-metadata.js.map +1 -1
  34. package/dist/lsp-client.d.ts +20 -0
  35. package/dist/lsp-client.d.ts.map +1 -0
  36. package/dist/lsp-client.js +194 -0
  37. package/dist/lsp-client.js.map +1 -0
  38. package/dist/pull-sources/atproto.d.ts +3 -11
  39. package/dist/pull-sources/atproto.d.ts.map +1 -1
  40. package/dist/pull-sources/atproto.js.map +1 -1
  41. package/dist/pull-sources/git.d.ts +3 -7
  42. package/dist/pull-sources/git.d.ts.map +1 -1
  43. package/dist/pull-sources/git.js.map +1 -1
  44. package/dist/shared-options.d.ts +1 -1
  45. package/package.json +10 -9
  46. package/src/cli.ts +3 -3
  47. package/src/codegen.ts +7 -27
  48. package/src/commands/export.ts +25 -33
  49. package/src/commands/generate.ts +31 -22
  50. package/src/commands/pull.ts +43 -45
  51. package/src/config.ts +19 -1
  52. package/src/formatter.ts +157 -0
  53. package/src/index.ts +1 -1
  54. package/src/lsp-client.ts +283 -0
  55. package/src/pull-sources/atproto.ts +2 -2
  56. package/src/pull-sources/git.ts +3 -3
@@ -8,13 +8,13 @@ import { message } from '@optique/core/message';
8
8
  import { type InferValue } from '@optique/core/parser';
9
9
  import { command, constant } from '@optique/core/primitives';
10
10
  import pc from 'picocolors';
11
- import prettier from 'prettier';
12
11
 
13
- import { loadConfig, type NormalizedConfig, type PullConfig, type SourceConfig } from '../config.js';
14
- import { pullAtprotoSource } from '../pull-sources/atproto.js';
15
- import { pullGitSource } from '../pull-sources/git.js';
16
- import type { PullResult, PulledLexicon, SourceLocation } from '../pull-sources/types.js';
17
- import { sharedOptions } from '../shared-options.js';
12
+ import { loadConfig, type NormalizedConfig, type PullConfig, type SourceConfig } from '../config.ts';
13
+ import { createFormatter, type Formatter } from '../formatter.ts';
14
+ import { pullAtprotoSource } from '../pull-sources/atproto.ts';
15
+ import { pullGitSource } from '../pull-sources/git.ts';
16
+ import type { PullResult, PulledLexicon, SourceLocation } from '../pull-sources/types.ts';
17
+ import { sharedOptions } from '../shared-options.ts';
18
18
 
19
19
  export const pullCommandSchema = command(
20
20
  'pull',
@@ -110,16 +110,13 @@ const writeLexicon = async (
110
110
  outdir: string,
111
111
  nsid: string,
112
112
  doc: LexiconDoc,
113
- prettierConfig: prettier.Options | null,
113
+ formatter: Formatter,
114
114
  ): Promise<void> => {
115
115
  const nsidPath = nsid.replaceAll('.', '/');
116
116
  const target = path.join(outdir, `${nsidPath}.json`);
117
117
  const dirname = path.dirname(target);
118
118
 
119
- const code = await prettier.format(JSON.stringify(doc, null, 2), {
120
- ...prettierConfig,
121
- parser: 'json',
122
- });
119
+ const code = await formatter.format(JSON.stringify(doc, null, 2), target);
123
120
 
124
121
  await fs.mkdir(dirname, { recursive: true });
125
122
  await fs.writeFile(target, code);
@@ -139,7 +136,7 @@ const pullSource = async (source: SourceConfig): Promise<PullResult> => {
139
136
  const writeSourceReadme = async (
140
137
  outdir: string,
141
138
  revisions: SourceRevision[],
142
- prettierConfig: prettier.Options | null,
139
+ formatter: Formatter,
143
140
  ): Promise<void> => {
144
141
  const lines = [
145
142
  '# lexicon sources',
@@ -173,12 +170,10 @@ const writeSourceReadme = async (
173
170
  lines.push('');
174
171
 
175
172
  const content = lines.join('\n');
176
- const formatted = await prettier.format(content, {
177
- ...prettierConfig,
178
- parser: 'markdown',
179
- });
173
+ const target = path.join(outdir, 'README.md');
174
+ const formatted = await formatter.format(content, target);
180
175
 
181
- await fs.writeFile(path.join(outdir, 'README.md'), formatted);
176
+ await fs.writeFile(target, formatted);
182
177
  };
183
178
 
184
179
  /**
@@ -190,43 +185,46 @@ export const runPull = async (args: PullCommand): Promise<void> => {
190
185
  const pullConfig = ensurePullConfig(config);
191
186
 
192
187
  const outdir = path.resolve(config.root, pullConfig.outdir);
193
- const prettierConfig = await prettier.resolveConfig(config.root, { editorconfig: true });
188
+ const formatter = await createFormatter(config.formatter, config.root);
194
189
 
195
- const seen = new Map<string, SourceLocation>();
196
- const collected: PulledLexicon[] = [];
197
- const sourceRevisions: SourceRevision[] = [];
190
+ try {
191
+ const seen = new Map<string, SourceLocation>();
192
+ const collected: PulledLexicon[] = [];
193
+ const sourceRevisions: SourceRevision[] = [];
198
194
 
199
- for (const source of pullConfig.sources) {
200
- const result = await pullSource(source);
195
+ for (const source of pullConfig.sources) {
196
+ const result = await pullSource(source);
201
197
 
202
- sourceRevisions.push({ source, rev: result.rev });
198
+ sourceRevisions.push({ source, rev: result.rev });
203
199
 
204
- for (const [nsid, entry] of result.pulled) {
205
- const existing = seen.get(nsid);
200
+ for (const [nsid, entry] of result.pulled) {
201
+ const existing = seen.get(nsid);
206
202
 
207
- if (existing) {
208
- console.error(pc.bold(pc.red(`duplicate lexicon "${nsid}"`)));
209
- console.error(`- found ${entry.location.relativePath} from ${entry.location.sourceDescription}`);
210
- console.error(` at ${entry.location.absolutePath}`);
211
- console.error(`- already found ${existing.relativePath} from ${existing.sourceDescription}`);
212
- console.error(` at ${existing.absolutePath}`);
213
- process.exit(1);
214
- }
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
+ }
215
211
 
216
- seen.set(nsid, entry.location);
217
- collected.push(entry);
212
+ seen.set(nsid, entry.location);
213
+ collected.push(entry);
214
+ }
218
215
  }
219
- }
220
216
 
221
- if (pullConfig.clean) {
222
- await fs.rm(outdir, { recursive: true, force: true });
223
- }
217
+ if (pullConfig.clean) {
218
+ await fs.rm(outdir, { recursive: true, force: true });
219
+ }
224
220
 
225
- await fs.mkdir(outdir, { recursive: true });
221
+ await fs.mkdir(outdir, { recursive: true });
226
222
 
227
- for (const entry of collected) {
228
- await writeLexicon(outdir, entry.nsid, entry.doc, prettierConfig);
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
229
  }
230
-
231
- await writeSourceReadme(outdir, sourceRevisions, prettierConfig);
232
230
  };
package/src/config.ts CHANGED
@@ -8,7 +8,7 @@ import { isHandle, isNsid } from '@atcute/lexicons/syntax';
8
8
  import * as v from '@badrap/valita';
9
9
  import pc from 'picocolors';
10
10
 
11
- import type { ImportMapping } from './codegen.js';
11
+ import type { ImportMapping } from './codegen.ts';
12
12
 
13
13
  const gitSourceConfigSchema = v.object({
14
14
  type: v.literal('git'),
@@ -63,6 +63,22 @@ const exportConfigSchema = v.object({
63
63
  clean: v.boolean().optional(),
64
64
  });
65
65
 
66
+ const formatterConfigSchema = v.union(
67
+ v.object({ type: v.literal('prettier') }),
68
+ v.object({
69
+ type: v.literal('command'),
70
+ command: v.string().assert((value) => value.length > 0, `must not be empty`),
71
+ concurrency: v
72
+ .number()
73
+ .assert((value) => Number.isInteger(value) && value > 0, `must be a positive integer`)
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`),
79
+ }),
80
+ );
81
+
66
82
  export type GitSourceConfig = v.Infer<typeof gitSourceConfigSchema>;
67
83
  export type AtprotoNsidsSourceConfig = v.Infer<typeof atprotoNsidsSourceConfigSchema>;
68
84
  export type AtprotoAuthoritySourceConfig = v.Infer<typeof atprotoAuthoritySourceConfigSchema>;
@@ -70,6 +86,7 @@ export type AtprotoSourceConfig = v.Infer<typeof atprotoSourceConfigSchema>;
70
86
  export type SourceConfig = v.Infer<typeof sourceConfigSchema>;
71
87
  export type PullConfig = v.Infer<typeof pullConfigSchema>;
72
88
  export type ExportConfig = v.Infer<typeof exportConfigSchema>;
89
+ export type FormatterConfig = v.Infer<typeof formatterConfigSchema>;
73
90
 
74
91
  const isValidLexiconPattern = (pattern: string): boolean => {
75
92
  if (pattern.endsWith('.*')) {
@@ -126,6 +143,7 @@ export const lexiconConfigSchema = v.object({
126
143
  })
127
144
  .partial()
128
145
  .optional(),
146
+ formatter: formatterConfigSchema.optional((): FormatterConfig => ({ type: 'prettier' })),
129
147
  pull: pullConfigSchema.optional(),
130
148
  export: exportConfigSchema.optional(),
131
149
  });
@@ -0,0 +1,157 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ import type { FormatterConfig } from './config.ts';
4
+ import { createLspClient } from './lsp-client.ts';
5
+
6
+ /** formats source code */
7
+ export interface Formatter {
8
+ /**
9
+ * formats the given code
10
+ * @param code source code to format
11
+ * @param filepath filepath hint for language detection and config resolution
12
+ * @returns formatted code
13
+ */
14
+ format(code: string, filepath: string): Promise<string>;
15
+ /** releases any resources held by the formatter */
16
+ dispose(): Promise<void>;
17
+ }
18
+
19
+ const inferPrettierParser = (filepath: string): string => {
20
+ if (filepath.endsWith('.ts') || filepath.endsWith('.tsx')) {
21
+ return 'typescript';
22
+ }
23
+ if (filepath.endsWith('.json')) {
24
+ return 'json';
25
+ }
26
+ if (filepath.endsWith('.md') || filepath.endsWith('.markdown')) {
27
+ return 'markdown';
28
+ }
29
+ return 'typescript';
30
+ };
31
+
32
+ // #region semaphore
33
+
34
+ interface Lock {
35
+ release(): void;
36
+ }
37
+
38
+ class Semaphore {
39
+ private waiting: (() => void)[] = [];
40
+ private active = 0;
41
+ private max: number;
42
+
43
+ constructor(max: number) {
44
+ this.max = max;
45
+ }
46
+
47
+ acquire(): Promise<Lock> {
48
+ const lock: Lock = {
49
+ release: () => {
50
+ this.active--;
51
+ const next = this.waiting.shift();
52
+ if (next) {
53
+ this.active++;
54
+ next();
55
+ }
56
+ },
57
+ };
58
+
59
+ if (this.active < this.max) {
60
+ this.active++;
61
+ return Promise.resolve(lock);
62
+ }
63
+
64
+ const { promise, resolve } = Promise.withResolvers<Lock>();
65
+ this.waiting.push(() => resolve(lock));
66
+ return promise;
67
+ }
68
+ }
69
+
70
+ // #endregion
71
+
72
+ /**
73
+ * creates a formatter from the given configuration
74
+ * @param config formatter configuration
75
+ * @param root project root for config resolution
76
+ * @returns a formatter instance
77
+ */
78
+ export const createFormatter = async (config: FormatterConfig, root: string): Promise<Formatter> => {
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
+ }
156
+ }
157
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { lexiconConfigSchema, type LexiconConfig } from './config.js';
1
+ import { lexiconConfigSchema, type LexiconConfig } from './config.ts';
2
2
 
3
3
  export type { LexiconConfig };
4
4
 
@@ -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
+ };
@@ -20,9 +20,9 @@ import {
20
20
 
21
21
  import pc from 'picocolors';
22
22
 
23
- import type { AtprotoSourceConfig } from '../config.js';
23
+ import type { AtprotoSourceConfig } from '../config.ts';
24
24
 
25
- import type { PullResult, SourceLocation } from './types.js';
25
+ import type { PullResult, SourceLocation } from './types.ts';
26
26
 
27
27
  /**
28
28
  * discovers all published lexicons for an authority by listing records in the
@@ -6,10 +6,10 @@ import type { LexiconDoc } from '@atcute/lexicon-doc';
6
6
 
7
7
  import pc from 'picocolors';
8
8
 
9
- import type { GitSourceConfig } from '../config.js';
10
- import { runGit, GitError } from '../git.js';
9
+ import type { GitSourceConfig } from '../config.ts';
10
+ import { runGit, GitError } from '../git.ts';
11
11
 
12
- import type { PullResult, PulledLexicon, SourceLocation } from './types.js';
12
+ import type { PullResult, PulledLexicon, SourceLocation } from './types.ts';
13
13
 
14
14
  /**
15
15
  * pulls lexicon documents from a git repository source