use crate::cli::style;
use anyhow::Result;
use super::super::args::Cli;
#[cfg(feature = "wasm")]
pub fn run(_cli: &Cli) -> Result<()> {
use super::{Flow, read_loop};
use std::cell::RefCell;
let runtime =
afterburner_wasi::pyodide_runner::resolve_runtime().map_err(|e| anyhow::anyhow!("{e}"))?;
style::repl_banner_lang(env!("CARGO_PKG_VERSION"), "python");
eprintln!(
" {}",
style::muted(
"each line re-runs the session (one CPython boot per line; two when echoing a value)"
)
);
let session: RefCell<Vec<String>> = RefCell::new(Vec::new());
let baseline: RefCell<usize> = RefCell::new(0);
read_loop("py", |trimmed| {
if let Some(rest) = trimmed.strip_prefix(':') {
match rest.trim() {
"clear" | "reset" => {
session.borrow_mut().clear();
*baseline.borrow_mut() = 0;
eprintln!(" {}", style::muted("session cleared"));
}
"help" | "?" => print_help(),
"exit" | "quit" => return Flow::Exit,
other => eprintln!(
" {}",
style::fail(&format!("unknown command :{other}, try :help"))
),
}
return Flow::Continue;
}
let is_expr = looks_like_expression(trimmed);
let display = build_program(&session.borrow(), trimmed, is_expr);
let prev = *baseline.borrow();
match run_program(&runtime, &display) {
Ok(stdout) => {
let suffix = if stdout.len() >= prev {
&stdout[prev..]
} else {
&stdout[..]
};
if !suffix.is_empty() {
print!("{suffix}");
use std::io::Write;
let _ = std::io::stdout().flush();
}
session.borrow_mut().push(trimmed.to_string());
let new_baseline = if is_expr {
let plain = build_program(&session.borrow(), "", false);
run_program(&runtime, &plain)
.map(|s| s.len())
.unwrap_or(stdout.len())
} else {
stdout.len()
};
*baseline.borrow_mut() = new_baseline;
}
Err(e) => {
eprintln!(" {}", style::fail(&clean_py_err(&e.to_string())));
}
}
Flow::Continue
})
}
#[cfg(not(feature = "wasm"))]
pub fn run(_cli: &Cli) -> Result<()> {
let _ = style::muted("");
anyhow::bail!("Python REPL requires the `wasm` cargo feature (rebuild with `--features wasm`).")
}
#[cfg(feature = "wasm")]
fn run_program(rt: &afterburner_wasi::pyodide_runner::PyRuntime, program: &str) -> Result<String> {
use afterburner_wasi::pyodide_runner::run_pyodide_with;
let out =
run_pyodide_with(rt, program).map_err(|e| anyhow::anyhow!("python runtime error: {e}"))?;
if out.exit_code != 0 {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
anyhow::bail!("{}", text.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn build_program(session: &[String], line: &str, echo: bool) -> String {
let mut out = String::new();
for prior in session {
out.push_str(prior);
out.push('\n');
}
let line = line.trim();
if line.is_empty() {
return out;
}
if echo {
out.push_str("__burn_v = (");
out.push_str(line);
out.push_str(")\nif __burn_v is not None:\n print(repr(__burn_v))\n");
} else {
out.push_str(line);
out.push('\n');
}
out
}
fn looks_like_expression(line: &str) -> bool {
let t = line.trim_start();
if t.is_empty() {
return false;
}
const STMT_KW: &[&str] = &[
"import ",
"from ",
"def ",
"class ",
"if ",
"elif ",
"else",
"for ",
"while ",
"with ",
"try",
"except",
"finally",
"return",
"raise ",
"pass",
"break",
"continue",
"global ",
"nonlocal ",
"assert ",
"del ",
"yield",
"async ",
"await ",
"@",
];
if STMT_KW.iter().any(|kw| t.starts_with(kw)) {
return false;
}
if is_top_level_assignment(t) {
return false;
}
true
}
fn is_top_level_assignment(s: &str) -> bool {
let bytes = s.as_bytes();
let mut depth: i32 = 0;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' | b'[' | b'{' => depth += 1,
b')' | b']' | b'}' => depth -= 1,
b'=' if depth == 0 => {
let prev = if i > 0 { bytes[i - 1] } else { b' ' };
let next = if i + 1 < bytes.len() {
bytes[i + 1]
} else {
b' '
};
let is_comparison = next == b'=' || matches!(prev, b'=' | b'!' | b'<' | b'>');
if !is_comparison {
return true;
}
}
_ => {}
}
i += 1;
}
false
}
#[cfg(feature = "wasm")]
fn clean_py_err(raw: &str) -> String {
let trimmed = raw.trim();
trimmed
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or(trimmed)
.trim()
.to_string()
}
#[cfg(feature = "wasm")]
fn print_help() {
for (cmd, desc) in [
(":clear", "forget the session"),
(":help", "show commands"),
(":exit | :quit", "leave the REPL"),
] {
eprintln!(
" {} {}",
style::accent(&format!("{cmd:<16}")),
style::muted(desc)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expression_is_wrapped_to_echo_its_value() {
let p = build_program(&[], "1 + 1", true);
assert!(p.contains("__burn_v = (1 + 1)"), "got: {p}");
assert!(p.contains("print(repr(__burn_v))"), "echoes value: {p}");
}
#[test]
fn assignment_is_a_statement_not_echoed() {
let p = build_program(&[], "x = 5", false);
assert!(p.contains("x = 5"), "got: {p}");
assert!(!p.contains("__burn_v"), "assignment is not echoed: {p}");
}
#[test]
fn empty_current_line_yields_committed_plain_program() {
let session = vec!["x = 1".to_string(), "print(x)".to_string()];
let p = build_program(&session, "", false);
assert!(p.contains("x = 1"), "prior assignment present: {p}");
assert!(p.contains("print(x)"), "prior call present: {p}");
assert!(!p.contains("__burn_v"), "no echo wrapper: {p}");
}
#[test]
fn def_is_a_statement() {
assert!(!looks_like_expression("def f(): return 1"));
assert!(!looks_like_expression("import os"));
assert!(!looks_like_expression("from os import path"));
assert!(!looks_like_expression("for i in range(3): pass"));
}
#[test]
fn call_and_arithmetic_are_expressions() {
assert!(looks_like_expression("print('hi')"));
assert!(looks_like_expression("len([1,2,3])"));
assert!(looks_like_expression("2 ** 10"));
assert!(looks_like_expression("x == 5"));
assert!(looks_like_expression("x <= 5"));
}
#[test]
fn keyword_arg_call_is_expression_not_assignment() {
assert!(!is_top_level_assignment("f(a=1)"));
assert!(looks_like_expression("sorted([3,1], key=abs)"));
}
#[test]
fn augmented_assignment_is_statement() {
assert!(is_top_level_assignment("x += 1"));
assert!(!looks_like_expression("x += 1"));
}
#[test]
fn comparison_is_not_assignment() {
assert!(!is_top_level_assignment("a == b"));
assert!(!is_top_level_assignment("a != b"));
assert!(!is_top_level_assignment("a >= b"));
}
#[test]
fn prior_session_lines_precede_the_current_line() {
let session = vec!["x = 10".to_string()];
let p = build_program(&session, "x * 2", true);
let x_at = p.find("x = 10").expect("session line present");
let expr_at = p.find("x * 2").expect("current line present");
assert!(x_at < expr_at, "session replays before the line: {p}");
}
#[cfg(feature = "wasm")]
#[test]
fn clean_py_err_keeps_the_exception_line() {
let raw = "Traceback (most recent call last):\n File \"<stdin>\"\nNameError: name 'z' is not defined";
assert_eq!(clean_py_err(raw), "NameError: name 'z' is not defined");
}
}