1
use std::fs;
2
use std::io::{self, BufRead, IsTerminal, Read, Write};
3
use std::path::Path;
4

            
5
use anyhow::Context as _;
6
use clap::Parser;
7
use scripting::nomiscript::{Reader, Value};
8
use uuid::Uuid;
9

            
10
use nms::interpreter;
11
use scripting::runtime::ProfilerStrategy;
12
mod repl;
13
mod rpc_eval;
14
mod slynk;
15
mod ssh_eval;
16

            
17
14
fn parse_profiler(s: Option<&str>) -> anyhow::Result<ProfilerStrategy> {
18
14
    match s {
19
10
        None => Ok(ProfilerStrategy::None),
20
4
        Some("jitdump") => Ok(ProfilerStrategy::JitDump),
21
4
        Some("perfmap") => Ok(ProfilerStrategy::PerfMap),
22
2
        Some(other) => Err(anyhow::anyhow!(
23
2
            "unknown --profile strategy '{other}' (expected: jitdump, perfmap)"
24
2
        )),
25
    }
26
14
}
27

            
28
#[derive(Parser)]
29
#[command(name = "nms")]
30
#[command(about = "Nomiscript interpreter", long_about = None)]
31
struct Cli {
32
    /// File to evaluate (use - for stdin)
33
    #[arg(value_name = "FILE")]
34
    file: Option<String>,
35

            
36
    /// Evaluate a string expression
37
    #[arg(short, long, value_name = "EXPR")]
38
    eval: Option<String>,
39

            
40
    /// Compile a source file to WASM bytecode
41
    #[arg(long, value_name = "FILE")]
42
    compile: Option<String>,
43

            
44
    /// Load and run a pre-compiled WASM file
45
    #[arg(long, value_name = "FILE")]
46
    load: Option<String>,
47

            
48
    /// Use TUI mode for the REPL
49
    #[arg(short, long)]
50
    tui: bool,
51

            
52
    /// Enable debug-level tracing output
53
    #[arg(long)]
54
    debug: bool,
55

            
56
    /// Route forms through `rpc::Session` for the given user, exposing
57
    /// the DB-touching natives (list-accounts, get-commodity, ...).
58
    /// Requires DATABASE_URL. Without this flag, `nms` is a pure language
59
    /// sandbox and the rpc/server crates are dormant.
60
    #[arg(long, value_name = "UUID")]
61
    rpc_user: Option<Uuid>,
62

            
63
    /// Start a SLYNK server on the given TCP port instead of a terminal REPL,
64
    /// so Emacs SLY can drive nomiscript via `M-x sly-connect localhost PORT`.
65
    /// Evaluates through `rpc::Session` — pass `--rpc-user <UUID>` (and set
66
    /// DATABASE_URL) to expose the DB-backed natives; without it the session
67
    /// runs as the nil user.
68
    #[arg(long, value_name = "PORT")]
69
    slynk_port: Option<u16>,
70

            
71
    /// Connect to a remote `nomisync-eval` subsystem over SSH and run
72
    /// forms there instead of a local session. Shells out to the system
73
    /// `ssh`, so authentication (key, ssh-agent, `~/.ssh/config`, or a
74
    /// password prompt) is OpenSSH's job and the SSH identity maps to a
75
    /// nomisync user server-side — no DATABASE_URL is needed locally.
76
    /// Value: `[user@]host`. Combine with `-e`/FILE for one-shot runs,
77
    /// or omit both for an interactive REPL.
78
    #[arg(long, value_name = "[USER@]HOST")]
79
    ssh: Option<String>,
80

            
81
    /// TCP port for `--ssh` (defaults to your ssh config / port 22).
82
    #[arg(long, value_name = "PORT")]
83
    ssh_port: Option<u16>,
84

            
85
    /// Disable inline kitty-graphics rendering of `Value::Bytes`
86
    /// image payloads. Forces the textual `#u8(...)` fallback even
87
    /// when the surrounding terminal advertises kitty support — use
88
    /// this in CI captures and when piping output through a tool
89
    /// that can't strip APC sequences.
90
    #[arg(long)]
91
    no_graphics: bool,
92

            
93
    /// Load every `*.nms` file under PATH (file or directory),
94
    /// then run `(run-tests)`. Exits non-zero if any test failed.
95
    /// PATH defaults to `tests/` when omitted as a bare flag.
96
    #[arg(long, value_name = "PATH")]
97
    test: Option<String>,
98

            
99
    /// With `--test`: also print the `(coverage-dump)` output after
100
    /// the test run. Useful for verifying every native fn the host
101
    /// exposes has at least one test that compiles against it (the
102
    /// parity contract from plan §"All-32 enumeration"). Off by
103
    /// default to keep CI output terse.
104
    #[arg(long)]
105
    coverage: bool,
106

            
107
    /// Enable wasmtime's JitDump profiler — emits a `jit-<pid>.dump`
108
    /// next to the working directory consumable by `perf record` and
109
    /// flame-graph tooling. Linux only; requires building with
110
    /// `--features profile-jitdump` (off by default to avoid the
111
    /// extra dep on non-Linux). Use `perfmap` for the lighter symbol-
112
    /// only variant.
113
    #[arg(long, value_name = "STRATEGY")]
114
    profile: Option<String>,
115
}
116

            
117
/// Render policy for `Value::Bytes` image payloads. Computed once at
118
/// startup from the `--no-graphics` flag + current env + stdout
119
/// terminal-state so every value printer sees the same decision.
120
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121
struct GraphicsPolicy {
122
    inline: bool,
123
}
124

            
125
impl GraphicsPolicy {
126
14
    fn from_cli(no_graphics: bool) -> Self {
127
        // `--no-graphics` or non-TTY stdout disables inline. Otherwise
128
        // we honour the terminal capability check in
129
        // `nms::graphics::supports_kitty`.
130
14
        let inline = !no_graphics
131
14
            && io::stdout().is_terminal()
132
            && nms::graphics::supports_kitty(|name| std::env::var(name).ok());
133
14
        Self { inline }
134
14
    }
135
}
136

            
137
14
fn main() -> anyhow::Result<()> {
138
14
    let cli = Cli::parse();
139
14
    let graphics = GraphicsPolicy::from_cli(cli.no_graphics);
140
14
    let profiler = parse_profiler(cli.profile.as_deref())?;
141

            
142
12
    if let Some(target) = cli.ssh.as_deref() {
143
        return run_ssh_mode(target, cli.ssh_port, &cli);
144
12
    } else if let Some(port) = cli.slynk_port {
145
        return run_slynk_mode(port, cli.rpc_user);
146
12
    } else if let Some(path) = cli.test.as_deref() {
147
10
        return run_test_mode(path, cli.debug, cli.coverage);
148
2
    } else if let Some(user_id) = cli.rpc_user {
149
        run_rpc_mode(user_id, &cli)?;
150
2
    } else if let Some(source) = cli.compile {
151
        compile_file(&source, cli.debug)?;
152
2
    } else if let Some(wasm_path) = cli.load {
153
        load_wasm(&wasm_path, cli.debug, graphics)?;
154
2
    } else if let Some(expr_str) = cli.eval {
155
2
        eval_string(&expr_str, cli.debug, graphics, profiler)?;
156
    } else if let Some(file) = cli.file {
157
        if file == "-" {
158
            eval_stdin(cli.debug, graphics, profiler)?;
159
        } else {
160
            eval_file(&file, cli.debug, graphics, profiler)?;
161
        }
162
    } else if cli.tui {
163
        repl::run()?;
164
    } else {
165
        plain_repl(cli.debug, graphics)?;
166
    }
167

            
168
2
    Ok(())
169
14
}
170

            
171
/// `nms --slynk-port PORT [--rpc-user UUID]`: serve SLY over SLYNK. Uses a
172
/// multi-thread runtime so the connection's reader task can act on an
173
/// `(:emacs-interrupt)` (epoch-bump the engine) while an eval is in flight on
174
/// the eval task. `user_id` defaults to the nil user when `--rpc-user` is
175
/// omitted; the DB-backed natives then error per-call rather than at startup.
176
fn run_slynk_mode(port: u16, user_id: Option<Uuid>) -> anyhow::Result<()> {
177
    // With a user, the DB-backed natives (list-accounts, set-split-tag, …) run
178
    // through `server::command::*`, which lazily connects to `DATABASE_URL`.
179
    // Verify it up front so a missing env surfaces as a clean startup error
180
    // rather than a panic deep inside the first DB-touching native (matching
181
    // the `--rpc-user` REPL). Without a user, nms is a pure-language sandbox
182
    // and no DB is needed.
183
    let database_url_set = std::env::var("DATABASE_URL")
184
        .map(|url| !url.trim().is_empty())
185
        .unwrap_or(false);
186
    if user_id.is_some() && !database_url_set {
187
        anyhow::bail!(
188
            "--rpc-user needs DATABASE_URL so the DB-backed natives can reach \
189
             Postgres; export it (or drop --rpc-user for a sandbox-only server)"
190
        );
191
    }
192
    let runtime = tokio::runtime::Builder::new_multi_thread()
193
        .enable_all()
194
        .build()
195
        .context("slynk-mode tokio runtime build failed")?;
196
    runtime.block_on(slynk::serve(port, user_id.unwrap_or_else(Uuid::nil)))
197
}
198

            
199
fn run_rpc_mode(user_id: Uuid, cli: &Cli) -> anyhow::Result<()> {
200
    let mut rpc = rpc_eval::RpcEval::new(user_id)?;
201
    if let Some(expr) = cli.eval.as_deref() {
202
        println!("{}", rpc.eval(expr));
203
        return Ok(());
204
    }
205
    if let Some(file) = cli.file.as_deref() {
206
        let content = if file == "-" {
207
            let mut s = String::new();
208
            io::stdin().read_to_string(&mut s)?;
209
            s
210
        } else {
211
            fs::read_to_string(file)?
212
        };
213
        for form in split_forms(&content) {
214
            println!("{}", rpc.eval(&form));
215
        }
216
        return Ok(());
217
    }
218
    rpc_repl(&mut rpc)
219
}
220

            
221
/// `nms --ssh [USER@]HOST`: drive a remote `nomisync-eval` subsystem
222
/// over the system `ssh`. `-e EXPR` runs one form, a FILE argument runs
223
/// each top-level form, and neither yields an interactive REPL — the
224
/// same dispatch shape as `--rpc-user`, but the session lives on the
225
/// server and auth is the SSH identity, so no local DATABASE_URL.
226
fn run_ssh_mode(target: &str, port: Option<u16>, cli: &Cli) -> anyhow::Result<()> {
227
    let mut ssh = ssh_eval::SshEval::connect(target, port)?;
228
    if let Some(expr) = cli.eval.as_deref() {
229
        println!("{}", ssh.eval(expr)?);
230
        return Ok(());
231
    }
232
    if let Some(file) = cli.file.as_deref() {
233
        let content = if file == "-" {
234
            let mut s = String::new();
235
            io::stdin().read_to_string(&mut s)?;
236
            s
237
        } else {
238
            fs::read_to_string(file)?
239
        };
240
        for form in split_forms(&content) {
241
            println!("{}", ssh.eval(&form)?);
242
        }
243
        return Ok(());
244
    }
245
    ssh_repl(&mut ssh)
246
}
247

            
248
/// Line REPL over an [`ssh_eval::SshEval`] connection. Buffers input
249
/// until the form is balanced (same rule as the local REPLs), sends it,
250
/// and prints the response envelope. A transport error (the ssh process
251
/// died / the connection dropped) ends the loop.
252
fn ssh_repl(ssh: &mut ssh_eval::SshEval) -> anyhow::Result<()> {
253
    let stdin = io::stdin();
254
    let mut stdout = io::stdout();
255
    let mut buffer = String::new();
256
    loop {
257
        if buffer.is_empty() {
258
            print!("\nssh-nms> ");
259
        } else {
260
            print!("    ");
261
        }
262
        stdout.flush()?;
263
        let mut line = String::new();
264
        if stdin.lock().read_line(&mut line)? == 0 {
265
            break;
266
        }
267
        if buffer.is_empty() && line.trim().is_empty() {
268
            continue;
269
        }
270
        buffer.push_str(&line);
271
        if Reader::is_incomplete(&buffer) {
272
            continue;
273
        }
274
        let input = buffer.trim();
275
        if !input.is_empty() {
276
            match ssh.eval(input) {
277
                Ok(response) => println!("{response}"),
278
                Err(err) => {
279
                    eprintln!("ssh-eval: {err}");
280
                    break;
281
                }
282
            }
283
        }
284
        buffer.clear();
285
    }
286
    Ok(())
287
}
288

            
289
/// `nms --test PATH`: collect every `*.nms` file under PATH (file
290
/// argument is treated as a single file; directories are walked one
291
/// level), load each into a single Interpreter so test registrations
292
/// accumulate, then call `(run-tests)`. Exits 1 if any test failed.
293
10
fn run_test_mode(path: &str, debug: bool, coverage: bool) -> anyhow::Result<()> {
294
10
    let files = collect_nms_files(path)?;
295
10
    if files.is_empty() {
296
2
        return Err(anyhow::anyhow!("no .nms files found at {path}"));
297
8
    }
298
8
    let mut interp = interpreter::Interpreter::new(debug)?;
299
8
    let use_color = io::stderr().is_terminal();
300
16
    for file in &files {
301
16
        let content = fs::read_to_string(file)?;
302
16
        if let Err(e) = interp.eval(&content) {
303
            eprintln!("loading {}:", file.display());
304
            eprint!("{}", e.render(use_color));
305
            std::process::exit(1);
306
16
        }
307
    }
308
8
    let results = interp
309
8
        .eval("(run-tests)")
310
8
        .map_err(|e| anyhow::anyhow!("run-tests failed: {}", e.render(use_color)))?;
311
8
    let summary = match results.last() {
312
8
        Some(Value::String(s)) => s.clone(),
313
        Some(other) => format!("{other:?}"),
314
        None => return Err(anyhow::anyhow!("run-tests produced no result")),
315
    };
316
8
    println!("{summary}");
317
8
    if coverage {
318
2
        let cov = interp
319
2
            .eval("(coverage-dump)")
320
2
            .map_err(|e| anyhow::anyhow!("coverage-dump failed: {}", e.render(use_color)))?;
321
2
        let dump = match cov.last() {
322
2
            Some(Value::String(s)) if s.is_empty() => "(no native fns referenced)".to_string(),
323
            Some(Value::String(s)) => s.clone(),
324
            Some(other) => format!("{other:?}"),
325
            None => "(no coverage output)".to_string(),
326
        };
327
2
        println!("--- coverage ---");
328
2
        println!("{dump}");
329
6
    }
330
8
    if test_summary_failed(&summary) {
331
2
        std::process::exit(1);
332
6
    }
333
6
    Ok(())
334
8
}
335

            
336
10
fn collect_nms_files(path: &str) -> anyhow::Result<Vec<std::path::PathBuf>> {
337
10
    let p = Path::new(path);
338
10
    if !p.exists() {
339
        return Err(anyhow::anyhow!("path does not exist: {path}"));
340
10
    }
341
10
    if p.is_file() {
342
2
        return Ok(vec![p.to_path_buf()]);
343
8
    }
344
8
    let mut files: Vec<_> = fs::read_dir(p)?
345
8
        .filter_map(Result::ok)
346
14
        .map(|e| e.path())
347
14
        .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("nms"))
