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}