@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.
- package/dist/codegen.d.ts +1 -7
- package/dist/codegen.d.ts.map +1 -1
- package/dist/codegen.js +7 -16
- package/dist/codegen.js.map +1 -1
- package/dist/commands/export.d.ts +2 -6
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +21 -28
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/generate.d.ts +2 -6
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +22 -15
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/pull.d.ts +2 -6
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +37 -37
- package/dist/commands/pull.js.map +1 -1
- package/dist/config.d.ts +23 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -1
- package/dist/config.js.map +1 -1
- package/dist/formatter.d.ts +21 -0
- package/dist/formatter.d.ts.map +1 -0
- package/dist/formatter.js +122 -0
- package/dist/formatter.js.map +1 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +2 -66
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lexicon-loader.d.ts.map +1 -1
- package/dist/lexicon-loader.js +9 -1
- package/dist/lexicon-loader.js.map +1 -1
- package/dist/lexicon-metadata.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/dist/pull-sources/atproto.d.ts +3 -11
- package/dist/pull-sources/atproto.d.ts.map +1 -1
- package/dist/pull-sources/atproto.js.map +1 -1
- package/dist/pull-sources/git.d.ts +3 -7
- package/dist/pull-sources/git.d.ts.map +1 -1
- package/dist/pull-sources/git.js.map +1 -1
- package/dist/shared-options.d.ts +1 -1
- package/package.json +10 -9
- package/src/cli.ts +3 -3
- package/src/codegen.ts +7 -27
- package/src/commands/export.ts +25 -33
- package/src/commands/generate.ts +31 -22
- package/src/commands/pull.ts +43 -45
- package/src/config.ts +19 -1
- package/src/formatter.ts +157 -0
- package/src/index.ts +1 -1
- package/src/lsp-client.ts +283 -0
- package/src/pull-sources/atproto.ts +2 -2
- package/src/pull-sources/git.ts +3 -3
package/src/commands/pull.ts
CHANGED
|
@@ -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.
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
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(
|
|
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
|
|
188
|
+
const formatter = await createFormatter(config.formatter, config.root);
|
|
194
189
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
190
|
+
try {
|
|
191
|
+
const seen = new Map<string, SourceLocation>();
|
|
192
|
+
const collected: PulledLexicon[] = [];
|
|
193
|
+
const sourceRevisions: SourceRevision[] = [];
|
|
198
194
|
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
for (const source of pullConfig.sources) {
|
|
196
|
+
const result = await pullSource(source);
|
|
201
197
|
|
|
202
|
-
|
|
198
|
+
sourceRevisions.push({ source, rev: result.rev });
|
|
203
199
|
|
|
204
|
-
|
|
205
|
-
|
|
200
|
+
for (const [nsid, entry] of result.pulled) {
|
|
201
|
+
const existing = seen.get(nsid);
|
|
206
202
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
212
|
+
seen.set(nsid, entry.location);
|
|
213
|
+
collected.push(entry);
|
|
214
|
+
}
|
|
218
215
|
}
|
|
219
|
-
}
|
|
220
216
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
217
|
+
if (pullConfig.clean) {
|
|
218
|
+
await fs.rm(outdir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
224
220
|
|
|
225
|
-
|
|
221
|
+
await fs.mkdir(outdir, { recursive: true });
|
|
226
222
|
|
|
227
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
});
|
package/src/formatter.ts
ADDED
|
@@ -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
|
@@ -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.
|
|
23
|
+
import type { AtprotoSourceConfig } from '../config.ts';
|
|
24
24
|
|
|
25
|
-
import type { PullResult, SourceLocation } from './types.
|
|
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
|
package/src/pull-sources/git.ts
CHANGED
|
@@ -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.
|
|
10
|
-
import { runGit, GitError } from '../git.
|
|
9
|
+
import type { GitSourceConfig } from '../config.ts';
|
|
10
|
+
import { runGit, GitError } from '../git.ts';
|
|
11
11
|
|
|
12
|
-
import type { PullResult, PulledLexicon, SourceLocation } from './types.
|
|
12
|
+
import type { PullResult, PulledLexicon, SourceLocation } from './types.ts';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* pulls lexicon documents from a git repository source
|