feat(engine): establish calcpad-engine workspace with Epic 1 modules

Cherry-picked and integrated the best code from 105 parallel epic
branches into a clean workspace structure:

- calcpad-engine/: Core Rust crate with lexer, parser, AST,
  interpreter, types, FFI (C ABI), pipeline, error handling,
  span tracking, eval context (from epic/1-5 base)
- calcpad-engine/src/number.rs: Arbitrary precision arithmetic
  via dashu crate, WASM-compatible, exact decimals (from epic/1-4)
- calcpad-engine/src/sheet_context.rs: SheetContext with dependency
  graph, dirty tracking, circular detection, cheap clone (from epic/1-8)
- calcpad-wasm/: Thin WASM wrapper crate via wasm-bindgen,
  delegates to calcpad-engine (from epic/1-6)
- Updated .gitignore for target/, node_modules/, build artifacts
- Fixed run-pipeline.sh for macOS compat and CalcPad phases

79 tests passing across workspace.
This commit is contained in:
C. Cassel
2026-03-17 07:54:17 -04:00
committed by C. Cassel
parent 922b591d68
commit 6a8fecd03e
26 changed files with 5046 additions and 877 deletions

13
calcpad-engine/Cargo.toml Normal file
View File

@@ -0,0 +1,13 @@
[package]
name = "calcpad-engine"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
chrono = "0.4"
dashu = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View File

@@ -0,0 +1,90 @@
/**
* CalcPad Engine — C FFI Header
*
* This header declares the C-compatible interface for the CalcPad calculation
* engine, built in Rust. It is designed for consumption by Swift via a
* bridging header or a module map.
*
* All functions are safe to call from any thread. Panics in Rust are caught
* and converted to error results — they never unwind into the caller.
*
* Memory ownership:
* - Strings returned by calcpad_eval_line / calcpad_eval_sheet are
* heap-allocated by Rust and MUST be freed by calling calcpad_free_result.
* - Passing NULL to calcpad_free_result is a safe no-op.
*
* JSON schema (version "1.0"):
*
* Single-line result (calcpad_eval_line):
* {
* "schema_version": "1.0",
* "result": {
* "value": { "kind": "Number", "value": 42.0 },
* "metadata": {
* "span": { "start": 0, "end": 4 },
* "result_type": "Number",
* "display": "42",
* "raw_value": 42.0
* }
* }
* }
*
* Multi-line result (calcpad_eval_sheet):
* {
* "schema_version": "1.0",
* "results": [ ... ] // array of result objects as above
* }
*/
#ifndef CALCPAD_H
#define CALCPAD_H
#ifdef __cplusplus
extern "C" {
#endif
/**
* Evaluate a single line of CalcPad input.
*
* @param input A null-terminated UTF-8 string containing the expression.
* Passing NULL returns a JSON error result.
*
* @return A heap-allocated, null-terminated JSON string containing the
* versioned result. The caller MUST free this with
* calcpad_free_result(). Returns NULL only on catastrophic
* allocation failure.
*/
char *calcpad_eval_line(const char *input);
/**
* Evaluate multiple lines of CalcPad input as a sheet.
*
* Variable assignments on earlier lines are visible to later lines
* (e.g., "x = 5" on line 1 makes x available on line 2).
*
* @param lines An array of null-terminated UTF-8 strings.
* NULL entries are treated as empty lines.
* @param count The number of elements in the lines array.
* Must be > 0.
*
* @return A heap-allocated, null-terminated JSON string containing the
* versioned results array. The caller MUST free this with
* calcpad_free_result(). Returns NULL only on catastrophic
* allocation failure.
*/
char *calcpad_eval_sheet(const char *const *lines, int count);
/**
* Free a result string previously returned by calcpad_eval_line or
* calcpad_eval_sheet.
*
* @param ptr The pointer to free. Passing NULL is safe (no-op).
* After this call the pointer is invalid.
*/
void calcpad_free_result(char *ptr);
#ifdef __cplusplus
}
#endif
#endif /* CALCPAD_H */

149
calcpad-engine/src/ast.rs Normal file
View File

@@ -0,0 +1,149 @@
use crate::span::Span;
/// A spanned AST node — every node carries its source location.
#[derive(Debug, Clone, PartialEq)]
pub struct Spanned<T> {
pub node: T,
pub span: Span,
}
impl<T> Spanned<T> {
pub fn new(node: T, span: Span) -> Self {
Self { node, span }
}
}
/// Top-level expression AST.
pub type Expr = Spanned<ExprKind>;
#[derive(Debug, Clone, PartialEq)]
pub enum ExprKind {
/// Numeric literal: `42`, `3.14`
Number(f64),
/// Number with a unit: `5kg`, `200g`
UnitNumber { value: f64, unit: String },
/// Currency value: `$20`, `€15`
CurrencyValue { amount: f64, currency: String },
/// Boolean literal
Boolean(bool),
/// Date literal (year, month, day)
DateLiteral { year: i32, month: u32, day: u32 },
/// The keyword `today`
Today,
/// Duration literal: `3 weeks`, `5 days`
Duration { value: f64, unit: DurationUnit },
/// Binary operation: `a + b`, `a * b`
BinaryOp {
op: BinOp,
left: Box<Expr>,
right: Box<Expr>,
},
/// Unary negation: `-x`
UnaryNeg(Box<Expr>),
/// Percentage applied to a value: `100 - 20%`, `$100 + 10%`
PercentOp {
op: PercentOp,
base: Box<Expr>,
percentage: f64,
},
/// Currency conversion: `$20 in EUR`
Conversion {
expr: Box<Expr>,
target_currency: String,
},
/// Date range: `March 12 to July 30`
DateRange {
from: Box<Expr>,
to: Box<Expr>,
},
/// Comparison: `5 > 3`
Comparison {
op: CmpOp,
left: Box<Expr>,
right: Box<Expr>,
},
/// Variable reference: `x`, `total`
Identifier(String),
/// Variable assignment: `x = 5`
Assignment {
name: String,
value: Box<Expr>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinOp {
Add,
Sub,
Mul,
Div,
Pow,
Mod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PercentOp {
Add,
Sub,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmpOp {
Gt,
Lt,
Gte,
Lte,
Eq,
Neq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DurationUnit {
Days,
Weeks,
Months,
Years,
Hours,
Minutes,
Seconds,
}
impl std::fmt::Display for BinOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BinOp::Add => write!(f, "+"),
BinOp::Sub => write!(f, "-"),
BinOp::Mul => write!(f, "*"),
BinOp::Div => write!(f, "/"),
BinOp::Pow => write!(f, "^"),
BinOp::Mod => write!(f, "%"),
}
}
}
impl std::fmt::Display for CmpOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CmpOp::Gt => write!(f, ">"),
CmpOp::Lt => write!(f, "<"),
CmpOp::Gte => write!(f, ">="),
CmpOp::Lte => write!(f, "<="),
CmpOp::Eq => write!(f, "=="),
CmpOp::Neq => write!(f, "!="),
}
}
}

View File

@@ -0,0 +1,77 @@
use crate::types::CalcResult;
use chrono::NaiveDate;
use std::collections::HashMap;
/// Evaluation context holding exchange rates, current date, variables, and other state
/// needed during interpretation.
pub struct EvalContext {
/// Exchange rates relative to USD. Key is currency code (e.g. "EUR"),
/// value is how many units of that currency per 1 USD.
pub exchange_rates: HashMap<String, f64>,
/// The current date for resolving `today`.
pub today: NaiveDate,
/// Named variables set via assignment expressions (e.g., `x = 5`).
pub variables: HashMap<String, CalcResult>,
}
impl EvalContext {
pub fn new() -> Self {
Self {
exchange_rates: HashMap::new(),
today: chrono::Local::now().date_naive(),
variables: HashMap::new(),
}
}
/// Create a context with a fixed date (useful for testing).
pub fn with_date(today: NaiveDate) -> Self {
Self {
exchange_rates: HashMap::new(),
today,
variables: HashMap::new(),
}
}
/// Set exchange rate: 1 USD = `rate` units of `currency`.
pub fn set_rate(&mut self, currency: &str, rate: f64) {
self.exchange_rates.insert(currency.to_string(), rate);
}
/// Store a variable result.
pub fn set_variable(&mut self, name: &str, result: CalcResult) {
self.variables.insert(name.to_string(), result);
}
/// Retrieve a variable result.
pub fn get_variable(&self, name: &str) -> Option<&CalcResult> {
self.variables.get(name)
}
/// Convert `amount` from `from_currency` to `to_currency`.
/// Returns None if either rate is missing.
pub fn convert_currency(
&self,
amount: f64,
from_currency: &str,
to_currency: &str,
) -> Option<f64> {
let from_rate = if from_currency == "USD" {
1.0
} else {
*self.exchange_rates.get(from_currency)?
};
let to_rate = if to_currency == "USD" {
1.0
} else {
*self.exchange_rates.get(to_currency)?
};
let usd_amount = amount / from_rate;
Some(usd_amount * to_rate)
}
}
impl Default for EvalContext {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,30 @@
use crate::span::Span;
use std::fmt;
/// An error produced during parsing.
#[derive(Debug, Clone, PartialEq)]
pub struct ParseError {
pub message: String,
pub span: Span,
}
impl ParseError {
pub fn new(message: impl Into<String>, span: Span) -> Self {
Self {
message: message.into(),
span,
}
}
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"parse error at {}..{}: {}",
self.span.start, self.span.end, self.message
)
}
}
impl std::error::Error for ParseError {}

165
calcpad-engine/src/ffi.rs Normal file
View File

@@ -0,0 +1,165 @@
use crate::context::EvalContext;
use crate::pipeline;
use crate::span::Span;
use crate::types::CalcResult;
use serde::{Deserialize, Serialize};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::panic;
use std::ptr;
/// Versioned FFI response wrapper for JSON serialization.
/// The schema_version field allows Swift to handle backward-compatible changes.
#[derive(Serialize, Deserialize)]
pub struct FfiResponse {
/// Schema version for forward compatibility.
pub schema_version: String,
/// The calculation result.
pub result: CalcResult,
}
/// Versioned FFI response for sheet (multi-line) evaluation.
#[derive(Serialize, Deserialize)]
pub struct FfiSheetResponse {
/// Schema version for forward compatibility.
pub schema_version: String,
/// Array of calculation results, one per line.
pub results: Vec<CalcResult>,
}
const SCHEMA_VERSION: &str = "1.0";
fn make_error_json(message: &str) -> *mut c_char {
let response = FfiResponse {
schema_version: SCHEMA_VERSION.to_string(),
result: CalcResult::error(message, Span::new(0, 0)),
};
match serde_json::to_string(&response) {
Ok(json) => match CString::new(json) {
Ok(cs) => cs.into_raw(),
Err(_) => ptr::null_mut(),
},
Err(_) => ptr::null_mut(),
}
}
/// Evaluate a single line of CalcPad input.
///
/// # Safety
/// - `input` must be a valid, null-terminated C string.
/// - The returned pointer is heap-allocated and must be freed by calling
/// `calcpad_free_result`.
///
/// Returns a JSON-serialized string with schema version, result type,
/// display value, raw value, and any error information.
#[no_mangle]
pub unsafe extern "C" fn calcpad_eval_line(input: *const c_char) -> *mut c_char {
// Catch any panics to prevent unwinding into Swift
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
if input.is_null() {
return make_error_json("null input pointer");
}
let c_str = match CStr::from_ptr(input).to_str() {
Ok(s) => s,
Err(_) => return make_error_json("invalid UTF-8 input"),
};
let mut ctx = EvalContext::new();
let calc_result = pipeline::eval_line(c_str, &mut ctx);
let response = FfiResponse {
schema_version: SCHEMA_VERSION.to_string(),
result: calc_result,
};
match serde_json::to_string(&response) {
Ok(json) => match CString::new(json) {
Ok(cs) => cs.into_raw(),
Err(_) => make_error_json("result contains null byte"),
},
Err(e) => make_error_json(&format!("serialization error: {}", e)),
}
}));
match result {
Ok(ptr) => ptr,
Err(_) => make_error_json("internal panic caught"),
}
}
/// Evaluate multiple lines of CalcPad input as a sheet.
///
/// Variable assignments on earlier lines are visible to later lines.
///
/// # Safety
/// - `lines` must be an array of `count` valid, null-terminated C strings.
/// - `count` must accurately reflect the number of elements in the array.
/// - The returned pointer is heap-allocated and must be freed by calling
/// `calcpad_free_result`.
///
/// Returns a JSON-serialized string containing an array of results.
#[no_mangle]
pub unsafe extern "C" fn calcpad_eval_sheet(
lines: *const *const c_char,
count: i32,
) -> *mut c_char {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
if lines.is_null() || count <= 0 {
return make_error_json("null or empty input");
}
let mut rust_lines: Vec<String> = Vec::with_capacity(count as usize);
for i in 0..count as usize {
let line_ptr = *lines.add(i);
if line_ptr.is_null() {
rust_lines.push(String::new());
continue;
}
match CStr::from_ptr(line_ptr).to_str() {
Ok(s) => rust_lines.push(s.to_string()),
Err(_) => rust_lines.push(String::new()),
}
}
let line_refs: Vec<&str> = rust_lines.iter().map(|s| s.as_str()).collect();
let mut ctx = EvalContext::new();
let results = pipeline::eval_sheet(&line_refs, &mut ctx);
let response = FfiSheetResponse {
schema_version: SCHEMA_VERSION.to_string(),
results,
};
match serde_json::to_string(&response) {
Ok(json) => match CString::new(json) {
Ok(cs) => cs.into_raw(),
Err(_) => make_error_json("result contains null byte"),
},
Err(e) => make_error_json(&format!("serialization error: {}", e)),
}
}));
match result {
Ok(ptr) => ptr,
Err(_) => make_error_json("internal panic caught"),
}
}
/// Free a result string previously returned by `calcpad_eval_line` or
/// `calcpad_eval_sheet`.
///
/// # Safety
/// - `ptr` must be a pointer previously returned by `calcpad_eval_line` or
/// `calcpad_eval_sheet`, or null.
/// - After calling this function, the pointer is invalid and must not be used.
/// - Passing null is safe and results in a no-op.
#[no_mangle]
pub unsafe extern "C" fn calcpad_free_result(ptr: *mut c_char) {
if ptr.is_null() {
return;
}
// Reconstruct the CString so Rust's allocator can free the memory
drop(CString::from_raw(ptr));
}

