Skip to main content

vm/
cli.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use crate as vm;
7
8use crate::{
9    CallOutcome, CallReturn, CompileSourceFileOptions, Debugger, DisassembleOptions, JitConfig,
10    OpCode, Program, ReplLocalBinding, SourceFlavor, SourceMap, SourcePathError, Value, Vm,
11    VmError, VmRecording, VmStatus, builtin_namespace_specs, compile_source_file_with_options,
12    disassemble_vmbc_with_options, encode_program, format_source_with_flavor_and_options,
13    render_source_error, render_vm_error, replay_recording_stdio,
14};
15use crate::{HostFunctionRegistry, HostImport};
16use rustyline::DefaultEditor;
17use rustyline::error::ReadlineError;
18
19pub struct CliRuntime {
20    pub binary_name: &'static str,
21    pub default_source: &'static str,
22    pub compile_options: fn() -> CompileSourceFileOptions,
23}
24
25impl Default for CliRuntime {
26    fn default() -> Self {
27        Self {
28            binary_name: "pd-vm-run",
29            default_source: "examples/example.rss",
30            compile_options: CompileSourceFileOptions::default,
31        }
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36struct CliConfig {
37    source: Option<String>,
38    emit_vmbc_path: Option<String>,
39    epoch_check_interval: Option<u32>,
40    disasm_vmbc_path: Option<String>,
41    record_path: Option<String>,
42    view_recording_path: Option<String>,
43    show_source: bool,
44    fmt: bool,
45    fmt_check: bool,
46    repl: bool,
47    debug: bool,
48    tcp_addr: Option<String>,
49    stop_on_entry: bool,
50    aot: bool,
51    aot_dump: bool,
52    aot_save_path: Option<String>,
53    aot_load_path: Option<String>,
54    jit_dump: bool,
55    jit_dump_show_machine_code: bool,
56    jit_hot_loop_threshold: Option<u32>,
57    fuel: Option<u64>,
58    epoch_deadline: Option<u64>,
59    help: bool,
60    version: bool,
61}
62
63impl Default for CliConfig {
64    fn default() -> Self {
65        Self {
66            source: None,
67            emit_vmbc_path: None,
68            epoch_check_interval: None,
69            disasm_vmbc_path: None,
70            record_path: None,
71            view_recording_path: None,
72            show_source: false,
73            fmt: false,
74            fmt_check: false,
75            repl: false,
76            debug: false,
77            tcp_addr: None,
78            stop_on_entry: true,
79            aot: false,
80            aot_dump: false,
81            aot_save_path: None,
82            aot_load_path: None,
83            jit_dump: false,
84            jit_dump_show_machine_code: true,
85            jit_hot_loop_threshold: None,
86            fuel: None,
87            epoch_deadline: None,
88            help: false,
89            version: false,
90        }
91    }
92}
93
94pub fn main(runtime: CliRuntime) -> Result<(), Box<dyn std::error::Error>> {
95    if let Err(err) = run_main(&runtime) {
96        eprintln!("{err}");
97        std::process::exit(1);
98    }
99    Ok(())
100}
101
102fn run_main(runtime: &CliRuntime) -> Result<(), Box<dyn std::error::Error>> {
103    let args: Vec<String> = std::env::args().skip(1).collect();
104    let cli = parse_cli_args(&args).map_err(io::Error::other)?;
105    if cli.version {
106        println!("{}", binary_version_text(runtime.binary_name));
107        return Ok(());
108    }
109    if cli.help {
110        print_usage(runtime.binary_name);
111        return Ok(());
112    }
113    if cli.fmt {
114        return run_fmt(&cli, runtime);
115    }
116    if cli.repl {
117        return run_repl();
118    }
119    if let Some(input_path) = cli.disasm_vmbc_path.as_ref() {
120        let bytes = std::fs::read(input_path)?;
121        let listing = disassemble_vmbc_with_options(
122            &bytes,
123            DisassembleOptions {
124                show_source: cli.show_source,
125            },
126        )?;
127        print!("{listing}");
128        return Ok(());
129    }
130    if let Some(recording_path) = cli.view_recording_path.as_ref() {
131        let recording = VmRecording::load_from_file(recording_path)?;
132        replay_recording_stdio(&recording);
133        return Ok(());
134    }
135
136    if let Some(mut vm) = try_new_cli_vm_from_standalone_aot(&cli)? {
137        if let Some(output_path) = cli.emit_vmbc_path.as_ref() {
138            let encoded = encode_program(vm.program())?;
139            std::fs::write(output_path, &encoded)?;
140            println!("wrote {} bytes to {}", encoded.len(), output_path);
141            return Ok(());
142        }
143
144        apply_runtime_flags(&mut vm, &cli)?;
145        run_vm_loop(&mut vm, None, cli.fuel)?;
146        if cli.aot_dump {
147            println!("{}", vm.dump_aot_info());
148        }
149        if cli.jit_dump {
150            println!(
151                "{}",
152                vm.dump_jit_info_with_machine_code(cli.jit_dump_show_machine_code)
153            );
154        }
155        return Ok(());
156    }
157
158    let source_path = resolve_source_path(cli.source.as_deref(), runtime.default_source)?;
159    let compiled = compile_source_file_with_options(&source_path, (runtime.compile_options)())
160        .map_err(|err| io::Error::other(render_source_path_error(&source_path, &err)))?;
161    if let Some(output_path) = cli.emit_vmbc_path.as_ref() {
162        let encoded = encode_program(&compiled.program)?;
163        std::fs::write(output_path, &encoded)?;
164        println!("wrote {} bytes to {}", encoded.len(), output_path);
165        return Ok(());
166    }
167    let recording_program = cli.record_path.as_ref().map(|_| compiled.program.clone());
168    let mut vm = new_cli_vm(compiled.program.with_local_count(compiled.locals), &cli);
169    apply_runtime_flags(&mut vm, &cli)?;
170    let imports = vm.program().imports.clone();
171    register_imports(&mut vm, &imports)?;
172    prepare_aot_for_cli(&mut vm, &cli)?;
173
174    if let Some(record_path) = cli.record_path.as_ref() {
175        let program = recording_program.expect("recording mode should clone program");
176        let mut debugger = Debugger::with_recording(program);
177        run_vm_loop(&mut vm, Some(&mut debugger), cli.fuel)?;
178        let recording = debugger
179            .take_recording()
180            .ok_or_else(|| io::Error::other("recording state unavailable"))?;
181        recording.save_to_file(record_path)?;
182        println!(
183            "recording saved to {} (frames={})",
184            record_path,
185            recording.frames.len()
186        );
187        return Ok(());
188    }
189
190    let mut debugger = if cli.debug {
191        let mut debugger = if let Some(addr) = &cli.tcp_addr {
192            println!("[debug] tcp debugger listening on {addr}");
193            Debugger::with_tcp(addr)?
194        } else {
195            Debugger::new()
196        };
197        if cli.stop_on_entry {
198            debugger.stop_on_entry();
199        }
200        Some(debugger)
201    } else {
202        None
203    };
204
205    run_vm_loop(&mut vm, debugger.as_mut(), cli.fuel)?;
206    if cli.aot_dump {
207        println!("{}", vm.dump_aot_info());
208    }
209    if cli.jit_dump {
210        println!(
211            "{}",
212            vm.dump_jit_info_with_machine_code(cli.jit_dump_show_machine_code)
213        );
214    }
215    Ok(())
216}
217
218fn try_new_cli_vm_from_standalone_aot(cli: &CliConfig) -> Result<Option<Vm>, io::Error> {
219    let Some(path) = cli.aot_load_path.as_deref() else {
220        return Ok(None);
221    };
222    if cli.source.is_some() {
223        return Ok(None);
224    }
225
226    let mut vm = Vm::new_from_aot_artifact_file_with_jit_config(path, cli_jit_config(cli))
227        .map_err(io::Error::other)?;
228    configure_cli_vm(&mut vm);
229    let imports = vm.program().imports.clone();
230    register_imports(&mut vm, &imports)?;
231
232    if let Some(save_path) = cli.aot_save_path.as_deref() {
233        vm.save_aot_artifact_to_file(save_path)
234            .map_err(io::Error::other)?;
235    }
236
237    Ok(Some(vm))
238}
239
240fn apply_runtime_flags(vm: &mut Vm, cli: &CliConfig) -> Result<(), io::Error> {
241    vm.set_jit_native_bridge_stats_enabled(cli.jit_dump);
242    if let Some(interval) = cli.epoch_check_interval {
243        vm.set_epoch_check_interval(interval)
244            .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
245    }
246    if let Some(fuel) = cli.fuel {
247        vm.set_fuel(fuel);
248    }
249    if let Some(deadline) = cli.epoch_deadline {
250        vm.set_epoch_deadline(deadline)
251            .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
252    }
253    Ok(())
254}
255
256fn prepare_aot_for_cli(vm: &mut Vm, cli: &CliConfig) -> Result<(), io::Error> {
257    if let Some(path) = cli.aot_load_path.as_deref() {
258        vm.load_aot_artifact_from_file(path)
259            .map_err(io::Error::other)?;
260    } else if cli.aot || cli.aot_save_path.is_some() {
261        vm.compile_aot()
262            .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
263    }
264
265    if let Some(path) = cli.aot_save_path.as_deref() {
266        vm.save_aot_artifact_to_file(path)
267            .map_err(io::Error::other)?;
268    }
269    Ok(())
270}
271
272fn run_vm_loop(
273    vm: &mut Vm,
274    mut debugger: Option<&mut Debugger>,
275    fuel_recharge: Option<u64>,
276) -> Result<(), io::Error> {
277    loop {
278        let status = if let Some(active_debugger) = debugger.as_deref_mut() {
279            vm.run_with_debugger(active_debugger)
280                .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?
281        } else {
282            vm.run()
283                .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?
284        };
285        match status {
286            VmStatus::Halted => {
287                println!("vm halted");
288                println!("stack: {:?}", vm.stack());
289                return Ok(());
290            }
291            VmStatus::Yielded => match vm.last_yield_reason() {
292                Some(vm::VmYieldReason::Fuel)
293                    if fuel_recharge.is_some() && vm.get_fuel() == Some(0) =>
294                {
295                    let recharge = fuel_recharge.unwrap_or(0);
296                    if recharge > 0 {
297                        vm.recharge_fuel(recharge)
298                            .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
299                        println!("vm yielded, recharged {recharge} fuel, resuming...");
300                    } else {
301                        println!("vm yielded, resuming...");
302                    }
303                }
304                Some(vm::VmYieldReason::Epoch) => {
305                    let deadline = vm
306                        .epoch_deadline()
307                        .map(|value| value.to_string())
308                        .unwrap_or_else(|| "disabled".to_string());
309                    println!(
310                        "vm yielded at epoch deadline (current={}, deadline={deadline})",
311                        vm.current_epoch()
312                    );
313                    return Ok(());
314                }
315                _ => {
316                    println!("vm yielded, resuming...");
317                }
318            },
319            VmStatus::Waiting(_op_id) => {
320                vm.wait_for_host_op_blocking()
321                    .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
322            }
323        }
324    }
325}
326
327fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String {
328    match err {
329        SourcePathError::Source(vm::SourceError::Parse(parse)) => {
330            let source = std::fs::read_to_string(source_path).unwrap_or_default();
331            let mut source_map = SourceMap::new();
332            let source_id = source_map.add_source(source_path.display().to_string(), source);
333            let parse = parse
334                .clone()
335                .with_line_span_from_source(&source_map, source_id);
336            render_source_error(&source_map, &parse, true)
337        }
338        SourcePathError::Source(vm::SourceError::Compile(compile)) => {
339            let render_path = compile
340                .source_name()
341                .map(Path::new)
342                .filter(|path| path.exists())
343                .unwrap_or(source_path);
344            let source = std::fs::read_to_string(render_path).unwrap_or_default();
345            let mut source_map = SourceMap::new();
346            source_map.add_source(render_path.display().to_string(), source);
347            vm::render_compile_error(&source_map, compile, true)
348        }
349        SourcePathError::InvalidImportSyntax {
350            path,
351            line,
352            message,
353        } => {
354            let source = std::fs::read_to_string(path).unwrap_or_default();
355            let mut source_map = SourceMap::new();
356            let source_id = source_map.add_source(path.display().to_string(), source);
357            let parse = vm::ParseError::at_line(*line, message.clone())
358                .with_line_span_from_source(&source_map, source_id);
359            render_source_error(&source_map, &parse, true)
360        }
361        _ => err.to_string(),
362    }
363}
364
365fn render_format_path_error(source_path: &Path, source: &str, err: &vm::FormatError) -> String {
366    match err {
367        vm::FormatError::Parse(parse) => {
368            let mut source_map = SourceMap::new();
369            let source_id =
370                source_map.add_source(source_path.display().to_string(), source.to_string());
371            let parse = parse
372                .clone()
373                .with_line_span_from_source(&source_map, source_id);
374            render_source_error(&source_map, &parse, true)
375        }
376        vm::FormatError::UnsupportedFlavor(_) => err.to_string(),
377    }
378}
379
380fn run_fmt(cli: &CliConfig, runtime: &CliRuntime) -> Result<(), Box<dyn std::error::Error>> {
381    let source_arg = cli
382        .source
383        .as_deref()
384        .ok_or_else(|| io::Error::other("fmt mode requires a source path"))?;
385    let source_path = resolve_source_path(Some(source_arg), runtime.default_source)?;
386    let flavor = source_flavor_from_path(&source_path)?;
387    let source = std::fs::read_to_string(&source_path)?;
388    let options = (runtime.compile_options)();
389    let formatted = format_source_with_flavor_and_options(&source, flavor, &options)
390        .map_err(|err| io::Error::other(render_format_path_error(&source_path, &source, &err)))?;
391
392    if cli.fmt_check {
393        if formatted == source {
394            return Ok(());
395        }
396        return Err(Box::new(io::Error::other(format!(
397            "would reformat {}",
398            source_path.display()
399        ))));
400    }
401
402    if formatted == source {
403        println!("already formatted {}", source_path.display());
404        return Ok(());
405    }
406
407    std::fs::write(&source_path, formatted)?;
408    println!("formatted {}", source_path.display());
409    Ok(())
410}
411
412fn parse_cli_args(args: &[String]) -> Result<CliConfig, String> {
413    let mut cfg = CliConfig::default();
414    if args.is_empty() {
415        cfg.repl = true;
416        return Ok(cfg);
417    }
418    if args
419        .iter()
420        .any(|arg| matches!(arg.as_str(), "-V" | "--version"))
421    {
422        cfg.version = true;
423        return Ok(cfg);
424    }
425    let mut index = 0usize;
426
427    if let Some(first) = args.first()
428        && first == "debug"
429    {
430        cfg.debug = true;
431        index = 1;
432    } else if let Some(first) = args.first()
433        && first == "repl"
434    {
435        cfg.repl = true;
436        index = 1;
437    } else if let Some(first) = args.first()
438        && first == "fmt"
439    {
440        cfg.fmt = true;
441        index = 1;
442    }
443
444    while index < args.len() {
445        match args[index].as_str() {
446            "-h" | "--help" => {
447                cfg.help = true;
448                index += 1;
449            }
450            "--debug" => {
451                cfg.debug = true;
452                index += 1;
453            }
454            "--tcp" => {
455                cfg.debug = true;
456                let addr = args
457                    .get(index + 1)
458                    .ok_or_else(|| "missing value for --tcp".to_string())?
459                    .clone();
460                cfg.tcp_addr = Some(addr);
461                index += 2;
462            }
463            "--stop-on-entry" => {
464                cfg.debug = true;
465                cfg.stop_on_entry = true;
466                index += 1;
467            }
468            "--no-stop-on-entry" => {
469                cfg.debug = true;
470                cfg.stop_on_entry = false;
471                index += 1;
472            }
473            "--aot" => {
474                cfg.aot = true;
475                index += 1;
476            }
477            "--aot-dump" => {
478                cfg.aot_dump = true;
479                index += 1;
480            }
481            "--aot-save" => {
482                let path = args
483                    .get(index + 1)
484                    .ok_or_else(|| "missing value for --aot-save".to_string())?;
485                cfg.aot_save_path = Some(path.clone());
486                index += 2;
487            }
488            "--aot-load" => {
489                let path = args
490                    .get(index + 1)
491                    .ok_or_else(|| "missing value for --aot-load".to_string())?;
492                cfg.aot_load_path = Some(path.clone());
493                index += 2;
494            }
495            "--jit-dump" | "--dump-jit" => {
496                cfg.jit_dump = true;
497                index += 1;
498            }
499            "--jit-dump-no-code" => {
500                cfg.jit_dump_show_machine_code = false;
501                index += 1;
502            }
503            "--jit-hot-loop" => {
504                let raw = args
505                    .get(index + 1)
506                    .ok_or_else(|| "missing value for --jit-hot-loop".to_string())?;
507                let value = raw
508                    .parse::<u32>()
509                    .map_err(|_| format!("invalid --jit-hot-loop value '{raw}'"))?;
510                cfg.jit_hot_loop_threshold = Some(value);
511                index += 2;
512            }
513            "--fuel" => {
514                let raw = args
515                    .get(index + 1)
516                    .ok_or_else(|| "missing value for --fuel".to_string())?;
517                let value = raw
518                    .parse::<u64>()
519                    .map_err(|_| format!("invalid --fuel value '{raw}'"))?;
520                cfg.fuel = Some(value);
521                index += 2;
522            }
523            "--epoch-deadline" => {
524                let raw = args
525                    .get(index + 1)
526                    .ok_or_else(|| "missing value for --epoch-deadline".to_string())?;
527                let value = raw
528                    .parse::<u64>()
529                    .map_err(|_| format!("invalid --epoch-deadline value '{raw}'"))?;
530                cfg.epoch_deadline = Some(value);
531                index += 2;
532            }
533            "--emit-vmbc" => {
534                let path = args
535                    .get(index + 1)
536                    .ok_or_else(|| "missing value for --emit-vmbc".to_string())?;
537                cfg.emit_vmbc_path = Some(path.clone());
538                index += 2;
539            }
540            "--epoch-check-interval" => {
541                let raw = args
542                    .get(index + 1)
543                    .ok_or_else(|| "missing value for --epoch-check-interval".to_string())?;
544                cfg.epoch_check_interval = Some(parse_cli_u32_flag("--epoch-check-interval", raw)?);
545                index += 2;
546            }
547            "--disasm-vmbc" => {
548                let path = args
549                    .get(index + 1)
550                    .ok_or_else(|| "missing value for --disasm-vmbc".to_string())?;
551                cfg.disasm_vmbc_path = Some(path.clone());
552                index += 2;
553            }
554            value if value.starts_with("--epoch-check-interval=") => {
555                let raw = value.trim_start_matches("--epoch-check-interval=");
556                cfg.epoch_check_interval = Some(parse_cli_u32_flag("--epoch-check-interval", raw)?);
557                index += 1;
558            }
559            "--record" => {
560                let path = args
561                    .get(index + 1)
562                    .ok_or_else(|| "missing value for --record".to_string())?;
563                cfg.record_path = Some(path.clone());
564                index += 2;
565            }
566            "--view-record" => {
567                let path = args
568                    .get(index + 1)
569                    .ok_or_else(|| "missing value for --view-record".to_string())?;
570                cfg.view_recording_path = Some(path.clone());
571                index += 2;
572            }
573            "--show-source" => {
574                cfg.show_source = true;
575                index += 1;
576            }
577            "--check" => {
578                cfg.fmt_check = true;
579                index += 1;
580            }
581            "--repl" => {
582                cfg.repl = true;
583                index += 1;
584            }
585            value if value.starts_with('-') => {
586                return Err(format!("unknown flag '{value}'"));
587            }
588            path => {
589                if cfg.source.is_some() {
590                    return Err("multiple source paths provided".to_string());
591                }
592                cfg.source = Some(path.to_string());
593                index += 1;
594            }
595        }
596    }
597
598    if !cfg.jit_dump_show_machine_code && !cfg.jit_dump {
599        return Err("--jit-dump-no-code requires --jit-dump or --dump-jit".to_string());
600    }
601    if cfg.fmt_check && !cfg.fmt {
602        return Err("--check requires fmt mode".to_string());
603    }
604    if cfg.fuel.is_some() && cfg.epoch_deadline.is_some() {
605        return Err("--fuel and --epoch-deadline are mutually exclusive".to_string());
606    }
607    if cfg.fuel.is_some() && cfg.epoch_check_interval.is_some() {
608        return Err("--fuel cannot be combined with --epoch-check-interval".to_string());
609    }
610    if cfg.aot && cfg.aot_load_path.is_some() {
611        return Err("--aot and --aot-load are mutually exclusive".to_string());
612    }
613
614    if cfg.repl {
615        if cfg.source.is_some() {
616            return Err("repl mode does not accept a source path".to_string());
617        }
618        if cfg.debug
619            || cfg.aot
620            || cfg.aot_dump
621            || cfg.aot_save_path.is_some()
622            || cfg.aot_load_path.is_some()
623            || cfg.tcp_addr.is_some()
624            || cfg.jit_dump
625            || cfg.jit_hot_loop_threshold.is_some()
626            || cfg.fuel.is_some()
627            || cfg.epoch_deadline.is_some()
628            || cfg.epoch_check_interval.is_some()
629            || cfg.emit_vmbc_path.is_some()
630            || cfg.disasm_vmbc_path.is_some()
631            || cfg.record_path.is_some()
632            || cfg.view_recording_path.is_some()
633        {
634            return Err(
635                "repl mode cannot be combined with debug/aot/jit/fuel/epoch/emit/disasm runtime flags"
636                    .to_string(),
637            );
638        }
639    }
640    if cfg.disasm_vmbc_path.is_some() {
641        if cfg.source.is_some() {
642            return Err("disasm mode does not accept a source path".to_string());
643        }
644        if cfg.repl
645            || cfg.debug
646            || cfg.aot
647            || cfg.aot_dump
648            || cfg.aot_save_path.is_some()
649            || cfg.aot_load_path.is_some()
650            || cfg.tcp_addr.is_some()
651            || cfg.jit_dump
652            || cfg.jit_hot_loop_threshold.is_some()
653            || cfg.fuel.is_some()
654            || cfg.epoch_deadline.is_some()
655            || cfg.epoch_check_interval.is_some()
656            || cfg.emit_vmbc_path.is_some()
657            || cfg.record_path.is_some()
658            || cfg.view_recording_path.is_some()
659        {
660            return Err(
661                "disasm mode cannot be combined with repl/debug/aot/jit/fuel/epoch/emit runtime flags"
662                    .to_string(),
663            );
664        }
665    } else if cfg.show_source {
666        return Err("--show-source requires --disasm-vmbc".to_string());
667    }
668
669    if cfg.fmt {
670        if cfg.source.is_none() && !cfg.help {
671            return Err("fmt mode requires a source path".to_string());
672        }
673        if cfg.repl
674            || cfg.debug
675            || cfg.aot
676            || cfg.aot_dump
677            || cfg.aot_save_path.is_some()
678            || cfg.aot_load_path.is_some()
679            || cfg.tcp_addr.is_some()
680            || cfg.jit_dump
681            || cfg.jit_hot_loop_threshold.is_some()
682            || cfg.fuel.is_some()
683            || cfg.epoch_deadline.is_some()
684            || cfg.epoch_check_interval.is_some()
685            || cfg.emit_vmbc_path.is_some()
686            || cfg.disasm_vmbc_path.is_some()
687            || cfg.record_path.is_some()
688            || cfg.view_recording_path.is_some()
689            || cfg.show_source
690        {
691            return Err(
692                "fmt mode cannot be combined with repl/debug/aot/jit/fuel/epoch/emit/disasm/record flags"
693                    .to_string(),
694            );
695        }
696    }
697
698    if cfg.debug
699        && (cfg.aot || cfg.aot_dump || cfg.aot_save_path.is_some() || cfg.aot_load_path.is_some())
700    {
701        return Err("debug mode cannot be combined with aot runtime flags".to_string());
702    }
703
704    if cfg.epoch_check_interval.is_some() && cfg.epoch_deadline.is_none() && !cfg.debug {
705        return Err("--epoch-check-interval requires --epoch-deadline or --debug".to_string());
706    }
707    if cfg.record_path.is_some()
708        && (cfg.debug
709            || cfg.aot
710            || cfg.aot_dump
711            || cfg.aot_save_path.is_some()
712            || cfg.aot_load_path.is_some()
713            || cfg.tcp_addr.is_some()
714            || cfg.jit_dump
715            || cfg.jit_hot_loop_threshold.is_some()
716            || cfg.emit_vmbc_path.is_some()
717            || cfg.disasm_vmbc_path.is_some()
718            || cfg.view_recording_path.is_some()
719            || cfg.show_source)
720    {
721        return Err(
722            "record mode cannot be combined with debug/aot/jit/emit/disasm/view-record flags"
723                .to_string(),
724        );
725    }
726    if cfg.view_recording_path.is_some()
727        && (cfg.source.is_some()
728            || cfg.debug
729            || cfg.aot
730            || cfg.aot_dump
731            || cfg.aot_save_path.is_some()
732            || cfg.aot_load_path.is_some()
733            || cfg.tcp_addr.is_some()
734            || cfg.jit_dump
735            || cfg.jit_hot_loop_threshold.is_some()
736            || cfg.fuel.is_some()
737            || cfg.epoch_deadline.is_some()
738            || cfg.epoch_check_interval.is_some()
739            || cfg.emit_vmbc_path.is_some()
740            || cfg.disasm_vmbc_path.is_some()
741            || cfg.record_path.is_some()
742            || cfg.show_source)
743    {
744        return Err(
745            "view-record mode cannot be combined with source/debug/aot/jit/fuel/epoch/emit/disasm flags"
746                .to_string(),
747        );
748    }
749
750    Ok(cfg)
751}
752
753fn resolve_source_path(arg: Option<&str>, default_source: &str) -> Result<PathBuf, io::Error> {
754    let rel = arg.unwrap_or(default_source);
755    let provided = PathBuf::from(rel);
756    if provided.is_absolute() {
757        return Ok(provided);
758    }
759
760    let cwd_path = std::env::current_dir()?.join(&provided);
761    if cwd_path.exists() {
762        return Ok(cwd_path);
763    }
764
765    Ok(Path::new(env!("CARGO_MANIFEST_DIR")).join(provided))
766}
767
768fn source_flavor_from_path(path: &Path) -> Result<SourceFlavor, io::Error> {
769    let ext = path
770        .extension()
771        .and_then(|value| value.to_str())
772        .ok_or_else(|| io::Error::other(SourcePathError::MissingExtension))?;
773    SourceFlavor::from_extension(ext)
774        .ok_or_else(|| io::Error::other(SourcePathError::UnsupportedExtension(ext.to_string())))
775}
776
777fn parse_cli_u32_flag(flag: &str, raw: &str) -> Result<u32, String> {
778    raw.parse::<u32>()
779        .map_err(|_| format!("invalid {flag} value '{raw}'"))
780}
781
782fn register_imports(vm: &mut Vm, imports: &[HostImport]) -> Result<(), io::Error> {
783    for import in imports {
784        if import.name.starts_with("http::") {
785            return Err(io::Error::other(format!(
786                "host function '{}' requires pd-edge runtime context",
787                import.name,
788            )));
789        }
790    }
791    if imports.is_empty() {
792        return Ok(());
793    }
794    let plan = cli_host_registry()
795        .prepare_shared_plan(imports)
796        .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
797    cli_host_registry()
798        .bind_vm_with_plan(vm, &plan)
799        .map_err(|err| io::Error::other(render_vm_error(vm, &err)))?;
800    Ok(())
801}
802
803fn new_cli_vm(program: Program, cli: &CliConfig) -> Vm {
804    let mut vm = Vm::new_with_jit_config(program, cli_jit_config(cli));
805    configure_cli_vm(&mut vm);
806    vm
807}
808
809fn cli_jit_config(cli: &CliConfig) -> JitConfig {
810    let mut jit_config = JitConfig::default();
811    if let Some(hot_loop_threshold) = cli.jit_hot_loop_threshold {
812        jit_config.hot_loop_threshold = hot_loop_threshold;
813    }
814    jit_config
815}
816
817fn configure_cli_vm(vm: &mut Vm) {
818    vm.set_runtime_print_sink(|rendered| {
819        print!("{rendered}");
820    });
821}
822
823fn cli_host_registry() -> &'static HostFunctionRegistry {
824    static REGISTRY: OnceLock<HostFunctionRegistry> = OnceLock::new();
825    REGISTRY.get_or_init(|| {
826        let mut registry = HostFunctionRegistry::new();
827        registry.register_static("add_one", 1, add_one_host_function);
828        registry.register_static("echo", 1, echo_host_function);
829        registry
830    })
831}
832
833fn print_usage(binary_name: &str) {
834    println!("features: {}", cli_build_feature_summary());
835    println!();
836    println!("Usage:");
837    println!("  {binary_name}                  (defaults to REPL)");
838    println!("  {binary_name} --version");
839    println!("  {binary_name} [source_path]");
840    println!("  {binary_name} fmt [--check] <source_path>");
841    println!("  {binary_name} --repl");
842    println!("  {binary_name} repl");
843    println!("  {binary_name} --emit-vmbc <output.vmbc> [source_path]");
844    println!("  {binary_name} --disasm-vmbc <input.vmbc> [--show-source]");
845    println!("  {binary_name} --record <output.pdr> [source_path]");
846    println!("  {binary_name} --view-record <input.pdr>");
847    println!("  {binary_name} --debug [--stop-on-entry|--no-stop-on-entry] [source_path]");
848    println!("  {binary_name} --debug --tcp <addr> [source_path]");
849    println!(
850        "  {binary_name} [--aot|--aot-load <artifact.pat>] [--aot-save <artifact.pat>] [--aot-dump] [source_path]"
851    );
852    println!(
853        "  {binary_name} [--jit-hot-loop <n>] [--jit-dump|--dump-jit] [--jit-dump-no-code] [--emit-vmbc <output.vmbc>] [source_path]"
854    );
855    println!(
856        "  {binary_name} [--fuel <n>|--epoch-deadline <n>] [--epoch-check-interval <n>] [source_path]"
857    );
858    println!("  {binary_name} debug [--tcp <addr>] [source_path]");
859    println!();
860    println!("Options:");
861    println!("  -V, --version              Show version with git metadata");
862    println!("  -h, --help                 Show this help");
863    println!("      --check                In fmt mode, fail if formatting would change the file");
864}
865
866fn cli_build_features() -> Vec<String> {
867    let mut features = Vec::new();
868    if cfg!(feature = "cranelift-jit") {
869        features.push("cranelift-jit".to_string());
870    }
871    if cfg!(feature = "runtime") {
872        features.push("runtime".to_string());
873    }
874    if CompileSourceFileOptions::default()
875        .module_override_source("stdlib/rss/strings.rss")
876        .is_some()
877    {
878        features.push("stdlibs".to_string());
879    }
880    let modules = builtin_namespace_specs()
881        .iter()
882        .map(|spec| spec.namespace)
883        .collect::<Vec<_>>()
884        .join(", ");
885    features.push(format!("modules={modules}"));
886    features
887}
888
889fn cli_build_feature_summary() -> String {
890    cli_build_features().join(", ")
891}
892
893fn binary_version_text(binary: &str) -> String {
894    let git_tag = option_env!("PD_BUILD_GIT_TAG").unwrap_or("untagged");
895    let git_commit = option_env!("PD_BUILD_GIT_COMMIT").unwrap_or("unknown");
896    let git_dirty = option_env!("PD_BUILD_GIT_DIRTY").unwrap_or("false");
897    let dirty = matches!(git_dirty, "true" | "1" | "yes" | "dirty");
898
899    if dirty {
900        format!("{binary} {git_tag} (dirty commit: {git_commit})")
901    } else {
902        format!("{binary} {git_tag}")
903    }
904}
905
906fn run_repl() -> Result<(), Box<dyn std::error::Error>> {
907    println!("pd-vm REPL (RustScript)");
908    println!("features: {}", cli_build_feature_summary());
909    println!("history: up/down arrows, commands: .help, .quit, .cancel");
910    let mut editor = DefaultEditor::new()?;
911    let mut session = ReplSession::default();
912    let mut pending_input = String::new();
913    loop {
914        let prompt = if pending_input.is_empty() {
915            "pd-vm> "
916        } else {
917            "...> "
918        };
919        match editor.readline(prompt) {
920            Ok(line) => {
921                let trimmed = line.trim();
922                if pending_input.is_empty() {
923                    if trimmed.is_empty() {
924                        continue;
925                    }
926                    if let Some(action) = handle_repl_command(trimmed) {
927                        if action == ReplAction::Break {
928                            break;
929                        }
930                        continue;
931                    }
932                } else if trimmed == ".cancel" {
933                    pending_input.clear();
934                    println!("pending input cleared");
935                    continue;
936                }
937
938                if !pending_input.is_empty() {
939                    pending_input.push('\n');
940                }
941                pending_input.push_str(line.trim_end());
942                if !is_repl_input_complete(&pending_input) {
943                    continue;
944                }
945
946                let snippet = pending_input.trim().to_string();
947                pending_input.clear();
948                if snippet.is_empty() {
949                    continue;
950                }
951
952                let _ = editor.add_history_entry(&snippet);
953                let compiled = match compile_repl_snippet(&snippet, &session.locals) {
954                    Ok(compiled) => compiled,
955                    Err(err) => {
956                        println!("{}", render_repl_compile_error(&snippet, &err));
957                        continue;
958                    }
959                };
960                let moved_by_rebinding =
961                    repl_locals_moved_by_rebinding(&compiled.compiled.program, &session.locals);
962                let no_repl_moves = BTreeSet::new();
963                let mut vm = Vm::new_with_jit_config(
964                    compiled
965                        .compiled
966                        .program
967                        .with_local_count(compiled.compiled.locals),
968                    JitConfig::default(),
969                );
970                configure_cli_vm(&mut vm);
971                let imports = vm.program().imports.clone();
972                if let Err(err) = register_imports(&mut vm, &imports) {
973                    println!("{err}");
974                    continue;
975                }
976                if let Err(err) = seed_repl_vm_locals(&mut vm, &session.locals) {
977                    println!("{}", render_vm_error(&vm, &err));
978                    continue;
979                }
980                loop {
981                    match vm.run() {
982                        Ok(VmStatus::Halted) => {
983                            sync_repl_session(
984                                &vm,
985                                &compiled.bindings,
986                                &moved_by_rebinding,
987                                &mut session,
988                            );
989                            if let Some(value) = vm.stack().last() {
990                                println!("=> {}", format_value(value));
991                            } else {
992                                println!("=> <empty>");
993                            }
994                            break;
995                        }
996                        Ok(VmStatus::Yielded) => continue,
997                        Ok(VmStatus::Waiting(_op_id)) => {
998                            if let Err(err) = vm.wait_for_host_op_blocking() {
999                                sync_repl_session(
1000                                    &vm,
1001                                    &compiled.bindings,
1002                                    &no_repl_moves,
1003                                    &mut session,
1004                                );
1005                                println!("{}", render_vm_error(&vm, &err));
1006                                break;
1007                            }
1008                            continue;
1009                        }
1010                        Err(err) => {
1011                            sync_repl_session(
1012                                &vm,
1013                                &compiled.bindings,
1014                                &no_repl_moves,
1015                                &mut session,
1016                            );
1017                            println!("{}", render_vm_error(&vm, &err));
1018                            break;
1019                        }
1020                    }
1021                }
1022            }
1023            Err(ReadlineError::Interrupted) => {
1024                if pending_input.is_empty() {
1025                    println!("bye");
1026                    break;
1027                }
1028                pending_input.clear();
1029                println!("pending input cleared");
1030            }
1031            Err(ReadlineError::Eof) => {
1032                println!("bye");
1033                break;
1034            }
1035            Err(err) => {
1036                return Err(Box::new(io::Error::other(err.to_string())));
1037            }
1038        }
1039    }
1040    Ok(())
1041}
1042
1043#[derive(Default)]
1044struct ReplSession {
1045    locals: BTreeMap<String, ReplSessionLocal>,
1046}
1047
1048#[derive(Clone, Debug, PartialEq)]
1049struct ReplSessionLocal {
1050    value: Value,
1051    mutable: bool,
1052    schema: Option<crate::compiler::TypeSchema>,
1053    optional: bool,
1054    moved: bool,
1055}
1056
1057fn repl_locals_moved_by_rebinding(
1058    program: &Program,
1059    locals: &BTreeMap<String, ReplSessionLocal>,
1060) -> BTreeSet<String> {
1061    let Some(debug) = program.debug.as_ref() else {
1062        return BTreeSet::new();
1063    };
1064    let persisted_by_slot = locals
1065        .keys()
1066        .filter_map(|name| debug.local_index(name).map(|slot| (slot, name.clone())))
1067        .collect::<BTreeMap<_, _>>();
1068    let mut moved = BTreeSet::new();
1069    let mut move_store_offsets = BTreeSet::new();
1070    let mut ip = 0;
1071
1072    while ip < program.code.len() {
1073        let Ok(opcode) = OpCode::try_from(program.code[ip]) else {
1074            break;
1075        };
1076        if opcode == OpCode::Ldloc
1077            && let Some(source) = program.code.get(ip + 1).copied()
1078            && let Some(name) = persisted_by_slot.get(&source)
1079        {
1080            let direct_target = program
1081                .code
1082                .get(ip + 2)
1083                .copied()
1084                .and_then(|byte| OpCode::try_from(byte).ok())
1085                .filter(|opcode| *opcode == OpCode::Stloc)
1086                .and_then(|_| program.code.get(ip + 3).copied());
1087            if direct_target.is_some_and(|target| target != source) {
1088                moved.insert(name.clone());
1089            }
1090            let null_store = program
1091                .code
1092                .get(ip + 2)
1093                .copied()
1094                .and_then(|byte| OpCode::try_from(byte).ok())
1095                .filter(|opcode| *opcode == OpCode::Ldc)
1096                .and_then(|_| program.code.get(ip + 3..ip + 7))
1097                .and_then(|bytes| bytes.try_into().ok())
1098                .map(u32::from_le_bytes)
1099                .and_then(|index| program.constants.get(index as usize))
1100                .is_some_and(|value| value == &Value::Null)
1101                && program.code.get(ip + 7).copied() == Some(OpCode::Stloc as u8)
1102                && program.code.get(ip + 8).copied() == Some(source);
1103            if null_store {
1104                moved.insert(name.clone());
1105                move_store_offsets.insert(ip + 7);
1106            }
1107        }
1108        if opcode == OpCode::Stloc
1109            && !move_store_offsets.contains(&ip)
1110            && let Some(target) = program.code.get(ip + 1).copied()
1111            && let Some(name) = persisted_by_slot.get(&target)
1112        {
1113            moved.remove(name);
1114        }
1115        ip += 1 + opcode.operand_len();
1116    }
1117    moved
1118}
1119
1120fn sync_repl_session(
1121    vm: &Vm,
1122    bindings: &[ReplLocalBinding],
1123    moved_by_rebinding: &BTreeSet<String>,
1124    session: &mut ReplSession,
1125) {
1126    if bindings.is_empty() {
1127        session.locals.clear();
1128        return;
1129    }
1130    let Some(debug) = vm.debug_info() else {
1131        session.locals.clear();
1132        return;
1133    };
1134    let mut next = BTreeMap::new();
1135    for binding in bindings {
1136        let Some(index) = debug.local_index(&binding.name) else {
1137            continue;
1138        };
1139        let Some(value) = vm.locals().get(index as usize) else {
1140            continue;
1141        };
1142        let (schema, optional) = repl_local_schema_from_vm(vm, index as usize, value);
1143        let moved = moved_by_rebinding.contains(&binding.name)
1144            || (!optional
1145                && value == &Value::Null
1146                && matches!(
1147                    schema,
1148                    Some(vm::compiler::TypeSchema::String | vm::compiler::TypeSchema::Bytes)
1149                ));
1150        next.insert(
1151            binding.name.clone(),
1152            ReplSessionLocal {
1153                value: value.clone(),
1154                mutable: binding.mutable,
1155                schema,
1156                optional,
1157                moved,
1158            },
1159        );
1160    }
1161    session.locals = next;
1162}
1163
1164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1165enum ReplAction {
1166    Continue,
1167    Break,
1168}
1169
1170fn handle_repl_command(line: &str) -> Option<ReplAction> {
1171    match line {
1172        ".quit" | ".exit" => Some(ReplAction::Break),
1173        ".cancel" => {
1174            println!("no pending input");
1175            Some(ReplAction::Continue)
1176        }
1177        ".help" => {
1178            println!("commands:");
1179            println!("  .help      show commands");
1180            println!("  .quit      quit repl");
1181            println!("  .exit      quit repl");
1182            println!("  .cancel    clear pending multiline input");
1183            Some(ReplAction::Continue)
1184        }
1185        _ if line.starts_with('.') => {
1186            println!("unknown command: {line}");
1187            Some(ReplAction::Continue)
1188        }
1189        _ => None,
1190    }
1191}
1192
1193fn compile_repl_snippet(
1194    input: &str,
1195    locals: &BTreeMap<String, ReplSessionLocal>,
1196) -> Result<vm::CompiledReplProgram, vm::SourceError> {
1197    let trimmed = input.trim_end();
1198    let bindings = locals
1199        .iter()
1200        .map(|(name, local)| vm::compiler::ReplLocalState {
1201            binding: ReplLocalBinding {
1202                name: name.clone(),
1203                mutable: local.mutable,
1204                schema: local.schema.clone(),
1205                optional: local.optional,
1206            },
1207            moved: local.moved,
1208        })
1209        .collect::<Vec<_>>();
1210    match vm::compiler::compile_source_for_repl_with_state(trimmed, &bindings) {
1211        Ok(compiled) => Ok(compiled),
1212        Err(first_err) => {
1213            if trimmed.ends_with(';') {
1214                return Err(first_err);
1215            }
1216            let fallback = format!("{trimmed};");
1217            match vm::compiler::compile_source_for_repl_with_state(&fallback, &bindings) {
1218                Ok(compiled) => Ok(compiled),
1219                Err(err @ vm::SourceError::Parse(vm::ParseError { code: Some(_), .. })) => Err(err),
1220                Err(err @ vm::SourceError::Compile(_)) => Err(err),
1221                Err(_) => Err(first_err),
1222            }
1223        }
1224    }
1225}
1226
1227fn seed_repl_vm_locals(
1228    vm: &mut Vm,
1229    locals: &BTreeMap<String, ReplSessionLocal>,
1230) -> Result<(), VmError> {
1231    if locals.is_empty() {
1232        return Ok(());
1233    }
1234    for (name, local) in locals {
1235        let index = {
1236            let Some(debug) = vm.debug_info() else {
1237                return Err(VmError::HostError(
1238                    "repl debug info unavailable while restoring locals".to_string(),
1239                ));
1240            };
1241            debug.local_index(name).ok_or_else(|| {
1242                VmError::HostError(format!("repl local '{name}' missing from compiled snippet"))
1243            })?
1244        };
1245        vm.set_local(index, local.value.clone())?;
1246    }
1247    Ok(())
1248}
1249
1250fn repl_local_schema_from_vm(
1251    vm: &Vm,
1252    index: usize,
1253    value: &Value,
1254) -> (Option<vm::compiler::TypeSchema>, bool) {
1255    let fallback = repl_schema_from_value(value);
1256    let Some(type_map) = vm.program().type_map.as_ref() else {
1257        return (fallback, false);
1258    };
1259    let schema = type_map
1260        .local_schemas
1261        .get(index)
1262        .cloned()
1263        .flatten()
1264        .or_else(|| {
1265            type_map
1266                .local_types
1267                .get(index)
1268                .copied()
1269                .and_then(repl_schema_from_value_type)
1270        })
1271        .or(fallback);
1272    let optional = type_map.optional_slots.get(index).copied().unwrap_or(false);
1273    (schema, optional)
1274}
1275
1276fn repl_schema_from_value(value: &Value) -> Option<vm::compiler::TypeSchema> {
1277    use vm::compiler::TypeSchema;
1278
1279    match value {
1280        Value::Null => Some(TypeSchema::Null),
1281        Value::Int(_) => Some(TypeSchema::Int),
1282        Value::Float(_) => Some(TypeSchema::Float),
1283        Value::Bool(_) => Some(TypeSchema::Bool),
1284        Value::String(_) => Some(TypeSchema::String),
1285        Value::Bytes(_) => Some(TypeSchema::Bytes),
1286        Value::Array(_) => Some(TypeSchema::Array(Box::new(TypeSchema::Unknown))),
1287        Value::Map(_) => Some(TypeSchema::Map(Box::new(TypeSchema::Unknown))),
1288    }
1289}
1290
1291fn repl_schema_from_value_type(value_type: vm::ValueType) -> Option<vm::compiler::TypeSchema> {
1292    use vm::compiler::TypeSchema;
1293
1294    match value_type {
1295        vm::ValueType::Unknown => None,
1296        vm::ValueType::Null => Some(TypeSchema::Null),
1297        vm::ValueType::Int => Some(TypeSchema::Int),
1298        vm::ValueType::Float => Some(TypeSchema::Float),
1299        vm::ValueType::Bool => Some(TypeSchema::Bool),
1300        vm::ValueType::String => Some(TypeSchema::String),
1301        vm::ValueType::Bytes => Some(TypeSchema::Bytes),
1302        vm::ValueType::Array => Some(TypeSchema::Array(Box::new(TypeSchema::Unknown))),
1303        vm::ValueType::Map => Some(TypeSchema::Map(Box::new(TypeSchema::Unknown))),
1304    }
1305}
1306
1307fn is_repl_input_complete(input: &str) -> bool {
1308    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1309    enum Delimiter {
1310        Paren,
1311        Bracket,
1312        Brace,
1313    }
1314
1315    let mut stack: Vec<Delimiter> = Vec::new();
1316    let mut chars = input.chars().peekable();
1317    let mut in_string = false;
1318    let mut escaped = false;
1319    let mut in_line_comment = false;
1320    let mut in_block_comment = false;
1321    let mut code = String::with_capacity(input.len());
1322
1323    while let Some(ch) = chars.next() {
1324        if in_line_comment {
1325            if ch == '\n' {
1326                in_line_comment = false;
1327                code.push('\n');
1328            }
1329            continue;
1330        }
1331        if in_block_comment {
1332            if ch == '*'
1333                && let Some('/') = chars.peek()
1334            {
1335                chars.next();
1336                in_block_comment = false;
1337            }
1338            continue;
1339        }
1340        if in_string {
1341            if escaped {
1342                escaped = false;
1343                continue;
1344            }
1345            match ch {
1346                '\\' => escaped = true,
1347                '"' => {
1348                    in_string = false;
1349                    code.push('"');
1350                }
1351                _ => {}
1352            }
1353            continue;
1354        }
1355
1356        if ch == '/' {
1357            match chars.peek().copied() {
1358                Some('/') => {
1359                    chars.next();
1360                    in_line_comment = true;
1361                    continue;
1362                }
1363                Some('*') => {
1364                    chars.next();
1365                    in_block_comment = true;
1366                    continue;
1367                }
1368                _ => {}
1369            }
1370        }
1371
1372        match ch {
1373            '"' => {
1374                in_string = true;
1375                code.push('"');
1376            }
1377            '(' => {
1378                stack.push(Delimiter::Paren);
1379                code.push(ch);
1380            }
1381            '[' => {
1382                stack.push(Delimiter::Bracket);
1383                code.push(ch);
1384            }
1385            '{' => {
1386                stack.push(Delimiter::Brace);
1387                code.push(ch);
1388            }
1389            ')' => {
1390                if stack.pop() != Some(Delimiter::Paren) {
1391                    return true;
1392                }
1393                code.push(ch);
1394            }
1395            ']' => {
1396                if stack.pop() != Some(Delimiter::Bracket) {
1397                    return true;
1398                }
1399                code.push(ch);
1400            }
1401            '}' => {
1402                if stack.pop() != Some(Delimiter::Brace) {
1403                    return true;
1404                }
1405                code.push(ch);
1406            }
1407            _ => code.push(ch),
1408        }
1409    }
1410
1411    if in_string || in_block_comment || !stack.is_empty() {
1412        return false;
1413    }
1414
1415    let trimmed = code.trim_end();
1416    if trimmed.is_empty() {
1417        return true;
1418    }
1419
1420    const TRAILING_INCOMPLETE_TOKENS: [&str; 18] = [
1421        "=>", "::", "&&", "||", "<=", ">=", "==", "!=", "=", ",", ".", "+", "-", "*", "/", "%",
1422        "!", ":",
1423    ];
1424    !TRAILING_INCOMPLETE_TOKENS
1425        .iter()
1426        .any(|token| trimmed.ends_with(token))
1427}
1428
1429fn render_repl_compile_error(snippet: &str, err: &vm::SourceError) -> String {
1430    match err {
1431        vm::SourceError::Parse(parse) => {
1432            let mut source_map = SourceMap::new();
1433            let source_id = source_map.add_source("<repl>", snippet.to_string());
1434            let parse = parse
1435                .clone()
1436                .with_line_span_from_source(&source_map, source_id);
1437            render_source_error(&source_map, &parse, true)
1438        }
1439        _ => err.to_string(),
1440    }
1441}
1442
1443fn add_one_host_function(_vm: &mut Vm, args: &[Value]) -> Result<CallOutcome, VmError> {
1444    let value = match args.first() {
1445        Some(Value::Int(value)) => *value,
1446        _ => return Err(VmError::TypeMismatch("int")),
1447    };
1448    Ok(CallOutcome::Return(CallReturn::one(Value::Int(value + 1))))
1449}
1450
1451fn echo_host_function(_vm: &mut Vm, args: &[Value]) -> Result<CallOutcome, VmError> {
1452    let value = args.first().cloned().ok_or(VmError::StackUnderflow)?;
1453    Ok(CallOutcome::Return(CallReturn::one(value)))
1454}
1455
1456fn format_value(value: &Value) -> String {
1457    match value {
1458        Value::Null => "null".to_string(),
1459        Value::Int(value) => value.to_string(),
1460        Value::Float(value) => value.to_string(),
1461        Value::Bool(value) => value.to_string(),
1462        Value::String(value) => value.as_str().to_string(),
1463        Value::Bytes(value) => format_bytes(value.as_ref()),
1464        Value::Array(values) => {
1465            let parts = values
1466                .iter()
1467                .map(format_value)
1468                .collect::<Vec<_>>()
1469                .join(", ");
1470            format!("[{parts}]")
1471        }
1472        Value::Map(entries) => {
1473            let parts = entries
1474                .iter()
1475                .map(|(key, value)| format!("{}: {}", format_value(key), format_value(value)))
1476                .collect::<Vec<_>>()
1477                .join(", ");
1478            format!("{{{parts}}}")
1479        }
1480    }
1481}
1482
1483fn format_bytes(bytes: &[u8]) -> String {
1484    let preview_len = bytes.len().min(16);
1485    let mut preview = String::with_capacity(preview_len * 2);
1486    for byte in &bytes[..preview_len] {
1487        preview.push(hex_nibble(byte >> 4));
1488        preview.push(hex_nibble(byte & 0x0F));
1489    }
1490    if bytes.len() > preview_len {
1491        format!("bytes[len={} hex={}..]", bytes.len(), preview)
1492    } else {
1493        format!("bytes[len={} hex={}]", bytes.len(), preview)
1494    }
1495}
1496
1497fn hex_nibble(value: u8) -> char {
1498    match value {
1499        0..=9 => char::from(b'0' + value),
1500        10..=15 => char::from(b'a' + (value - 10)),
1501        _ => unreachable!("hex nibble out of range"),
1502    }
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use crate as vm;
1508    use std::collections::BTreeMap;
1509    use std::time::{SystemTime, UNIX_EPOCH};
1510
1511    use super::{
1512        CliConfig, parse_cli_args, prepare_aot_for_cli, register_imports,
1513        try_new_cli_vm_from_standalone_aot,
1514    };
1515    use vm::{
1516        CompileSourceFileOptions, HostImport, OpCode, Program, Value, ValueType, Vm, VmStatus,
1517    };
1518
1519    fn s(value: &str) -> String {
1520        value.to_string()
1521    }
1522
1523    #[test]
1524    fn cli_build_features_report_compiled_capabilities() {
1525        let features = super::cli_build_features();
1526
1527        assert_eq!(
1528            features,
1529            vec![
1530                cfg!(feature = "cranelift-jit").then_some("cranelift-jit".to_string()),
1531                cfg!(feature = "runtime").then_some("runtime".to_string()),
1532                CompileSourceFileOptions::default()
1533                    .module_override_source("stdlib/rss/strings.rss")
1534                    .is_some()
1535                    .then_some("stdlibs".to_string()),
1536                Some("modules=bytes, io, re, json, jit, math".to_string()),
1537            ]
1538            .into_iter()
1539            .flatten()
1540            .collect::<Vec<_>>()
1541        );
1542        assert!(!super::cli_build_feature_summary().contains("enabled"));
1543    }
1544
1545    fn native_aot_supported() -> bool {
1546        (cfg!(target_arch = "x86_64")
1547            && (cfg!(target_os = "windows") || (cfg!(unix) && !cfg!(target_os = "macos"))))
1548            || (cfg!(target_arch = "aarch64")
1549                && (cfg!(target_os = "linux") || cfg!(target_os = "macos")))
1550    }
1551
1552    fn unique_artifact_path() -> std::path::PathBuf {
1553        let mut path = std::env::temp_dir();
1554        let stamp = SystemTime::now()
1555            .duration_since(UNIX_EPOCH)
1556            .expect("system clock should be after unix epoch")
1557            .as_nanos();
1558        path.push(format!("pd-vm-run-aot-{}-{stamp}.pat", std::process::id()));
1559        path
1560    }
1561
1562    fn run_repl_snippet_and_sync(session: &mut super::ReplSession, snippet: &str) -> Vm {
1563        let compiled =
1564            super::compile_repl_snippet(snippet, &session.locals).expect("compile should succeed");
1565        let moved_by_rebinding =
1566            super::repl_locals_moved_by_rebinding(&compiled.compiled.program, &session.locals);
1567        let mut vm = Vm::new(
1568            compiled
1569                .compiled
1570                .program
1571                .with_local_count(compiled.compiled.locals),
1572        );
1573        super::configure_cli_vm(&mut vm);
1574        let imports = vm.program().imports.clone();
1575        super::register_imports(&mut vm, &imports).expect("register should succeed");
1576        super::seed_repl_vm_locals(&mut vm, &session.locals).expect("locals should restore");
1577        loop {
1578            match vm.run().expect("snippet should run") {
1579                VmStatus::Halted => break,
1580                VmStatus::Yielded => continue,
1581                VmStatus::Waiting(_) => vm
1582                    .wait_for_host_op_blocking()
1583                    .expect("snippet should not block"),
1584            }
1585        }
1586        super::sync_repl_session(&vm, &compiled.bindings, &moved_by_rebinding, session);
1587        vm
1588    }
1589
1590    #[test]
1591    fn register_imports_binds_cached_cli_host_registry_plan() {
1592        let imports = vec![
1593            HostImport {
1594                name: "print".to_string(),
1595                arity: 1,
1596                return_type: ValueType::Unknown,
1597            },
1598            HostImport {
1599                name: "echo".to_string(),
1600                arity: 1,
1601                return_type: ValueType::Unknown,
1602            },
1603        ];
1604        let program =
1605            Program::with_imports_and_debug(vec![], vec![OpCode::Ret as u8], imports.clone(), None);
1606
1607        let mut first = Vm::new(program.clone());
1608        register_imports(&mut first, &imports).expect("first vm should bind imports");
1609        assert_eq!(first.bound_function_count(), 2);
1610
1611        let mut second = Vm::new(program);
1612        register_imports(&mut second, &imports).expect("second vm should reuse cached plan");
1613        assert_eq!(second.bound_function_count(), 2);
1614    }
1615
1616    #[test]
1617    fn parse_cli_defaults() {
1618        let cfg = parse_cli_args(&[]).expect("parse should succeed");
1619        assert!(cfg.repl);
1620        assert!(!cfg.debug);
1621        assert!(!cfg.version);
1622        assert!(cfg.tcp_addr.is_none());
1623        assert!(cfg.stop_on_entry);
1624        assert!(!cfg.aot);
1625        assert!(!cfg.aot_dump);
1626        assert!(cfg.aot_save_path.is_none());
1627        assert!(cfg.aot_load_path.is_none());
1628        assert!(!cfg.jit_dump);
1629        assert!(cfg.jit_dump_show_machine_code);
1630        assert!(cfg.jit_hot_loop_threshold.is_none());
1631        assert!(cfg.fuel.is_none());
1632        assert!(cfg.epoch_deadline.is_none());
1633        assert!(cfg.source.is_none());
1634        assert!(cfg.epoch_check_interval.is_none());
1635        assert!(cfg.emit_vmbc_path.is_none());
1636        assert!(cfg.disasm_vmbc_path.is_none());
1637        assert!(!cfg.show_source);
1638        assert!(!cfg.fmt);
1639        assert!(!cfg.fmt_check);
1640    }
1641
1642    #[test]
1643    fn parse_cli_version_flag() {
1644        let cfg = parse_cli_args(&[s("--version")]).expect("parse should succeed");
1645        assert!(cfg.version);
1646        assert!(!cfg.repl);
1647    }
1648
1649    #[test]
1650    fn parse_cli_version_short_flag() {
1651        let cfg = parse_cli_args(&[s("-V")]).expect("parse should succeed");
1652        assert!(cfg.version);
1653        assert!(!cfg.repl);
1654    }
1655
1656    #[test]
1657    fn parse_cli_debug_with_source_and_tcp() {
1658        let cfg = parse_cli_args(&[
1659            s("--debug"),
1660            s("--tcp"),
1661            s("127.0.0.1:9002"),
1662            s("examples/example.lua"),
1663        ])
1664        .expect("parse should succeed");
1665        assert!(cfg.debug);
1666        assert_eq!(cfg.tcp_addr.as_deref(), Some("127.0.0.1:9002"));
1667        assert_eq!(cfg.source.as_deref(), Some("examples/example.lua"));
1668    }
1669
1670    #[test]
1671    fn parse_cli_legacy_debug_command() {
1672        let cfg =
1673            parse_cli_args(&[s("debug"), s("examples/example.rss")]).expect("parse should succeed");
1674        assert!(cfg.debug);
1675        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1676    }
1677
1678    #[test]
1679    fn parse_cli_rejects_multiple_sources() {
1680        let err = parse_cli_args(&[s("a.rss"), s("b.rss")]).expect_err("parse should fail");
1681        assert!(err.contains("multiple source paths"));
1682    }
1683
1684    #[test]
1685    fn parse_cli_jit_flags() {
1686        let cfg = parse_cli_args(&[
1687            s("--jit-hot-loop"),
1688            s("2"),
1689            s("--jit-dump"),
1690            s("--jit-dump-no-code"),
1691            s("examples/example.rss"),
1692        ])
1693        .expect("parse should succeed");
1694        assert_eq!(cfg.jit_hot_loop_threshold, Some(2));
1695        assert!(cfg.jit_dump);
1696        assert!(!cfg.jit_dump_show_machine_code);
1697        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1698    }
1699
1700    #[test]
1701    fn parse_cli_aot_flags() {
1702        let cfg = parse_cli_args(&[
1703            s("--aot"),
1704            s("--aot-dump"),
1705            s("--aot-save"),
1706            s("out/program.pat"),
1707            s("examples/example.rss"),
1708        ])
1709        .expect("parse should succeed");
1710        assert!(cfg.aot);
1711        assert!(cfg.aot_dump);
1712        assert_eq!(cfg.aot_save_path.as_deref(), Some("out/program.pat"));
1713        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1714    }
1715
1716    #[test]
1717    fn parse_cli_aot_load_without_source_path() {
1718        let cfg =
1719            parse_cli_args(&[s("--aot-load"), s("out/program.pat")]).expect("parse should succeed");
1720        assert_eq!(cfg.aot_load_path.as_deref(), Some("out/program.pat"));
1721        assert!(cfg.source.is_none());
1722    }
1723
1724    #[test]
1725    fn parse_cli_rejects_aot_and_load_together() {
1726        let err = parse_cli_args(&[
1727            s("--aot"),
1728            s("--aot-load"),
1729            s("out/program.pat"),
1730            s("examples/example.rss"),
1731        ])
1732        .expect_err("parse should fail");
1733        assert!(err.contains("mutually exclusive"));
1734    }
1735
1736    #[test]
1737    fn parse_cli_debug_rejects_aot_runtime_flags() {
1738        let err = parse_cli_args(&[s("--debug"), s("--aot"), s("examples/example.rss")])
1739            .expect_err("parse should fail");
1740        assert!(err.contains("debug mode"));
1741    }
1742
1743    #[test]
1744    fn parse_cli_dump_jit_alias() {
1745        let cfg = parse_cli_args(&[s("--dump-jit"), s("examples/example.rss")])
1746            .expect("parse should succeed");
1747        assert!(cfg.jit_dump);
1748        assert!(cfg.jit_dump_show_machine_code);
1749        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1750    }
1751
1752    #[test]
1753    fn parse_cli_jit_dump_no_code_requires_dump_flag() {
1754        let err = parse_cli_args(&[s("--jit-dump-no-code"), s("examples/example.rss")])
1755            .expect_err("parse should fail");
1756        assert!(err.contains("requires --jit-dump or --dump-jit"));
1757    }
1758
1759    #[test]
1760    fn parse_cli_fuel_flag() {
1761        let cfg = parse_cli_args(&[s("--fuel"), s("123"), s("examples/example.rss")])
1762            .expect("parse should succeed");
1763        assert_eq!(cfg.fuel, Some(123));
1764        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1765    }
1766
1767    #[test]
1768    fn parse_cli_fuel_requires_value() {
1769        let err = parse_cli_args(&[s("--fuel")]).expect_err("parse should fail");
1770        assert!(err.contains("missing value for --fuel"));
1771    }
1772
1773    #[test]
1774    fn parse_cli_epoch_deadline_flag() {
1775        let cfg = parse_cli_args(&[s("--epoch-deadline"), s("3"), s("examples/example.rss")])
1776            .expect("parse should succeed");
1777        assert_eq!(cfg.epoch_deadline, Some(3));
1778        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1779    }
1780
1781    #[test]
1782    fn parse_cli_rejects_fuel_and_epoch_deadline_together() {
1783        let err = parse_cli_args(&[
1784            s("--fuel"),
1785            s("10"),
1786            s("--epoch-deadline"),
1787            s("3"),
1788            s("examples/example.rss"),
1789        ])
1790        .expect_err("parse should fail");
1791        assert!(err.contains("mutually exclusive"));
1792    }
1793
1794    #[test]
1795    fn parse_cli_emit_vmbc_path() {
1796        let cfg = parse_cli_args(&[
1797            s("--emit-vmbc"),
1798            s("out/program.vmbc"),
1799            s("examples/example.rss"),
1800        ])
1801        .expect("parse should succeed");
1802        assert_eq!(cfg.emit_vmbc_path.as_deref(), Some("out/program.vmbc"));
1803        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1804    }
1805
1806    #[test]
1807    fn parse_cli_emit_vmbc_requires_path() {
1808        let err = parse_cli_args(&[s("--emit-vmbc")]).expect_err("parse should fail");
1809        assert!(err.contains("missing value for --emit-vmbc"));
1810    }
1811
1812    #[test]
1813    fn parse_cli_disasm_vmbc_path() {
1814        let cfg = parse_cli_args(&[
1815            s("--disasm-vmbc"),
1816            s("out/program.vmbc"),
1817            s("--show-source"),
1818        ])
1819        .expect("parse should succeed");
1820        assert_eq!(cfg.disasm_vmbc_path.as_deref(), Some("out/program.vmbc"));
1821        assert!(cfg.show_source);
1822    }
1823
1824    #[test]
1825    fn parse_cli_disasm_requires_path() {
1826        let err = parse_cli_args(&[s("--disasm-vmbc")]).expect_err("parse should fail");
1827        assert!(err.contains("missing value for --disasm-vmbc"));
1828    }
1829
1830    #[test]
1831    fn parse_cli_show_source_requires_disasm() {
1832        let err = parse_cli_args(&[s("--show-source")]).expect_err("parse should fail");
1833        assert!(err.contains("requires --disasm-vmbc"));
1834    }
1835
1836    #[test]
1837    fn parse_cli_disasm_rejects_source_path() {
1838        let err = parse_cli_args(&[
1839            s("--disasm-vmbc"),
1840            s("program.vmbc"),
1841            s("examples/example.rss"),
1842        ])
1843        .expect_err("parse should fail");
1844        assert!(err.contains("does not accept a source path"));
1845    }
1846
1847    #[test]
1848    fn parse_cli_record_path() {
1849        let cfg = parse_cli_args(&[s("--record"), s("out/run.pdr"), s("examples/example.rss")])
1850            .expect("parse should succeed");
1851        assert_eq!(cfg.record_path.as_deref(), Some("out/run.pdr"));
1852        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1853    }
1854
1855    #[test]
1856    fn parse_cli_view_record_path() {
1857        let cfg =
1858            parse_cli_args(&[s("--view-record"), s("out/run.pdr")]).expect("parse should succeed");
1859        assert_eq!(cfg.view_recording_path.as_deref(), Some("out/run.pdr"));
1860        assert!(cfg.source.is_none());
1861    }
1862
1863    #[test]
1864    fn parse_cli_view_record_rejects_fuel() {
1865        let err = parse_cli_args(&[s("--view-record"), s("out/run.pdr"), s("--fuel"), s("10")])
1866            .expect_err("parse should fail");
1867        assert!(err.contains("view-record mode"));
1868    }
1869
1870    #[test]
1871    fn parse_cli_record_rejects_debug() {
1872        let err = parse_cli_args(&[s("--record"), s("run.pdr"), s("--debug")])
1873            .expect_err("parse should fail");
1874        assert!(err.contains("record mode"));
1875    }
1876
1877    #[test]
1878    fn parse_cli_repl_flag() {
1879        let cfg = parse_cli_args(&[s("--repl")]).expect("parse should succeed");
1880        assert!(cfg.repl);
1881    }
1882
1883    #[test]
1884    fn parse_cli_fmt_command() {
1885        let cfg =
1886            parse_cli_args(&[s("fmt"), s("examples/example.rss")]).expect("parse should succeed");
1887        assert!(cfg.fmt);
1888        assert!(!cfg.fmt_check);
1889        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1890    }
1891
1892    #[test]
1893    fn parse_cli_fmt_check_flag() {
1894        let cfg = parse_cli_args(&[s("fmt"), s("--check"), s("examples/example.rss")])
1895            .expect("parse should succeed");
1896        assert!(cfg.fmt);
1897        assert!(cfg.fmt_check);
1898        assert_eq!(cfg.source.as_deref(), Some("examples/example.rss"));
1899    }
1900
1901    #[test]
1902    fn parse_cli_fmt_requires_source_path() {
1903        let err = parse_cli_args(&[s("fmt")]).expect_err("parse should fail");
1904        assert!(err.contains("requires a source path"));
1905    }
1906
1907    #[test]
1908    fn parse_cli_check_requires_fmt() {
1909        let err = parse_cli_args(&[s("--check"), s("examples/example.rss")])
1910            .expect_err("parse should fail");
1911        assert!(err.contains("requires fmt mode"));
1912    }
1913
1914    #[test]
1915    fn parse_cli_fmt_rejects_debug_flag() {
1916        let err = parse_cli_args(&[s("fmt"), s("--debug"), s("examples/example.rss")])
1917            .expect_err("parse should fail");
1918        assert!(err.contains("fmt mode"));
1919    }
1920
1921    #[test]
1922    fn parse_cli_repl_legacy_command() {
1923        let cfg = parse_cli_args(&[s("repl")]).expect("parse should succeed");
1924        assert!(cfg.repl);
1925    }
1926
1927    #[test]
1928    fn parse_cli_repl_rejects_source_path() {
1929        let err = parse_cli_args(&[s("--repl"), s("examples/example.rss")])
1930            .expect_err("parse should fail");
1931        assert!(err.contains("does not accept a source path"));
1932    }
1933
1934    #[test]
1935    fn parse_cli_repl_rejects_emit_vmbc() {
1936        let err = parse_cli_args(&[s("--repl"), s("--emit-vmbc"), s("out.vmbc")])
1937            .expect_err("parse should fail");
1938        assert!(err.contains("cannot be combined"));
1939    }
1940
1941    #[test]
1942    fn parse_cli_repl_rejects_fuel() {
1943        let err =
1944            parse_cli_args(&[s("--repl"), s("--fuel"), s("10")]).expect_err("parse should fail");
1945        assert!(err.contains("cannot be combined"));
1946    }
1947
1948    #[test]
1949    fn prepare_cli_aot_can_save_and_reload_artifact() {
1950        if !native_aot_supported() {
1951            return;
1952        }
1953
1954        let program = Program::new(
1955            vec![Value::Int(9)],
1956            vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8],
1957        );
1958        let artifact_path = unique_artifact_path();
1959
1960        let mut save_vm = Vm::new(program.clone());
1961        let save_cfg = CliConfig {
1962            aot_save_path: Some(artifact_path.display().to_string()),
1963            ..CliConfig::default()
1964        };
1965        prepare_aot_for_cli(&mut save_vm, &save_cfg).expect("aot save should succeed");
1966        assert!(save_vm.has_aot_program(), "save path should install aot");
1967
1968        let mut load_vm = Vm::new(program);
1969        let load_cfg = CliConfig {
1970            aot_load_path: Some(artifact_path.display().to_string()),
1971            ..CliConfig::default()
1972        };
1973        prepare_aot_for_cli(&mut load_vm, &load_cfg).expect("aot load should succeed");
1974        assert!(load_vm.has_aot_program(), "load path should install aot");
1975        let status = load_vm.run().expect("loaded aot vm should run");
1976        assert_eq!(status, VmStatus::Halted);
1977        assert_eq!(load_vm.stack(), &[Value::Int(9)]);
1978
1979        std::fs::remove_file(&artifact_path).expect("artifact cleanup should succeed");
1980    }
1981
1982    #[test]
1983    fn standalone_cli_aot_load_without_source_uses_embedded_program() {
1984        if !native_aot_supported() {
1985            return;
1986        }
1987
1988        let program = Program::new(
1989            vec![Value::Int(9)],
1990            vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8],
1991        )
1992        .with_local_count(5);
1993        let artifact_path = unique_artifact_path();
1994
1995        let mut save_vm = Vm::new(program.clone());
1996        let save_cfg = CliConfig {
1997            aot_save_path: Some(artifact_path.display().to_string()),
1998            ..CliConfig::default()
1999        };
2000        prepare_aot_for_cli(&mut save_vm, &save_cfg).expect("aot save should succeed");
2001
2002        let load_cfg = CliConfig {
2003            aot_load_path: Some(artifact_path.display().to_string()),
2004            ..CliConfig::default()
2005        };
2006        let mut loaded_vm = try_new_cli_vm_from_standalone_aot(&load_cfg)
2007            .expect("standalone load should succeed")
2008            .expect("standalone load should create a vm");
2009
2010        assert!(
2011            loaded_vm.has_aot_program(),
2012            "standalone load should install aot"
2013        );
2014        assert_eq!(loaded_vm.program().local_count, 5);
2015
2016        let status = loaded_vm.run().expect("standalone aot vm should run");
2017        assert_eq!(status, VmStatus::Halted);
2018        assert_eq!(loaded_vm.stack(), &[Value::Int(9)]);
2019
2020        std::fs::remove_file(&artifact_path).expect("artifact cleanup should succeed");
2021    }
2022
2023    #[test]
2024    fn repl_compile_falls_back_to_expression_semicolon() {
2025        let compiled =
2026            super::compile_repl_snippet("1 + 2", &BTreeMap::new()).expect("compile should succeed");
2027        assert_eq!(compiled.compiled.locals, 0);
2028    }
2029
2030    #[test]
2031    fn repl_compile_uses_persisted_locals() {
2032        let mut locals = BTreeMap::new();
2033        locals.insert(
2034            "x".to_string(),
2035            super::ReplSessionLocal {
2036                value: Value::Int(41),
2037                mutable: false,
2038                schema: Some(vm::compiler::TypeSchema::Int),
2039                optional: false,
2040                moved: false,
2041            },
2042        );
2043        let compiled =
2044            super::compile_repl_snippet("x + 1", &locals).expect("compile should succeed");
2045        assert!(compiled.compiled.locals >= 1);
2046    }
2047
2048    #[test]
2049    fn repl_session_persists_locals_between_entries() {
2050        let mut session = super::ReplSession::default();
2051        let _ = run_repl_snippet_and_sync(&mut session, "let x = 41;");
2052        assert_eq!(
2053            session.locals.get("x").map(|local| &local.value),
2054            Some(&Value::Int(41))
2055        );
2056
2057        let vm = run_repl_snippet_and_sync(&mut session, "x + 1");
2058        assert_eq!(vm.stack().last(), Some(&Value::Int(42)));
2059    }
2060
2061    #[test]
2062    fn repl_session_preserves_move_state_between_entries() {
2063        let mut session = super::ReplSession::default();
2064        let _ = run_repl_snippet_and_sync(&mut session, "let a = \"payload\";");
2065        let _ = run_repl_snippet_and_sync(&mut session, "let b = a;");
2066
2067        assert_eq!(session.locals.get("a").map(|local| local.moved), Some(true));
2068        assert_eq!(
2069            session.locals.get("b").map(|local| &local.value),
2070            Some(&Value::string("payload"))
2071        );
2072        match super::compile_repl_snippet("a", &session.locals) {
2073            Err(vm::SourceError::Parse(parse)) => {
2074                assert_eq!(parse.code.as_deref(), Some("E_LOCAL_MOVED"));
2075            }
2076            Err(other) => panic!("expected moved-local parse error, got {other}"),
2077            Ok(_) => panic!("expected moved-local parse error, got successful compile"),
2078        }
2079    }
2080
2081    #[test]
2082    fn repl_session_preserves_optional_string_move_state() {
2083        let mut session = super::ReplSession::default();
2084        let _ = run_repl_snippet_and_sync(&mut session, "let a: string? = \"payload\";");
2085        let _ = run_repl_snippet_and_sync(&mut session, "let b = a;");
2086
2087        assert_eq!(session.locals.get("a").map(|local| local.moved), Some(true));
2088        match super::compile_repl_snippet("a", &session.locals) {
2089            Err(vm::SourceError::Parse(parse)) => {
2090                assert_eq!(parse.code.as_deref(), Some("E_LOCAL_MOVED"));
2091            }
2092            Err(other) => panic!("expected moved-local parse error, got {other}"),
2093            Ok(_) => panic!("expected moved-local parse error, got successful compile"),
2094        }
2095    }
2096
2097    #[test]
2098    fn repl_session_marks_copy_types_moved_after_binding() {
2099        let mut session = super::ReplSession::default();
2100        let _ = run_repl_snippet_and_sync(&mut session, "let a = 1;");
2101        let _ = run_repl_snippet_and_sync(&mut session, "let b = a;");
2102
2103        assert_eq!(session.locals.get("a").map(|local| local.moved), Some(true));
2104        match super::compile_repl_snippet("a", &session.locals) {
2105            Err(vm::SourceError::Parse(parse)) => {
2106                assert_eq!(parse.code.as_deref(), Some("E_LOCAL_MOVED"));
2107            }
2108            Err(other) => panic!("expected moved-local parse error, got {other}"),
2109            Ok(_) => panic!("expected moved-local parse error, got successful compile"),
2110        }
2111    }
2112
2113    #[test]
2114    fn repl_session_marks_all_value_types_moved_after_binding() {
2115        let cases = [
2116            ("null", "let a = null;"),
2117            ("int", "let a = 1;"),
2118            ("float", "let a = 1.5;"),
2119            ("bool", "let a = true;"),
2120            ("string", "let a = \"payload\";"),
2121            ("bytes", "use bytes; let a = bytes::from_hex(\"00ff\");"),
2122            ("array", "let a = [1, 2];"),
2123            ("map", "let a = { key: 1 };"),
2124        ];
2125
2126        for (value_type, initializer) in cases {
2127            let mut session = super::ReplSession::default();
2128            let _ = run_repl_snippet_and_sync(&mut session, initializer);
2129            let _ = run_repl_snippet_and_sync(&mut session, "let b = a;");
2130
2131            assert_eq!(
2132                session.locals.get("a").map(|local| local.moved),
2133                Some(true),
2134                "expected {value_type} local to be moved after rebinding"
2135            );
2136            match super::compile_repl_snippet("a", &session.locals) {
2137                Err(vm::SourceError::Parse(parse)) => {
2138                    assert_eq!(
2139                        parse.code.as_deref(),
2140                        Some("E_LOCAL_MOVED"),
2141                        "expected {value_type} local to reject a later use"
2142                    );
2143                }
2144                Err(other) => {
2145                    panic!("expected moved-local parse error for {value_type}, got {other}")
2146                }
2147                Ok(_) => panic!("expected {value_type} local to reject a later use"),
2148            }
2149        }
2150    }
2151
2152    #[test]
2153    fn repl_session_restores_rebound_copy_local_after_reassignment() {
2154        let mut session = super::ReplSession::default();
2155        let _ = run_repl_snippet_and_sync(&mut session, "let mut a = 1;");
2156        let _ = run_repl_snippet_and_sync(&mut session, "let b = a; a = 2;");
2157        let vm = run_repl_snippet_and_sync(&mut session, "a");
2158
2159        assert_eq!(vm.stack().last(), Some(&Value::Int(2)));
2160        assert_eq!(
2161            session.locals.get("a").map(|local| local.moved),
2162            Some(false)
2163        );
2164    }
2165
2166    #[test]
2167    fn repl_session_persists_mutable_locals_between_entries() {
2168        let mut session = super::ReplSession::default();
2169        let _ = run_repl_snippet_and_sync(&mut session, "let mut x = 1;");
2170        assert_eq!(
2171            session.locals.get("x").map(|local| local.mutable),
2172            Some(true)
2173        );
2174
2175        let _ = run_repl_snippet_and_sync(&mut session, "x = x + 1;");
2176        let vm = run_repl_snippet_and_sync(&mut session, "x");
2177        assert_eq!(vm.stack().last(), Some(&Value::Int(2)));
2178    }
2179
2180    #[test]
2181    fn repl_session_persists_null_between_entries() {
2182        let mut session = super::ReplSession::default();
2183        let _ = run_repl_snippet_and_sync(&mut session, "let x = null;");
2184        assert_eq!(
2185            session.locals.get("x").map(|local| &local.value),
2186            Some(&Value::Null)
2187        );
2188
2189        let vm = run_repl_snippet_and_sync(&mut session, "x");
2190        assert_eq!(vm.stack().last(), Some(&Value::Null));
2191    }
2192
2193    #[test]
2194    fn repl_session_persists_float_between_entries() {
2195        let mut session = super::ReplSession::default();
2196        let _ = run_repl_snippet_and_sync(&mut session, "let x = 1.5;");
2197        assert_eq!(
2198            session.locals.get("x").map(|local| &local.value),
2199            Some(&Value::Float(1.5))
2200        );
2201
2202        let vm = run_repl_snippet_and_sync(&mut session, "x + 0.5");
2203        assert_eq!(vm.stack().last(), Some(&Value::Float(2.0)));
2204    }
2205
2206    #[test]
2207    fn repl_compile_remaps_parse_error_line_numbers() {
2208        let mut locals = BTreeMap::new();
2209        locals.insert(
2210            "x".to_string(),
2211            super::ReplSessionLocal {
2212                value: Value::Int(1),
2213                mutable: false,
2214                schema: Some(vm::compiler::TypeSchema::Int),
2215                optional: false,
2216                moved: false,
2217            },
2218        );
2219        match super::compile_repl_snippet("let y = ;", &locals) {
2220            Err(vm::SourceError::Parse(parse)) => assert_eq!(parse.line, 1),
2221            Err(other) => panic!("expected parse error, got {other}"),
2222            Ok(_) => panic!("expected parse error, got successful compile"),
2223        }
2224    }
2225
2226    #[test]
2227    fn repl_input_complete_for_balanced_match_block() {
2228        let input = "let b = match a {\n    Some(String) => 2,\n    _ => 3,\n};";
2229        assert!(super::is_repl_input_complete(input));
2230    }
2231
2232    #[test]
2233    fn repl_input_incomplete_for_open_brace() {
2234        assert!(!super::is_repl_input_complete("let b = match a {"));
2235    }
2236
2237    #[test]
2238    fn repl_input_incomplete_for_unclosed_string() {
2239        assert!(!super::is_repl_input_complete("let s = \"hello"));
2240    }
2241
2242    #[test]
2243    fn repl_input_incomplete_for_unclosed_block_comment() {
2244        assert!(!super::is_repl_input_complete("let a = 1; /* comment"));
2245    }
2246
2247    #[test]
2248    fn repl_input_ignores_comment_delimiters() {
2249        assert!(super::is_repl_input_complete("// {\nlet a = 1;"));
2250    }
2251
2252    #[test]
2253    fn repl_input_incomplete_for_trailing_operator() {
2254        assert!(!super::is_repl_input_complete("let a = 1 +"));
2255    }
2256}