@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/rex-cli.ts CHANGED
@@ -1,11 +1,13 @@
1
- import { compile, parse, parseToIR, stringify } from "./rex.ts";
2
- import { evaluateSource } from "./rexc-interpreter.ts";
1
+ import { compile, parse, parseToIR, stringify, grammar } from "./rex.ts";
2
+ import { evaluateRexc, evaluateSource } from "./rexc-interpreter.ts";
3
+ import { highlightLine, highlightJSON, highlightAuto, setColorEnabled } from "./rex-repl.ts";
3
4
  import { dirname, resolve } from "node:path";
4
5
  import { readFile, writeFile } from "node:fs/promises";
5
6
 
7
+ type SourceSegment = { type: "expr"; value: string } | { type: "file"; path: string };
8
+
6
9
  type CliOptions = {
7
- expr?: string;
8
- file?: string;
10
+ sources: SourceSegment[];
9
11
  out?: string;
10
12
  compile: boolean;
11
13
  ir: boolean;
@@ -13,15 +15,36 @@ type CliOptions = {
13
15
  dedupeValues: boolean;
14
16
  dedupeMinBytes?: number;
15
17
  help: boolean;
18
+ cat: boolean;
19
+ showSource: boolean;
20
+ showExpr: boolean;
21
+ showIR: boolean;
22
+ showRexc: boolean;
23
+ showVars: boolean;
24
+ color: boolean;
25
+ colorExplicit: boolean;
26
+ showExprExplicit: boolean;
27
+ format: "rex" | "json";
16
28
  };
17
29
 
18
30
  function parseArgs(argv: string[]): CliOptions {
19
31
  const options: CliOptions = {
32
+ sources: [],
20
33
  compile: false,
21
34
  ir: false,
22
35
  minifyNames: false,
23
36
  dedupeValues: false,
24
37
  help: false,
38
+ cat: false,
39
+ showSource: false,
40
+ showExpr: true,
41
+ showIR: false,
42
+ showRexc: false,
43
+ showVars: false,
44
+ color: process.stdout.isTTY,
45
+ colorExplicit: false,
46
+ showExprExplicit: false,
47
+ format: "rex",
25
48
  };
26
49
  for (let index = 0; index < argv.length; index += 1) {
27
50
  const arg = argv[index];
@@ -30,6 +53,58 @@ function parseArgs(argv: string[]): CliOptions {
30
53
  options.help = true;
31
54
  continue;
32
55
  }
56
+ if (arg === "--cat") {
57
+ options.cat = true;
58
+ continue;
59
+ }
60
+ if (arg === "--show-source") {
61
+ options.showSource = true;
62
+ continue;
63
+ }
64
+ if (arg === "--show-expr") {
65
+ options.showExpr = true;
66
+ options.showExprExplicit = true;
67
+ continue;
68
+ }
69
+ if (arg === "--no-expr") {
70
+ options.showExpr = false;
71
+ options.showExprExplicit = true;
72
+ continue;
73
+ }
74
+ if (arg === "--show-ir") {
75
+ options.showIR = true;
76
+ continue;
77
+ }
78
+ if (arg === "--show-rexc") {
79
+ options.showRexc = true;
80
+ continue;
81
+ }
82
+ if (arg === "--show-vars") {
83
+ options.showVars = true;
84
+ continue;
85
+ }
86
+ if (arg === "--json") {
87
+ options.format = "json";
88
+ continue;
89
+ }
90
+ if (arg === "--format") {
91
+ const value = argv[index + 1];
92
+ if (!value) throw new Error("Missing value for --format");
93
+ if (value !== "rex" && value !== "json") throw new Error("--format must be 'rex' or 'json'");
94
+ options.format = value;
95
+ index += 1;
96
+ continue;
97
+ }
98
+ if (arg === "--color") {
99
+ options.color = true;
100
+ options.colorExplicit = true;
101
+ continue;
102
+ }
103
+ if (arg === "--no-color") {
104
+ options.color = false;
105
+ options.colorExplicit = true;
106
+ continue;
107
+ }
33
108
  if (arg === "--compile" || arg === "-c") {
34
109
  options.compile = true;
35
110
  continue;
@@ -58,14 +133,14 @@ function parseArgs(argv: string[]): CliOptions {
58
133
  if (arg === "--expr" || arg === "-e") {
59
134
  const value = argv[index + 1];
60
135
  if (!value) throw new Error("Missing value for --expr");
61
- options.expr = value;
136
+ options.sources.push({ type: "expr", value });
62
137
  index += 1;
63
138
  continue;
64
139
  }
65
140
  if (arg === "--file" || arg === "-f") {
66
141
  const value = argv[index + 1];
67
142
  if (!value) throw new Error("Missing value for --file");
68
- options.file = value;
143
+ options.sources.push({ type: "file", path: value });
69
144
  index += 1;
70
145
  continue;
71
146
  }
@@ -78,8 +153,7 @@ function parseArgs(argv: string[]): CliOptions {
78
153
  }
79
154
  // Positional argument = file path
80
155
  if (!arg.startsWith("-")) {
81
- if (options.file) throw new Error("Multiple file arguments provided");
82
- options.file = arg;
156
+ options.sources.push({ type: "file", path: arg });
83
157
  continue;
84
158
  }
85
159
  throw new Error(`Unknown option: ${arg}`);
@@ -98,15 +172,39 @@ function usage() {
98
172
  " cat input.rex | rex Evaluate from stdin",
99
173
  " rex -c input.rex Compile to rexc bytecode",
100
174
  "",
175
+ " Sources are concatenated in order, so flags and files can be mixed:",
176
+ " rex -e 'max = 200' primes.rex Set max before running script",
177
+ " rex primes.rex -e '42' Run script, then evaluate 42",
178
+ "",
101
179
  "Input:",
102
180
  " <file> Evaluate/compile a Rex source file",
103
181
  " -e, --expr <source> Evaluate/compile an inline expression",
104
182
  " -f, --file <path> Evaluate/compile source from a file",
105
183
  "",
184
+ " Multiple -e and -f flags (and positional files) can be combined.",
185
+ " They are concatenated in the order they appear on the command line.",
186
+ "",
106
187
  "Output mode:",
107
- " (default) Evaluate and output result as JSON",
108
- " -c, --compile Compile to rexc bytecode",
109
- " --ir Output lowered IR as JSON",
188
+ " (default) Evaluate and output result",
189
+ " -c, --compile Show rexc only (same as --show-rexc --no-expr)",
190
+ " --ir Show IR only (same as --show-ir --no-expr)",
191
+ " --cat Print input with Rex highlighting",
192
+ " --show-source Show input source text",
193
+ " --show-expr Show expression result",
194
+ " --no-expr Hide expression result",
195
+ " --show-ir Show IR JSON",
196
+ " --show-rexc Show rexc bytecode",
197
+ " --show-vars Show variable state",
198
+ "",
199
+ "Output formatting:",
200
+ " --format <type> Output format: rex|json (default: rex)",
201
+ " --json Shortcut for --format json",
202
+ " --color Force color output",
203
+ " --no-color Disable color output",
204
+ "",
205
+ "TTY vs non-TTY:",
206
+ " TTY output prints labeled blocks when multiple outputs are selected.",
207
+ " Non-TTY output emits a single value (object if multiple outputs).",
110
208
  "",
111
209
  "Compile options:",
112
210
  " -m, --minify-names Minify local variable names",
@@ -128,9 +226,14 @@ async function readStdin(): Promise<string> {
128
226
  }
129
227
 
130
228
  async function resolveSource(options: CliOptions): Promise<string> {
131
- if (options.expr && options.file) throw new Error("Use only one of --expr, --file, or a positional file path");
132
- if (options.expr) return options.expr;
133
- if (options.file) return readFile(options.file, "utf8");
229
+ if (options.sources.length > 0) {
230
+ const parts: string[] = [];
231
+ for (const seg of options.sources) {
232
+ if (seg.type === "expr") parts.push(seg.value);
233
+ else parts.push(await readFile(seg.path, "utf8"));
234
+ }
235
+ return parts.join("\n");
236
+ }
134
237
  if (!process.stdin.isTTY) {
135
238
  const piped = await readStdin();
136
239
  if (piped.trim().length > 0) return piped;
@@ -138,6 +241,13 @@ async function resolveSource(options: CliOptions): Promise<string> {
138
241
  throw new Error("No input provided. Use a file path, --expr, or pipe source via stdin.");
139
242
  }
140
243
 
244
+ function findFirstFilePath(sources: SourceSegment[]): string | undefined {
245
+ for (const seg of sources) {
246
+ if (seg.type === "file") return seg.path;
247
+ }
248
+ return undefined;
249
+ }
250
+
141
251
  async function loadDomainConfigFromFolder(folderPath: string): Promise<unknown | undefined> {
142
252
  const configPath = resolve(folderPath, ".config.rex");
143
253
  try {
@@ -149,19 +259,64 @@ async function loadDomainConfigFromFolder(folderPath: string): Promise<unknown |
149
259
  }
150
260
 
151
261
  async function resolveDomainConfig(options: CliOptions): Promise<unknown | undefined> {
152
- const baseFolder = options.file ? dirname(resolve(options.file)) : process.cwd();
262
+ const filePath = findFirstFilePath(options.sources);
263
+ const baseFolder = filePath ? dirname(resolve(filePath)) : process.cwd();
153
264
  return loadDomainConfigFromFolder(baseFolder);
154
265
  }
155
266
 
267
+ function formatJson(value: unknown, indent = 2): string {
268
+ const normalized = normalizeJsonValue(value, false);
269
+ const text = JSON.stringify(normalized, null, indent);
270
+ return text ?? "null";
271
+ }
272
+
273
+ function normalizeJsonValue(value: unknown, inArray: boolean): unknown {
274
+ if (value === undefined) return inArray ? null : undefined;
275
+ if (value === null || typeof value !== "object") return value;
276
+ if (Array.isArray(value)) {
277
+ return value.map((item) => normalizeJsonValue(item, true));
278
+ }
279
+ const out: Record<string, unknown> = {};
280
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
281
+ const normalized = normalizeJsonValue(val, false);
282
+ if (normalized !== undefined) out[key] = normalized;
283
+ }
284
+ return out;
285
+ }
286
+
287
+ function isRexSource(source: string): boolean {
288
+ try {
289
+ return grammar.match(source).succeeded();
290
+ } catch {
291
+ return false;
292
+ }
293
+ }
294
+
156
295
  async function main() {
157
296
  const options = parseArgs(process.argv.slice(2));
158
297
  if (options.help) {
159
298
  console.log(usage());
160
299
  return;
161
300
  }
301
+ setColorEnabled(options.color);
302
+ if (options.cat) {
303
+ const source = await resolveSource(options);
304
+ const hasRexc = options.sources.some((seg) => seg.type === "file" && seg.path.endsWith(".rexc"));
305
+ const hasRex = options.sources.some((seg) => seg.type === "file" && seg.path.endsWith(".rex"));
306
+ const hint = hasRexc && !hasRex ? "rexc" : hasRex && !hasRexc ? "rex" : undefined;
307
+ const output = (process.stdout.isTTY && options.color)
308
+ ? highlightAuto(source, hint)
309
+ : source;
310
+ if (options.out) {
311
+ await writeFile(options.out, `${output}\n`, "utf8");
312
+ return;
313
+ }
314
+ console.log(output);
315
+ return;
316
+ }
162
317
 
163
318
  // No source provided on a TTY → launch interactive REPL
164
- const hasSource = options.expr || options.file || !process.stdin.isTTY;
319
+ const hasSource = options.sources.length > 0 || !process.stdin.isTTY;
165
320
  if (!hasSource && !options.compile && !options.ir) {
166
321
  const { startRepl } = await import("./rex-repl.ts");
167
322
  await startRepl();
@@ -169,21 +324,107 @@ async function main() {
169
324
  }
170
325
 
171
326
  const source = await resolveSource(options);
327
+ const sourceIsRex = isRexSource(source);
172
328
 
173
- let output: string;
329
+ if (options.compile) {
330
+ if (!sourceIsRex) throw new Error("--compile requires Rex source");
331
+ options.showRexc = true;
332
+ if (!options.showExprExplicit) options.showExpr = false;
333
+ }
174
334
  if (options.ir) {
175
- output = JSON.stringify(parseToIR(source), null, 2);
176
- } else if (options.compile) {
335
+ if (!sourceIsRex) throw new Error("--ir requires Rex source");
336
+ options.showIR = true;
337
+ if (!options.showExprExplicit) options.showExpr = false;
338
+ }
339
+
340
+ const outputFlags = [options.showSource, options.showExpr, options.showIR, options.showRexc, options.showVars].filter(Boolean).length;
341
+ if (outputFlags === 0) {
342
+ throw new Error("No output selected. Use --show-source, --show-expr, --show-ir, --show-rexc, or --show-vars.");
343
+ }
344
+ const humanMode = process.stdout.isTTY && options.color;
345
+ type OutputKey = "source" | "ir" | "rexc" | "vars" | "result";
346
+ const outputs: Partial<Record<OutputKey, unknown>> = {};
347
+ if (options.showSource) outputs.source = source;
348
+ if (!sourceIsRex && options.showIR) {
349
+ throw new Error("--show-ir is only available for Rex source");
350
+ }
351
+
352
+ if (options.showIR) {
353
+ outputs.ir = parseToIR(source);
354
+ }
355
+
356
+ if (options.showRexc) {
177
357
  const domainConfig = await resolveDomainConfig(options);
178
- output = compile(source, {
179
- minifyNames: options.minifyNames,
180
- dedupeValues: options.dedupeValues,
181
- dedupeMinBytes: options.dedupeMinBytes,
182
- domainConfig,
183
- });
358
+ outputs.rexc = sourceIsRex
359
+ ? compile(source, {
360
+ minifyNames: options.minifyNames,
361
+ dedupeValues: options.dedupeValues,
362
+ dedupeMinBytes: options.dedupeMinBytes,
363
+ domainConfig,
364
+ })
365
+ : source;
366
+ }
367
+
368
+ if (options.showExpr || options.showVars) {
369
+ const result = sourceIsRex ? evaluateSource(source) : evaluateRexc(source);
370
+ if (options.showExpr) outputs.result = result.value;
371
+ if (options.showVars) outputs.vars = result.state.vars;
372
+ }
373
+
374
+ const order: OutputKey[] = ["source", "ir", "rexc", "vars", "result"];
375
+ const selected = order.filter((key) => outputs[key] !== undefined);
376
+
377
+ function formatValue(value: unknown, kind: OutputKey): string {
378
+ if (kind === "source") {
379
+ const raw = String(value ?? "");
380
+ if (options.format === "json") {
381
+ const json = formatJson(raw, 2);
382
+ return humanMode && options.color ? highlightJSON(json) : json;
383
+ }
384
+ return humanMode && options.color ? highlightAuto(raw) : raw;
385
+ }
386
+ if (kind === "rexc" && humanMode) {
387
+ const raw = String(value ?? "");
388
+ return options.color ? highlightAuto(raw, "rexc") : raw;
389
+ }
390
+ if (options.format === "json") {
391
+ const raw = formatJson(value, 2);
392
+ return humanMode && options.color ? highlightJSON(raw) : raw;
393
+ }
394
+ if (kind === "rexc") {
395
+ const raw = stringify(String(value ?? ""));
396
+ return humanMode && options.color ? highlightLine(raw) : raw;
397
+ }
398
+ const raw = stringify(value);
399
+ return humanMode && options.color ? highlightLine(raw) : raw;
400
+ }
401
+
402
+ let output: string;
403
+ if (humanMode) {
404
+ const header = options.color ? "\x1b[90m" : "";
405
+ const reset = options.color ? "\x1b[0m" : "";
406
+ const lines: string[] = [];
407
+ for (const key of order) {
408
+ const value = outputs[key];
409
+ if (value === undefined) continue;
410
+ lines.push(`${header}${key}:${reset} ${formatValue(value, key)}`);
411
+ }
412
+ output = lines.join("\n");
184
413
  } else {
185
- const { value } = evaluateSource(source);
186
- output = stringify(value);
414
+ let value: unknown;
415
+ if (selected.length === 1) {
416
+ const only = selected[0];
417
+ if (!only) throw new Error("No output selected.");
418
+ value = outputs[only];
419
+ } else {
420
+ const out: Record<string, unknown> = {};
421
+ for (const key of order) {
422
+ const v = outputs[key];
423
+ if (v !== undefined) out[key] = v;
424
+ }
425
+ value = out;
426
+ }
427
+ output = options.format === "json" ? formatJson(value, 2) : stringify(value);
187
428
  }
188
429
 
189
430
  if (options.out) {