View File

@@ -0,0 +1,545 @@
use crate::ast::*;
use crate::context::EvalContext;
use crate::span::Span;
use crate::types::*;
use chrono::{Duration, NaiveDate};
/// Internal intermediate value used during evaluation.
#[derive(Debug, Clone)]
enum Value {
Number(f64),
UnitValue { value: f64, unit: String },
CurrencyValue { amount: f64, currency: String },
DateTime(NaiveDate),
TimeDelta { days: i64, description: String },
Boolean(bool),
}
/// Evaluate an AST expression within the given context.
pub fn evaluate(expr: &Expr, ctx: &mut EvalContext) -> CalcResult {
let span = expr.span;
match eval_inner(expr, ctx) {
Ok(val) => value_to_result(val, span),
Err(msg) => CalcResult::error(&msg, span),
}
}
fn eval_inner(expr: &Expr, ctx: &mut EvalContext) -> Result<Value, String> {
match &expr.node {
ExprKind::Number(n) => Ok(Value::Number(*n)),
ExprKind::Boolean(b) => Ok(Value::Boolean(*b)),
ExprKind::UnitNumber { value, unit } => Ok(Value::UnitValue {
value: *value,
unit: unit.clone(),
}),
ExprKind::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue {
amount: *amount,
currency: currency.clone(),
}),
ExprKind::DateLiteral { year, month, day } => {
let date = NaiveDate::from_ymd_opt(*year, *month, *day)
.ok_or_else(|| format!("Invalid date: {}-{}-{}", year, month, day))?;
Ok(Value::DateTime(date))
}
ExprKind::Today => Ok(Value::DateTime(ctx.today)),
ExprKind::Duration { value, unit } => {
let days = duration_to_days(*value, *unit);
let desc = format_duration(*value, *unit);
Ok(Value::TimeDelta {
days,
description: desc,
})
}
ExprKind::BinaryOp { op, left, right } => {
let lval = eval_inner(left, ctx)?;
let rval = eval_inner(right, ctx)?;
eval_binary(*op, lval, rval, ctx)
}
ExprKind::UnaryNeg(inner) => {
let val = eval_inner(inner, ctx)?;
match val {
Value::Number(n) => Ok(Value::Number(-n)),
Value::UnitValue { value, unit } => Ok(Value::UnitValue {
value: -value,
unit,
}),
Value::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue {
amount: -amount,
currency,
}),
_ => Err("cannot negate this value".to_string()),
}
}
ExprKind::PercentOp {
op,
base,
percentage,
} => {
let base_val = eval_inner(base, ctx)?;
eval_percent(*op, base_val, *percentage)
}
ExprKind::Conversion {
expr,
target_currency,
} => {
let val = eval_inner(expr, ctx)?;
eval_conversion(val, target_currency, ctx)
}
ExprKind::DateRange { from, to } => {
let from_val = eval_inner(from, ctx)?;
let to_val = eval_inner(to, ctx)?;
match (from_val, to_val) {
(Value::DateTime(d1), Value::DateTime(d2)) => {
let days = (d2 - d1).num_days();
Ok(Value::TimeDelta {
days,
description: format!("{} days", days),
})
}
_ => Err("date range requires two dates".to_string()),
}
}
ExprKind::Comparison { op, left, right } => {
let lval = eval_inner(left, ctx)?;
let rval = eval_inner(right, ctx)?;
eval_comparison(*op, lval, rval)
}
ExprKind::Identifier(name) => {
if let Some(result) = ctx.get_variable(name) {
result_to_value(result)
} else {
Err(format!("undefined variable: {}", name))
}
}
ExprKind::Assignment { name, value } => {
let val = eval_inner(value, ctx)?;
let result = value_to_result(val.clone(), value.span);
ctx.set_variable(name, result);
Ok(val)
}
}
}
fn result_to_value(result: &CalcResult) -> Result<Value, String> {
match &result.value {
CalcValue::Number { value } => Ok(Value::Number(*value)),
CalcValue::UnitValue { value, unit } => Ok(Value::UnitValue {
value: *value,
unit: unit.clone(),
}),
CalcValue::CurrencyValue { amount, currency } => Ok(Value::CurrencyValue {
amount: *amount,
currency: currency.clone(),
}),
CalcValue::Boolean { value } => Ok(Value::Boolean(*value)),
CalcValue::DateTime { date } => {
let d = NaiveDate::parse_from_str(date, "%Y-%m-%d")
.map_err(|e| format!("invalid date: {}", e))?;
Ok(Value::DateTime(d))
}
CalcValue::TimeDelta { days, description } => Ok(Value::TimeDelta {
days: *days,
description: description.clone(),
}),
CalcValue::Error { message, .. } => Err(message.clone()),
}
}
fn eval_binary(
op: BinOp,
lval: Value,
rval: Value,
ctx: &EvalContext,
) -> Result<Value, String> {
match (lval, rval) {
// Number op Number
(Value::Number(a), Value::Number(b)) => {
let result = match op {
BinOp::Add => a + b,
BinOp::Sub => a - b,
BinOp::Mul => a * b,
BinOp::Div => {
if b == 0.0 {
return Err("division by zero".to_string());
}
a / b
}
BinOp::Pow => a.powf(b),
BinOp::Mod => {
if b == 0.0 {
return Err("modulo by zero".to_string());
}
a % b
}
};
Ok(Value::Number(result))
}
// UnitValue op Number (scalar operations)
(Value::UnitValue { value, unit }, Value::Number(b)) => {
let result = match op {
BinOp::Mul => b * value,
BinOp::Div => {
if b == 0.0 {
return Err("division by zero".to_string());
}
value / b
}
BinOp::Add => value + b,
BinOp::Sub => value - b,
_ => return Err(format!("unsupported operation: unit {} number", op)),
};
Ok(Value::UnitValue {
value: result,
unit,
})
}
// Number op UnitValue
(Value::Number(a), Value::UnitValue { value, unit }) => {
let result = match op {
BinOp::Mul => a * value,
_ => return Err(format!("unsupported operation: number {} unit", op)),
};
Ok(Value::UnitValue {
value: result,
unit,
})
}
// UnitValue op UnitValue (same unit)
(
Value::UnitValue {
value: a,
unit: u1,
},
Value::UnitValue {
value: b,
unit: u2,
},
) => {
if u1 != u2 {
// Try unit conversion
if let Some(converted) = convert_units(b, &u2, &u1) {
let result = match op {
BinOp::Add => a + converted,
BinOp::Sub => a - converted,
_ => {
return Err(format!(
"unsupported operation between {} and {}",
u1, u2
))
}
};
return Ok(Value::UnitValue {
value: result,
unit: u1,
});
}
return Err(format!("incompatible units: {} and {}", u1, u2));
}
let result = match op {
BinOp::Add => a + b,
BinOp::Sub => a - b,
_ => return Err(format!("unsupported operation between units: {}", op)),
};
Ok(Value::UnitValue {
value: result,
unit: u1,
})
}
// Currency op Currency
(
Value::CurrencyValue {
amount: a,
currency: c1,
},
Value::CurrencyValue {
amount: b,
currency: c2,
},
) => {
if c1 != c2 {
if let Some(converted) = ctx.convert_currency(b, &c2, &c1) {
let result = match op {
BinOp::Add => a + converted,
BinOp::Sub => a - converted,
_ => return Err("unsupported currency operation".to_string()),
};
return Ok(Value::CurrencyValue {
amount: result,
currency: c1,
});
}
return Err(format!("cannot convert between {} and {}", c1, c2));
}
let result = match op {
BinOp::Add => a + b,
BinOp::Sub => a - b,
_ => return Err("unsupported currency operation".to_string()),
};
Ok(Value::CurrencyValue {
amount: result,
currency: c1,
})
}
// Currency op Number
(Value::CurrencyValue { amount, currency }, Value::Number(b)) => {
let result = match op {
BinOp::Mul => amount * b,
BinOp::Div => {
if b == 0.0 {
return Err("division by zero".to_string());
}
amount / b
}
BinOp::Add => amount + b,
BinOp::Sub => amount - b,
_ => return Err("unsupported currency operation".to_string()),
};
Ok(Value::CurrencyValue {
amount: result,
currency,
})
}
// Number op Currency
(Value::Number(a), Value::CurrencyValue { amount, currency }) => {
let result = match op {
BinOp::Mul => a * amount,
_ => return Err("unsupported currency operation".to_string()),
};
Ok(Value::CurrencyValue {
amount: result,
currency,
})
}
// DateTime +/- Duration
(Value::DateTime(date), Value::TimeDelta { days, .. }) => match op {
BinOp::Add => {
let new_date = date + Duration::days(days);
Ok(Value::DateTime(new_date))
}
BinOp::Sub => {
let new_date = date - Duration::days(days);
Ok(Value::DateTime(new_date))
}
_ => Err("unsupported date operation".to_string()),
},
// DateTime - DateTime
(Value::DateTime(d1), Value::DateTime(d2)) => match op {
BinOp::Sub => {
let days = (d1 - d2).num_days();
Ok(Value::TimeDelta {
days,
description: format!("{} days", days),
})
}
_ => Err("unsupported date operation".to_string()),
},
_ => Err(format!("unsupported binary operation: {}", op)),
}
}
fn eval_percent(op: PercentOp, base: Value, percentage: f64) -> Result<Value, String> {
let factor = percentage / 100.0;
match base {
Value::Number(n) => {
let result = match op {
PercentOp::Add => n + n * factor,
PercentOp::Sub => n - n * factor,
};
Ok(Value::Number(result))
}
Value::CurrencyValue { amount, currency } => {
let result = match op {
PercentOp::Add => amount + amount * factor,
PercentOp::Sub => amount - amount * factor,
};
Ok(Value::CurrencyValue {
amount: result,
currency,
})
}
Value::UnitValue { value, unit } => {
let result = match op {
PercentOp::Add => value + value * factor,
PercentOp::Sub => value - value * factor,
};
Ok(Value::UnitValue {
value: result,
unit,
})
}
_ => Err("percentage operation requires numeric value".to_string()),
}
}
fn eval_conversion(
val: Value,
target: &str,
ctx: &EvalContext,
) -> Result<Value, String> {
match val {
Value::CurrencyValue { amount, currency } => {
if let Some(converted) = ctx.convert_currency(amount, &currency, target) {
Ok(Value::CurrencyValue {
amount: converted,
currency: target.to_string(),
})
} else {
Err(format!(
"no exchange rate available for {} to {}",
currency, target
))
}
}
Value::UnitValue { value, unit } => {
if let Some(converted) = convert_units(value, &unit, target) {
Ok(Value::UnitValue {
value: converted,
unit: target.to_string(),
})
} else {
Err(format!("cannot convert {} to {}", unit, target))
}
}
_ => Err("conversion requires a currency or unit value".to_string()),
}
}
fn eval_comparison(op: CmpOp, lval: Value, rval: Value) -> Result<Value, String> {
let (a, b) = match (lval, rval) {
(Value::Number(a), Value::Number(b)) => (a, b),
_ => return Err("comparison requires numeric values".to_string()),
};
let result = match op {
CmpOp::Gt => a > b,
CmpOp::Lt => a < b,
CmpOp::Gte => a >= b,
CmpOp::Lte => a <= b,
CmpOp::Eq => (a - b).abs() < f64::EPSILON,
CmpOp::Neq => (a - b).abs() >= f64::EPSILON,
};
Ok(Value::Boolean(result))
}
fn value_to_result(val: Value, span: Span) -> CalcResult {
match val {
Value::Number(n) => CalcResult::number(n, span),
Value::UnitValue { value, unit } => CalcResult::unit_value(value, &unit, span),
Value::CurrencyValue { amount, currency } => {
CalcResult::currency_value(amount, &currency, span)
}
Value::DateTime(date) => CalcResult::datetime(date, span),
Value::TimeDelta { days, description } => CalcResult::time_delta(days, &description, span),
Value::Boolean(b) => CalcResult::boolean(b, span),
}
}
fn duration_to_days(value: f64, unit: DurationUnit) -> i64 {
let days = match unit {
DurationUnit::Days => value,
DurationUnit::Weeks => value * 7.0,
DurationUnit::Months => value * 30.0,
DurationUnit::Years => value * 365.0,
DurationUnit::Hours => value / 24.0,
DurationUnit::Minutes => value / (24.0 * 60.0),
DurationUnit::Seconds => value / (24.0 * 60.0 * 60.0),
};
days as i64
}
fn format_duration(value: f64, unit: DurationUnit) -> String {
let unit_str = match unit {
DurationUnit::Days => "days",
DurationUnit::Weeks => "weeks",
DurationUnit::Months => "months",
DurationUnit::Years => "years",
DurationUnit::Hours => "hours",
DurationUnit::Minutes => "minutes",
DurationUnit::Seconds => "seconds",
};
if value == 1.0 {
format!("1 {}", unit_str.trim_end_matches('s'))
} else {
format!("{} {}", value, unit_str)
}
}
fn convert_units(value: f64, from: &str, to: &str) -> Option<f64> {
// Normalize to base unit, then convert to target
let (base_value, base_unit) = to_base_unit(value, from)?;
from_base_unit(base_value, &base_unit, to)
}
fn to_base_unit(value: f64, unit: &str) -> Option<(f64, String)> {
match unit {
// Mass → grams
"kg" => Some((value * 1000.0, "g".to_string())),
"g" => Some((value, "g".to_string())),
"mg" => Some((value / 1000.0, "g".to_string())),
"lb" => Some((value * 453.592, "g".to_string())),
"oz" => Some((value * 28.3495, "g".to_string())),
// Length → meters
"m" => Some((value, "m".to_string())),
"cm" => Some((value / 100.0, "m".to_string())),
"mm" => Some((value / 1000.0, "m".to_string())),
"km" => Some((value * 1000.0, "m".to_string())),
"ft" => Some((value * 0.3048, "m".to_string())),
"in" => Some((value * 0.0254, "m".to_string())),
// Volume → liters
"l" => Some((value, "l".to_string())),
"ml" => Some((value / 1000.0, "l".to_string())),
// Time → seconds
"s" => Some((value, "s".to_string())),
"min" => Some((value * 60.0, "s".to_string())),
"h" => Some((value * 3600.0, "s".to_string())),
_ => None,
}
}
fn from_base_unit(base_value: f64, base_unit: &str, target: &str) -> Option<f64> {
match (base_unit, target) {
// Mass (base: grams)
("g", "kg") => Some(base_value / 1000.0),
("g", "g") => Some(base_value),
("g", "mg") => Some(base_value * 1000.0),
("g", "lb") => Some(base_value / 453.592),
("g", "oz") => Some(base_value / 28.3495),
// Length (base: meters)
("m", "m") => Some(base_value),
("m", "cm") => Some(base_value * 100.0),
("m", "mm") => Some(base_value * 1000.0),
("m", "km") => Some(base_value / 1000.0),
("m", "ft") => Some(base_value / 0.3048),
("m", "in") => Some(base_value / 0.0254),
// Volume (base: liters)
("l", "l") => Some(base_value),
("l", "ml") => Some(base_value * 1000.0),
// Time (base: seconds)
("s", "s") => Some(base_value),
("s", "min") => Some(base_value / 60.0),
("s", "h") => Some(base_value / 3600.0),
_ => None,
}
}

