mod ansi_formatter;
mod completer;
mod config;
mod highlighter;
use ansi_formatter::ansi_format;
use colored::control::SHOULD_COLORIZE;
use completer::NumbatCompleter;
use config::{
ColorMode, Config, EditMode, ExchangeRateFetchingPolicy, IntroBanner, PrettyPrintMode,
};
use highlighter::NumbatHighlighter;
use itertools::Itertools;
use numbat::command::{CommandControlFlow, CommandRunner};
use numbat::diagnostic::{ErrorDiagnostic, ResolverDiagnostic};
use numbat::module_importer::{BuiltinModuleImporter, ChainedImporter, FileSystemImporter};
use numbat::pretty_print::PrettyPrint;
use numbat::resolver::CodeSource;
use numbat::session_history::{ParseEvaluationResult, SessionHistory};
use numbat::{Context, NumbatError};
use numbat::{InterpreterSettings, NameResolutionError};
use numbat::{RuntimeErrorKind, markup as m};
use anyhow::{Context as AnyhowContext, Result, bail};
use clap::Parser;
use rustyline::config::Configurer;
use rustyline::{
Completer, Editor, Helper, Hinter, Validator, error::ReadlineError, history::DefaultHistory,
};
use rustyline::{EventHandler, Highlighter, KeyCode, KeyEvent, Modifiers};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::{env, fs, thread};
#[derive(Debug, PartialEq, Eq)]
pub enum ExitStatus {
Success,
Error,
}
type ControlFlow = std::ops::ControlFlow<ExitStatus>;
#[derive(Parser, Debug)]
#[command(
version,
about,
name("numbat"),
max_term_width = 90,
trailing_var_arg = true
)]
struct Args {
file: Option<PathBuf>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
script_args: Vec<String>,
#[arg(
short,
long,
value_name = "CODE",
action = clap::ArgAction::Append
)]
expression: Option<Vec<String>>,
#[arg(short, long)]
inspect_interactively: bool,
#[arg(long, hide_short_help = true)]
no_config: bool,
#[arg(short = 'N', long, hide_short_help = true)]
no_prelude: bool,
#[arg(long, hide_short_help = true)]
no_init: bool,
#[arg(long, value_name = "WHEN")]
pretty_print: Option<PrettyPrintMode>,
#[arg(long, value_name = "WHEN")]
color: Option<ColorMode>,
#[arg(long, value_name = "MODE")]
intro_banner: Option<IntroBanner>,
#[arg(long, hide_short_help = true)]
generate_config: bool,
#[arg(long, short, hide = true)]
debug: bool,
}
struct ParseEvaluationOutcome {
control_flow: ControlFlow,
result: ParseEvaluationResult,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ExecutionMode {
Normal,
Interactive,
}
impl ExecutionMode {
fn exit_status_in_case_of_error(self) -> ControlFlow {
if matches!(self, ExecutionMode::Normal) {
ControlFlow::Break(ExitStatus::Error)
} else {
ControlFlow::Continue(())
}
}
}
#[derive(Completer, Helper, Hinter, Validator, Highlighter)]
struct NumbatHelper {
#[rustyline(Completer)]
completer: NumbatCompleter,
#[rustyline(Highlighter)]
highlighter: NumbatHighlighter,
}
struct Cli {
config: Config,
context: Arc<Mutex<Context>>,
file: Option<PathBuf>,
expression: Option<Vec<String>>,
}
impl Cli {
fn make_fresh_context() -> Context {
let mut fs_importer = FileSystemImporter::default();
for path in Self::get_modules_paths() {
fs_importer.add_path(path);
}
let importer = ChainedImporter::new(
Box::new(fs_importer),
Box::<BuiltinModuleImporter>::default(),
);
let mut context = Context::new(importer);
context.set_terminal_width(
terminal_size::terminal_size().map(|(terminal_size::Width(w), _)| w as usize),
);
context
}
fn initialize_context(&mut self) -> Result<()> {
if self.config.load_prelude {
let result = self.parse_and_evaluate(
"use prelude",
CodeSource::Internal,
ExecutionMode::Normal,
PrettyPrintMode::Never,
);
if result.control_flow.is_break() {
bail!("Interpreter error in Prelude code")
}
}
if self.config.load_user_init {
let user_init_path = Self::get_config_path().join("init.nbt");
if let Ok(user_init_code) = fs::read_to_string(&user_init_path) {
let result = self.parse_and_evaluate(
&user_init_code,
CodeSource::File(user_init_path),
ExecutionMode::Normal,
PrettyPrintMode::Never,
);
if result.control_flow.is_break() {
bail!("Interpreter error in user initialization code")
}
}
}
if self.config.load_prelude
&& self.config.exchange_rates.fetching_policy != ExchangeRateFetchingPolicy::Never
{
self.context
.lock()
.unwrap()
.load_currency_module_on_demand(true);
}
Ok(())
}
fn new(args: Args) -> Result<Self> {
let user_config_path = Self::get_config_path().join("config.toml");
let mut config = if args.no_config {
Config::default()
} else if let Ok(contents) = fs::read_to_string(&user_config_path) {
toml::from_str(&contents).context(format!(
"Error while loading {}",
user_config_path.to_string_lossy()
))?
} else {
Config::default()
};
config.load_prelude &= !args.no_prelude;
config.load_user_init &= !(args.no_prelude || args.no_init);
config.intro_banner = args.intro_banner.unwrap_or(config.intro_banner);
config.pretty_print = args.pretty_print.unwrap_or(config.pretty_print);
config.color = args.color.unwrap_or(config.color);
config.enter_repl =
(args.file.is_none() && args.expression.is_none()) || args.inspect_interactively;
let mut context = Self::make_fresh_context();
context.set_debug(args.debug);
Ok(Self {
context: Arc::new(Mutex::new(context)),
config,
file: args.file,
expression: args.expression,
})
}
fn run(&mut self) -> Result<()> {
#[cfg(windows)]
colored::control::set_virtual_terminal(true).unwrap();
match self.config.color {
ColorMode::Never => SHOULD_COLORIZE.set_override(false),
ColorMode::Always => SHOULD_COLORIZE.set_override(true),
ColorMode::Auto => (), }
self.initialize_context()?;
let mut code_and_source = Vec::new();
if let Some(ref path) = self.file {
code_and_source.push((
(fs::read_to_string(path).context(format!(
"Could not load source file '{}'",
path.to_string_lossy()
))?),
CodeSource::File(path.clone()),
));
};
if let Some(expressions) = &self.expression {
code_and_source.push((expressions.iter().join("\n"), CodeSource::Text));
}
let mut run_result = Ok(());
if !code_and_source.is_empty() {
for (code, code_source) in code_and_source {
let result = self.parse_and_evaluate(
&code,
code_source,
ExecutionMode::Normal,
self.config.pretty_print,
);
let result_status = match result.control_flow {
std::ops::ControlFlow::Continue(()) => Ok(()),
std::ops::ControlFlow::Break(_) => {
bail!("Interpreter stopped")
}
};
run_result = run_result.and(result_status);
}
}
if self.config.enter_repl {
let mut currency_fetch_thread = if self.config.load_prelude
&& self.config.exchange_rates.fetching_policy
== ExchangeRateFetchingPolicy::OnStartup
{
Some(thread::spawn(move || {
numbat::Context::prefetch_exchange_rates();
}))
} else {
None
};
let repl_result = self.repl();
if let Some(thread) = currency_fetch_thread.take() {
let _ = thread.join();
}
run_result = run_result.and(repl_result);
}
run_result
}
fn repl(&mut self) -> Result<()> {
let interactive = std::io::stdin().is_terminal();
let history_path = self.get_history_path()?;
let mut rl = Editor::<NumbatHelper, DefaultHistory>::new()?;
rl.set_edit_mode(match self.config.edit_mode {
EditMode::Emacs => rustyline::EditMode::Emacs,
EditMode::Vi => rustyline::EditMode::Vi,
});
rl.set_max_history_size(1000)
.context("Error while configuring history size")?;
rl.set_completion_type(rustyline::CompletionType::List);
rl.set_helper(Some(NumbatHelper {
completer: NumbatCompleter {
context: self.context.clone(),
modules: self.context.lock().unwrap().list_modules().collect(),
all_timezones: jiff::tz::db()
.available()
.map(|name| name.as_str().into())
.collect(),
},
highlighter: NumbatHighlighter {
context: self.context.clone(),
},
}));
rl.bind_sequence(
KeyEvent(KeyCode::Enter, Modifiers::ALT),
EventHandler::Simple(rustyline::Cmd::Newline),
);
rl.load_history(&history_path).ok();
if interactive {
match self.config.intro_banner {
IntroBanner::Long => {
println!();
println!(
" █▄░█ █░█ █▀▄▀█ █▄▄ ▄▀█ ▀█▀ Numbat {}",
env!("CARGO_PKG_VERSION")
);
println!(
" █░▀█ █▄█ █░▀░█ █▄█ █▀█ ░█░ {}",
env!("CARGO_PKG_HOMEPAGE")
);
println!();
}
IntroBanner::Short => {
println!("Numbat {}", env!("CARGO_PKG_VERSION"));
}
IntroBanner::Off => {}
}
}
let result = self.repl_loop(&mut rl, interactive, &history_path);
if interactive {
rl.save_history(&history_path).context(format!(
"Error while saving history to '{}'",
history_path.to_string_lossy()
))?;
}
result
}
fn repl_loop(
&mut self,
rl: &mut Editor<NumbatHelper, DefaultHistory>,
interactive: bool,
history_path: &Path,
) -> Result<()> {
let mut cmd_runner = CommandRunner::<Editor<NumbatHelper, DefaultHistory>>::new()
.print_with(|m| println!("{}", ansi_format(m, true)))
.enable_clear(|rl| match rl.clear_screen() {
Ok(_) => CommandControlFlow::Continue,
Err(_) => CommandControlFlow::Return,
})
.enable_save(SessionHistory::default())
.enable_reset()
.enable_quit();
loop {
let readline = rl.readline(&self.config.prompt);
match readline {
Ok(line) => {
if line.trim().is_empty() {
continue;
}
rl.add_history_entry(&line)?;
let mut ctx = self.context.lock().unwrap();
if interactive && rl.append_history(history_path).is_err() {
ctx.print_diagnostic(
ResolverDiagnostic {
resolver: ctx.resolver(),
error: &ctx.runtime_error(RuntimeErrorKind::HistoryWrite(
history_path.to_owned(),
)),
},
colored::control::SHOULD_COLORIZE.should_colorize(),
);
}
match cmd_runner.try_run_command(&line, &mut ctx, rl) {
Ok(cf) => match cf {
CommandControlFlow::Continue => continue,
CommandControlFlow::Return => return Ok(()),
CommandControlFlow::Reset => {
*ctx = Self::make_fresh_context();
drop(ctx);
let _ = self.initialize_context();
continue;
}
CommandControlFlow::NotACommand => {}
},
Err(err) => {
ctx.print_diagnostic(
ResolverDiagnostic {
resolver: ctx.resolver(),
error: &*err,
},
colored::control::SHOULD_COLORIZE.should_colorize(),
);
continue;
}
}
drop(ctx);
let ParseEvaluationOutcome {
control_flow,
result,
} = self.parse_and_evaluate(
&line,
CodeSource::Text,
if interactive {
ExecutionMode::Interactive
} else {
ExecutionMode::Normal
},
self.config.pretty_print,
);
match control_flow {
std::ops::ControlFlow::Continue(()) => {}
std::ops::ControlFlow::Break(ExitStatus::Success) => {
return Ok(());
}
std::ops::ControlFlow::Break(ExitStatus::Error) => {
bail!("Interpreter stopped due to error")
}
}
cmd_runner.push_to_history(&line, result);
}
Err(ReadlineError::Interrupted) => {}
Err(ReadlineError::Eof) => {
return Ok(());
}
Err(err) => {
bail!(err);
}
}
}
}
#[must_use]
fn parse_and_evaluate(
&mut self,
input: &str,
code_source: CodeSource,
execution_mode: ExecutionMode,
pretty_print_mode: PrettyPrintMode,
) -> ParseEvaluationOutcome {
let to_be_printed: Arc<Mutex<Vec<m::Markup>>> = Arc::new(Mutex::new(vec![]));
let to_be_printed_c = to_be_printed.clone();
let mut settings = InterpreterSettings {
print_fn: Box::new(move |s: &m::Markup| {
to_be_printed_c.lock().unwrap().push(s.clone());
}),
};
let interpretation_result =
self.context
.lock()
.unwrap()
.interpret_with_settings(&mut settings, input, code_source);
let interactive = execution_mode == ExecutionMode::Interactive;
let pretty_print = match pretty_print_mode {
PrettyPrintMode::Always => true,
PrettyPrintMode::Never => false,
PrettyPrintMode::Auto => interactive,
};
let parse_eval_result = match &interpretation_result {
Ok(_) => Ok(()),
Err(_) => Err(()),
};
let control_flow = match interpretation_result.map_err(|b| *b) {
Ok((statements, interpreter_result)) => {
if interactive || pretty_print {
println!();
}
if pretty_print {
for statement in &statements {
let repr = ansi_format(&statement.pretty_print(), true);
println!("{repr}");
println!();
}
}
let to_be_printed = to_be_printed.lock().unwrap();
for s in to_be_printed.iter() {
println!("{}", ansi_format(s, interactive));
}
if interactive && !to_be_printed.is_empty() {
println!();
}
let ctx = self.context.lock().unwrap();
let registry = ctx.dimension_registry();
let format_options = self.config.formatting.to_format_options();
let result_markup = interpreter_result.to_markup(
statements.last(),
registry,
interactive || pretty_print,
interactive || pretty_print,
&format_options,
);
print!("{}", ansi_format(&result_markup, false));
if (interactive || pretty_print) && interpreter_result.is_value() {
println!();
}
ControlFlow::Continue(())
}
Err(NumbatError::ResolverError(e)) => {
self.print_diagnostic(e);
execution_mode.exit_status_in_case_of_error()
}
Err(NumbatError::NameResolutionError(
e @ (NameResolutionError::IdentifierClash { .. }
| NameResolutionError::ReservedIdentifier(_)),
)) => {
self.print_diagnostic(e);
execution_mode.exit_status_in_case_of_error()
}
Err(NumbatError::TypeCheckError(e)) => {
self.print_diagnostic(e);
execution_mode.exit_status_in_case_of_error()
}
Err(NumbatError::RuntimeError(e)) => {
let ctx = self.context.lock().unwrap();
ctx.print_diagnostic(
ResolverDiagnostic {
resolver: ctx.resolver(),
error: &e,
},
colored::control::SHOULD_COLORIZE.should_colorize(),
);
execution_mode.exit_status_in_case_of_error()
}
};
ParseEvaluationOutcome {
control_flow,
result: parse_eval_result,
}
}
fn print_diagnostic(&mut self, error: impl ErrorDiagnostic) {
self.context
.lock()
.unwrap()
.print_diagnostic(error, colored::control::SHOULD_COLORIZE.should_colorize())
}
fn get_config_path() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("numbat")
}
fn get_modules_paths() -> Vec<PathBuf> {
let mut paths = vec![];
if let Some(modules_path) = std::env::var_os("NUMBAT_MODULES_PATH") {
for path in modules_path.to_string_lossy().split(':') {
paths.push(path.into());
}
}
paths.push(Self::get_config_path().join("modules"));
if let Some(system_module_path) = option_env!("NUMBAT_SYSTEM_MODULE_PATH") {
if !system_module_path.is_empty() {
paths.push(system_module_path.into());
}
} else if cfg!(unix) {
paths.push("/usr/share/numbat/modules".into());
} else {
paths.push("C:\\Program Files\\numbat\\modules".into());
}
paths
}
fn get_history_path(&self) -> Result<PathBuf> {
if let Ok(history) = env::var("NUMBAT_HISTORY") {
let history_path = PathBuf::from(history);
if let Some(parent) = history_path.parent() {
fs::create_dir_all(parent).ok();
}
return Ok(history_path);
}
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("numbat");
fs::create_dir_all(&data_dir).ok();
Ok(data_dir.join("history"))
}
}
fn generate_config() -> Result<()> {
let config_folder_path = Cli::get_config_path();
let config_file_path = config_folder_path.join("config.toml");
if config_file_path.exists() {
bail!(
"The file '{}' exists already.",
config_file_path.to_string_lossy()
);
}
std::fs::create_dir_all(&config_folder_path).context(format!(
"Error while creating folder '{}'",
config_folder_path.to_string_lossy()
))?;
let config = Config::default();
let content = toml::to_string(&config).context("Error while creating TOML from config")?;
std::fs::write(&config_file_path, content)?;
println!(
"A default configuration has been written to '{}'.",
config_file_path.to_string_lossy()
);
println!(
"Open the file in a text editor. Modify whatever you want to change and remove the other fields"
);
Ok(())
}
fn main() {
let args = Args::parse();
if args.generate_config {
if let Err(e) = generate_config() {
eprintln!("{e:#}");
std::process::exit(1);
}
std::process::exit(0);
}
if let Err(e) = Cli::new(args).and_then(|mut cli| cli.run()) {
let mut stdout = termcolor::StandardStream::stderr(termcolor::ColorChoice::Never);
writeln!(stdout, "{e:#}").unwrap();
std::process::exit(1);
}
}