use std::{
fmt, fs,
process::ExitCode,
time::{Duration, Instant},
};
use clap::{Parser, Subcommand};
use monty::{
LimitedTracker, MontyObject, MontyRepl, MontyRun, NameLookupResult, NoLimitTracker, PrintWriter,
ReplContinuationMode, ReplProgress, ResourceLimits, ResourceTracker, RunProgress, detect_repl_continuation_mode,
fs::{MountMode, MountTable, OverlayState},
};
use rustyline::{DefaultEditor, error::ReadlineError};
#[rustfmt::skip]
use monty_type_checking::{SourceFile, type_check};
mod subprocess;
const DIM: &str = "\x1b[2m";
const BOLD_RED: &str = "\x1b[1m\x1b[31m";
const BOLD_GREEN: &str = "\x1b[1m\x1b[32m";
const BOLD_CYAN: &str = "\x1b[1m\x1b[36m";
const RESET: &str = "\x1b[0m";
const ARROW: &str = "❯";
#[derive(Parser)]
#[command(version)]
struct Cli {
#[arg(short = 'i', long = "interactive")]
interactive: bool,
#[arg(short = 't', long = "type-check")]
type_check: bool,
#[arg(short = 'c')]
command: Option<String>,
file: Option<String>,
#[arg(short = 'm', long = "mount")]
mounts: Vec<String>,
#[arg(long)]
max_allocations: Option<usize>,
#[arg(long)]
max_duration: Option<f64>,
#[arg(long, value_parser = parse_memory_size)]
max_memory: Option<usize>,
#[arg(long)]
gc_interval: Option<usize>,
#[arg(long)]
max_recursion_depth: Option<usize>,
#[command(subcommand)]
subcommand: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Subprocess,
}
impl Cli {
fn subprocess_conflict(&self) -> Option<&'static str> {
if self.interactive {
Some("--interactive")
} else if self.command.is_some() {
Some("-c")
} else if self.file.is_some() {
Some("a file argument")
} else if self.type_check {
Some("--type-check")
} else if !self.mounts.is_empty() {
Some("--mount")
} else if self.any_resource_limit_flag() {
Some("a resource-limit flag")
} else {
None
}
}
fn any_resource_limit_flag(&self) -> bool {
self.max_allocations.is_some()
|| self.max_duration.is_some()
|| self.max_memory.is_some()
|| self.gc_interval.is_some()
|| self.max_recursion_depth.is_some()
}
fn resource_limits(&self) -> Result<Option<ResourceLimits>, String> {
if !self.any_resource_limit_flag() {
return Ok(None);
}
let mut limits = ResourceLimits::new();
if let Some(n) = self.max_allocations {
limits = limits.max_allocations(n);
}
if let Some(secs) = self.max_duration {
limits = limits.max_duration(
Duration::try_from_secs_f64(secs).map_err(|err| format!("invalid --max-duration: {err}"))?,
);
}
if let Some(bytes) = self.max_memory {
limits = limits.max_memory(bytes);
}
if let Some(interval) = self.gc_interval {
limits = limits.gc_interval(interval);
}
if let Some(depth) = self.max_recursion_depth {
limits = limits.max_recursion_depth(Some(depth));
}
Ok(Some(limits))
}
}
const EXT_FUNCTIONS: bool = false;
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(Command::Subprocess) = cli.subcommand {
if let Some(flag) = cli.subprocess_conflict() {
eprintln!("{BOLD_RED}error{RESET}: `subprocess` cannot be combined with {flag}");
return ExitCode::FAILURE;
}
return subprocess::run();
}
let type_check_enabled = cli.type_check;
let limits = match cli.resource_limits() {
Ok(limits) => limits,
Err(err) => {
eprintln!("{BOLD_RED}error{RESET}: {err}");
return ExitCode::FAILURE;
}
};
let mount_table = match build_mount_table(&cli.mounts) {
Ok(mt) => mt,
Err(err) => {
eprintln!("{BOLD_RED}error{RESET}: {err}");
return ExitCode::FAILURE;
}
};
if let Some(cmd) = cli.command {
if cli.file.is_some() {
eprintln!("{BOLD_RED}error{RESET}: cannot specify both -c and a file");
return ExitCode::FAILURE;
}
return if cli.interactive {
dispatch_repl("<string>", &cmd, limits, mount_table)
} else {
dispatch_script("<string>", cmd, type_check_enabled, limits, mount_table)
};
}
if let Some(file_path) = cli.file.as_deref() {
let code = match read_file(file_path) {
Ok(code) => code,
Err(err) => {
eprintln!("{BOLD_RED}error{RESET}: {err}");
return ExitCode::FAILURE;
}
};
return if cli.interactive {
dispatch_repl(file_path, &code, limits, mount_table)
} else {
dispatch_script(file_path, code, type_check_enabled, limits, mount_table)
};
}
dispatch_repl("repl.py", "", limits, mount_table)
}
fn dispatch_script(
file_path: &str,
code: String,
type_check_enabled: bool,
limits: Option<ResourceLimits>,
mount_table: Option<MountTable>,
) -> ExitCode {
if let Some(limits) = limits {
run_script(
file_path,
code,
type_check_enabled,
LimitedTracker::new(limits),
mount_table,
)
} else {
run_script(file_path, code, type_check_enabled, NoLimitTracker, mount_table)
}
}
fn dispatch_repl(
file_path: &str,
code: &str,
limits: Option<ResourceLimits>,
mount_table: Option<MountTable>,
) -> ExitCode {
if let Some(limits) = limits {
run_repl(file_path, code, LimitedTracker::new(limits), mount_table)
} else {
run_repl(file_path, code, NoLimitTracker, mount_table)
}
}
fn run_script(
file_path: &str,
code: String,
type_check_enabled: bool,
tracker: impl ResourceTracker,
mut mount_table: Option<MountTable>,
) -> ExitCode {
if type_check_enabled {
let start = Instant::now();
if let Some(failure) = type_check(&SourceFile::new(&code, file_path), None).unwrap() {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {BOLD_RED}type check failed{RESET}:\n{failure}",
FormattedDuration(elapsed)
);
} else {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {BOLD_GREEN}type check passed{RESET}",
FormattedDuration(elapsed)
);
}
}
let input_names = vec![];
let inputs = vec![];
let runner = match MontyRun::new(code, file_path, input_names) {
Ok(ex) => ex,
Err(err) => {
eprintln!("{BOLD_RED}error{RESET}:\n{err}");
return ExitCode::FAILURE;
}
};
if EXT_FUNCTIONS || mount_table.is_some() {
let start = Instant::now();
let progress = match runner.start(inputs, tracker, PrintWriter::Stdout) {
Ok(p) => p,
Err(err) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {BOLD_RED}error{RESET}: {err}",
FormattedDuration(elapsed)
);
return ExitCode::FAILURE;
}
};
match run_until_complete(progress, &mut mount_table) {
Ok(value) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {value}",
FormattedDuration(elapsed)
);
ExitCode::SUCCESS
}
Err(err) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {BOLD_RED}error{RESET}: {err}",
FormattedDuration(elapsed)
);
ExitCode::FAILURE
}
}
} else {
let start = Instant::now();
let value = match runner.run(inputs, tracker, PrintWriter::Stdout) {
Ok(p) => p,
Err(err) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {BOLD_RED}error{RESET}: {err}",
FormattedDuration(elapsed)
);
return ExitCode::FAILURE;
}
};
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{RESET} {BOLD_CYAN}{ARROW}{RESET} {value}",
FormattedDuration(elapsed)
);
ExitCode::SUCCESS
}
}
fn run_repl(
file_path: &str,
code: &str,
tracker: impl ResourceTracker,
mut mount_table: Option<MountTable>,
) -> ExitCode {
let mut repl = Some(MontyRepl::new(file_path, tracker));
if !code.is_empty() {
execute_repl_snippet(&mut repl, code, &mut mount_table);
}
eprintln!("Monty v{} REPL. Type `exit` to exit.", env!("CARGO_PKG_VERSION"));
let mut rl = match DefaultEditor::new() {
Ok(rl) => rl,
Err(err) => {
eprintln!("{BOLD_RED}error{RESET} initializing editor: {err}");
return ExitCode::FAILURE;
}
};
let mut pending_snippet = String::new();
let mut continuation_mode = ReplContinuationMode::Complete;
loop {
let prompt = if continuation_mode == ReplContinuationMode::Complete {
format!("{BOLD_CYAN}{ARROW}{RESET} ")
} else {
"… ".to_owned()
};
let line = match rl.readline(&prompt) {
Ok(line) => line,
Err(ReadlineError::Eof) => return ExitCode::SUCCESS,
Err(ReadlineError::Interrupted) => {
pending_snippet.clear();
continuation_mode = ReplContinuationMode::Complete;
continue;
}
Err(err) => {
eprintln!("{BOLD_RED}error{RESET} reading input: {err}");
return ExitCode::FAILURE;
}
};
let snippet = line.trim_end();
if continuation_mode == ReplContinuationMode::Complete && snippet.is_empty() {
continue;
}
if continuation_mode == ReplContinuationMode::Complete && snippet == "exit" {
return ExitCode::SUCCESS;
}
pending_snippet.push_str(snippet);
pending_snippet.push('\n');
if continuation_mode == ReplContinuationMode::IncompleteBlock && snippet.is_empty() {
let _ = rl.add_history_entry(pending_snippet.trim_end());
execute_repl_snippet(&mut repl, &pending_snippet, &mut mount_table);
pending_snippet.clear();
continuation_mode = ReplContinuationMode::Complete;
continue;
}
let detected_mode = detect_repl_continuation_mode(&pending_snippet);
match detected_mode {
ReplContinuationMode::Complete => {
if continuation_mode == ReplContinuationMode::IncompleteBlock {
continue;
}
let _ = rl.add_history_entry(pending_snippet.trim_end());
execute_repl_snippet(&mut repl, &pending_snippet, &mut mount_table);
pending_snippet.clear();
continuation_mode = ReplContinuationMode::Complete;
}
ReplContinuationMode::IncompleteBlock => continuation_mode = ReplContinuationMode::IncompleteBlock,
ReplContinuationMode::IncompleteImplicit => {
if continuation_mode != ReplContinuationMode::IncompleteBlock {
continuation_mode = ReplContinuationMode::IncompleteImplicit;
}
}
}
}
}
fn execute_repl_snippet(
repl: &mut Option<MontyRepl<impl ResourceTracker>>,
snippet: &str,
mount_table: &mut Option<MountTable>,
) {
let r = repl.take().expect("repl must be present");
if mount_table.is_some() {
match execute_repl_with_mounts(r, snippet, mount_table) {
Ok((returned_repl, output)) => {
if output != MontyObject::None {
println!("{output}");
}
*repl = Some(returned_repl);
}
Err((returned_repl, err)) => {
eprintln!("{BOLD_RED}error{RESET}: {err}");
*repl = Some(returned_repl);
}
}
} else {
let mut r = r;
match r.feed_run(snippet, vec![], PrintWriter::Stdout) {
Ok(output) => {
if output != MontyObject::None {
println!("{output}");
}
}
Err(err) => {
eprintln!("{BOLD_RED}error{RESET}: {err}");
}
}
*repl = Some(r);
}
}
#[expect(clippy::result_large_err)]
fn execute_repl_with_mounts<T: ResourceTracker>(
r: MontyRepl<T>,
snippet: &str,
mount_table: &mut Option<MountTable>,
) -> Result<(MontyRepl<T>, MontyObject), (MontyRepl<T>, String)> {
let mut progress = match r.feed_start(snippet, vec![], PrintWriter::Stdout) {
Ok(p) => p,
Err(err) => return Err((err.repl, format!("{}", err.error))),
};
loop {
match progress {
ReplProgress::Complete { repl, value } => return Ok((repl, value)),
ReplProgress::OsCall(call) => {
let result = handle_os_call(&call.function_call, mount_table);
match call.resume(result, PrintWriter::Stdout) {
Ok(p) => progress = p,
Err(err) => return Err((err.repl, format!("{}", err.error))),
}
}
ReplProgress::FunctionCall(call) => {
return Err((
call.into_repl(),
"external function calls not supported in CLI".to_owned(),
));
}
ReplProgress::NameLookup(lookup) => match lookup.resume(NameLookupResult::Undefined, PrintWriter::Stdout) {
Ok(p) => progress = p,
Err(err) => return Err((err.repl, format!("{}", err.error))),
},
ReplProgress::ResolveFutures(state) => {
return Err((state.into_repl(), "async futures not supported in CLI".to_owned()));
}
}
}
}
fn run_until_complete(
mut progress: RunProgress<impl ResourceTracker>,
mount_table: &mut Option<MountTable>,
) -> Result<MontyObject, String> {
loop {
match progress {
RunProgress::Complete(value) => return Ok(value),
RunProgress::FunctionCall(call) => {
let return_value = resolve_external_call(&call.function_name, &call.args)?;
progress = call
.resume(return_value, PrintWriter::Stdout)
.map_err(|err| format!("{err}"))?;
}
RunProgress::ResolveFutures(state) => {
return Err(format!(
"async futures not supported in CLI: {:?}",
state.pending_call_ids()
));
}
RunProgress::NameLookup(lookup) => {
let result = if lookup.name == "add_ints" {
NameLookupResult::Value(MontyObject::Function {
name: "add_ints".to_string(),
docstring: None,
})
} else {
NameLookupResult::Undefined
};
progress = lookup
.resume(result, PrintWriter::Stdout)
.map_err(|err| format!("{err}"))?;
}
RunProgress::OsCall(call) => {
let result = handle_os_call(&call.function_call, mount_table);
progress = call
.resume(result, PrintWriter::Stdout)
.map_err(|err| format!("{err}"))?;
}
}
}
}
fn handle_os_call(call: &monty::OsFunctionCall, mount_table: &mut Option<MountTable>) -> monty::ExtFunctionResult {
if let Some(mounts) = mount_table.as_mut() {
match mounts.handle_os_call(call) {
Some(Ok(obj)) => obj.into(),
Some(Err(err)) => err.into_exception().into(),
None => call.on_no_handler().into(),
}
} else {
call.on_no_handler().into()
}
}
fn resolve_external_call(function_name: &str, args: &[MontyObject]) -> Result<MontyObject, String> {
if function_name != "add_ints" {
return Err(format!("unknown external function: {function_name}({args:?})"));
}
if args.len() != 2 {
return Err(format!("add_ints requires exactly 2 arguments, got {}", args.len()));
}
if let (MontyObject::Int(a), MontyObject::Int(b)) = (&args[0], &args[1]) {
Ok(MontyObject::Int(a + b))
} else {
Err(format!("add_ints requires integer arguments, got {args:?}"))
}
}
fn build_mount_table(mount_args: &[String]) -> Result<Option<MountTable>, String> {
if mount_args.is_empty() {
return Ok(None);
}
let mut table = MountTable::new();
for arg in mount_args {
let (host_path, virtual_path, mode, write_bytes_limit) = parse_mount(arg)?;
table
.mount(&virtual_path, &host_path, mode, write_bytes_limit)
.map_err(|e| format!("mount {arg}: {e}"))?;
}
Ok(Some(table))
}
fn parse_mount(spec: &str) -> Result<(String, String, MountMode, Option<u64>), String> {
let parts: Vec<&str> = spec.split("::").collect();
let (host_path, virtual_path, mode_str, limit_str) = match parts.len() {
2 => (parts[0], parts[1], "ro", None),
3 => (parts[0], parts[1], parts[2], None),
4 => (parts[0], parts[1], parts[2], Some(parts[3])),
_ => {
return Err(format!(
"invalid mount spec '{spec}': expected host_path::virtual_path[::mode[::write_limit_bytes]]"
));
}
};
if host_path.is_empty() || virtual_path.is_empty() {
return Err(format!(
"invalid mount spec '{spec}': host and virtual paths must not be empty"
));
}
let mode = match mode_str {
"ro" => MountMode::ReadOnly,
"rw" => MountMode::ReadWrite,
"overlay" => MountMode::OverlayMemory(OverlayState::new()),
other => {
return Err(format!(
"invalid mount mode '{other}' in '{spec}': expected 'ro', 'rw', or 'overlay'"
));
}
};
let write_bytes_limit = match limit_str {
Some("") => {
return Err(format!("invalid write limit in '{spec}': value must not be empty"));
}
Some(limit) => Some(
limit
.parse::<u64>()
.map_err(|_| format!("invalid write limit '{limit}' in '{spec}': expected a non-negative integer"))?,
),
None => None,
};
Ok((host_path.to_owned(), virtual_path.to_owned(), mode, write_bytes_limit))
}
fn read_file(file_path: &str) -> Result<String, String> {
match fs::metadata(file_path) {
Ok(metadata) => {
if !metadata.is_file() {
return Err(format!("{file_path} is not a file"));
}
}
Err(err) => {
return Err(format!("reading {file_path}: {err}"));
}
}
match fs::read_to_string(file_path) {
Ok(contents) => Ok(contents),
Err(err) => Err(format!("reading file: {err}")),
}
}
struct FormattedDuration(Duration);
impl fmt::Display for FormattedDuration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let duration = self.0;
let total_secs = duration.as_secs_f64();
if total_secs < 1e-3 {
let us = total_secs * 1e6;
let decimals = sig_digits_after_decimal(us);
write!(f, "{us:.decimals$}μs")
} else if total_secs < 1.0 {
let ms = total_secs * 1e3;
let decimals = sig_digits_after_decimal(ms);
write!(f, "{ms:.decimals$}ms")
} else {
let decimals = sig_digits_after_decimal(total_secs);
write!(f, "{total_secs:.decimals$}s")
}
}
}
fn sig_digits_after_decimal(value: f64) -> usize {
let before = if value < 1.0 {
1
} else {
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let digits = (value.log10().floor() as u32) + 1;
digits as usize
};
5usize.saturating_sub(before)
}
fn parse_memory_size(s: &str) -> Result<usize, String> {
let s = s.trim();
let (num_str, multiplier) = if let Some(n) = s.strip_suffix("GB").or_else(|| s.strip_suffix("gb")) {
(n.trim(), 1024 * 1024 * 1024)
} else if let Some(n) = s.strip_suffix("MB").or_else(|| s.strip_suffix("mb")) {
(n.trim(), 1024 * 1024)
} else if let Some(n) = s.strip_suffix("KB").or_else(|| s.strip_suffix("kb")) {
(n.trim(), 1024)
} else {
(s, 1)
};
let value: usize = num_str.parse().map_err(|e| format!("invalid memory size '{s}': {e}"))?;
value
.checked_mul(multiplier)
.ok_or_else(|| format!("memory size '{s}' overflows"))
}