430
calcpad-engine/src/lexer.rs Normal file
View File

@@ -0,0 +1,430 @@
use crate::span::Span;
use crate::token::{Operator, Token, TokenKind};
/// A line-oriented lexer for CalcPad expressions.
pub struct Lexer<'a> {
input: &'a str,
bytes: &'a [u8],
pos: usize,
pending: Option<Token>,
}
impl<'a> Lexer<'a> {
pub fn new(input: &'a str) -> Self {
Self {
input,
bytes: input.as_bytes(),
pos: 0,
pending: None,
}
}
/// Tokenize the entire input line into a token stream, terminated with Eof.
pub fn tokenize(&mut self) -> Vec<Token> {
if self.input.trim().is_empty() {
return vec![Token::new(TokenKind::Eof, Span::new(0, 0))];
}
// Full-line comment check
let trimmed = self.input.trim_start();
let trimmed_start = self.input.len() - trimmed.len();
if trimmed.starts_with("//") {
let comment_text = self.input[trimmed_start + 2..].to_string();
return vec![
Token::new(
TokenKind::Comment(comment_text),
Span::new(trimmed_start, self.input.len()),
),
Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())),
];
}
let mut tokens = Vec::new();
loop {
if let Some(tok) = self.pending.take() {
tokens.push(tok);
continue;
}
if self.pos >= self.bytes.len() {
break;
}
self.skip_whitespace();
if self.pos >= self.bytes.len() {
break;
}
if let Some(tok) = self.scan_token() {
tokens.push(tok);
}
}
// If no calculable tokens, return as text
if tokens.is_empty() && !self.input.trim().is_empty() {
return vec![
Token::new(
TokenKind::Text(self.input.to_string()),
Span::new(0, self.input.len()),
),
Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())),
];
}
let has_assign = tokens.iter().any(|t| matches!(t.kind, TokenKind::Assign));
let has_calculable = tokens.iter().any(|t| {
matches!(
t.kind,
TokenKind::Number(_)
| TokenKind::Op(_)
| TokenKind::Assign
| TokenKind::Percent(_)
| TokenKind::CurrencySymbol(_)
| TokenKind::Unit(_)
| TokenKind::LParen
| TokenKind::RParen
)
});
// A single identifier token (potential variable reference) is also calculable
let is_single_identifier = !has_calculable
&& tokens.len() == 1
&& matches!(tokens[0].kind, TokenKind::Identifier(_));
// An identifier with assignment is also calculable
let has_identifier_with_assign = has_assign
&& tokens.iter().any(|t| matches!(t.kind, TokenKind::Identifier(_)));
if !has_calculable && !is_single_identifier && !has_identifier_with_assign {
return vec![
Token::new(
TokenKind::Text(self.input.to_string()),
Span::new(0, self.input.len()),
),
Token::new(TokenKind::Eof, Span::new(self.input.len(), self.input.len())),
];
}
let end = self.input.len();
tokens.push(Token::new(TokenKind::Eof, Span::new(end, end)));
tokens
}
fn skip_whitespace(&mut self) {
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
self.pos += 1;
}
}
fn peek(&self) -> Option<u8> {
self.bytes.get(self.pos).copied()
}
fn peek_ahead(&self, n: usize) -> Option<u8> {
self.bytes.get(self.pos + n).copied()
}
fn advance(&mut self) -> Option<u8> {
if self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
self.pos += 1;
Some(b)
} else {
None
}
}
fn matches_word(&self, word: &str) -> bool {
let remaining = &self.input[self.pos..];
if remaining.len() < word.len() {
return false;
}
if !remaining[..word.len()].eq_ignore_ascii_case(word) {
return false;
}
if remaining.len() == word.len() {
return true;
}
let next = remaining.as_bytes()[word.len()];
!next.is_ascii_alphanumeric() && next != b'_'
}
fn scan_token(&mut self) -> Option<Token> {
let b = self.peek()?;
// Comment: // ...
if b == b'/' && self.peek_ahead(1) == Some(b'/') {
return Some(self.scan_comment());
}
// Currency symbols
if let Some(tok) = self.try_scan_currency() {
return Some(tok);
}
// Numbers
if b.is_ascii_digit()
|| (b == b'.' && self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit()))
{
return Some(self.scan_number());
}
// Two-character comparison operators
if b == b'>' && self.peek_ahead(1) == Some(b'=') {
let start = self.pos;
self.pos += 2;
return Some(Token::new(TokenKind::GreaterEq, Span::new(start, self.pos)));
}
if b == b'<' && self.peek_ahead(1) == Some(b'=') {
let start = self.pos;
self.pos += 2;
return Some(Token::new(TokenKind::LessEq, Span::new(start, self.pos)));
}
if b == b'=' && self.peek_ahead(1) == Some(b'=') {
let start = self.pos;
self.pos += 2;
return Some(Token::new(TokenKind::Equal, Span::new(start, self.pos)));
}
if b == b'!' && self.peek_ahead(1) == Some(b'=') {
let start = self.pos;
self.pos += 2;
return Some(Token::new(TokenKind::NotEqual, Span::new(start, self.pos)));
}
// Single-character operators and punctuation
match b {
b'+' => return Some(self.single_char_token(TokenKind::Op(Operator::Add))),
b'-' => return Some(self.single_char_token(TokenKind::Op(Operator::Subtract))),
b'*' => return Some(self.single_char_token(TokenKind::Op(Operator::Multiply))),
b'/' => return Some(self.single_char_token(TokenKind::Op(Operator::Divide))),
b'^' => return Some(self.single_char_token(TokenKind::Op(Operator::Power))),
b'(' => return Some(self.single_char_token(TokenKind::LParen)),
b')' => return Some(self.single_char_token(TokenKind::RParen)),
b'>' => return Some(self.single_char_token(TokenKind::Greater)),
b'<' => return Some(self.single_char_token(TokenKind::Less)),
b'=' => return Some(self.single_char_token(TokenKind::Assign)),
b'%' => return Some(self.single_char_token(TokenKind::Op(Operator::Modulo))),
_ => {}
}
// Alphabetic — could be keyword, unit, or identifier
if b.is_ascii_alphabetic() || b == b'_' {
return Some(self.scan_word());
}
// Unknown character — skip
self.advance();
None
}
fn single_char_token(&mut self, kind: TokenKind) -> Token {
let start = self.pos;
self.advance();
Token::new(kind, Span::new(start, self.pos))
}
fn scan_comment(&mut self) -> Token {
let start = self.pos;
self.pos += 2;
let text = self.input[self.pos..].to_string();
self.pos = self.bytes.len();
Token::new(TokenKind::Comment(text), Span::new(start, self.pos))
}
fn try_scan_currency(&mut self) -> Option<Token> {
let start = self.pos;
let remaining = &self.input[self.pos..];
if remaining.starts_with("R$") {
self.pos += 2;
return Some(Token::new(
TokenKind::CurrencySymbol("R$".to_string()),
Span::new(start, self.pos),
));
}
if remaining.starts_with('$') {
self.pos += 1;
return Some(Token::new(
TokenKind::CurrencySymbol("$".to_string()),
Span::new(start, self.pos),
));
}
for sym in &["", "£", "¥"] {
if remaining.starts_with(sym) {
self.pos += sym.len();
return Some(Token::new(
TokenKind::CurrencySymbol(sym.to_string()),
Span::new(start, self.pos),
));
}
}
None
}
fn scan_number(&mut self) -> Token {
let start = self.pos;
self.consume_digits();
// Decimal point
if self.peek() == Some(b'.')
&& self.peek_ahead(1).is_some_and(|c| c.is_ascii_digit())
{
self.advance();
self.consume_digits();
}
// Scientific notation
if let Some(e) = self.peek() {
if e == b'e' || e == b'E' {
let next = self.peek_ahead(1);
let has_digits = match next {
Some(b'+') | Some(b'-') => {
self.peek_ahead(2).is_some_and(|c| c.is_ascii_digit())
}
Some(c) => c.is_ascii_digit(),
None => false,
};
if has_digits {
self.advance();
if let Some(b'+') | Some(b'-') = self.peek() {
self.advance();
}
self.consume_digits();
}
}
}
let number_end = self.pos;
let raw_number: f64 = self.input[start..number_end].parse().unwrap_or(0.0);
// Check for percent suffix
if self.peek() == Some(b'%') {
self.advance();
return Token::new(TokenKind::Percent(raw_number), Span::new(start, self.pos));
}
// SI scale suffixes
if let Some(suffix) = self.peek() {
let scale = match suffix {
b'k' if !self.is_unit_suffix_start() => Some(1_000.0),
b'M' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => {
Some(1_000_000.0)
}
b'B' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => {
Some(1_000_000_000.0)
}
b'T' if !self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic()) => {
Some(1_000_000_000_000.0)
}
_ => None,
};
if let Some(multiplier) = scale {
self.advance();
return Token::new(
TokenKind::Number(raw_number * multiplier),
Span::new(start, self.pos),
);
}
}
// Unit suffix directly after number
if let Some(b) = self.peek() {
if b.is_ascii_alphabetic() {
let unit_start = self.pos;
self.consume_alpha();
let unit_str = self.input[unit_start..self.pos].to_string();
self.pending = Some(Token::new(
TokenKind::Unit(unit_str),
Span::new(unit_start, self.pos),
));
return Token::new(
TokenKind::Number(raw_number),
Span::new(start, number_end),
);
}
}
Token::new(TokenKind::Number(raw_number), Span::new(start, number_end))
}
fn is_unit_suffix_start(&self) -> bool {
self.peek_ahead(1).is_some_and(|c| c.is_ascii_alphabetic())
}
fn consume_digits(&mut self) {
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
self.pos += 1;
}
}
fn consume_alpha(&mut self) {
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_alphabetic() {
self.pos += 1;
}
}
fn scan_word(&mut self) -> Token {
// "divided by" two-word operator
if self.matches_word("divided") {
let start = self.pos;
self.pos += "divided".len();
self.skip_whitespace();
if self.matches_word("by") {
self.pos += "by".len();
return Token::new(
TokenKind::Op(Operator::Divide),
Span::new(start, self.pos),
);
}
self.pos = start + "divided".len();
return Token::new(
TokenKind::Identifier("divided".to_string()),
Span::new(start, self.pos),
);
}
// Natural language operators
let nl_ops: &[(&str, Operator)] = &[
("plus", Operator::Add),
("minus", Operator::Subtract),
("times", Operator::Multiply),
];
for &(word, ref op) in nl_ops {
if self.matches_word(word) {
let start = self.pos;
self.pos += word.len();
return Token::new(TokenKind::Op(*op), Span::new(start, self.pos));
}
}
// The keyword `in` (for conversions)
if self.matches_word("in") {
let start = self.pos;
self.pos += 2;
return Token::new(TokenKind::In, Span::new(start, self.pos));
}
// Other keywords
let keywords = ["to", "as", "of", "discount", "off", "euro", "usd", "gbp"];
for kw in &keywords {
if self.matches_word(kw) {
let start = self.pos;
self.pos += kw.len();
return Token::new(
TokenKind::Keyword(kw.to_string()),
Span::new(start, self.pos),
);
}
}
// Generic identifier
let start = self.pos;
while self.pos < self.bytes.len()
&& (self.bytes[self.pos].is_ascii_alphanumeric() || self.bytes[self.pos] == b'_')
{
self.pos += 1;
}
let word = self.input[start..self.pos].to_string();
Token::new(TokenKind::Identifier(word), Span::new(start, self.pos))
}
}
/// Convenience function to tokenize an input line.
pub fn tokenize(input: &str) -> Vec<Token> {
Lexer::new(input).tokenize()
}

