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