use std::sync::LazyLock;
use regex::{Captures, Regex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Statement {
SaveAns(String),
Assign { name: String, expr: String },
Expression(String),
}
pub const COMMENT_CHAR: char = '#';
pub fn strip_comment(input: &str) -> &str {
match input.split_once(COMMENT_CHAR) {
Some((code, _comment)) => code,
None => input,
}
}
const INPUT_PREFIXES: &[(char, i32)] = &[
('p', -12),
('n', -9),
('µ', -6),
('u', -6),
('m', -3),
('k', 3),
('M', 6),
('G', 9),
('T', 12),
];
pub fn is_si_prefix(c: char) -> bool {
INPUT_PREFIXES.iter().any(|(prefix, _)| *prefix == c)
}
static SI_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\b(\d+(?:\.\d+)?)([pnµumkMGT])\b")
.expect("the SI-prefix pattern is a valid, fixed regex")
});
pub fn classify(input: &str) -> Statement {
let trimmed = input.trim();
if let Some(name) = trimmed.strip_prefix('=') {
return Statement::SaveAns(name.trim().to_string());
}
match trimmed.split_once('=') {
Some((name, expr)) => Statement::Assign {
name: name.trim().to_string(),
expr: expr.trim().to_string(),
},
None => Statement::Expression(trimmed.to_string()),
}
}
pub fn preprocess(expr: &str, decimal_separator: char) -> String {
let replaced = expr.replace("**", "^");
let normalized = normalize_separators(&replaced, decimal_separator);
expand_si_prefixes(&normalized)
}
fn normalize_separators(expr: &str, decimal_separator: char) -> String {
let thousands = if decimal_separator == '.' { ',' } else { '.' };
let mut out = String::with_capacity(expr.len());
for ch in expr.chars() {
match ch {
' ' | '_' => continue,
c if c == thousands => continue,
c if c == decimal_separator => out.push('.'),
';' => out.push(','),
other => out.push(other),
}
}
out
}
fn expand_si_prefixes(expr: &str) -> String {
SI_PATTERN
.replace_all(expr, |caps: &Captures| {
let number = &caps[1];
let prefix = caps[2].chars().next().expect("group 2 is one char");
let exponent = INPUT_PREFIXES
.iter()
.find(|(c, _)| *c == prefix)
.map(|(_, e)| *e)
.expect("the regex only matches known prefixes");
format!("{number}*1e{exponent}")
})
.into_owned()
}
pub fn prepend_ans(expr: &str, ans: Option<f64>) -> String {
let starts_with_operator = expr
.chars()
.next()
.is_some_and(|c| matches!(c, '+' | '-' | '*' | '/' | '^'));
if starts_with_operator && ans.is_some() {
return format!("ans{expr}");
}
expr.to_string()
}
pub fn substitute_ans(expr: &str, ans: Option<f64>) -> String {
match ans {
Some(value) => substitute_identifier(expr, "ans", value),
None => expr.to_string(),
}
}
pub fn substitute_identifier(expr: &str, name: &str, value: f64) -> String {
let pattern = format!(r"\b{}\b", regex::escape(name));
let Ok(re) = Regex::new(&pattern) else {
return expr.to_string();
};
re.replace_all(expr, format!("({value})")).into_owned()
}
pub fn substitute_identifier_with(
expr: &str,
name: &str,
replacement: &str,
) -> String {
let pattern = format!(r"\b{}\b", regex::escape(name));
let Ok(re) = Regex::new(&pattern) else {
return expr.to_string();
};
re.replace_all(expr, |_: &Captures| replacement.to_string())
.into_owned()
}
pub fn references(expr: &str, name: &str) -> bool {
let pattern = format!(r"\b{}\b", regex::escape(name));
Regex::new(&pattern)
.map(|re| re.is_match(expr))
.unwrap_or(false)
}
pub fn is_valid_var_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn looks_incomplete(input: &str) -> bool {
let trimmed = input.trim_end();
let Some(last) = trimmed.chars().last() else {
return true;
};
if matches!(
last,
'+' | '-' | '*' | '/' | '^' | '(' | '.' | ',' | ';' | 'e' | 'E'
) {
return true;
}
let open = trimmed.matches('(').count();
let close = trimmed.matches(')').count();
open > close
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_comment_keeps_only_the_code_part() {
assert_eq!(strip_comment("2*pi*r # circumference"), "2*pi*r ");
assert_eq!(strip_comment("# just a note"), "");
assert_eq!(strip_comment("2+3"), "2+3");
}
#[test]
fn classify_detects_save_assign_and_expression() {
assert_eq!(classify("=x"), Statement::SaveAns("x".to_string()));
assert_eq!(
classify("y = 5 + 1"),
Statement::Assign {
name: "y".to_string(),
expr: "5 + 1".to_string(),
}
);
assert_eq!(classify("2+2"), Statement::Expression("2+2".to_string()));
}
#[test]
fn preprocess_replaces_power_operator() {
assert_eq!(preprocess("2**8", '.'), "2^8");
}
#[test]
fn preprocess_strips_thousands_with_dot_decimal() {
assert_eq!(preprocess("1 000 000.5", '.'), "1000000.5");
assert_eq!(preprocess("1_000_000.5", '.'), "1000000.5");
assert_eq!(preprocess("1,000,000.5", '.'), "1000000.5");
}
#[test]
fn preprocess_strips_thousands_with_comma_decimal() {
assert_eq!(preprocess("1.000.000,5", ','), "1000000.5");
assert_eq!(preprocess("1 000 000,5", ','), "1000000.5");
assert_eq!(preprocess("1_000_000,5", ','), "1000000.5");
}
#[test]
fn preprocess_maps_semicolon_to_argument_comma() {
assert_eq!(preprocess("max(1;2)", '.'), "max(1,2)");
}
#[test]
fn preprocess_expands_si_prefixes() {
assert_eq!(preprocess("3.3k", '.'), "3.3*1e3");
assert_eq!(preprocess("100u", '.'), "100*1e-6");
assert_eq!(preprocess("2M+5", '.'), "2*1e6+5");
}
#[test]
fn si_expansion_leaves_identifiers_alone() {
assert_eq!(expand_si_prefixes("pi"), "pi");
assert_eq!(expand_si_prefixes("5km"), "5km");
assert_eq!(expand_si_prefixes("a5k"), "a5k");
}
#[test]
fn prepend_ans_only_with_a_leading_operator_and_a_value() {
assert_eq!(prepend_ans("+5", Some(3.0)), "ans+5");
assert_eq!(prepend_ans("+5", None), "+5");
assert_eq!(prepend_ans("5+5", Some(3.0)), "5+5");
}
#[test]
fn substitute_wraps_values_in_parentheses() {
assert_eq!(substitute_ans("ans+1", Some(-5.0)), "(-5)+1");
assert_eq!(substitute_identifier("2*x", "x", 4.0), "2*(4)");
assert_eq!(substitute_identifier("xy+x", "x", 1.0), "xy+(1)");
}
#[test]
fn valid_variable_names() {
assert!(is_valid_var_name("x"));
assert!(is_valid_var_name("alpha_1"));
assert!(is_valid_var_name("_tmp"));
assert!(!is_valid_var_name(""));
assert!(!is_valid_var_name("1x"));
assert!(!is_valid_var_name("a b"));
}
#[test]
fn incomplete_input_suppresses_warnings_while_complete_input_does_not() {
assert!(looks_incomplete(""));
assert!(looks_incomplete("2+"));
assert!(looks_incomplete("2 * "));
assert!(looks_incomplete("sin("));
assert!(looks_incomplete("(2+3"));
assert!(looks_incomplete("1e"));
assert!(!looks_incomplete("2+3"));
assert!(!looks_incomplete("2+3)"));
assert!(!looks_incomplete("sin(90)"));
}
}