21
calcpad-engine/src/lib.rs Normal file
View File

@@ -0,0 +1,21 @@
pub mod ast;
pub mod context;
pub mod error;
pub mod ffi;
pub mod interpreter;
pub mod lexer;
pub mod number;
pub mod parser;
pub mod pipeline;
pub mod sheet_context;
pub mod span;
pub mod token;
pub mod types;
pub use context::EvalContext;
pub use ffi::{FfiResponse, FfiSheetResponse};
pub use interpreter::evaluate;
pub use pipeline::{eval_line, eval_sheet};
pub use sheet_context::SheetContext;
pub use span::Span;
pub use types::{CalcResult, CalcValue, ResultMetadata, ResultType};

View File

@@ -0,0 +1,698 @@
//! Arbitrary-precision number type for CalcText.
//!
//! Uses [`dashu`](https://docs.rs/dashu) for exact integer and rational arithmetic,
//! which is pure Rust and WASM-compatible. Numbers are stored as either exact integers
//! (`IBig`) or exact rationals (`RBig`), avoiding floating-point representation errors.
//!
//! # Examples
//!
//! ```
//! use calcpad_engine::number::Number;
//!
//! // 0.1 + 0.2 == 0.3 exactly (no floating-point drift)
//! let a = Number::parse("0.1").unwrap();
//! let b = Number::parse("0.2").unwrap();
//! let sum = a + b;
//! assert_eq!(sum.to_string(), "0.3");
//! ```
use dashu::integer::IBig;
use dashu::rational::RBig;
use serde::{Deserialize, Serialize};
use std::fmt;
/// Default number of significant digits for formatting non-terminating decimals.
pub const DEFAULT_PRECISION: usize = 30;
/// Arbitrary-precision number.
///
/// Uses `Integer` for exact integer values and `Rational` for exact
/// fractional values. Decimal inputs like `"0.1"` are stored as rationals
/// (1/10) to avoid floating-point representation errors.
#[derive(Clone, Debug)]
pub enum Number {
Integer(IBig),
Rational(RBig),
}
// ---------------------------------------------------------------------------
// Construction & parsing
// ---------------------------------------------------------------------------
impl Number {
/// Parse a decimal string into a `Number`, preserving exact representation.
///
/// - `"42"` becomes `Integer(42)`
/// - `"0.1"` becomes `Rational(1/10)`
/// - `"-3.14"` becomes `Rational(-314/100)` (auto-reduced)
pub fn parse(s: &str) -> Option<Number> {
let s = s.trim();
if s.is_empty() {
return None;
}
// Handle optional leading sign
let (is_negative, s) = if let Some(rest) = s.strip_prefix('-') {
(true, rest)
} else if let Some(rest) = s.strip_prefix('+') {
(false, rest)
} else {
(false, s)
};
let result = if s.contains('.') {
// Parse as exact rational: "3.14" -> 314/100, auto-reduced by RBig
let parts: Vec<&str> = s.splitn(2, '.').collect();
let integer_part = parts[0];
let fractional_part = parts[1];
let decimal_places = fractional_part.len() as u32;
let numerator_str = format!("{}{}", integer_part, fractional_part);
let numerator: IBig = numerator_str.parse().ok()?;
let denominator: IBig = IBig::from(10).pow(decimal_places as usize);
let rational = RBig::from_parts(numerator.into(), denominator.try_into().ok()?);
Number::from_rational(rational)
} else {
let i: IBig = s.parse().ok()?;
Number::Integer(i)
};
if is_negative {
Some(-result)
} else {
Some(result)
}
}
/// Construct from a rational, normalising to `Integer` if the denominator is 1.
pub fn from_rational(r: RBig) -> Number {
let (num, den) = r.clone().into_parts();
if *den.as_ibig() == IBig::from(1) {
Number::Integer(num)
} else {
Number::Rational(r)
}
}
/// Promote to `RBig` regardless of variant.
pub fn to_rational(&self) -> RBig {
match self {
Number::Integer(i) => RBig::from(i.clone()),
Number::Rational(r) => r.clone(),
}
}
/// Convert to `f64` (lossy — for serialisation / FFI only).
pub fn to_f64(&self) -> f64 {
match self {
Number::Integer(i) => {
// Try i64 first for exactness on small ints
if let Ok(n) = i64::try_from(i.clone()) {
n as f64
} else {
// Fall back to string parsing for huge numbers
i.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
}
}
Number::Rational(r) => {
let (num, den) = r.clone().into_parts();
let n = if let Ok(n) = i64::try_from(num.clone()) {
n as f64
} else {
num.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
};
let d = if let Ok(d) = i64::try_from(den.as_ibig().clone()) {
d as f64
} else {
den.as_ibig().to_string().parse::<f64>().unwrap_or(f64::INFINITY)
};
n / d
}
}
}
/// Construct from an `f64`. Converts through string representation to
/// capture the exact decimal shown by Rust's `Display` formatting.
pub fn from_f64(v: f64) -> Number {
if v.fract() == 0.0 && v.abs() < 1e18 {
Number::Integer(IBig::from(v as i64))
} else {
// Use Display which gives a shortest-round-trip representation
let s = format!("{}", v);
Number::parse(&s).unwrap_or_else(|| Number::Integer(IBig::from(0)))
}
}
// -----------------------------------------------------------------------
// Arithmetic helpers
// -----------------------------------------------------------------------
/// Integer exponentiation: `self ^ exp`.
///
/// Returns `None` for non-integer exponents (fractional powers are not yet
/// supported in exact arithmetic).
pub fn pow(&self, exp: &Number) -> Option<Number> {
match exp {
Number::Integer(e) => {
if *e < IBig::from(0) {
// Negative exponent: compute base^|e| then invert
let abs_exp: usize = (-e.clone()).try_into().ok()?;
let base_r = self.to_rational();
let result = rational_pow(&base_r, abs_exp);
let (num, den) = result.into_parts();
let inverted = RBig::from_parts(
den.as_ibig().clone().into(),
num.try_into().ok()?,
);
Some(Number::from_rational(inverted))
} else {
let exp_usize: usize = e.clone().try_into().ok()?;
match self {
Number::Integer(base) => {
Some(Number::Integer(base.clone().pow(exp_usize)))
}
Number::Rational(base) => {
Some(Number::from_rational(rational_pow(base, exp_usize)))
}
}
}
}
Number::Rational(_) => None,
}
}
/// Factorial. Only defined for non-negative integers.
pub fn factorial(&self) -> Option<Number> {
match self {
Number::Integer(n) => {
if *n < IBig::from(0) {
return None;
}
let n_usize: usize = n.clone().try_into().ok()?;
let mut result = IBig::from(1);
for i in 2..=n_usize {
result *= IBig::from(i);
}
Some(Number::Integer(result))
}
Number::Rational(_) => None,
}
}
/// Returns `true` if the value is zero.
pub fn is_zero(&self) -> bool {
match self {
Number::Integer(i) => *i == IBig::from(0),
Number::Rational(r) => {
let (num, _) = r.clone().into_parts();
num == IBig::from(0)
}
}
}
/// Returns `true` if this is an exact integer value (denominator == 1).
pub fn is_integer(&self) -> bool {
matches!(self, Number::Integer(_))
}
/// Format with a given cap on significant digits for non-terminating results.
pub fn format(&self, max_sig_digits: usize) -> String {
match self {
Number::Integer(i) => i.to_string(),
Number::Rational(r) => format_rational(r, max_sig_digits),
}
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
/// Raise a rational to a non-negative integer power.
fn rational_pow(base: &RBig, exp: usize) -> RBig {
if exp == 0 {
return RBig::from(IBig::from(1));
}
let (num, den) = base.clone().into_parts();
let num_pow = num.pow(exp);
let den_pow = den.as_ibig().clone().pow(exp);
RBig::from_parts(num_pow.into(), den_pow.try_into().unwrap())
}
/// Format a rational as a decimal string.
///
/// If the decimal terminates (denominator has only factors 2 and 5), the exact
/// value is printed. Otherwise, `max_sig_digits` significant digits are shown
/// with rounding.
fn format_rational(r: &RBig, max_sig_digits: usize) -> String {
let (num, den) = r.clone().into_parts();
let den_ibig = den.as_ibig().clone();
let is_negative = num < IBig::from(0);
let abs_num = if is_negative { -num.clone() } else { num.clone() };
if is_terminating_decimal(&den_ibig) {
format_terminating(&abs_num, &den_ibig, is_negative)
} else {
format_non_terminating(&abs_num, &den_ibig, max_sig_digits, is_negative)
}
}
fn format_terminating(abs_num: &IBig, den: &IBig, is_negative: bool) -> String {
let quotient = abs_num / den;
let remainder = abs_num % den;
if remainder == IBig::from(0) {
let s = quotient.to_string();
return if is_negative { format!("-{}", s) } else { s };
}
// Long division for the fractional part
let mut frac_digits = String::new();
let mut rem = remainder;
loop {
rem *= IBig::from(10);
let digit = &rem / den;
rem = &rem % den;
frac_digits.push_str(&digit.to_string());
if rem == IBig::from(0) {
break;
}
}
let s = format!("{}.{}", quotient, frac_digits);
if is_negative { format!("-{}", s) } else { s }
}
fn format_non_terminating(
abs_num: &IBig,
den: &IBig,
max_sig_digits: usize,
is_negative: bool,
) -> String {
let quotient = abs_num / den;
let remainder = abs_num % den;
let int_str = quotient.to_string();
let int_sig_digits = if quotient == IBig::from(0) { 0 } else { int_str.len() };
let frac_digits_needed = if int_sig_digits >= max_sig_digits {
1 // always show at least 1 fractional digit
} else {
max_sig_digits - int_sig_digits
};
let mut frac_digits = String::new();
let mut rem = remainder;
let mut sig_count = int_sig_digits;
for _ in 0..frac_digits_needed + 100 {
// +100 headroom for leading zeros
rem *= IBig::from(10);
let digit = &rem / den;
rem = &rem % den;
let d = digit.to_string();
frac_digits.push_str(&d);
if sig_count > 0 || d != "0" {
sig_count += 1;
}
if sig_count >= max_sig_digits {
// Round based on next digit
rem *= IBig::from(10);
let next_digit = &rem / den;
if next_digit >= IBig::from(5) {
frac_digits = round_up_decimal_str(&frac_digits);
}
break;
}
}
// Trim trailing zeros after rounding
let frac_trimmed = frac_digits.trim_end_matches('0');
let s = if frac_trimmed.is_empty() {
int_str
} else {
format!("{}.{}", int_str, frac_trimmed)
};
if is_negative { format!("-{}", s) } else { s }
}
/// True when the denominator has only factors of 2 and 5 (decimal terminates).
fn is_terminating_decimal(den: &IBig) -> bool {
let mut d = den.clone();
while &d % IBig::from(2) == IBig::from(0) {
d /= IBig::from(2);
}
while &d % IBig::from(5) == IBig::from(0) {
d /= IBig::from(5);
}
d == IBig::from(1)
}
/// Increment the last digit of a decimal-fraction digit string, carrying as needed.
fn round_up_decimal_str(s: &str) -> String {
let mut chars: Vec<u8> = s.bytes().collect();
let mut carry = true;
for i in (0..chars.len()).rev() {
if carry {
if chars[i] == b'9' {
chars[i] = b'0';
} else {
chars[i] += 1;
carry = false;
}
}
}
let result: String = chars.iter().map(|&b| b as char).collect();
if carry {
format!("1{}", result)
} else {
result
}
}
// ===========================================================================
// Trait implementations
// ===========================================================================
impl PartialEq for Number {
fn eq(&self, other: &Self) -> bool {
self.to_rational() == other.to_rational()
}
}
impl Eq for Number {}
impl PartialOrd for Number {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Number {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.to_rational().cmp(&other.to_rational())
}
}
impl fmt::Display for Number {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.format(DEFAULT_PRECISION))
}
}
// -- Conversions from primitives -------------------------------------------
impl From<i64> for Number {
fn from(v: i64) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<i32> for Number {
fn from(v: i32) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<u64> for Number {
fn from(v: u64) -> Self {
Number::Integer(IBig::from(v))
}
}
impl From<f64> for Number {
fn from(v: f64) -> Self {
Number::from_f64(v)
}
}
// -- Arithmetic operators ---------------------------------------------------
impl std::ops::Add for Number {
type Output = Number;
fn add(self, rhs: Self) -> Self::Output {
Number::from_rational(self.to_rational() + rhs.to_rational())
}
}
impl std::ops::Sub for Number {
type Output = Number;
fn sub(self, rhs: Self) -> Self::Output {
Number::from_rational(self.to_rational() - rhs.to_rational())
}
}
impl std::ops::Mul for Number {
type Output = Number;
fn mul(self, rhs: Self) -> Self::Output {
Number::from_rational(self.to_rational() * rhs.to_rational())
}
}
impl std::ops::Div for Number {
type Output = Option<Number>;
fn div(self, rhs: Self) -> Self::Output {
if rhs.is_zero() {
return None;
}
Some(Number::from_rational(self.to_rational() / rhs.to_rational()))
}
}
impl std::ops::Neg for Number {
type Output = Number;
fn neg(self) -> Self::Output {
match self {
Number::Integer(i) => Number::Integer(-i),
Number::Rational(r) => Number::Rational(-r),
}
}
}
impl std::ops::Rem for Number {
type Output = Option<Number>;
fn rem(self, rhs: Self) -> Self::Output {
if rhs.is_zero() {
return None;
}
match (&self, &rhs) {
(Number::Integer(a), Number::Integer(b)) => {
Some(Number::Integer(a % b))
}
_ => {
// a % b = a - b * floor(a / b)
let a = self.to_rational();
let b = rhs.to_rational();
let div = &a / &b;
let (num, den) = div.into_parts();
let floor_div = &num / den.as_ibig();
let result = a - b * RBig::from(floor_div);
Some(Number::from_rational(result))
}
}
}
}
// -- Serde ------------------------------------------------------------------
//
// We serialize as a decimal string so that JSON consumers always get a
// human-readable (and exact) representation, regardless of magnitude.
impl Serialize for Number {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Number {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Number::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid number: {}", s)))
}
}
// ===========================================================================
// Tests
// ===========================================================================
#[cfg(test)]
mod tests {
use super::*;
// -- Parsing ------------------------------------------------------------
#[test]
fn test_parse_integer() {
let n = Number::parse("42").unwrap();
assert_eq!(n, Number::from(42i64));
}
#[test]
fn test_parse_negative_integer() {
let n = Number::parse("-7").unwrap();
assert_eq!(n, Number::from(-7i64));
}
#[test]
fn test_parse_decimal() {
let n = Number::parse("3.14").unwrap();
assert_eq!(n.to_string(), "3.14");
}
#[test]
fn test_parse_zero_point_one() {
let n = Number::parse("0.1").unwrap();
assert!(matches!(n, Number::Rational(_)));
assert_eq!(n.to_string(), "0.1");
}
#[test]
fn test_parse_empty_returns_none() {
assert!(Number::parse("").is_none());
assert!(Number::parse(" ").is_none());
}
// -- Exact decimal arithmetic -------------------------------------------
#[test]
fn test_exact_decimal_addition() {
let a = Number::parse("0.1").unwrap();
let b = Number::parse("0.2").unwrap();
assert_eq!((a + b).to_string(), "0.3");
}
#[test]
fn test_ten_tenths_equal_one() {
let tenth = Number::parse("0.1").unwrap();
let mut sum = Number::from(0i64);
for _ in 0..10 {
sum = sum + tenth.clone();
}
assert_eq!(sum, Number::from(1i64));
}
#[test]
fn test_financial_multiplication() {
let amount = Number::parse("1000000.01").unwrap();
let days = Number::from(365i64);
assert_eq!((amount * days).to_string(), "365000003.65");
}
// -- Large numbers ------------------------------------------------------
#[test]
fn test_factorial_small() {
assert_eq!(Number::from(5i64).factorial().unwrap(), Number::from(120i64));
}
#[test]
fn test_factorial_100() {
let result = Number::from(100i64).factorial().unwrap();
let s = result.to_string();
assert_eq!(s.len(), 158); // 100! has 158 digits
assert!(s.starts_with("933262154439441526816992388562667004"));
}
#[test]
fn test_pow_2_to_1000() {
let result = Number::from(2i64).pow(&Number::from(1000i64)).unwrap();
let s = result.to_string();
assert!(s.starts_with("1071508607186267320948425049060001"));
assert_eq!(s.len(), 302);
}
#[test]
fn test_negative_exponent() {
let result = Number::from(2i64).pow(&Number::from(-3i64)).unwrap();
assert_eq!(result.to_string(), "0.125");
}
// -- Division & formatting ----------------------------------------------
#[test]
fn test_division_precision() {
let result = (Number::from(1i64) / Number::from(3i64)).unwrap();
let s = result.format(DEFAULT_PRECISION);
assert!(s.starts_with("0."));
let after_dot = &s[2..];
assert!(after_dot.len() >= 30);
assert!(after_dot.chars().all(|c| c == '3'));
}
#[test]
fn test_integer_division_exact() {
let result = (Number::from(10i64) / Number::from(2i64)).unwrap();
assert_eq!(result, Number::from(5i64));
assert!(result.is_integer());
}
#[test]
fn test_division_by_zero() {
assert!((Number::from(1i64) / Number::from(0i64)).is_none());
}
// -- Modulo -------------------------------------------------------------
#[test]
fn test_modulo_integer() {
let result = (Number::from(10i64) % Number::from(3i64)).unwrap();
assert_eq!(result, Number::from(1i64));
}
#[test]
fn test_modulo_by_zero() {
assert!((Number::from(5i64) % Number::from(0i64)).is_none());
}
// -- Negation & ordering ------------------------------------------------
#[test]
fn test_negation() {
assert_eq!(-Number::from(5i64), Number::from(-5i64));
}
#[test]
fn test_ordering() {
let a = Number::from(3i64);
let b = Number::parse("3.5").unwrap();
assert!(a < b);
}
// -- f64 round-trip -----------------------------------------------------
#[test]
fn test_to_f64() {
assert_eq!(Number::from(42i64).to_f64(), 42.0);
let half = (Number::from(1i64) / Number::from(2i64)).unwrap();
assert_eq!(half.to_f64(), 0.5);
}
#[test]
fn test_from_f64() {
let n = Number::from_f64(3.14);
assert_eq!(n.to_string(), "3.14");
}
// -- Serde round-trip ---------------------------------------------------
#[test]
fn test_serde_roundtrip() {
let n = Number::parse("123.456").unwrap();
let json = serde_json::to_string(&n).unwrap();
assert_eq!(json, "\"123.456\"");
let back: Number = serde_json::from_str(&json).unwrap();
assert_eq!(back, n);
}
#[test]
fn test_serde_integer_roundtrip() {
let n = Number::from(42i64);
let json = serde_json::to_string(&n).unwrap();
assert_eq!(json, "\"42\"");
let back: Number = serde_json::from_str(&json).unwrap();
assert_eq!(back, n);
}
}

View File

@@ -0,0 +1,439 @@
use crate::ast::*;
use crate::error::ParseError;
use crate::span::Span;
use crate::token::{Operator, Token, TokenKind};
/// Precedence levels for Pratt parsing (higher = tighter binding).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Precedence {
None = 0,
Assignment = 1,
Conversion = 2,
Comparison = 3,
Sum = 4,
Product = 5,
Exponent = 6,
Unary = 7,
}
/// A recursive-descent Pratt parser that consumes a token stream and produces an AST.
pub struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
pub fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
/// Parse the entire token stream into an expression.
pub fn parse(mut self) -> Result<Expr, ParseError> {
if self.at_end() {
return Err(ParseError::new(
"unexpected end of input: expected an expression",
Span::new(0, 0),
));
}
let expr = self.parse_expr(Precedence::None)?;
// Allow trailing comments and Eof
while self.check(&TokenKind::Eof) || matches!(self.peek().kind, TokenKind::Comment(_)) {
self.advance();
if self.pos >= self.tokens.len() {
break;
}
}
Ok(expr)
}
fn parse_expr(&mut self, min_prec: Precedence) -> Result<Expr, ParseError> {
let mut left = self.parse_prefix()?;
loop {
if self.at_end() {
break;
}
let tok = self.peek();
if matches!(tok.kind, TokenKind::Eof | TokenKind::Comment(_)) {
break;
}
// Check for assignment: if left is Identifier and we see `=`
if min_prec <= Precedence::Assignment {
if let ExprKind::Identifier(ref name) = left.node {
if self.check(&TokenKind::Assign) {
let name = name.clone();
let assign_span_start = left.span.start;
self.advance(); // consume `=`
let value = self.parse_expr(Precedence::Assignment)?;
let span = Span::new(assign_span_start, value.span.end);
left = Spanned::new(
ExprKind::Assignment {
name,
value: Box::new(value),
},
span,
);
continue;
}
}
}
// Percent operation: `expr +/- N%`
if matches!(
self.peek().kind,
TokenKind::Percent(_)
) {
// Look back at the previous operator we might have consumed
// Actually, percent is handled as infix after +/- in the binary op path
break;
}
let prec = self.infix_precedence();
if prec <= min_prec {
break;
}
left = self.parse_infix(left, prec)?;
}
Ok(left)
}
fn parse_prefix(&mut self) -> Result<Expr, ParseError> {
let tok = self.peek().clone();
match &tok.kind {
TokenKind::Number(val) => {
let val = *val;
let span = tok.span;
self.advance();
// Check for unit suffix
if !self.at_end() {
if let TokenKind::Unit(unit) = &self.peek().kind {
let unit = unit.clone();
let unit_span = self.peek().span;
self.advance();
return Ok(Spanned::new(
ExprKind::UnitNumber { value: val, unit },
span.merge(unit_span),
));
}
}
Ok(Spanned::new(ExprKind::Number(val), span))
}
TokenKind::CurrencySymbol(sym) => {
let currency = symbol_to_currency(sym);
let sym_span = tok.span;
self.advance();
// Expect a number after currency symbol
if self.at_end() {
return Err(ParseError::new(
"expected number after currency symbol",
sym_span,
));
}
let amount_expr = self.parse_prefix()?;
let amount = extract_number(&amount_expr)?;
let span = sym_span.merge(amount_expr.span);
Ok(Spanned::new(
ExprKind::CurrencyValue { amount, currency },
span,
))
}
TokenKind::Op(Operator::Subtract) => {
let op_span = tok.span;
self.advance();
let operand = self.parse_expr(Precedence::Unary)?;
let span = op_span.merge(operand.span);
Ok(Spanned::new(ExprKind::UnaryNeg(Box::new(operand)), span))
}
TokenKind::LParen => {
let open_span = tok.span;
self.advance();
let inner = self.parse_expr(Precedence::None)?;
if !self.check(&TokenKind::RParen) {
return Err(ParseError::new(
"expected closing parenthesis ')'",
self.current_span(),
));
}
let close_span = self.peek().span;
self.advance();
// Re-wrap with the full span including parens
Ok(Spanned::new(inner.node, open_span.merge(close_span)))
}
TokenKind::Identifier(name) => {
let name = name.clone();
let span = tok.span;
self.advance();
Ok(Spanned::new(ExprKind::Identifier(name), span))
}
_ => Err(ParseError::new(
format!("unexpected token: {:?}", tok.kind),
tok.span,
)),
}
}
fn parse_infix(&mut self, left: Expr, prec: Precedence) -> Result<Expr, ParseError> {
let tok = self.peek().clone();
match &tok.kind {
TokenKind::Op(op) => {
let bin_op = operator_to_binop(op);
self.advance();
// Check for percent operation: `expr +/- N%`
if matches!(bin_op, BinOp::Add | BinOp::Sub) {
if let Some(pct) = self.try_parse_percent() {
let pct_op = if matches!(bin_op, BinOp::Add) {
PercentOp::Add
} else {
PercentOp::Sub
};
let span = left.span.merge(Span::new(
self.tokens[self.pos - 1].span.start,
self.tokens[self.pos - 1].span.end,
));
return Ok(Spanned::new(
ExprKind::PercentOp {
op: pct_op,
base: Box::new(left),
percentage: pct,
},
span,
));
}
}
let right = self.parse_expr(prec)?;
let span = left.span.merge(right.span);
Ok(Spanned::new(
ExprKind::BinaryOp {
op: bin_op,
left: Box::new(left),
right: Box::new(right),
},
span,
))
}
TokenKind::Greater | TokenKind::Less | TokenKind::GreaterEq
| TokenKind::LessEq | TokenKind::Equal | TokenKind::NotEqual => {
let cmp_op = match &tok.kind {
TokenKind::Greater => CmpOp::Gt,
TokenKind::Less => CmpOp::Lt,
TokenKind::GreaterEq => CmpOp::Gte,
TokenKind::LessEq => CmpOp::Lte,
TokenKind::Equal => CmpOp::Eq,
TokenKind::NotEqual => CmpOp::Neq,
_ => unreachable!(),
};
self.advance();
let right = self.parse_expr(Precedence::Comparison)?;
let span = left.span.merge(right.span);
Ok(Spanned::new(
ExprKind::Comparison {
op: cmp_op,
left: Box::new(left),
right: Box::new(right),
},
span,
))
}
TokenKind::In => {
self.advance();
// Parse conversion target (keyword or identifier)
let target = self.parse_conversion_target()?;
let span = left.span.merge(self.tokens[self.pos - 1].span);
Ok(Spanned::new(
ExprKind::Conversion {
expr: Box::new(left),
target_currency: target,
},
span,
))
}
_ => Err(ParseError::new(
format!("unexpected infix token: {:?}", tok.kind),
tok.span,
)),
}
}
fn try_parse_percent(&mut self) -> Option<f64> {
if self.at_end() {
return None;
}
if let TokenKind::Percent(val) = &self.peek().kind {
let val = *val;
self.advance();
// Skip optional "discount" or "off" keyword
if !self.at_end() {
if let TokenKind::Keyword(kw) = &self.peek().kind {
if kw == "discount" || kw == "off" {
self.advance();
}
}
}
Some(val)
} else {
None
}
}
fn parse_conversion_target(&mut self) -> Result<String, ParseError> {
if self.at_end() {
return Err(ParseError::new(
"expected conversion target after 'in'",
self.current_span(),
));
}
let tok = self.peek().clone();
match &tok.kind {
TokenKind::Keyword(kw) => {
let target = keyword_to_currency(kw);
self.advance();
Ok(target)
}
TokenKind::Identifier(id) => {
let target = id.to_uppercase();
self.advance();
Ok(target)
}
TokenKind::Unit(u) => {
let target = u.clone();
self.advance();
Ok(target)
}
_ => Err(ParseError::new(
format!("expected conversion target, got {:?}", tok.kind),
tok.span,
)),
}
}
fn infix_precedence(&self) -> Precedence {
if self.at_end() {
return Precedence::None;
}
match &self.peek().kind {
TokenKind::Op(Operator::Add) | TokenKind::Op(Operator::Subtract) => Precedence::Sum,
TokenKind::Op(Operator::Multiply)
| TokenKind::Op(Operator::Divide)
| TokenKind::Op(Operator::Modulo) => Precedence::Product,
TokenKind::Op(Operator::Power) => Precedence::Exponent,
TokenKind::Greater
| TokenKind::Less
| TokenKind::GreaterEq
| TokenKind::LessEq
| TokenKind::Equal
| TokenKind::NotEqual => Precedence::Comparison,
TokenKind::In => Precedence::Conversion,
_ => Precedence::None,
}
}
fn peek(&self) -> &Token {
&self.tokens[self.pos]
}
fn check(&self, kind: &TokenKind) -> bool {
if self.at_end() {
return false;
}
std::mem::discriminant(&self.peek().kind) == std::mem::discriminant(kind)
}
fn advance(&mut self) {
if self.pos < self.tokens.len() {
self.pos += 1;
}
}
fn at_end(&self) -> bool {
self.pos >= self.tokens.len()
}
fn current_span(&self) -> Span {
if self.at_end() {
if let Some(last) = self.tokens.last() {
last.span
} else {
Span::new(0, 0)
}
} else {
self.peek().span
}
}
}
/// Top-level parse function.
pub fn parse(tokens: Vec<Token>) -> Result<Expr, ParseError> {
// Filter out text-only token streams
let has_expr = tokens.iter().any(|t| {
!matches!(
t.kind,
TokenKind::Text(_) | TokenKind::Comment(_) | TokenKind::Eof
)
});
if !has_expr {
return Err(ParseError::new("no expression to parse", Span::new(0, 0)));
}
Parser::new(tokens).parse()
}
fn operator_to_binop(op: &Operator) -> BinOp {
match op {
Operator::Add => BinOp::Add,
Operator::Subtract => BinOp::Sub,
Operator::Multiply => BinOp::Mul,
Operator::Divide => BinOp::Div,
Operator::Power => BinOp::Pow,
Operator::Modulo => BinOp::Mod,
}
}
fn symbol_to_currency(sym: &str) -> String {
match sym {
"$" => "USD".to_string(),
"" => "EUR".to_string(),
"£" => "GBP".to_string(),
"¥" => "JPY".to_string(),
"R$" => "BRL".to_string(),
other => other.to_string(),
}
}
fn keyword_to_currency(kw: &str) -> String {
match kw.to_lowercase().as_str() {
"euro" | "eur" => "EUR".to_string(),
"usd" | "dollar" | "dollars" => "USD".to_string(),
"gbp" | "pound" | "pounds" => "GBP".to_string(),
other => other.to_uppercase(),
}
}
fn extract_number(expr: &Expr) -> Result<f64, ParseError> {
match &expr.node {
ExprKind::Number(v) => Ok(*v),
ExprKind::UnaryNeg(inner) => {
let v = extract_number(inner)?;
Ok(-v)
}
_ => Err(ParseError::new(
"expected numeric value",
expr.span,
)),
}
}

