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