a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(lex-cli): add lsp formatter support

Mary 9197920d ef31b005

+402 -71
+5
.changeset/seven-dolls-flash.md
··· 1 + --- 2 + '@atcute/lex-cli': patch 3 + --- 4 + 5 + add lsp formatter support
+1 -1
packages/definitions/atproto/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 9 9 pull: { 10 10 outdir: 'lexicons/',
+1 -1
packages/definitions/bluemoji/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto', '@atcute/bluesky'], 9 9 10 10 // pull: {
+1 -1
packages/definitions/bluesky/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/frontpage/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/leaflet/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/lexicon-community/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/microcosm/lex.config.js
··· 4 4 files: ['lexicons-src/**/*.ts'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 export: { 9 9 outdir: 'lexicons/', 10 10 clean: true,
+1 -1
packages/definitions/ozone/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto', '@atcute/bluesky'], 9 9 10 10 pull: {
+1 -1
packages/definitions/pckt/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/standard-site/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/tangled/lex.config.js
··· 4 4 files: ['lexicons/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 imports: ['@atcute/atproto'], 9 9 10 10 pull: {
+1 -1
packages/definitions/whitewind/lex.config.js
··· 4 4 files: ['lexicons/com/whtwnd/**/*.json'], 5 5 outdir: 'lib/lexicons/', 6 6 modules: { importSuffix: '.ts' }, 7 - formatter: { type: 'command', command: 'oxfmt --stdin-filepath={filepath}' }, 7 + formatter: { type: 'lsp', command: 'oxfmt --lsp' }, 8 8 9 9 pull: { 10 10 outdir: 'lexicons/',
+1
packages/lexicons/lex-cli/package.json
··· 41 41 "prettier": "^3.8.1" 42 42 }, 43 43 "devDependencies": { 44 + "@atcute/uint8array": "workspace:^", 44 45 "@types/node": "^25.5.2", 45 46 "tschema": "^3.2.0" 46 47 }
+18 -14
packages/lexicons/lex-cli/src/commands/export.ts
··· 73 73 const outdir = path.resolve(config.root, exportConfig.outdir); 74 74 const formatter = await createFormatter(config.formatter, config.root); 75 75 76 - // load lexicons from files 77 - const loaded = await loadLexicons(files, config.root); 76 + try { 77 + // load lexicons from files 78 + const loaded = await loadLexicons(files, config.root); 78 79 79 - if (loaded.length === 0) { 80 - console.warn(pc.yellow(`warning: no lexicons found to export`)); 81 - return; 82 - } 80 + if (loaded.length === 0) { 81 + console.warn(pc.yellow(`warning: no lexicons found to export`)); 82 + return; 83 + } 83 84 84 - // clean output directory if requested 85 - if (exportConfig.clean) { 86 - await fs.rm(outdir, { recursive: true, force: true }); 87 - } 85 + // clean output directory if requested 86 + if (exportConfig.clean) { 87 + await fs.rm(outdir, { recursive: true, force: true }); 88 + } 88 89 89 - await fs.mkdir(outdir, { recursive: true }); 90 + await fs.mkdir(outdir, { recursive: true }); 90 91 91 - // write each lexicon as JSON 92 - await Promise.all(loaded.map(({ nsid, doc }) => writeLexicon(outdir, nsid, doc, formatter))); 92 + // write each lexicon as JSON 93 + await Promise.all(loaded.map(({ nsid, doc }) => writeLexicon(outdir, nsid, doc, formatter))); 93 94 94 - console.log(pc.green(`exported ${loaded.length} lexicon(s) to ${outdir}`)); 95 + console.log(pc.green(`exported ${loaded.length} lexicon(s) to ${outdir}`)); 96 + } finally { 97 + await formatter.dispose(); 98 + } 95 99 };
+23 -18
packages/lexicons/lex-cli/src/commands/generate.ts
··· 150 150 151 151 const outdir = path.join(config.root, config.outdir); 152 152 const formatter = await createFormatter(config.formatter, config.root); 153 - const pending: Promise<void>[] = []; 153 + 154 + try { 155 + const pending: Promise<void>[] = []; 156 + 157 + for (const file of generateLexiconApi({ 158 + documents: documents, 159 + mappings: allMappings, 160 + modules: { 161 + importSuffix: config.modules?.importSuffix ?? '.js', 162 + }, 163 + })) { 164 + const filename = path.join(outdir, file.filename); 154 165 155 - for (const file of generateLexiconApi({ 156 - documents: documents, 157 - mappings: allMappings, 158 - modules: { 159 - importSuffix: config.modules?.importSuffix ?? '.js', 160 - }, 161 - })) { 162 - const filename = path.join(outdir, file.filename); 166 + pending.push( 167 + (async () => { 168 + const formatted = await formatter.format(file.code, filename); 169 + await fs.mkdir(path.dirname(filename), { recursive: true }); 170 + await fs.writeFile(filename, formatted); 171 + })(), 172 + ); 173 + } 163 174 164 - pending.push( 165 - (async () => { 166 - const formatted = await formatter.format(file.code, filename); 167 - await fs.mkdir(path.dirname(filename), { recursive: true }); 168 - await fs.writeFile(filename, formatted); 169 - })(), 170 - ); 175 + await Promise.all(pending); 176 + } finally { 177 + await formatter.dispose(); 171 178 } 172 - 173 - await Promise.all(pending); 174 179 };
+31 -27
packages/lexicons/lex-cli/src/commands/pull.ts
··· 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 };
+4
packages/lexicons/lex-cli/src/config.ts
··· 73 73 .assert((value) => Number.isInteger(value) && value > 0, `must be a positive integer`) 74 74 .optional(() => 1), 75 75 }), 76 + v.object({ 77 + type: v.literal('lsp'), 78 + command: v.string().assert((value) => value.length > 0, `must not be empty`), 79 + }), 76 80 ); 77 81 78 82 export type GitSourceConfig = v.Infer<typeof gitSourceConfigSchema>;
+23
packages/lexicons/lex-cli/src/formatter.ts
··· 1 1 import { spawn } from 'node:child_process'; 2 2 3 3 import type { FormatterConfig } from './config.ts'; 4 + import { createLspClient } from './lsp-client.ts'; 4 5 5 6 /** formats source code */ 6 7 export interface Formatter { ··· 11 12 * @returns formatted code 12 13 */ 13 14 format(code: string, filepath: string): Promise<string>; 15 + /** releases any resources held by the formatter */ 16 + dispose(): Promise<void>; 14 17 } 15 18 16 19 const inferPrettierParser = (filepath: string): string => { ··· 82 85 async format(code, filepath) { 83 86 return prettier.format(code, { ...prettierConfig, parser: inferPrettierParser(filepath) }); 84 87 }, 88 + async dispose() {}, 85 89 }; 86 90 } 87 91 case 'command': { ··· 127 131 } finally { 128 132 lock.release(); 129 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(); 130 153 }, 131 154 }; 132 155 }
+282
packages/lexicons/lex-cli/src/lsp-client.ts
··· 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 + let buffer = Buffer.alloc(0); 152 + let contentLength = -1; 153 + const HEADER_SEPARATOR = Buffer.from('\r\n\r\n'); 154 + 155 + const processBuffer = (): void => { 156 + while (true) { 157 + if (contentLength === -1) { 158 + const separatorIndex = buffer.indexOf(HEADER_SEPARATOR); 159 + if (separatorIndex === -1) { 160 + break; 161 + } 162 + 163 + const header = buffer.subarray(0, separatorIndex).toString(); 164 + const match = header.match(/Content-Length:\s*(\d+)/i); 165 + 166 + if (!match) { 167 + buffer = buffer.subarray(separatorIndex + 4); 168 + continue; 169 + } 170 + 171 + contentLength = parseInt(match[1], 10); 172 + buffer = buffer.subarray(separatorIndex + 4); 173 + } 174 + 175 + if (buffer.length < contentLength) { 176 + break; 177 + } 178 + 179 + const body = buffer.subarray(0, contentLength).toString(); 180 + buffer = buffer.subarray(contentLength); 181 + contentLength = -1; 182 + 183 + let message: JsonRpcMessage; 184 + try { 185 + message = JSON.parse(body); 186 + } catch { 187 + continue; 188 + } 189 + 190 + // dispatch responses to pending requests, ignore everything else 191 + if (message.id != null) { 192 + const entry = pending.get(message.id); 193 + if (entry) { 194 + pending.delete(message.id); 195 + 196 + if (message.error) { 197 + entry.reject(new Error(`LSP error ${message.error.code}: ${message.error.message}`)); 198 + } else { 199 + entry.resolve(message.result); 200 + } 201 + } 202 + } 203 + } 204 + }; 205 + 206 + child.stdout.on('data', (chunk: Buffer) => { 207 + buffer = Buffer.concat([buffer, chunk]); 208 + processBuffer(); 209 + }); 210 + 211 + const rejectPending = (error: Error): void => { 212 + for (const [, entry] of pending) { 213 + entry.reject(error); 214 + } 215 + pending.clear(); 216 + }; 217 + 218 + child.on('error', (err) => { 219 + exited = true; 220 + rejectPending(err); 221 + }); 222 + 223 + child.on('close', (exitCode) => { 224 + exited = true; 225 + rejectPending(new Error(`LSP server exited unexpectedly with code ${exitCode}`)); 226 + }); 227 + 228 + // #endregion 229 + 230 + // #region initialize handshake 231 + 232 + const rootUri = url.pathToFileURL(root).href; 233 + 234 + await sendRequest('initialize', { 235 + processId: process.pid, 236 + clientInfo: { name: 'lex-cli' }, 237 + rootUri, 238 + capabilities: {}, 239 + }); 240 + 241 + sendNotification('initialized'); 242 + 243 + // #endregion 244 + 245 + return { 246 + async formatDocument(code, filepath) { 247 + const uri = url.pathToFileURL(filepath).href; 248 + const languageId = inferLanguageId(filepath); 249 + 250 + sendNotification('textDocument/didOpen', { 251 + textDocument: { uri, languageId, version: 1, text: code }, 252 + }); 253 + 254 + const edits = (await sendRequest('textDocument/formatting', { 255 + textDocument: { uri }, 256 + options: { tabSize: 2, insertSpaces: false }, 257 + })) as TextEdit[] | null; 258 + 259 + sendNotification('textDocument/didClose', { 260 + textDocument: { uri }, 261 + }); 262 + 263 + if (!edits || edits.length === 0) { 264 + return code; 265 + } 266 + 267 + return applyTextEdits(code, edits); 268 + }, 269 + 270 + async dispose() { 271 + if (!exited) { 272 + await sendRequest('shutdown', null); 273 + sendNotification('exit'); 274 + } 275 + 276 + if (!exited) { 277 + child.kill(); 278 + await new Promise<void>((resolve) => child.on('close', resolve)); 279 + } 280 + }, 281 + }; 282 + };
+3
pnpm-lock.yaml
··· 629 629 specifier: ^3.8.1 630 630 version: 3.8.1 631 631 devDependencies: 632 + '@atcute/uint8array': 633 + specifier: workspace:^ 634 + version: link:../../misc/uint8array 632 635 '@types/node': 633 636 specifier: ^25.5.2 634 637 version: 25.5.2