View File

@@ -0,0 +1,40 @@
use crate::context::EvalContext;
use crate::interpreter::evaluate;
use crate::lexer::tokenize;
use crate::parser::parse;
use crate::span::Span;
use crate::types::CalcResult;
/// Evaluate a single line of input and return the result.
pub fn eval_line(input: &str, ctx: &mut EvalContext) -> CalcResult {
let trimmed = input.trim();
if trimmed.is_empty() {
return CalcResult::error("empty input", Span::new(0, 0));
}
let tokens = tokenize(trimmed);
// Check for text-only or comment-only lines
let has_expr = tokens.iter().any(|t| {
!matches!(
t.kind,
crate::token::TokenKind::Text(_)
| crate::token::TokenKind::Comment(_)
| crate::token::TokenKind::Eof
)
});
if !has_expr {
return CalcResult::error("no expression found", Span::new(0, trimmed.len()));
}
match parse(tokens) {
Ok(expr) => evaluate(&expr, ctx),
Err(e) => CalcResult::error(&e.message, e.span),
}
}
/// Evaluate multiple lines of input, sharing context across lines.
/// Variable assignments on earlier lines are visible to later lines.
pub fn eval_sheet(lines: &[&str], ctx: &mut EvalContext) -> Vec<CalcResult> {
lines.iter().map(|line| eval_line(line, ctx)).collect()
}

