@creationix/rex 0.3.1 → 0.6.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.
package/rx-cli.ts ADDED
@@ -0,0 +1,298 @@
1
+ import * as rexc from "./rexc.ts";
2
+ import { stringify as rexStringify } from "./rex.ts";
3
+ import {
4
+ setColorEnabled,
5
+ highlightLine,
6
+ highlightJSON,
7
+ } from "./rex-repl.ts";
8
+ import { readFile, writeFile } from "node:fs/promises";
9
+
10
+ // ── Types ────────────────────────────────────────────────────
11
+
12
+ type Format = "json" | "rexc";
13
+ type OutputFormat = Format | "tree";
14
+
15
+ type RxOptions = {
16
+ files: string[];
17
+ fromFormat?: Format;
18
+ toFormat?: OutputFormat;
19
+ select?: string;
20
+ out?: string;
21
+ color: boolean;
22
+ colorExplicit: boolean;
23
+ help: boolean;
24
+ };
25
+
26
+ // ── Arg parsing ──────────────────────────────────────────────
27
+
28
+ function parseArgs(argv: string[]): RxOptions {
29
+ const opts: RxOptions = {
30
+ files: [],
31
+ color: process.stdout.isTTY ?? false,
32
+ colorExplicit: false,
33
+ help: false,
34
+ };
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const arg = argv[i]!;
37
+ if (arg === "-h" || arg === "--help") { opts.help = true; continue; }
38
+ if (arg === "--color") { opts.color = true; opts.colorExplicit = true; continue; }
39
+ if (arg === "--no-color") { opts.color = false; opts.colorExplicit = true; continue; }
40
+ if (arg === "-j" || arg === "--json") { opts.toFormat = "json"; continue; }
41
+ if (arg === "-r" || arg === "--rexc") { opts.toFormat = "rexc"; continue; }
42
+ if (arg === "-t" || arg === "--tree") { opts.toFormat = "tree"; continue; }
43
+ if (arg === "--from") {
44
+ const v = argv[++i];
45
+ if (v !== "json" && v !== "rexc") throw new Error("--from must be 'json' or 'rexc'");
46
+ opts.fromFormat = v;
47
+ continue;
48
+ }
49
+ if (arg === "--to") {
50
+ const v = argv[++i];
51
+ if (v !== "json" && v !== "rexc" && v !== "tree") throw new Error("--to must be 'json', 'rexc', or 'tree'");
52
+ opts.toFormat = v;
53
+ continue;
54
+ }
55
+ if (arg === "-s" || arg === "--select") {
56
+ const v = argv[++i];
57
+ if (!v) throw new Error("Missing value for --select");
58
+ opts.select = v;
59
+ continue;
60
+ }
61
+ if (arg === "-o" || arg === "--out") {
62
+ const v = argv[++i];
63
+ if (!v) throw new Error("Missing value for --out");
64
+ opts.out = v;
65
+ continue;
66
+ }
67
+ if (!arg.startsWith("-") || arg === "-") {
68
+ opts.files.push(arg);
69
+ continue;
70
+ }
71
+ throw new Error(`Unknown option: ${arg}`);
72
+ }
73
+ return opts;
74
+ }
75
+
76
+ function usage(): string {
77
+ return [
78
+ "rx — inspect, convert, and filter REXC & JSON data.",
79
+ "",
80
+ "Usage:",
81
+ " rx data.rexc Pretty-print rexc as a tree",
82
+ " rx data.rexc --to json Convert rexc to JSON",
83
+ " rx data.json --to rexc Convert JSON to rexc",
84
+ " cat data.rexc | rx Read from stdin (auto-detect)",
85
+ " rx -s .routes[0].op data.rexc Select a sub-value",
86
+ "",
87
+ "Input:",
88
+ " <file> Read from file (format auto-detected by extension)",
89
+ " - Read from stdin explicitly",
90
+ " (no args, piped) Read from stdin automatically",
91
+ "",
92
+ "Format control:",
93
+ " --from json|rexc Force input format (default: auto-detect)",
94
+ " --to json|rexc|tree Output format",
95
+ " -j, --json Shortcut for --to json",
96
+ " -r, --rexc Shortcut for --to rexc",
97
+ " -t, --tree Shortcut for --to tree",
98
+ "",
99
+ " Default output: tree on TTY, json when piped.",
100
+ "",
101
+ "Filtering:",
102
+ " -s, --select <path> Dot-path selector (e.g. .foo.bar[0].baz)",
103
+ "",
104
+ "Output:",
105
+ " -o, --out <path> Write to file instead of stdout",
106
+ " --color Force ANSI color",
107
+ " --no-color Disable ANSI color",
108
+ " -h, --help Show this message",
109
+ ].join("\n");
110
+ }
111
+
112
+ // ── Format detection ─────────────────────────────────────────
113
+
114
+ function formatFromExt(path: string): Format | undefined {
115
+ if (path.endsWith(".json")) return "json";
116
+ if (path.endsWith(".rexc")) return "rexc";
117
+ return undefined;
118
+ }
119
+
120
+ function detectFormat(content: string): Format {
121
+ const t = content.trimStart();
122
+ if (/^[\[{"0-9tfn\-]/.test(t)) {
123
+ try { JSON.parse(content); return "json"; } catch { /* not json */ }
124
+ }
125
+ return "rexc";
126
+ }
127
+
128
+ // ── Input reading ────────────────────────────────────────────
129
+
130
+ async function readStdin(): Promise<string> {
131
+ const chunks: Buffer[] = [];
132
+ for await (const chunk of process.stdin) {
133
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
134
+ }
135
+ return Buffer.concat(chunks).toString("utf8");
136
+ }
137
+
138
+ type ParsedInput = { value: unknown };
139
+
140
+ function parseRaw(raw: string, format: Format): unknown {
141
+ if (format === "json") return JSON.parse(raw);
142
+ return rexc.parse(raw.trim());
143
+ }
144
+
145
+ async function readInput(opts: RxOptions): Promise<ParsedInput> {
146
+ if (opts.files.length === 0) {
147
+ // stdin
148
+ if (process.stdin.isTTY) throw new Error("No input. Provide a file or pipe data via stdin.");
149
+ const raw = await readStdin();
150
+ if (!raw.trim()) throw new Error("Empty stdin.");
151
+ const format = opts.fromFormat ?? detectFormat(raw);
152
+ return { value: parseRaw(raw, format) };
153
+ }
154
+
155
+ if (opts.files.length === 1) {
156
+ const file = opts.files[0]!;
157
+ const raw = file === "-" ? await readStdin() : await readFile(file, "utf8");
158
+ const format = opts.fromFormat ?? (file === "-" ? detectFormat(raw) : formatFromExt(file) ?? detectFormat(raw));
159
+ return { value: parseRaw(raw, format) };
160
+ }
161
+
162
+ // Multiple files → array
163
+ const values: unknown[] = [];
164
+ for (const file of opts.files) {
165
+ const raw = file === "-" ? await readStdin() : await readFile(file, "utf8");
166
+ const format = opts.fromFormat ?? (file === "-" ? detectFormat(raw) : formatFromExt(file) ?? detectFormat(raw));
167
+ values.push(parseRaw(raw, format));
168
+ }
169
+ return { value: values };
170
+ }
171
+
172
+ // ── Selector ─────────────────────────────────────────────────
173
+
174
+ type Segment = { type: "key"; name: string } | { type: "index"; value: number };
175
+
176
+ function parseSelector(selector: string): Segment[] {
177
+ const segments: Segment[] = [];
178
+ const re = /\.([a-zA-Z_][\w-]*)|(\[(\d+)\])/g;
179
+ // Allow leading bare key without dot
180
+ let s = selector;
181
+ if (s.startsWith(".")) s = s; // keep as-is, regex handles it
182
+ else if (!s.startsWith("[")) s = "." + s; // prepend dot for bare key
183
+
184
+ let match: RegExpExecArray | null;
185
+ let lastIndex = 0;
186
+ while ((match = re.exec(s)) !== null) {
187
+ if (match.index !== lastIndex) {
188
+ throw new Error(`Invalid selector at position ${lastIndex}: ${selector}`);
189
+ }
190
+ if (match[1] !== undefined) {
191
+ segments.push({ type: "key", name: match[1] });
192
+ } else if (match[3] !== undefined) {
193
+ segments.push({ type: "index", value: parseInt(match[3], 10) });
194
+ }
195
+ lastIndex = re.lastIndex;
196
+ }
197
+ if (lastIndex !== s.length) {
198
+ throw new Error(`Invalid selector at position ${lastIndex}: ${selector}`);
199
+ }
200
+ return segments;
201
+ }
202
+
203
+ function applySelector(value: unknown, selector: string): unknown {
204
+ if (selector === "." || selector === "") return value;
205
+ const segments = parseSelector(selector);
206
+ let current = value;
207
+ let path = "";
208
+ for (const seg of segments) {
209
+ if (seg.type === "key") {
210
+ path += `.${seg.name}`;
211
+ if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
212
+ throw new Error(`Selector ${path}: cannot access property '${seg.name}' on ${typeLabel(current)}`);
213
+ }
214
+ const obj = current as Record<string, unknown>;
215
+ if (!(seg.name in obj)) {
216
+ throw new Error(`Selector ${path}: property '${seg.name}' not found`);
217
+ }
218
+ current = obj[seg.name];
219
+ } else {
220
+ path += `[${seg.value}]`;
221
+ if (!Array.isArray(current)) {
222
+ throw new Error(`Selector ${path}: cannot index into ${typeLabel(current)}`);
223
+ }
224
+ if (seg.value < 0 || seg.value >= current.length) {
225
+ throw new Error(`Selector ${path}: index ${seg.value} out of range (length ${current.length})`);
226
+ }
227
+ current = current[seg.value];
228
+ }
229
+ }
230
+ return current;
231
+ }
232
+
233
+ function typeLabel(v: unknown): string {
234
+ if (v === null) return "null";
235
+ if (v === undefined) return "undefined";
236
+ if (Array.isArray(v)) return "array";
237
+ return typeof v;
238
+ }
239
+
240
+ // ── Output formatting ────────────────────────────────────────
241
+
242
+ function formatTree(value: unknown, color: boolean): string {
243
+ const text = rexStringify(value, { indent: 2, maxWidth: 80 });
244
+ if (!color) return text;
245
+ return text.split("\n").map((line) => highlightLine(line)).join("\n");
246
+ }
247
+
248
+ function normalizeForJson(value: unknown, inArray: boolean): unknown {
249
+ if (value === undefined) return inArray ? null : undefined;
250
+ if (value === null || typeof value !== "object") return value;
251
+ if (Array.isArray(value)) return value.map((item) => normalizeForJson(item, true));
252
+ const out: Record<string, unknown> = {};
253
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
254
+ const n = normalizeForJson(val, false);
255
+ if (n !== undefined) out[key] = n;
256
+ }
257
+ return out;
258
+ }
259
+
260
+ function formatOutput(value: unknown, format: OutputFormat, color: boolean): string {
261
+ if (format === "tree") return formatTree(value, color);
262
+ if (format === "json") {
263
+ const text = JSON.stringify(normalizeForJson(value, false), null, 2) ?? "null";
264
+ return color ? highlightJSON(text) : text;
265
+ }
266
+ // rexc
267
+ return rexc.stringify(value);
268
+ }
269
+
270
+ // ── Main ─────────────────────────────────────────────────────
271
+
272
+ async function main() {
273
+ const opts = parseArgs(process.argv.slice(2));
274
+ if (opts.help) { console.log(usage()); return; }
275
+
276
+ setColorEnabled(opts.color);
277
+
278
+ const toFormat: OutputFormat = opts.toFormat
279
+ ?? (process.stdout.isTTY ? "tree" : "json");
280
+
281
+ const { value: parsed } = await readInput(opts);
282
+
283
+ const value = opts.select ? applySelector(parsed, opts.select) : parsed;
284
+
285
+ const output = formatOutput(value, toFormat, opts.color);
286
+
287
+ if (opts.out) {
288
+ await writeFile(opts.out, output + "\n", "utf8");
289
+ } else {
290
+ process.stdout.write(output + "\n");
291
+ }
292
+ }
293
+
294
+ await main().catch((error) => {
295
+ const message = error instanceof Error ? error.message : String(error);
296
+ process.stderr.write(`rx: ${message}\n`);
297
+ process.exit(1);
298
+ });