348
8
        .collect();
349
8
    files.sort();
350
8
    Ok(files)
351
10
}
352

            
353
8
fn test_summary_failed(summary: &str) -> bool {
354
8
    summary
355
8
        .split_once("passed, ")
356
8
        .and_then(|(_, rest)| rest.split_once(" failed"))
357
8
        .and_then(|(num, _)| num.trim().parse::<usize>().ok())
358
8
        .is_some_and(|n| n > 0)
359
8
}
360

            
361
fn rpc_repl(rpc: &mut rpc_eval::RpcEval) -> anyhow::Result<()> {
362
    let stdin = io::stdin();
363
    let mut stdout = io::stdout();
364
    let mut buffer = String::new();
365
    loop {
366
        if buffer.is_empty() {
367
            print!("\nrpc> ");
368
        } else {
369
            print!("   ");
370
        }
371
        stdout.flush()?;
372
        let mut line = String::new();
373
        if stdin.lock().read_line(&mut line)? == 0 {
374
            break;
375
        }
376
        if buffer.is_empty() && line.trim().is_empty() {
377
            continue;
378
        }
379
        buffer.push_str(&line);
380
        if Reader::is_incomplete(&buffer) {
381
            continue;
382
        }
383
        let input = buffer.trim();
384
        if !input.is_empty() {
385
            println!("{}", rpc.eval(input));
386
        }
387
        buffer.clear();
388
    }
389
    Ok(())
390
}
391

            
392
/// Splits whitespace-separated top-level s-expressions in source order.
393
/// Used by file/stdin rpc-mode runs so each form gets its own envelope.
394
/// Splits a source file into the top-level forms to dispatch (one rpc
395
/// envelope per form). A chunk is emitted only once it parses as a COMPLETE
396
/// program carrying at least one expression — comment-only / blank chunks
397
/// (which `Reader::parse` yields as zero-expression programs) are dropped
398
/// rather than wrapped into a bogus `(:id N :form ; comment)` envelope whose
399
/// `;` would comment out the closing paren.
400
5
fn split_forms(content: &str) -> Vec<String> {
401
5
    let mut forms = Vec::new();
402
5
    let mut current = String::new();
403
16
    for line in content.lines() {
404
16
        current.push_str(line);
405
16
        current.push('\n');
406
16
        let trimmed = current.trim();
407
16
        if trimmed.is_empty() || Reader::is_incomplete(trimmed) {
408
5
            continue;
409
11
        }
410
        // A complete chunk with no expression is pure comments/whitespace —
411
        // discard it; otherwise emit and reset.
412
11
        if Reader::parse(trimmed)
413
11
            .map(|p| !p.exprs.is_empty())
414
11
            .unwrap_or(true)
415
6
        {
416
6
            forms.push(trimmed.to_string());
417
6
        }
418
11
        current.clear();
419
    }
420
5
    let tail = current.trim();
421
5
    if !tail.is_empty()
422
        && Reader::parse(tail)
423
            .map(|p| !p.exprs.is_empty())
424
            .unwrap_or(true)
425
    {
426
        forms.push(tail.to_string());
427
5
    }
428
5
    forms
429
5
}
430

            
431
fn plain_repl(debug: bool, graphics: GraphicsPolicy) -> anyhow::Result<()> {
432
    let stdin = io::stdin();
433
    let mut stdout = io::stdout();
434
    let use_color = stdout.is_terminal();
435
    let mut interp = interpreter::Interpreter::new(debug)?;
436
    let mut buffer = String::new();
437

            
438
    loop {
439
        if buffer.is_empty() {
440
            print!("\n> ");
441
        } else {
442
            print!("  ");
443
        }
444
        stdout.flush()?;
445

            
446
        let mut line = String::new();
447
        if stdin.lock().read_line(&mut line)? == 0 {
448
            break;
449
        }
450

            
451
        if buffer.is_empty() && line.trim().is_empty() {
452
            continue;
453
        }
454

            
455
        buffer.push_str(&line);
456

            
457
        if Reader::is_incomplete(&buffer) {
458
            continue;
459
        }
460

            
461
        let input = buffer.trim();
462
        if input.is_empty() {
463
            buffer.clear();
464
            continue;
465
        }
466

            
467
        match interp.eval(input) {
468
            Ok(values) => {
469
                for value in values {
470
                    println!("{}", format_value(&value, use_color, graphics));
471
                }
472
            }
473
            Err(e) => eprint!("{}", e.render(use_color)),
474
        }
475
        buffer.clear();
476
    }
477

            
478
    Ok(())
479
}
480

            
481
fn compile_file(path: &str, debug: bool) -> anyhow::Result<()> {
482
    let content = fs::read_to_string(path)?;
483
    let mut interp = interpreter::Interpreter::new(debug)?;
484
    let use_color = io::stderr().is_terminal();
485
    match interp.compile_to_wasm(&content) {
486
        Ok(wasm) => {
487
            let out_path = Path::new(path).with_extension("wasm");
488
            fs::write(&out_path, &wasm)?;
489
            eprintln!("wrote {}", out_path.display());
490
            Ok(())
491
        }
492
        Err(e) => {
493
            eprint!("{}", e.render(use_color));
494
            std::process::exit(1);
495
        }
496
    }
497
}
498

            
499
fn load_wasm(path: &str, debug: bool, graphics: GraphicsPolicy) -> anyhow::Result<()> {
500
    let wasm = fs::read(path)?;
501
    let interp = interpreter::Interpreter::new(debug)?;
502
    let use_color = io::stderr().is_terminal();
503
    match interp.run_wasm(&wasm) {
504
        Ok(value) => {
505
            let use_color = io::stdout().is_terminal();
506
            println!("{}", format_value(&value, use_color, graphics));
507
            Ok(())
508
        }
509
        Err(e) => {
510
            eprint!("{}", e.render(use_color));
511
            std::process::exit(1);
512
        }
513
    }
514
}
515

            
516
2
fn eval_string(
517
2
    input: &str,
518
2
    debug: bool,
519
2
    graphics: GraphicsPolicy,
520
2
    profiler: ProfilerStrategy,
521
2
) -> anyhow::Result<()> {
522
2
    let mut interp = interpreter::Interpreter::with_profiler(debug, profiler)?;
523
2
    print_results(&mut interp, input, graphics)
524
2
}
525

            
526
fn eval_file(
527
    path: &str,
528
    debug: bool,
529
    graphics: GraphicsPolicy,
530
    profiler: ProfilerStrategy,
531
) -> anyhow::Result<()> {
532
    let content = fs::read_to_string(path)?;
533
    let mut interp = interpreter::Interpreter::with_profiler(debug, profiler)?;
534
    print_results(&mut interp, &content, graphics)
535
}
536

            
537
fn eval_stdin(
538
    debug: bool,
539
    graphics: GraphicsPolicy,
540
    profiler: ProfilerStrategy,
541
) -> anyhow::Result<()> {
542
    let mut content = String::new();
543
    io::stdin().read_to_string(&mut content)?;
544
    let mut interp = interpreter::Interpreter::with_profiler(debug, profiler)?;
545
    print_results(&mut interp, &content, graphics)
546
}
547

            
548
2
fn print_results(
549
2
    interp: &mut interpreter::Interpreter,
550
2
    input: &str,
551
2
    graphics: GraphicsPolicy,
552
2
) -> anyhow::Result<()> {
553
2
    let use_color = io::stderr().is_terminal();
554
2
    match interp.eval(input) {
555
2
        Ok(values) => {
556
2
            let use_color = io::stdout().is_terminal();
557
2
            for value in values {
558
2
                println!("{}", format_value(&value, use_color, graphics));
559
2
            }
560
        }
561
        Err(e) => {
562
            eprint!("{}", e.render(use_color));
563
            std::process::exit(1);
564
        }
565
    }
566
2
    Ok(())
567
2
}
568

            
569
2
fn format_value(value: &Value, use_color: bool, graphics: GraphicsPolicy) -> String {
570
2
    match value {
571
        Value::Nil | Value::Bool(false) => {
572
            if use_color {
573
                "\x1b[90mNIL\x1b[0m".to_string()
574
            } else {
575
                "NIL".to_string()
576
            }
577
        }
578
        Value::Bool(true) => {
579
            if use_color {
580
                "\x1b[32m#T\x1b[0m".to_string()
581
            } else {
582
                "#T".to_string()
583
            }
584
        }
585
2
        Value::Number(n) => {
586
2
            let text = if *n.denom() == 1 {
587
2
                n.numer().to_string()
588
            } else {
589
                format!("{}/{}", n.numer(), n.denom())
590
            };
591
2
            if use_color {
592
                format!("\x1b[36m{text}\x1b[0m")
593
            } else {
594
2
                text
595
            }
596
        }
597
        Value::String(s) => {
598
            if use_color {
599
                format!("\x1b[33m{s}\x1b[0m")
600
            } else {
601
                s.clone()
602
            }
603
        }
604
        Value::Symbol(s) => {
605
            if use_color {
606
                format!("\x1b[35m{s}\x1b[0m")
607
            } else {
608
                s.clone()
609
            }
610
        }
611
        Value::Bytes(b) => {
612
            if graphics.inline
613
                && let Some(apc) =
614
                    nms::graphics::try_render_inline(b, |name| std::env::var(name).ok())
615
            {
616
                return apc;
617
            }
618
            let parts: Vec<String> = b.iter().map(u8::to_string).collect();
619
            let text = format!("#u8({})", parts.join(" "));
620
            if use_color {
621
                format!("\x1b[33m{text}\x1b[0m")
622
            } else {
623
                text
624
            }
625
        }
626
        Value::Pair(_) => "<pair>".to_string(),
627
        Value::Vector(_) => "<vector>".to_string(),
628
        Value::Closure(_) => "<closure>".to_string(),
629
        Value::Struct { name, fields } => {
630
            if use_color {
631
                format!("\x1b[94m#{name}({} fields)\x1b[0m", fields.len())
632
            } else {
633
                format!("#{name}({} fields)", fields.len())
634
            }
635
        }
636
        Value::Commodity {
637
            amount,
638
            commodity_id,
639
        } => {
640
            let amt = if *amount.denom() == 1 {
641
                amount.numer().to_string()
642
            } else {
643
                format!("{}/{}", amount.numer(), amount.denom())
644
            };
645
            let text = format!("(:commodity {amt} :id \"{commodity_id}\")");
646
            if use_color {
647
                format!("\x1b[36m{text}\x1b[0m")
648
            } else {
649
                text
650
            }
651
        }
652
    }
653
2
}
654

            
655
#[cfg(test)]
656
mod tests {
657
    use super::split_forms;
658

            
659
    #[test]
660
1
    fn split_forms_drops_comment_only_chunks() {
661
        // Regression: leading comment lines used to each become a bogus form,
662
        // wrapped as `(:id N :form ; comment)` whose `;` ate the closing paren.
663
1
        let src = "\
664
1
; a leading comment
665
1
; another comment
666
1
(defun f (x) x)
667
1
; trailing-style comment between forms
668
1
(f 1)
669
1
";
670
1
        assert_eq!(split_forms(src), vec!["(defun f (x) x)", "(f 1)"]);
671
1
    }
672

            
673
    #[test]
674
1
    fn split_forms_handles_multiline_forms() {
675
1
        let src = "(defun g (x)\n  (+ x\n     1))\n(g 2)\n";
676
1
        let forms = split_forms(src);
677
1
        assert_eq!(forms.len(), 2);
678
1
        assert!(forms[0].contains("defun g"));
679
1
        assert_eq!(forms[1], "(g 2)");
680
1
    }
681

            
682
    #[test]
683
1
    fn split_forms_all_comments_yields_nothing() {
684
1
        assert!(split_forms("; just a comment\n;; and another\n").is_empty());
685
1
    }
686

            
687
    #[test]
688
1
    fn split_forms_keeps_form_with_trailing_comment() {
689
        // A real form with a same-line trailing comment parses to a non-empty
690
        // program, so it is emitted (not dropped as a comment-only chunk).
691
1
        assert_eq!(split_forms("(f 1) ; note\n"), vec!["(f 1) ; note"]);
692
1
    }
693

            
694
    #[test]
695
1
    fn split_forms_tolerates_blank_and_comment_lines_inside_a_form() {
696
        // Blank + comment lines inside an incomplete multi-line form are reader
697
        // whitespace; the form stays one chunk and is emitted intact.
698
1
        let src = "(defun h (x)\n\n  ; midway comment\n  (+ x 1))\n";
699
1
        let forms = split_forms(src);
700
1
        assert_eq!(forms.len(), 1);
701
1
        assert!(forms[0].contains("defun h") && forms[0].contains("(+ x 1)"));
702
1
    }
703
}