View File

@@ -0,0 +1,683 @@
use crate::ast::{Expr, ExprKind};
use crate::context::EvalContext;
use crate::interpreter;
use crate::lexer;
use crate::parser;
use crate::span::Span;
use crate::types::CalcResult;
use std::collections::{HashMap, HashSet};
/// A parsed line in the sheet.
#[derive(Debug, Clone)]
struct LineEntry {
/// The raw source text.
source: String,
/// The parsed AST, or None if parse failed.
parsed: Option<Expr>,
/// The parse error, if any.
parse_error: Option<String>,
/// Variable name defined by this line (if assignment).
defines_var: Option<String>,
/// Variable names referenced by this line.
references_vars: Vec<String>,
/// Whether this line is a non-calculable text/comment line.
is_text: bool,
}
/// SheetContext holds all evaluation state for a multi-line sheet.
///
/// It manages variables, line results, dependency graphs, and supports
/// selective re-evaluation when lines change.
#[derive(Debug, Clone)]
pub struct SheetContext {
/// Lines stored by index. Sparse -- not all indices need to be filled.
lines: HashMap<usize, LineEntry>,
/// Cached results per line from the last evaluation.
results: HashMap<usize, CalcResult>,
/// The maximum line index seen (for iteration order).
max_line: usize,
/// Tracks which lines have been modified since last eval.
dirty_lines: HashSet<usize>,
/// Whether a full evaluation has been performed at least once.
has_evaluated: bool,
}
impl SheetContext {
/// Create a new, empty SheetContext.
pub fn new() -> Self {
SheetContext {
lines: HashMap::new(),
results: HashMap::new(),
max_line: 0,
dirty_lines: HashSet::new(),
has_evaluated: false,
}
}
/// Set (or replace) a line at the given index.
pub fn set_line(&mut self, index: usize, source: &str) {
if index > self.max_line {
self.max_line = index;
}
let trimmed = source.trim();
// Tokenize and parse through the real engine pipeline
let tokens = lexer::tokenize(trimmed);
// Check if this is a text-only or comment-only line
let is_text = !tokens.iter().any(|t| {
!matches!(
t.kind,
crate::token::TokenKind::Text(_)
| crate::token::TokenKind::Comment(_)
| crate::token::TokenKind::Eof
)
});
let (parsed, parse_error) = if is_text || trimmed.is_empty() {
(None, None)
} else {
match parser::parse(tokens) {
Ok(expr) => (Some(expr), None),
Err(e) => (None, Some(e.message)),
}
};
let defines_var = parsed.as_ref().and_then(defined_variable);
let references_vars = parsed
.as_ref()
.map(referenced_variables)
.unwrap_or_default();
let entry = LineEntry {
source: source.to_string(),
parsed,
parse_error,
defines_var,
references_vars,
is_text,
};
self.lines.insert(index, entry);
self.dirty_lines.insert(index);
}
/// Remove a line at the given index.
pub fn remove_line(&mut self, index: usize) {
self.lines.remove(&index);
self.results.remove(&index);
self.dirty_lines.insert(index);
}
/// Get the number of lines in the sheet.
pub fn line_count(&self) -> usize {
self.lines.len()
}
/// Check if the sheet has any variables defined.
pub fn has_variables(&self) -> bool {
self.lines.values().any(|e| e.defines_var.is_some())
}
/// Evaluate all lines in the sheet, returning results in line-index order.
///
/// This method performs dependency analysis and selective re-evaluation:
/// - Lines whose dependencies haven't changed are not recomputed.
/// - Circular dependencies are detected and reported as errors.
pub fn eval(&mut self) -> Vec<CalcResult> {
let line_indices = self.sorted_line_indices();
if line_indices.is_empty() {
self.has_evaluated = true;
self.dirty_lines.clear();
return Vec::new();
}
// Build the dependency graph
let var_to_line = self.build_var_to_line_map(&line_indices);
// Detect circular dependencies
let circular_lines = self.detect_circular_deps(&line_indices, &var_to_line);
// Determine which lines need re-evaluation
let lines_to_eval = if !self.has_evaluated {
// First eval: evaluate everything
line_indices.iter().copied().collect::<HashSet<_>>()
} else {
self.compute_dirty_set(&line_indices, &var_to_line)
};
// Build a shared EvalContext and evaluate in order.
// We rebuild the context for the full pass so variables propagate correctly.
let mut ctx = EvalContext::new();
for &idx in &line_indices {
if circular_lines.contains(&idx) {
let result = CalcResult::error(
"Circular dependency detected",
Span::new(0, 1),
);
self.results.insert(idx, result);
continue;
}
let entry = &self.lines[&idx];
// Text/empty lines produce no result -- skip them
if entry.is_text || entry.source.trim().is_empty() {
let result = CalcResult::error("no expression found", Span::new(0, entry.source.len()));
self.results.insert(idx, result);
continue;
}
if lines_to_eval.contains(&idx) {
// Evaluate this line
if let Some(ref expr) = entry.parsed {
let result = interpreter::evaluate(expr, &mut ctx);
self.results.insert(idx, result);
} else {
// Parse error
let err_msg = entry
.parse_error
.as_deref()
.unwrap_or("Parse error");
let result = CalcResult::error(err_msg, Span::new(0, 1));
self.results.insert(idx, result);
}
} else {
// Reuse cached result, but still replay variable definitions into ctx
if let Some(cached) = self.results.get(&idx) {
if let Some(ref var_name) = entry.defines_var {
ctx.set_variable(var_name, cached.clone());
}
}
}
}
self.has_evaluated = true;
self.dirty_lines.clear();
// Return results in line order
line_indices
.iter()
.map(|idx| {
self.results
.get(idx)
.cloned()
.unwrap_or_else(|| CalcResult::error("No result", Span::new(0, 1)))
})
.collect()
}
/// Get the dependency graph as a map of line index -> set of line indices it depends on.
pub fn dependency_graph(&self) -> HashMap<usize, HashSet<usize>> {
let line_indices = self.sorted_line_indices();
let var_to_line = self.build_var_to_line_map(&line_indices);
let mut graph: HashMap<usize, HashSet<usize>> = HashMap::new();
for &idx in &line_indices {
let mut deps = HashSet::new();
if let Some(entry) = self.lines.get(&idx) {
for var in &entry.references_vars {
if let Some(&def_line) = var_to_line.get(var) {
deps.insert(def_line);
}
}
}
graph.insert(idx, deps);
}
graph
}
// --- Internal helpers ---
fn sorted_line_indices(&self) -> Vec<usize> {
let mut indices: Vec<usize> = self.lines.keys().copied().collect();
indices.sort();
indices
}
/// Build a map from variable name to the line index that defines it.
fn build_var_to_line_map(&self, line_indices: &[usize]) -> HashMap<String, usize> {
let mut map = HashMap::new();
for &idx in line_indices {
if let Some(entry) = self.lines.get(&idx) {
if let Some(ref var_name) = entry.defines_var {
map.insert(var_name.clone(), idx);
}
}
}
map
}
/// Detect circular dependencies using DFS cycle detection.
fn detect_circular_deps(
&self,
line_indices: &[usize],
var_to_line: &HashMap<String, usize>,
) -> HashSet<usize> {
let mut circular = HashSet::new();
// Build adjacency list: line -> lines it depends on
let mut adj: HashMap<usize, Vec<usize>> = HashMap::new();
for &idx in line_indices {
let mut deps = Vec::new();
if let Some(entry) = self.lines.get(&idx) {
for var in &entry.references_vars {
if let Some(&dep_line) = var_to_line.get(var) {
deps.push(dep_line);
}
}
}
adj.insert(idx, deps);
}
// DFS for each node
let mut visited = HashSet::new();
let mut in_stack = HashSet::new();
for &idx in line_indices {
if !visited.contains(&idx) {
Self::dfs_cycle(idx, &adj, &mut visited, &mut in_stack, &mut circular);
}
}
circular
}
fn dfs_cycle(
node: usize,
adj: &HashMap<usize, Vec<usize>>,
visited: &mut HashSet<usize>,
in_stack: &mut HashSet<usize>,
circular: &mut HashSet<usize>,
) {
visited.insert(node);
in_stack.insert(node);
if let Some(deps) = adj.get(&node) {
for &dep in deps {
if !visited.contains(&dep) {
Self::dfs_cycle(dep, adj, visited, in_stack, circular);
} else if in_stack.contains(&dep) {
// Found a cycle -- mark both nodes
circular.insert(dep);
circular.insert(node);
}
}
}
in_stack.remove(&node);
}
/// Compute the set of lines that need re-evaluation based on dirty lines
/// and their dependents (transitive closure).
fn compute_dirty_set(
&self,
line_indices: &[usize],
var_to_line: &HashMap<String, usize>,
) -> HashSet<usize> {
let mut dirty = self.dirty_lines.clone();
// Build reverse map: line -> lines that depend on it
let mut dependents: HashMap<usize, Vec<usize>> = HashMap::new();
for &idx in line_indices {
if let Some(entry) = self.lines.get(&idx) {
for var in &entry.references_vars {
if let Some(&def_line) = var_to_line.get(var) {
dependents
.entry(def_line)
.or_default()
.push(idx);
}
}
}
}
// BFS to propagate dirtiness
let mut queue: Vec<usize> = dirty.iter().copied().collect();
while let Some(line) = queue.pop() {
if let Some(deps) = dependents.get(&line) {
for &dep in deps {
if dirty.insert(dep) {
queue.push(dep);
}
}
}
}
dirty
}
}
impl Default for SheetContext {
fn default() -> Self {
Self::new()
}
}
// --- AST helper functions ---
/// Extract the variable name defined by an assignment expression.
fn defined_variable(expr: &Expr) -> Option<String> {
match &expr.node {
ExprKind::Assignment { name, .. } => Some(name.clone()),
_ => None,
}
}
/// Collect all variable names referenced (but not defined) by an expression.
fn referenced_variables(expr: &Expr) -> Vec<String> {
let mut vars = Vec::new();
collect_references(&expr.node, &mut vars);
vars
}
fn collect_references(node: &ExprKind, vars: &mut Vec<String>) {
match node {
ExprKind::Identifier(name) => {
if !vars.contains(name) {
vars.push(name.clone());
}
}
ExprKind::Assignment { value, .. } => {
collect_references(&value.node, vars);
}
ExprKind::BinaryOp { left, right, .. } => {
collect_references(&left.node, vars);
collect_references(&right.node, vars);
}
ExprKind::UnaryNeg(inner) => {
collect_references(&inner.node, vars);
}
ExprKind::PercentOp { base, .. } => {
collect_references(&base.node, vars);
}
ExprKind::Conversion { expr, .. } => {
collect_references(&expr.node, vars);
}
ExprKind::DateRange { from, to } => {
collect_references(&from.node, vars);
collect_references(&to.node, vars);
}
ExprKind::Comparison { left, right, .. } => {
collect_references(&left.node, vars);
collect_references(&right.node, vars);
}
// Leaf nodes with no variable references
ExprKind::Number(_)
| ExprKind::UnitNumber { .. }
| ExprKind::CurrencyValue { .. }
| ExprKind::Boolean(_)
| ExprKind::DateLiteral { .. }
| ExprKind::Today
| ExprKind::Duration { .. } => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CalcValue, ResultType};
// === AC: New SheetContext is empty ===
#[test]
fn test_new_sheet_context_is_empty() {
let ctx = SheetContext::new();
assert_eq!(ctx.line_count(), 0);
assert!(!ctx.has_variables());
let dep_graph = ctx.dependency_graph();
assert!(dep_graph.is_empty());
}
// === AC: Basic two-line evaluation ===
#[test]
fn test_basic_two_line_eval() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(1, "x * 2");
let results = ctx.eval();
assert_eq!(results.len(), 2);
assert_eq!(results[0].value, CalcValue::Number { value: 10.0 });
assert_eq!(results[1].value, CalcValue::Number { value: 20.0 });
}
// === AC: Variable update propagates ===
#[test]
fn test_variable_update_propagates() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(2, "x");
let results = ctx.eval();
assert_eq!(results[1].value, CalcValue::Number { value: 10.0 });
// Change x to 20
ctx.set_line(0, "x = 20");
let results = ctx.eval();
assert_eq!(results[0].value, CalcValue::Number { value: 20.0 });
assert_eq!(results[1].value, CalcValue::Number { value: 20.0 });
}
// === AC: Clone is deep copy ===
#[test]
fn test_clone_is_deep_copy() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(1, "x * 2");
ctx.eval();
let mut clone = ctx.clone();
clone.set_line(0, "x = 99");
clone.eval();
// Original should be unchanged
let orig_results = ctx.eval();
assert_eq!(orig_results[0].value, CalcValue::Number { value: 10.0 });
assert_eq!(orig_results[1].value, CalcValue::Number { value: 20.0 });
// Clone should have new values
let clone_results = clone.eval();
assert_eq!(clone_results[0].value, CalcValue::Number { value: 99.0 });
assert_eq!(clone_results[1].value, CalcValue::Number { value: 198.0 });
}
// === AC: Clone performance for undo/redo ===
#[test]
fn test_clone_performance_100_lines() {
let mut ctx = SheetContext::new();
for i in 0..100 {
ctx.set_line(i, &format!("v{} = {}", i, i * 10));
}
ctx.eval();
let start = std::time::Instant::now();
let _clone = ctx.clone();
let elapsed = start.elapsed();
// Sub-millisecond requirement
assert!(
elapsed.as_millis() < 1,
"Clone took {}ms, expected < 1ms",
elapsed.as_millis()
);
}
// === AC: Selective re-evaluation ===
#[test]
fn test_selective_re_evaluation() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "a = 10");
ctx.set_line(1, "b = 20");
ctx.set_line(2, "c = a + 5");
ctx.set_line(3, "d = b + 5");
ctx.eval();
// Modify only line 1 (b = 20 -> b = 30)
ctx.set_line(1, "b = 30");
let results = ctx.eval();
// line 0 (a = 10) and line 2 (c = a + 5 = 15) should be unchanged
assert_eq!(results[0].value, CalcValue::Number { value: 10.0 });
assert_eq!(results[2].value, CalcValue::Number { value: 15.0 });
// line 1 (b = 30) and line 3 (d = b + 5 = 35) should be updated
assert_eq!(results[1].value, CalcValue::Number { value: 30.0 });
assert_eq!(results[3].value, CalcValue::Number { value: 35.0 });
}
// === AC: Dependency graph tracks correctly ===
#[test]
fn test_dependency_graph() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(1, "y = 20");
ctx.set_line(2, "x + y");
ctx.set_line(3, "x * 2");
let graph = ctx.dependency_graph();
// Line 0 and 1 depend on nothing
assert!(graph[&0].is_empty());
assert!(graph[&1].is_empty());
// Line 2 depends on lines 0 and 1
assert!(graph[&2].contains(&0));
assert!(graph[&2].contains(&1));
// Line 3 depends on line 0
assert!(graph[&3].contains(&0));
assert!(!graph[&3].contains(&1));
}
// === AC: Circular dependency detection ===
#[test]
fn test_circular_dependency_detection() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "a = b");
ctx.set_line(1, "b = a");
let results = ctx.eval();
assert_eq!(results.len(), 2);
// Both lines should be errors
assert_eq!(results[0].result_type(), ResultType::Error);
assert_eq!(results[1].result_type(), ResultType::Error);
// Check that error messages mention circular dependency
if let CalcValue::Error { message, .. } = &results[0].value {
assert!(
message.contains("ircular"),
"Expected circular dependency error, got: {}",
message
);
}
}
// === Additional tests ===
#[test]
fn test_empty_eval() {
let mut ctx = SheetContext::new();
let results = ctx.eval();
assert!(results.is_empty());
}
#[test]
fn test_parse_error_line() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "10");
ctx.set_line(1, "???");
ctx.set_line(2, "20");
let results = ctx.eval();
assert_eq!(results.len(), 3);
assert_eq!(results[0].value, CalcValue::Number { value: 10.0 });
assert_eq!(results[1].result_type(), ResultType::Error);
assert_eq!(results[2].value, CalcValue::Number { value: 20.0 });
}
#[test]
fn test_chained_variables() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "a = 5");
ctx.set_line(1, "b = a * 2");
ctx.set_line(2, "c = b + a");
let results = ctx.eval();
assert_eq!(results[0].value, CalcValue::Number { value: 5.0 });
assert_eq!(results[1].value, CalcValue::Number { value: 10.0 });
assert_eq!(results[2].value, CalcValue::Number { value: 15.0 });
}
#[test]
fn test_remove_line() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "10");
ctx.set_line(1, "20");
assert_eq!(ctx.line_count(), 2);
ctx.remove_line(1);
assert_eq!(ctx.line_count(), 1);
}
#[test]
fn test_sparse_line_indices() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(5, "x + 5");
let results = ctx.eval();
assert_eq!(results.len(), 2);
assert_eq!(results[0].value, CalcValue::Number { value: 10.0 });
assert_eq!(results[1].value, CalcValue::Number { value: 15.0 });
}
#[test]
fn test_multiple_evals_without_changes() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "x = 10");
ctx.set_line(1, "x * 2");
let results1 = ctx.eval();
let results2 = ctx.eval();
assert_eq!(results1, results2);
}
#[test]
fn test_no_infinite_loop_on_circular() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "a = b");
ctx.set_line(1, "b = c");
ctx.set_line(2, "c = a");
// This should complete without hanging
let results = ctx.eval();
assert_eq!(results.len(), 3);
// All lines in the cycle should be errors
for r in &results {
assert_eq!(r.result_type(), ResultType::Error);
}
}
#[test]
fn test_independent_lines_not_recomputed() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "a = 10");
ctx.set_line(1, "b = 20");
ctx.set_line(2, "c = a + 5");
ctx.eval();
// Modify only line 1
ctx.set_line(1, "b = 30");
// Check dirty set: only line 1 should be dirty
// Line 0 and line 2 should NOT be dirty since they don't depend on b
let line_indices = ctx.sorted_line_indices();
let var_to_line = ctx.build_var_to_line_map(&line_indices);
let dirty = ctx.compute_dirty_set(&line_indices, &var_to_line);
assert!(dirty.contains(&1), "Line 1 should be dirty");
assert!(!dirty.contains(&0), "Line 0 should NOT be dirty");
assert!(!dirty.contains(&2), "Line 2 should NOT be dirty (doesn't depend on b)");
}
}

