#[cfg(feature = "telemetry")]
use std::env;
use std::{
fmt, fs, io,
process::ExitCode,
time::{Duration, Instant},
};
use anstream::{AutoStream, ColorChoice, eprintln};
use anstyle::{AnsiColor, Color, Style};
use monty::{MontyRepl, MontyRun, ReplContinuationMode, ReplProgress, RunProgress, detect_repl_continuation_mode};
use monty_fs::{MountCallOutcome, MountMode, MountTable, OverlayState};
use monty_type_checking::{SourceFile, TypeChecker};
use monty_types::{
CompileOptions, DEFAULT_MAX_SUSPENSIONS, ExcType, ExtFunctionResult, MontyException, MontyObject, NameLookupResult,
OsFunctionCall, PrintWriter, ResourceLimits, ResourceTracker, TypeCheckingConfig,
};
use rustyline::{DefaultEditor, error::ReadlineError};
#[cfg(feature = "telemetry")]
use tracing::field::Empty;
use crate::Cli;
const DIM: Style = Style::new().dimmed();
const BOLD_RED: Style = Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Red)));
const BOLD_GREEN: Style = Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Green)));
const BOLD_CYAN: Style = Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
const ARROW: &str = "❯";
const EXT_FUNCTIONS: bool = false;
#[cfg(feature = "telemetry")]
pub(crate) fn run(cli: Cli) -> ExitCode {
let logfire = match configure_logfire() {
Ok(logfire) => logfire,
Err(err) => {
eprintln!("{BOLD_RED}error{BOLD_RED:#}: failed to configure telemetry: {err}");
return ExitCode::FAILURE;
}
};
let run_span = logfire.as_ref().map(|_| logfire::span!("run monty", success = Empty,));
let exit_code = run_cli(cli);
if let Some(run_span) = run_span {
run_span.record("success", exit_code == ExitCode::SUCCESS);
}
exit_code
}
#[cfg(not(feature = "telemetry"))]
pub(crate) fn run(cli: Cli) -> ExitCode {
run_cli(cli)
}
#[cfg(feature = "telemetry")]
fn configure_logfire() -> Result<Option<logfire::ShutdownGuard>, logfire::ConfigureError> {
if env::var_os("LOGFIRE_TOKEN").is_some() {
logfire::configure()
.with_service_name("monty")
.with_service_version(env!("CARGO_PKG_VERSION"))
.finish()
.map(logfire::Logfire::shutdown_guard)
.map(Some)
} else {
Ok(None)
}
}
fn run_cli(cli: Cli) -> ExitCode {
let type_check = cli.type_check.then(|| TypeCheckingConfig {
format: cli.type_check_format.unwrap_or_default(),
color: stderr_styled(),
});
let limits = match cli.resource_limits() {
Ok(limits) => limits,
Err(err) => {
eprintln!("{BOLD_RED}error{BOLD_RED:#}: {err}");
return ExitCode::FAILURE;
}
};
monty_alloc::set_limit(limits.max_memory, type_check.is_some())
.expect("monty-runtime must install LimitedAllocator globally");
let mount_table = match build_mount_table(&cli.mounts) {
Ok(mt) => mt,
Err(err) => {
eprintln!("{BOLD_RED}error{BOLD_RED:#}: {err}");
return ExitCode::FAILURE;
}
};
if let Some(cmd) = cli.command {
if cli.file.is_some() {
eprintln!("{BOLD_RED}error{BOLD_RED:#}: 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, 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{BOLD_RED:#}: {err}");
return ExitCode::FAILURE;
}
};
return if cli.interactive {
dispatch_repl(file_path, &code, limits, mount_table)
} else {
dispatch_script(file_path, code, type_check, limits, mount_table)
};
}
dispatch_repl("repl.py", "", limits, mount_table)
}
fn dispatch_script(
file_path: &str,
code: String,
type_check: Option<TypeCheckingConfig>,
limits: ResourceLimits,
mount_table: Option<MountTable>,
) -> ExitCode {
run_script(file_path, code, type_check, ResourceTracker::new(limits), mount_table)
}
fn dispatch_repl(file_path: &str, code: &str, limits: ResourceLimits, mount_table: Option<MountTable>) -> ExitCode {
run_repl(file_path, code, ResourceTracker::new(limits), mount_table)
}
fn run_script(
file_path: &str,
code: String,
type_check: Option<TypeCheckingConfig>,
tracker: ResourceTracker,
mut mount_table: Option<MountTable>,
) -> ExitCode {
if let Some(config) = type_check {
let start = Instant::now();
let mut checker = TypeChecker::default();
if let Some(failure) = checker.run(&SourceFile::new(&code, file_path), None, config).unwrap() {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {BOLD_RED}type check failed{BOLD_RED:#}:\n{failure}",
FormattedDuration(elapsed)
);
} else {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {BOLD_GREEN}type check passed{BOLD_GREEN:#}",
FormattedDuration(elapsed)
);
}
}
let input_names = vec![];
let inputs = vec![];
let runner = match MontyRun::new(code, file_path, input_names, CompileOptions::default()) {
Ok(ex) => ex,
Err(err) => {
eprintln!("{BOLD_RED}error{BOLD_RED:#}:\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}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {BOLD_RED}error{BOLD_RED:#}: {err}",
FormattedDuration(elapsed)
);
return ExitCode::FAILURE;
}
};
let mut suspensions = SuspensionBudget::from_progress(&progress);
match run_until_complete(progress, &mut mount_table, &mut suspensions) {
Ok(value) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {value}",
FormattedDuration(elapsed)
);
ExitCode::SUCCESS
}
Err(err) => {
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {BOLD_RED}error{BOLD_RED:#}: {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}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {BOLD_RED}error{BOLD_RED:#}: {err}",
FormattedDuration(elapsed)
);
return ExitCode::FAILURE;
}
};
let elapsed = start.elapsed();
eprintln!(
"{DIM}{}{DIM:#} {BOLD_CYAN}{ARROW}{BOLD_CYAN:#} {value}",
FormattedDuration(elapsed)
);
ExitCode::SUCCESS
}
}
fn run_repl(file_path: &str, code: &str, tracker: ResourceTracker, mut mount_table: Option<MountTable>) -> ExitCode {
let mut suspensions = SuspensionBudget::new(&tracker);
let mut repl = Some(MontyRepl::new(file_path, tracker, CompileOptions::default()));
if !code.is_empty() {
execute_repl_snippet(&mut repl, code, &mut mount_table, &mut suspensions);
}
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{BOLD_RED:#} initializing editor: {err}");
return ExitCode::FAILURE;
}
};
let mut pending_snippet = String::new();
let mut continuation_mode = ReplContinuationMode::Complete;
let statement_prompt = if stdout_styled() {
format!("{BOLD_CYAN}{ARROW}{BOLD_CYAN:#} ")
} else {
format!("{ARROW} ")
};
loop {
let prompt = if continuation_mode == ReplContinuationMode::Complete {
statement_prompt.as_str()
} else {
"… "
};
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{BOLD_RED:#} 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, &mut suspensions);
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, &mut suspensions);
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>,
snippet: &str,
mount_table: &mut Option<MountTable>,
suspensions: &mut SuspensionBudget,
) {
let r = repl.take().expect("repl must be present");
if mount_table.is_some() {
match execute_repl_with_mounts(r, snippet, mount_table, suspensions) {
Ok((returned_repl, output)) => {
if output != MontyObject::None {
println!("{output}");
}
*repl = Some(returned_repl);
}
Err((returned_repl, err)) => {
eprintln!("{BOLD_RED}error{BOLD_RED:#}: {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{BOLD_RED:#}: {err}");
}
}
*repl = Some(r);
}
}
#[expect(clippy::result_large_err)]
fn execute_repl_with_mounts(
r: MontyRepl,
snippet: &str,
mount_table: &mut Option<MountTable>,
suspensions: &mut SuspensionBudget,
) -> Result<(MontyRepl, MontyObject), (MontyRepl, 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 {
if !matches!(progress, ReplProgress::Complete { .. })
&& let Some(exc) = suspensions.record_suspension()
{
let outcome = match progress {
ReplProgress::OsCall(call) => call.abort(exc, PrintWriter::Stdout),
ReplProgress::FunctionCall(call) => call.abort(exc, PrintWriter::Stdout),
ReplProgress::NameLookup(lookup) => lookup.abort(exc, PrintWriter::Stdout),
ReplProgress::ResolveFutures(state) => state.abort(exc, PrintWriter::Stdout),
ReplProgress::Complete { .. } => unreachable!("only suspensions are counted"),
};
match outcome {
Ok(p) => progress = p,
Err(err) => return Err((err.repl, format!("{}", err.error))),
}
continue;
}
match progress {
ReplProgress::Complete { repl, value } => return Ok((repl, value)),
ReplProgress::OsCall(call) => {
match call.resume_with(PrintWriter::Stdout, |fc| handle_os_call(fc, mount_table)) {
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,
mount_table: &mut Option<MountTable>,
suspensions: &mut SuspensionBudget,
) -> Result<MontyObject, String> {
loop {
if !matches!(progress, RunProgress::Complete(_))
&& let Some(exc) = suspensions.record_suspension()
{
progress = match progress {
RunProgress::OsCall(call) => call.abort(exc, PrintWriter::Stdout),
RunProgress::FunctionCall(call) => call.abort(exc, PrintWriter::Stdout),
RunProgress::NameLookup(lookup) => lookup.abort(exc, PrintWriter::Stdout),
RunProgress::ResolveFutures(state) => state.abort(exc, PrintWriter::Stdout),
RunProgress::Complete(_) => unreachable!("only suspensions are counted"),
}
.map_err(|err| format!("{err}"))?;
continue;
}
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) => {
progress = call
.resume_with(PrintWriter::Stdout, |fc| handle_os_call(fc, mount_table))
.map_err(|err| format!("{err}"))?;
}
}
}
}
struct SuspensionBudget {
limit: usize,
seen: usize,
}
impl SuspensionBudget {
fn new(tracker: &ResourceTracker) -> Self {
Self {
limit: tracker.max_suspensions(),
seen: 0,
}
}
fn from_progress(progress: &RunProgress) -> Self {
let limit = match progress {
RunProgress::FunctionCall(call) => call.tracker().max_suspensions(),
RunProgress::OsCall(call) => call.tracker().max_suspensions(),
RunProgress::NameLookup(lookup) => lookup.tracker().max_suspensions(),
RunProgress::ResolveFutures(state) => state.tracker().max_suspensions(),
RunProgress::Complete(_) => DEFAULT_MAX_SUSPENSIONS,
};
Self { limit, seen: 0 }
}
fn record_suspension(&mut self) -> Option<MontyException> {
self.seen += 1;
let limit = self.limit;
(self.seen > limit).then(|| {
MontyException::new(
ExcType::RuntimeError,
Some(format!("suspension limit {limit} exceeded")),
)
})
}
}
fn handle_os_call(call: OsFunctionCall, mount_table: &mut Option<MountTable>) -> ExtFunctionResult {
match mount_table.as_mut() {
Some(mounts) => match mounts.handle_os_call(call) {
MountCallOutcome::Handled(Ok(obj)) => obj.into(),
MountCallOutcome::Handled(Err(err)) => err.into_exception().into(),
MountCallOutcome::NotHandled(call) => call.on_no_handler().into(),
},
None => 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 stderr_styled() -> bool {
AutoStream::choice(&io::stderr()) != ColorChoice::Never
}
fn stdout_styled() -> bool {
AutoStream::choice(&io::stdout()) != ColorChoice::Never
}