use std::cell::RefCell;
use std::process::ExitCode;
use std::rc::Rc;
use bop::{BopHost, BopLimits, ReplSession, Value};
use bop_sys::StdHost;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{Context, Editor, Helper};
const KEYWORDS: &[&str] = &[
"let", "const", "fn", "if", "else", "while", "repeat", "for", "in",
"return", "break", "continue", "match", "struct", "enum", "use",
"as", "true", "false", "none", "try",
];
const META_HELP: &[(&str, &str)] = &[
(":help", "show this help"),
(":vars", "list bindings in the current session"),
(":reset", "drop all session state and start fresh"),
(":quit | :q | :exit", "exit the REPL"),
];
struct BopHelper {
typed_names: RefCell<std::collections::BTreeSet<String>>,
session_names: Rc<RefCell<Vec<String>>>,
}
impl BopHelper {
fn new(session_names: Rc<RefCell<Vec<String>>>) -> Self {
Self {
typed_names: RefCell::new(std::collections::BTreeSet::new()),
session_names,
}
}
fn absorb_names(&self, source: &str) {
let mut out = self.typed_names.borrow_mut();
let mut chars = source.char_indices().peekable();
while let Some((start, ch)) = chars.next() {
if !ch.is_alphabetic() && ch != '_' {
continue;
}
let mut end = start + ch.len_utf8();
while let Some(&(i, c)) = chars.peek() {
if c.is_alphanumeric() || c == '_' {
end = i + c.len_utf8();
chars.next();
} else {
break;
}
}
let word = &source[start..end];
if word.len() >= 2 {
out.insert(word.to_string());
}
}
}
}
impl Helper for BopHelper {}
impl Highlighter for BopHelper {}
impl Hinter for BopHelper {
type Hint = String;
}
impl Completer for BopHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let prefix_start = line[..pos]
.char_indices()
.rev()
.take_while(|(_, c)| c.is_alphanumeric() || *c == '_')
.last()
.map(|(i, _)| i)
.unwrap_or(pos);
let prefix = &line[prefix_start..pos];
if prefix.is_empty() {
return Ok((pos, Vec::new()));
}
let typed = self.typed_names.borrow();
let session = self.session_names.borrow();
let mut candidates: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for &kw in KEYWORDS {
if kw.starts_with(prefix) {
candidates.insert(kw.to_string());
}
}
for &b in bop::suggest::CORE_CALLABLE_BUILTINS {
if b.starts_with(prefix) {
candidates.insert(b.to_string());
}
}
for name in session.iter() {
if name.starts_with(prefix) && name.as_str() != prefix {
candidates.insert(name.clone());
}
}
for name in typed.iter() {
if name.starts_with(prefix) && name.as_str() != prefix {
candidates.insert(name.clone());
}
}
let pairs = candidates
.into_iter()
.map(|s| Pair {
display: s.clone(),
replacement: s,
})
.collect();
Ok((prefix_start, pairs))
}
}
fn is_incomplete_input(input: &str) -> bool {
if input.trim().is_empty() {
return false;
}
if input.trim_start().starts_with(':') {
return false;
}
match bop::parse(input) {
Ok(_) => false,
Err(e) => {
let msg = e.message.to_lowercase();
msg.contains("end of code")
|| msg.contains("end of input")
|| msg.contains("unexpected eof")
}
}
}
impl Validator for BopHelper {
fn validate(
&self,
ctx: &mut ValidationContext,
) -> rustyline::Result<ValidationResult> {
if is_incomplete_input(ctx.input()) {
Ok(ValidationResult::Incomplete)
} else {
Ok(ValidationResult::Valid(None))
}
}
fn validate_while_typing(&self) -> bool {
false
}
}
#[derive(Debug)]
enum StepOutcome {
Ok,
Value(Value),
Err(bop::BopError),
Quit,
Note(String),
}
fn step<H: BopHost>(
session: &mut ReplSession,
host: &mut H,
input: &str,
) -> StepOutcome {
let trimmed = input.trim();
if trimmed.is_empty() {
return StepOutcome::Ok;
}
if trimmed.starts_with(':') {
return handle_meta(session, trimmed);
}
match session.eval(input, host, &BopLimits::standard()) {
Ok(Some(Value::None)) => StepOutcome::Ok,
Ok(Some(v)) => StepOutcome::Value(v),
Ok(None) => StepOutcome::Ok,
Err(e) => StepOutcome::Err(e),
}
}
fn handle_meta(session: &mut ReplSession, cmd: &str) -> StepOutcome {
match cmd {
":help" | ":h" | ":?" => {
let mut out = String::from("REPL commands:\n");
for (name, desc) in META_HELP {
out.push_str(&format!(" {:<22} {}\n", name, desc));
}
StepOutcome::Note(out)
}
":vars" => {
let names = session.binding_names();
if names.is_empty() {
StepOutcome::Note(String::from("(no bindings yet)\n"))
} else {
StepOutcome::Note(format!("{}\n", names.join("\n")))
}
}
":reset" | ":clear" => {
*session = ReplSession::new();
StepOutcome::Note(String::from("session cleared.\n"))
}
":quit" | ":q" | ":exit" => StepOutcome::Quit,
other => StepOutcome::Note(format!(
"unknown command `{}` — try `:help`\n",
other
)),
}
}
fn render_outcome<W: std::io::Write, E: std::io::Write>(
outcome: &StepOutcome,
source: &str,
out: &mut W,
err: &mut E,
) {
match outcome {
StepOutcome::Ok | StepOutcome::Quit => {}
StepOutcome::Value(v) => {
let _ = writeln!(out, "{}", v);
}
StepOutcome::Err(e) => {
let _ = write!(err, "{}", e.render(source));
}
StepOutcome::Note(s) => {
let _ = write!(out, "{}", s);
}
}
}
fn history_path() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|h| {
let mut p = std::path::PathBuf::from(h);
p.push(".bop_history");
p
})
}
pub fn run() -> ExitCode {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return run_non_tty();
}
let session_names = Rc::new(RefCell::new(Vec::<String>::new()));
let helper = BopHelper::new(Rc::clone(&session_names));
let mut rl = match Editor::<BopHelper, rustyline::history::FileHistory>::new() {
Ok(r) => r,
Err(e) => {
eprintln!("error: couldn't initialise line editor: {}", e);
return ExitCode::from(1);
}
};
rl.set_helper(Some(helper));
let hist = history_path();
if let Some(ref p) = hist {
let _ = rl.load_history(p);
}
let mut host = StdHost::new();
let mut session = ReplSession::new();
let stdout = std::io::stdout();
let stderr = std::io::stderr();
loop {
match rl.readline("> ") {
Ok(line) => {
if line.trim().is_empty() {
continue;
}
let _ = rl.add_history_entry(line.as_str());
if let Some(h) = rl.helper() {
h.absorb_names(&line);
}
let outcome = step(&mut session, &mut host, &line);
let mut out = stdout.lock();
let mut err = stderr.lock();
render_outcome(&outcome, &line, &mut out, &mut err);
*session_names.borrow_mut() = session.binding_names();
if matches!(outcome, StepOutcome::Quit) {
break;
}
}
Err(ReadlineError::Interrupted) => continue, Err(ReadlineError::Eof) => break, Err(e) => {
eprintln!("readline error: {}", e);
break;
}
}
}
if let Some(ref p) = hist {
let _ = rl.save_history(p);
}
ExitCode::SUCCESS
}
fn run_non_tty() -> ExitCode {
use std::io::BufRead;
let mut host = StdHost::new();
let mut session = ReplSession::new();
let stdout = std::io::stdout();
let stderr = std::io::stderr();
let mut any_error = false;
let stdin = std::io::stdin();
let mut buffer = String::new();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
eprintln!("error reading stdin: {}", e);
return ExitCode::from(1);
}
};
if !buffer.is_empty() {
buffer.push('\n');
}
buffer.push_str(&line);
if is_incomplete_input(&buffer) {
continue;
}
let submission = std::mem::take(&mut buffer);
let outcome = step(&mut session, &mut host, &submission);
let mut out = stdout.lock();
let mut err = stderr.lock();
render_outcome(&outcome, &submission, &mut out, &mut err);
match outcome {
StepOutcome::Err(_) => any_error = true,
StepOutcome::Quit => break,
_ => {}
}
}
if !buffer.trim().is_empty() {
let outcome = step(&mut session, &mut host, &buffer);
let mut out = stdout.lock();
let mut err = stderr.lock();
render_outcome(&outcome, &buffer, &mut out, &mut err);
if matches!(outcome, StepOutcome::Err(_)) {
any_error = true;
}
}
if any_error {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct TestHost {
prints: RefCell<Vec<String>>,
}
impl TestHost {
fn new() -> Self {
Self {
prints: RefCell::new(Vec::new()),
}
}
}
impl BopHost for TestHost {
fn call(
&mut self,
_: &str,
_: &[Value],
_: u32,
) -> Option<Result<Value, bop::BopError>> {
None
}
fn on_print(&mut self, message: &str) {
self.prints.borrow_mut().push(message.to_string());
}
}
fn drive(inputs: &[&str]) -> (Vec<String>, Vec<String>, Vec<StepOutcome>) {
let mut host = TestHost::new();
let mut session = ReplSession::new();
let mut outcomes = Vec::new();
let mut stdout_lines = Vec::new();
for input in inputs {
let outcome = step(&mut session, &mut host, input);
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
render_outcome(&outcome, input, &mut out, &mut err);
let text = String::from_utf8(out).unwrap();
if !text.is_empty() {
stdout_lines.push(text);
}
outcomes.push(outcome);
}
let prints = host.prints.borrow().clone();
(prints, stdout_lines, outcomes)
}
#[test]
fn complete_statement_is_valid() {
assert!(!is_incomplete_input("let x = 1"));
}
#[test]
fn unclosed_fn_body_is_incomplete() {
assert!(is_incomplete_input(
"fn double(x) {\n return x + x"
));
}
#[test]
fn unclosed_if_body_is_incomplete() {
assert!(is_incomplete_input("if true {"));
}
#[test]
fn trailing_plus_is_incomplete() {
assert!(is_incomplete_input("let x = 1 +"));
}
#[test]
fn real_parse_error_is_not_incomplete() {
assert!(!is_incomplete_input("let 1x = 2"));
}
#[test]
fn empty_input_is_not_incomplete() {
assert!(!is_incomplete_input(""));
assert!(!is_incomplete_input(" \n\t "));
}
#[test]
fn meta_command_is_always_complete() {
assert!(!is_incomplete_input(":help"));
assert!(!is_incomplete_input(":vars"));
}
#[test]
fn completer_prefix_matches_keyword() {
let session_names = Rc::new(RefCell::new(Vec::new()));
let h = BopHelper::new(session_names);
let (_start, cands) = h
.complete(
"le",
2,
&rustyline::Context::new(&rustyline::history::FileHistory::new()),
)
.unwrap();
let disp: Vec<String> = cands.iter().map(|p| p.display.clone()).collect();
assert!(disp.contains(&"let".to_string()));
}
#[test]
fn completer_uses_session_binding_names() {
let session_names = Rc::new(RefCell::new(vec!["my_binding".to_string()]));
let h = BopHelper::new(Rc::clone(&session_names));
let (_start, cands) = h
.complete(
"my",
2,
&rustyline::Context::new(&rustyline::history::FileHistory::new()),
)
.unwrap();
let disp: Vec<String> = cands.iter().map(|p| p.display.clone()).collect();
assert!(
disp.contains(&"my_binding".to_string()),
"expected session binding to surface in completions, got: {:?}",
disp,
);
}
#[test]
fn let_survives_between_steps() {
let (prints, _, _) = drive(&["let x = 5", "print(x)"]);
assert_eq!(prints, vec!["5"]);
}
#[test]
fn fn_survives_between_steps() {
let (prints, _, _) =
drive(&["fn double(x) { return x + x }", "print(double(21))"]);
assert_eq!(prints, vec!["42"]);
}
#[test]
fn struct_and_method_survive_between_steps() {
let (prints, _, _) = drive(&[
"struct Point { x, y }\nfn Point.sum(self) { return self.x + self.y }",
"print(Point { x: 3, y: 4 }.sum())",
]);
assert_eq!(prints, vec!["7"]);
}
#[test]
fn bare_expression_echoes_its_value() {
let (_prints, stdout, _) = drive(&["let x = 5", "x + 1"]);
assert_eq!(stdout.len(), 1);
assert_eq!(stdout[0].trim_end(), "6");
}
#[test]
fn bare_expression_does_not_echo_none_for_statements() {
let (_prints, stdout, _) = drive(&["let x = 5"]);
assert!(stdout.is_empty(), "got unexpected stdout: {:?}", stdout);
}
#[test]
fn print_call_does_not_echo_trailing_none() {
let (prints, stdout, _) = drive(&["print(42)"]);
assert_eq!(prints, vec!["42"]);
assert!(
stdout.is_empty(),
"expected no echo after print(), got: {:?}",
stdout
);
}
#[test]
fn explicit_none_literal_is_suppressed_too() {
let (_prints, stdout, _) = drive(&["none"]);
assert!(stdout.is_empty());
}
#[test]
fn error_in_step_is_captured_without_aborting() {
let (prints, _, outcomes) = drive(&[
"let good = 1",
"let bad = undefined", "print(good)", ]);
assert!(matches!(outcomes[1], StepOutcome::Err(_)));
assert_eq!(prints, vec!["1"]);
}
#[test]
fn help_meta_command_prints_known_commands() {
let (_prints, stdout, _) = drive(&[":help"]);
assert_eq!(stdout.len(), 1);
let text = &stdout[0];
assert!(text.contains(":help"));
assert!(text.contains(":vars"));
assert!(text.contains(":reset"));
assert!(text.contains(":quit"));
}
#[test]
fn vars_lists_current_bindings() {
let (_prints, stdout, _) = drive(&[
"let alpha = 1",
"fn beta() { return 2 }",
":vars",
]);
let last = stdout.last().unwrap();
assert!(last.contains("alpha"));
assert!(last.contains("beta"));
}
#[test]
fn reset_drops_previous_bindings() {
let (prints, _, outcomes) = drive(&[
"let x = 5",
":reset",
"print(x)", ]);
assert!(matches!(outcomes[2], StepOutcome::Err(_)));
assert!(prints.is_empty());
}
#[test]
fn quit_signals_shutdown() {
let (_, _, outcomes) = drive(&[":quit"]);
assert!(matches!(outcomes[0], StepOutcome::Quit));
}
#[test]
fn unknown_meta_command_surfaces_friendly_note() {
let (_, stdout, _) = drive(&[":what"]);
let text = stdout.last().unwrap();
assert!(text.contains("unknown command"));
assert!(text.contains(":help"));
}
#[test]
fn render_writes_value_to_stdout_not_stderr() {
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
render_outcome(
&StepOutcome::Value(Value::Int(42)),
"source",
&mut out,
&mut err,
);
assert_eq!(String::from_utf8(out).unwrap().trim_end(), "42");
assert!(err.is_empty());
}
#[test]
fn render_writes_errors_to_stderr_with_source_snippet() {
let err_val = bop::BopError::runtime_at(
"boom",
1,
std::num::NonZeroU32::new(5),
);
let mut out: Vec<u8> = Vec::new();
let mut err: Vec<u8> = Vec::new();
render_outcome(
&StepOutcome::Err(err_val),
"let x = 1",
&mut out,
&mut err,
);
let err_text = String::from_utf8(err).unwrap();
assert!(err_text.contains("boom"));
assert!(err_text.contains("let x = 1"));
assert!(out.is_empty());
}
}