View File

@@ -0,0 +1,20 @@
/// Byte-offset span within source input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
/// Create a span that covers both `self` and `other`.
pub fn merge(self, other: Span) -> Span {
Span {
start: self.start.min(other.start),
end: self.end.max(other.end),
}
}
}

View File

@@ -0,0 +1,70 @@
use crate::span::Span;
/// Arithmetic / symbolic operators.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operator {
Add,
Subtract,
Multiply,
Divide,
Power,
Modulo,
}
/// The kind of a token (payload).
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
/// Numeric literal.
Number(f64),
/// Arithmetic operator.
Op(Operator),
/// Opening parenthesis `(`.
LParen,
/// Closing parenthesis `)`.
RParen,
/// An identifier (variable name, constant like `pi`).
Identifier(String),
/// A unit suffix such as `kg`, `g`, `m`, `lb`.
Unit(String),
/// A currency symbol prefix such as `$`, `€`, `£`.
CurrencySymbol(String),
/// The keyword `in` (for conversions).
In,
/// A percentage literal, e.g. `5%` → stores `5.0`.
Percent(f64),
/// Comparison `>`.
Greater,
/// Comparison `<`.
Less,
/// Comparison `>=`.
GreaterEq,
/// Comparison `<=`.
LessEq,
/// Comparison `==`.
Equal,
/// Comparison `!=`.
NotEqual,
/// Assignment `=`.
Assign,
/// A generic keyword (discount, off, etc.).
Keyword(String),
/// A comment token.
Comment(String),
/// Plain text (non-calculable).
Text(String),
/// End of input sentinel.
Eof,
}
/// A token produced by the lexer, pairing a kind with its source span.
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
pub kind: TokenKind,
pub span: Span,
}
impl Token {
pub fn new(kind: TokenKind, span: Span) -> Self {
Self { kind, span }
}
}

214
calcpad-engine/src/types.rs Normal file
View File

@@ -0,0 +1,214 @@
use crate::span::Span;
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::fmt;
/// The result type tag for metadata purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ResultType {
Number,
UnitValue,
CurrencyValue,
DateTime,
TimeDelta,
Boolean,
Error,
}
impl fmt::Display for ResultType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ResultType::Number => write!(f, "Number"),
ResultType::UnitValue => write!(f, "UnitValue"),
ResultType::CurrencyValue => write!(f, "CurrencyValue"),
ResultType::DateTime => write!(f, "DateTime"),
ResultType::TimeDelta => write!(f, "TimeDelta"),
ResultType::Boolean => write!(f, "Boolean"),
ResultType::Error => write!(f, "Error"),
}
}
}
/// Serializable span for JSON output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SerializableSpan {
pub start: usize,
pub end: usize,
}
impl From<Span> for SerializableSpan {
fn from(s: Span) -> Self {
SerializableSpan {
start: s.start,
end: s.end,
}
}
}
/// Metadata attached to every evaluation result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResultMetadata {
/// The source span of the expression that produced this result.
pub span: SerializableSpan,
/// The type tag of the result.
pub result_type: ResultType,
/// A display-formatted string of the result.
pub display: String,
/// The raw numeric value, if applicable.
pub raw_value: Option<f64>,
}
/// The value payload of a calculation result.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum CalcValue {
Number { value: f64 },
UnitValue { value: f64, unit: String },
CurrencyValue { amount: f64, currency: String },
DateTime { date: String },
TimeDelta { days: i64, description: String },
Boolean { value: bool },
Error { message: String, span: SerializableSpan },
}
/// A complete calculation result: value + metadata.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalcResult {
pub value: CalcValue,
pub metadata: ResultMetadata,
}
impl CalcResult {
pub fn number(val: f64, span: Span) -> Self {
let display = format_number(val);
CalcResult {
value: CalcValue::Number { value: val },
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Number,
display,
raw_value: Some(val),
},
}
}
pub fn unit_value(val: f64, unit: &str, span: Span) -> Self {
let display = format!("{} {}", format_number(val), unit);
CalcResult {
value: CalcValue::UnitValue {
value: val,
unit: unit.to_string(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::UnitValue,
display,
raw_value: Some(val),
},
}
}
pub fn currency_value(amount: f64, currency: &str, span: Span) -> Self {
let display = format_currency(amount, currency);
CalcResult {
value: CalcValue::CurrencyValue {
amount,
currency: currency.to_string(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::CurrencyValue,
display,
raw_value: Some(amount),
},
}
}
pub fn datetime(date: NaiveDate, span: Span) -> Self {
let display = date.format("%Y-%m-%d").to_string();
CalcResult {
value: CalcValue::DateTime {
date: display.clone(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::DateTime,
display,
raw_value: None,
},
}
}
pub fn time_delta(days: i64, description: &str, span: Span) -> Self {
let display = description.to_string();
CalcResult {
value: CalcValue::TimeDelta {
days,
description: description.to_string(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::TimeDelta,
display,
raw_value: Some(days as f64),
},
}
}
pub fn boolean(val: bool, span: Span) -> Self {
let display = val.to_string();
CalcResult {
value: CalcValue::Boolean { value: val },
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Boolean,
display,
raw_value: None,
},
}
}
pub fn error(message: &str, span: Span) -> Self {
CalcResult {
value: CalcValue::Error {
message: message.to_string(),
span: span.into(),
},
metadata: ResultMetadata {
span: span.into(),
result_type: ResultType::Error,
display: format!("Error: {}", message),
raw_value: None,
},
}
}
pub fn result_type(&self) -> ResultType {
self.metadata.result_type
}
}
fn format_number(val: f64) -> String {
if val == val.floor() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
format!("{}", val)
}
}
fn format_currency(amount: f64, currency: &str) -> String {
let symbol = match currency {
"USD" => "$",
"EUR" => "",
"GBP" => "£",
"JPY" => "¥",
"BRL" => "R$",
_ => "",
};
if symbol.is_empty() {
format!("{:.2} {}", amount, currency)
} else {
format!("{}{:.2}", symbol, amount)
}
}

View File

@@ -0,0 +1,445 @@
use calcpad_engine::context::EvalContext;
use calcpad_engine::pipeline::{eval_line, eval_sheet};
use calcpad_engine::types::ResultType;
use calcpad_engine::ffi::{FfiResponse, FfiSheetResponse};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::ptr;
// Re-declare FFI functions for testing
extern "C" {
fn calcpad_eval_line(input: *const c_char) -> *mut c_char;
fn calcpad_eval_sheet(lines: *const *const c_char, count: i32) -> *mut c_char;
fn calcpad_free_result(ptr: *mut c_char);
}
// ===== Pipeline tests =====
#[test]
fn pipeline_eval_simple_addition() {
let mut ctx = EvalContext::new();
let result = eval_line("2 + 3", &mut ctx);
assert_eq!(result.result_type(), ResultType::Number);
assert_eq!(result.metadata.display, "5");
assert_eq!(result.metadata.raw_value, Some(5.0));
}
#[test]
fn pipeline_eval_multiplication() {
let mut ctx = EvalContext::new();
let result = eval_line("6 * 7", &mut ctx);
assert_eq!(result.metadata.display, "42");
}
#[test]
fn pipeline_eval_division() {
let mut ctx = EvalContext::new();
let result = eval_line("10 / 4", &mut ctx);
assert_eq!(result.metadata.raw_value, Some(2.5));
}
#[test]
fn pipeline_eval_exponentiation() {
let mut ctx = EvalContext::new();
let result = eval_line("2 ^ 10", &mut ctx);
assert_eq!(result.metadata.display, "1024");
}
#[test]
fn pipeline_eval_parentheses() {
let mut ctx = EvalContext::new();
let result = eval_line("(2 + 3) * 4", &mut ctx);
assert_eq!(result.metadata.display, "20");
}
#[test]
fn pipeline_eval_unary_neg() {
let mut ctx = EvalContext::new();
let result = eval_line("-5 + 3", &mut ctx);
assert_eq!(result.metadata.display, "-2");
}
#[test]
fn pipeline_eval_percentage() {
let mut ctx = EvalContext::new();
let result = eval_line("100 - 20%", &mut ctx);
assert_eq!(result.metadata.display, "80");
}
#[test]
fn pipeline_eval_unit_number() {
let mut ctx = EvalContext::new();
let result = eval_line("5kg", &mut ctx);
assert_eq!(result.result_type(), ResultType::UnitValue);
assert_eq!(result.metadata.display, "5 kg");
}
#[test]
fn pipeline_eval_currency() {
let mut ctx = EvalContext::new();
let result = eval_line("$20", &mut ctx);
assert_eq!(result.result_type(), ResultType::CurrencyValue);
assert!(result.metadata.display.contains("20"));
}
#[test]
fn pipeline_eval_comparison() {
let mut ctx = EvalContext::new();
let result = eval_line("5 > 3", &mut ctx);
assert_eq!(result.result_type(), ResultType::Boolean);
assert_eq!(result.metadata.display, "true");
}
#[test]
fn pipeline_eval_division_by_zero() {
let mut ctx = EvalContext::new();
let result = eval_line("10 / 0", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
}
#[test]
fn pipeline_eval_empty_input() {
let mut ctx = EvalContext::new();
let result = eval_line("", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
}
#[test]
fn pipeline_eval_comment_only() {
let mut ctx = EvalContext::new();
let result = eval_line("// this is a comment", &mut ctx);
assert_eq!(result.result_type(), ResultType::Error);
}
// ===== Variable assignment tests (eval_sheet) =====
#[test]
fn pipeline_eval_sheet_basic() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["2 + 3", "10 * 2"], &mut ctx);
assert_eq!(results.len(), 2);
assert_eq!(results[0].metadata.display, "5");
assert_eq!(results[1].metadata.display, "20");
}
#[test]
fn pipeline_eval_sheet_variable_assignment() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = 5", "x + 3"], &mut ctx);
assert_eq!(results.len(), 2);
assert_eq!(results[0].metadata.display, "5");
assert_eq!(results[1].metadata.display, "8");
}
#[test]
fn pipeline_eval_sheet_multiple_variables() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["a = 10", "b = 20", "a + b"], &mut ctx);
assert_eq!(results.len(), 3);
assert_eq!(results[0].metadata.display, "10");
assert_eq!(results[1].metadata.display, "20");
assert_eq!(results[2].metadata.display, "30");
}
#[test]
fn pipeline_eval_sheet_variable_reassignment() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = 5", "x = 10", "x"], &mut ctx);
assert_eq!(results.len(), 3);
assert_eq!(results[2].metadata.display, "10");
}
#[test]
fn pipeline_eval_sheet_undefined_variable() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["y + 3"], &mut ctx);
assert_eq!(results[0].result_type(), ResultType::Error);
}
// ===== FFI function tests =====
#[test]
fn ffi_eval_line_basic() {
let input = CString::new("2 + 3").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.schema_version, "1.0");
assert_eq!(response.result.result_type(), ResultType::Number);
assert_eq!(response.result.metadata.display, "5");
assert_eq!(response.result.metadata.raw_value, Some(5.0));
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_eval_line_complex_expression() {
let input = CString::new("(10 + 5) * 2").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.result.metadata.display, "30");
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_eval_line_null_input() {
unsafe {
let result_ptr = calcpad_eval_line(ptr::null());
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.result.result_type(), ResultType::Error);
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_eval_sheet_basic() {
let line1 = CString::new("2 + 3").unwrap();
let line2 = CString::new("10 * 2").unwrap();
let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()];
unsafe {
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2);
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.schema_version, "1.0");
assert_eq!(response.results.len(), 2);
assert_eq!(response.results[0].metadata.display, "5");
assert_eq!(response.results[1].metadata.display, "20");
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_eval_sheet_with_variables() {
let line1 = CString::new("x = 5").unwrap();
let line2 = CString::new("x + 10").unwrap();
let lines: Vec<*const c_char> = vec![line1.as_ptr(), line2.as_ptr()];
unsafe {
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 2);
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiSheetResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.results.len(), 2);
assert_eq!(response.results[0].metadata.display, "5");
assert_eq!(response.results[1].metadata.display, "15");
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_eval_sheet_null_lines() {
unsafe {
let result_ptr = calcpad_eval_sheet(ptr::null(), 0);
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let response: FfiResponse = serde_json::from_str(result_str).unwrap();
assert_eq!(response.result.result_type(), ResultType::Error);
calcpad_free_result(result_ptr);
}
}
// ===== Panic safety tests =====
#[test]
fn ffi_panic_safety_eval_line() {
// The catch_unwind in eval_line should handle any internal panics.
// Test with various edge cases that might trigger unexpected behavior.
let input = CString::new("2 + 3").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
assert!(!result_ptr.is_null());
calcpad_free_result(result_ptr);
}
}
#[test]
fn ffi_panic_safety_null_input() {
// Null input should not crash — should return error JSON
unsafe {
let result_ptr = calcpad_eval_line(ptr::null());
assert!(!result_ptr.is_null());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
assert!(result_str.contains("error") || result_str.contains("Error"));
calcpad_free_result(result_ptr);
}
}
// ===== Memory management tests =====
#[test]
fn ffi_free_result_null() {
// Freeing null should be a safe no-op
unsafe {
calcpad_free_result(ptr::null_mut());
}
// If we get here without crashing, the test passes
}
#[test]
fn ffi_free_result_valid() {
let input = CString::new("42").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
assert!(!result_ptr.is_null());
// Free the result — should not crash
calcpad_free_result(result_ptr);
}
// If we get here without crashing, the test passes
}
#[test]
fn ffi_multiple_allocations() {
// Allocate and free multiple results to check for memory leaks
for _ in 0..100 {
let input = CString::new("1 + 1").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
assert!(!result_ptr.is_null());
calcpad_free_result(result_ptr);
}
}
}
// ===== JSON schema versioning tests =====
#[test]
fn json_schema_version_present() {
let input = CString::new("42").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
assert_eq!(json["schema_version"], "1.0");
calcpad_free_result(result_ptr);
}
}
#[test]
fn json_schema_contains_required_fields() {
let input = CString::new("42").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
// Check schema_version
assert!(json["schema_version"].is_string());
// Check result structure
assert!(json["result"]["value"].is_object());
assert!(json["result"]["metadata"].is_object());
// Check metadata fields
let metadata = &json["result"]["metadata"];
assert!(metadata["result_type"].is_string());
assert!(metadata["display"].is_string());
assert!(metadata["span"].is_object());
assert!(metadata["span"]["start"].is_number());
assert!(metadata["span"]["end"].is_number());
// Check raw_value is present (can be number or null)
assert!(
metadata["raw_value"].is_number() || metadata["raw_value"].is_null(),
"raw_value should be a number or null"
);
calcpad_free_result(result_ptr);
}
}
#[test]
fn json_schema_result_type_field() {
let input = CString::new("42").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
assert_eq!(json["result"]["metadata"]["result_type"], "Number");
assert_eq!(json["result"]["value"]["kind"], "Number");
calcpad_free_result(result_ptr);
}
}
#[test]
fn json_schema_error_result() {
let input = CString::new("10 / 0").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
assert_eq!(json["result"]["metadata"]["result_type"], "Error");
assert_eq!(json["result"]["value"]["kind"], "Error");
assert!(json["result"]["value"]["message"].is_string());
calcpad_free_result(result_ptr);
}
}
#[test]
fn json_schema_sheet_version() {
let line1 = CString::new("1 + 1").unwrap();
let lines: Vec<*const c_char> = vec![line1.as_ptr()];
unsafe {
let result_ptr = calcpad_eval_sheet(lines.as_ptr(), 1);
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
assert_eq!(json["schema_version"], "1.0");
assert!(json["results"].is_array());
assert_eq!(json["results"].as_array().unwrap().len(), 1);
calcpad_free_result(result_ptr);
}
}
#[test]
fn json_display_value_and_raw_value() {
let input = CString::new("2.5 * 4").unwrap();
unsafe {
let result_ptr = calcpad_eval_line(input.as_ptr());
let result_str = CStr::from_ptr(result_ptr).to_str().unwrap();
let json: serde_json::Value = serde_json::from_str(result_str).unwrap();
assert_eq!(json["result"]["metadata"]["display"], "10");
assert_eq!(json["result"]["metadata"]["raw_value"], 10.0);
calcpad_free_result(result_ptr);
}
}