feat: add platform shells, CLI, formatting, plugins, tests, and benchmarks

Phase 4 — Platform shells:
- calcpad-macos/: SwiftUI two-column editor with Rust FFI bridge (16 files)
- calcpad-windows/: iced GUI with Windows 11 Fluent theme (7 files, 13 tests)
- calcpad-web/: React 18 + CodeMirror 6 + WASM Worker + PWA (20 files)
- calcpad-cli/: clap-based CLI with expression eval, pipe/stdin, JSON/CSV
  output, and interactive REPL with rustyline history

Phase 5 — Engine modules:
- formatting/: answer formatting (decimal/scientific/SI notation, thousands
  separators, currency), line type classification, clipboard values (93 tests)
- plugins/: CalcPadPlugin trait, PluginRegistry, Rhai scripting stub (43 tests)
- benches/: criterion benchmarks (single-line, 100/500-line sheets, DAG, incremental)
- tests/sheet_scenarios.rs: 20 real-world integration tests
- tests/proptest_fuzz.rs: 12 property-based fuzz tests

771 tests passing across workspace, 0 failures.
This commit is contained in:
C. Cassel
2026-03-17 09:46:40 -04:00
committed by C. Cassel
parent 68fa54615a
commit 806e2f1ec6
73 changed files with 11715 additions and 32 deletions

View File

@@ -16,3 +16,9 @@ ureq = { version = "2", features = ["json"] }
[dev-dependencies]
tempfile = "3"
criterion = { version = "0.5", features = ["html_reports"] }
proptest = "1"
[[bench]]
name = "eval_benchmark"
harness = false

View File

@@ -0,0 +1,155 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use calcpad_engine::context::EvalContext;
use calcpad_engine::pipeline::{eval_line, eval_sheet};
use calcpad_engine::SheetContext;
// --- Single-line benchmarks ---
fn bench_single_line_arithmetic(c: &mut Criterion) {
c.bench_function("single_line_arithmetic", |b| {
b.iter(|| {
let mut ctx = EvalContext::new();
eval_line(black_box("(3 + 4) * 2 ^ 3 - 1"), &mut ctx)
})
});
}
fn bench_single_line_unit_conversion(c: &mut Criterion) {
c.bench_function("single_line_unit_conversion", |b| {
b.iter(|| {
let mut ctx = EvalContext::new();
eval_line(black_box("5kg in lb"), &mut ctx)
})
});
}
// --- Sheet benchmarks (pipeline API) ---
fn bench_100_line_sheet(c: &mut Criterion) {
let lines: Vec<String> = (0..100)
.map(|i| {
if i == 0 {
"x = 1".to_string()
} else {
format!("x = x + {}", i)
}
})
.collect();
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
c.bench_function("100_line_sheet_pipeline", |b| {
b.iter(|| {
let mut ctx = EvalContext::new();
eval_sheet(black_box(&line_refs), &mut ctx)
})
});
}
fn bench_variable_heavy_sheet(c: &mut Criterion) {
let mut lines: Vec<String> = Vec::with_capacity(50);
for i in 0..10 {
lines.push(format!("v{} = {}", i, i * 10 + 1));
}
for i in 10..50 {
let a = i % 10;
let b = (i + 3) % 10;
lines.push(format!("r{} = v{} + v{}", i, a, b));
}
let line_refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
c.bench_function("variable_heavy_sheet_dag", |b| {
b.iter(|| {
let mut ctx = EvalContext::new();
eval_sheet(black_box(&line_refs), &mut ctx)
})
});
}
// --- SheetContext benchmarks (with dependency tracking) ---
fn bench_sheet_context_500_lines(c: &mut Criterion) {
let lines: Vec<String> = (0..500)
.map(|i| match i % 5 {
0 => format!("x{} = {}", i, i),
1 => format!("{} + {} * {}", i, i + 1, i + 2),
2 => format!("{}kg in g", (i as f64) * 0.1),
3 => format!("// Comment line {}", i),
4 => format!("sqrt({})", (i * i) as f64),
_ => unreachable!(),
})
.collect();
c.bench_function("sheet_context_500_lines_full_eval", |b| {
b.iter(|| {
let mut sheet = SheetContext::new();
for (i, line) in lines.iter().enumerate() {
sheet.set_line(i, line);
}
sheet.eval()
})
});
}
fn bench_sheet_context_incremental_edit(c: &mut Criterion) {
let lines: Vec<String> = (0..500)
.map(|i| match i % 3 {
0 => format!("x{} = {}", i, i),
1 => format!("{} + {}", i, i + 1),
2 => format!("sqrt({})", (i * i) as f64),
_ => unreachable!(),
})
.collect();
let mut sheet = SheetContext::new();
for (i, line) in lines.iter().enumerate() {
sheet.set_line(i, line);
}
sheet.eval();
c.bench_function("sheet_context_incremental_single_edit", |b| {
let mut s = sheet.clone();
let mut counter = 0;
b.iter(|| {
counter += 1;
// Edit a line that doesn't affect others (no variable)
s.set_line(250, &format!("{} + {}", counter, counter + 1));
s.eval()
})
});
}
fn bench_sheet_context_incremental_variable_change(c: &mut Criterion) {
let mut sheet = SheetContext::new();
sheet.set_line(0, "base = 100");
for i in 1..500 {
if i % 10 == 0 {
sheet.set_line(i, "base + 1");
} else {
sheet.set_line(i, &format!("{} + {}", i, i + 1));
}
}
sheet.eval();
c.bench_function("sheet_context_incremental_variable_change", |b| {
let mut s = sheet.clone();
let mut counter = 100;
b.iter(|| {
counter += 1;
s.set_line(0, &format!("base = {}", counter));
s.eval()
})
});
}
criterion_group!(
benches,
bench_single_line_arithmetic,
bench_single_line_unit_conversion,
bench_100_line_sheet,
bench_variable_heavy_sheet,
bench_sheet_context_500_lines,
bench_sheet_context_incremental_edit,
bench_sheet_context_incremental_variable_change,
);
criterion_main!(benches);

View File

@@ -0,0 +1,666 @@
//! Answer column formatting for CalcPad.
//!
//! Pure-Rust formatting utilities ported from the cross-platform spec in
//! Epic 10-4 (Answer Column Formatting). Supports standard, scientific, and
//! SI notation with configurable decimal places (0--10), thousands separators,
//! and currency symbol placement.
//!
//! This module is platform-agnostic: the same options produce identical output
//! on macOS (SwiftUI), Windows (Iced), and Web (WASM), ensuring the engine is
//! the single source of truth for how numbers appear in the answer column.
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Notation style for the answer column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Notation {
/// Fixed-point with thousands separators (e.g. `1,234.50`).
Standard,
/// Mantissa + exponent (e.g. `1.50e6`).
Scientific,
/// SI prefix (e.g. `1.50M`, `3.00k`).
SI,
}
/// Thousands-separator style.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThousandsSeparator {
/// `1,234,567.89`
Comma,
/// `1.234.567,89` (European-style; decimal separator becomes `,`).
Period,
/// `1 234 567.89`
Space,
/// `1234567.89`
None,
}
/// Where to place a currency symbol relative to the number.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CurrencyPosition {
/// `$1,234.50`
Prefix,
/// `1,234.50€`
Suffix,
}
/// Complete set of formatting options for the answer column.
///
/// Persisted per-sheet (or globally) and optionally overridden per-line.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FormattingOptions {
/// Number of decimal places (clamped to 0..=10).
pub decimal_places: u8,
/// Notation style.
pub notation: Notation,
/// Thousands-separator style.
pub thousands_separator: ThousandsSeparator,
/// Currency symbol string (e.g. `"$"`, `"€"`). Empty means no symbol.
pub currency_symbol: String,
/// Where to place the currency symbol.
pub currency_position: CurrencyPosition,
}
/// Per-line override. Every field is optional; `None` inherits from global.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LineFormatOverride {
pub decimal_places: Option<u8>,
pub notation: Option<Notation>,
pub thousands_separator: Option<ThousandsSeparator>,
pub currency_symbol: Option<String>,
pub currency_position: Option<CurrencyPosition>,
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
impl Default for FormattingOptions {
fn default() -> Self {
Self {
decimal_places: 2,
notation: Notation::Standard,
thousands_separator: ThousandsSeparator::Comma,
currency_symbol: String::new(),
currency_position: CurrencyPosition::Prefix,
}
}
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/// Merge a per-line override on top of global settings. Missing override
/// fields inherit from the global options.
pub fn resolve_formatting(
global: &FormattingOptions,
line_override: Option<&LineFormatOverride>,
) -> FormattingOptions {
match line_override {
None => global.clone(),
Some(ov) => FormattingOptions {
decimal_places: ov.decimal_places.unwrap_or(global.decimal_places),
notation: ov.notation.unwrap_or(global.notation),
thousands_separator: ov.thousands_separator.unwrap_or(global.thousands_separator),
currency_symbol: ov
.currency_symbol
.clone()
.unwrap_or_else(|| global.currency_symbol.clone()),
currency_position: ov.currency_position.unwrap_or(global.currency_position),
},
}
}
// ---------------------------------------------------------------------------
// SI prefixes
// ---------------------------------------------------------------------------
struct SIPrefix {
symbol: &'static str,
exponent: i32,
}
const SI_PREFIXES: &[SIPrefix] = &[
SIPrefix { symbol: "T", exponent: 12 },
SIPrefix { symbol: "G", exponent: 9 },
SIPrefix { symbol: "M", exponent: 6 },
SIPrefix { symbol: "k", exponent: 3 },
SIPrefix { symbol: "", exponent: 0 },
SIPrefix { symbol: "m", exponent: -3 },
SIPrefix { symbol: "\u{03bc}", exponent: -6 }, // μ
SIPrefix { symbol: "n", exponent: -9 },
SIPrefix { symbol: "p", exponent: -12 },
];
// ---------------------------------------------------------------------------
// Core formatting helpers
// ---------------------------------------------------------------------------
/// Insert thousands separators into an integer-part string.
fn insert_thousands_separator(integer_part: &str, sep: ThousandsSeparator) -> String {
if sep == ThousandsSeparator::None {
return integer_part.to_string();
}
let sep_char = match sep {
ThousandsSeparator::Comma => ',',
ThousandsSeparator::Period => '.',
ThousandsSeparator::Space => ' ',
ThousandsSeparator::None => unreachable!(),
};
let (negative, digits) = if let Some(rest) = integer_part.strip_prefix('-') {
(true, rest)
} else {
(false, integer_part)
};
let mut parts: Vec<&str> = Vec::new();
let len = digits.len();
let mut end = len;
while end > 3 {
let start = end - 3;
parts.push(&digits[start..end]);
end = start;
}
parts.push(&digits[..end]);
parts.reverse();
let joined = parts.join(&sep_char.to_string());
if negative {
format!("-{}", joined)
} else {
joined
}
}
/// Format in standard (fixed-point) notation.
fn format_standard(value: f64, decimal_places: u8, thousands_sep: ThousandsSeparator) -> String {
let dp = decimal_places as usize;
let fixed = format!("{:.prec$}", value, prec = dp);
let (int_part, dec_part) = if let Some(dot_pos) = fixed.find('.') {
(&fixed[..dot_pos], Some(&fixed[dot_pos + 1..]))
} else {
(fixed.as_str(), None)
};
let formatted_int = insert_thousands_separator(int_part, thousands_sep);
if dp == 0 {
return formatted_int;
}
// When using period as thousands separator, the decimal separator is comma.
let dec_sep = if thousands_sep == ThousandsSeparator::Period {
','
} else {
'.'
};
match dec_part {
Some(d) => format!("{}{}{}", formatted_int, dec_sep, d),
None => formatted_int,
}
}
/// Format in scientific notation (e.g. `1.50e6`).
fn format_scientific(value: f64, decimal_places: u8) -> String {
let dp = decimal_places as usize;
if value == 0.0 {
let mantissa = format!("{:.prec$}", 0.0_f64, prec = dp);
return format!("{}e0", mantissa);
}
if !value.is_finite() {
return format!("{}", value);
}
let exponent = value.abs().log10().floor() as i32;
let mantissa = value / 10_f64.powi(exponent);
let mantissa_str = format!("{:.prec$}", mantissa, prec = dp);
format!("{}e{}", mantissa_str, exponent)
}
/// Format in SI notation (e.g. `1.50M`).
fn format_si(value: f64, decimal_places: u8) -> String {
let dp = decimal_places as usize;
if value == 0.0 {
return format!("{:.prec$}", 0.0_f64, prec = dp);
}
if !value.is_finite() {
return format!("{}", value);
}
let abs_value = value.abs();
let sign = if value < 0.0 { "-" } else { "" };
for prefix in SI_PREFIXES {
let threshold = 10_f64.powi(prefix.exponent);
let is_last = prefix.exponent == SI_PREFIXES.last().unwrap().exponent;
if abs_value >= threshold || is_last {
let scaled = abs_value / threshold;
return format!("{}{:.prec$}{}", sign, scaled, prefix.symbol, prec = dp);
}
}
// Fallback (should not be reached).
format!("{:.prec$}", value, prec = dp)
}
/// Wrap a formatted number string with currency symbol.
fn apply_currency(formatted: &str, symbol: &str, position: CurrencyPosition) -> String {
if symbol.is_empty() {
return formatted.to_string();
}
match position {
CurrencyPosition::Prefix => format!("{}{}", symbol, formatted),
CurrencyPosition::Suffix => format!("{}{}", formatted, symbol),
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Format a numeric value according to the given formatting options.
///
/// This is the main entry point for answer-column number formatting.
///
/// # Examples
///
/// ```
/// use calcpad_engine::formatting::answer_format::*;
///
/// let opts = FormattingOptions::default();
/// assert_eq!(format_number(1234.5, &opts), "1,234.50");
///
/// let sci = FormattingOptions { notation: Notation::Scientific, ..Default::default() };
/// assert_eq!(format_number(1500000.0, &sci), "1.50e6");
/// ```
pub fn format_number(value: f64, options: &FormattingOptions) -> String {
if value.is_nan() {
return "NaN".to_string();
}
if value.is_infinite() {
return if value > 0.0 {
"Infinity".to_string()
} else {
"-Infinity".to_string()
};
}
let formatted = match options.notation {
Notation::Standard => {
format_standard(value, options.decimal_places, options.thousands_separator)
}
Notation::Scientific => format_scientific(value, options.decimal_places),
Notation::SI => format_si(value, options.decimal_places),
};
apply_currency(&formatted, &options.currency_symbol, options.currency_position)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn default_opts() -> FormattingOptions {
FormattingOptions::default()
}
// ── Standard notation ────────────────────────────────────────────
#[test]
fn standard_default() {
assert_eq!(format_number(1234.5, &default_opts()), "1,234.50");
}
#[test]
fn standard_zero_decimal_places() {
let opts = FormattingOptions { decimal_places: 0, ..default_opts() };
assert_eq!(format_number(1234.567, &opts), "1,235");
}
#[test]
fn standard_four_decimal_places() {
let opts = FormattingOptions { decimal_places: 4, ..default_opts() };
assert_eq!(format_number(1234.5, &opts), "1,234.5000");
}
#[test]
fn standard_ten_decimal_places() {
let opts = FormattingOptions { decimal_places: 10, ..default_opts() };
assert_eq!(format_number(3.14, &opts), "3.1400000000");
}
#[test]
fn standard_small_number_no_thousands() {
assert_eq!(format_number(42.0, &default_opts()), "42.00");
}
#[test]
fn standard_negative() {
assert_eq!(format_number(-1234.5, &default_opts()), "-1,234.50");
}
#[test]
fn standard_zero() {
assert_eq!(format_number(0.0, &default_opts()), "0.00");
}
// ── Thousands separators ─────────────────────────────────────────
#[test]
fn thousands_comma() {
let opts = FormattingOptions {
thousands_separator: ThousandsSeparator::Comma,
..default_opts()
};
assert_eq!(format_number(1234567.89, &opts), "1,234,567.89");
}
#[test]
fn thousands_period() {
let opts = FormattingOptions {
thousands_separator: ThousandsSeparator::Period,
..default_opts()
};
assert_eq!(format_number(1234567.89, &opts), "1.234.567,89");
}
#[test]
fn thousands_space() {
let opts = FormattingOptions {
thousands_separator: ThousandsSeparator::Space,
..default_opts()
};
assert_eq!(format_number(1234567.89, &opts), "1 234 567.89");
}
#[test]
fn thousands_none() {
let opts = FormattingOptions {
thousands_separator: ThousandsSeparator::None,
..default_opts()
};
assert_eq!(format_number(1234567.89, &opts), "1234567.89");
}
#[test]
fn thousands_period_negative() {
let opts = FormattingOptions {
thousands_separator: ThousandsSeparator::Period,
..default_opts()
};
assert_eq!(format_number(-1234.56, &opts), "-1.234,56");
}
// ── Scientific notation ──────────────────────────────────────────
#[test]
fn scientific_large() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(1500000.0, &opts), "1.50e6");
}
#[test]
fn scientific_small() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(0.005, &opts), "5.00e-3");
}
#[test]
fn scientific_42() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(42.0, &opts), "4.20e1");
}
#[test]
fn scientific_negative() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(-1500000.0, &opts), "-1.50e6");
}
#[test]
fn scientific_zero() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(0.0, &opts), "0.00e0");
}
#[test]
fn scientific_one() {
let opts = FormattingOptions { notation: Notation::Scientific, ..default_opts() };
assert_eq!(format_number(1.0, &opts), "1.00e0");
}
#[test]
fn scientific_four_dp() {
let opts = FormattingOptions {
notation: Notation::Scientific,
decimal_places: 4,
..default_opts()
};
assert_eq!(format_number(1500000.0, &opts), "1.5000e6");
}
#[test]
fn scientific_zero_dp() {
let opts = FormattingOptions {
notation: Notation::Scientific,
decimal_places: 0,
..default_opts()
};
assert_eq!(format_number(1500000.0, &opts), "2e6");
}
// ── SI notation ──────────────────────────────────────────────────
#[test]
fn si_mega() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(1500000.0, &opts), "1.50M");
}
#[test]
fn si_kilo() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(1500.0, &opts), "1.50k");
}
#[test]
fn si_giga() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(1500000000.0, &opts), "1.50G");
}
#[test]
fn si_tera() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(1500000000000.0, &opts), "1.50T");
}
#[test]
fn si_plain_42() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(42.0, &opts), "42.00");
}
#[test]
fn si_milli() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(0.005, &opts), "5.00m");
}
#[test]
fn si_micro() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(0.000005, &opts), "5.00\u{03bc}");
}
#[test]
fn si_negative() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(-1500000.0, &opts), "-1.50M");
}
#[test]
fn si_zero() {
let opts = FormattingOptions { notation: Notation::SI, ..default_opts() };
assert_eq!(format_number(0.0, &opts), "0.00");
}
#[test]
fn si_zero_dp() {
let opts = FormattingOptions {
notation: Notation::SI,
decimal_places: 0,
..default_opts()
};
assert_eq!(format_number(1500000.0, &opts), "2M");
}
// ── Currency ─────────────────────────────────────────────────────
#[test]
fn currency_prefix() {
let opts = FormattingOptions {
currency_symbol: "$".to_string(),
currency_position: CurrencyPosition::Prefix,
..default_opts()
};
assert_eq!(format_number(1234.5, &opts), "$1,234.50");
}
#[test]
fn currency_suffix() {
let opts = FormattingOptions {
currency_symbol: "\u{20ac}".to_string(), // €
currency_position: CurrencyPosition::Suffix,
..default_opts()
};
assert_eq!(format_number(1234.5, &opts), "1,234.50\u{20ac}");
}
#[test]
fn currency_empty_means_none() {
assert_eq!(format_number(1234.5, &default_opts()), "1,234.50");
}
#[test]
fn currency_with_scientific() {
let opts = FormattingOptions {
notation: Notation::Scientific,
currency_symbol: "$".to_string(),
currency_position: CurrencyPosition::Prefix,
..default_opts()
};
assert_eq!(format_number(1500000.0, &opts), "$1.50e6");
}
#[test]
fn currency_with_si() {
let opts = FormattingOptions {
notation: Notation::SI,
currency_symbol: "\u{20ac}".to_string(),
currency_position: CurrencyPosition::Suffix,
..default_opts()
};
assert_eq!(format_number(1500000.0, &opts), "1.50M\u{20ac}");
}
// ── Edge cases ───────────────────────────────────────────────────
#[test]
fn nan() {
assert_eq!(format_number(f64::NAN, &default_opts()), "NaN");
}
#[test]
fn pos_infinity() {
assert_eq!(format_number(f64::INFINITY, &default_opts()), "Infinity");
}
#[test]
fn neg_infinity() {
assert_eq!(format_number(f64::NEG_INFINITY, &default_opts()), "-Infinity");
}
#[test]
fn very_large_standard() {
let opts = FormattingOptions { decimal_places: 0, ..default_opts() };
assert_eq!(format_number(1e15, &opts), "1,000,000,000,000,000");
}
#[test]
fn very_small_standard() {
let opts = FormattingOptions { decimal_places: 6, ..default_opts() };
assert_eq!(format_number(0.000001, &opts), "0.000001");
}
// ── resolve_formatting ───────────────────────────────────────────
#[test]
fn resolve_no_override() {
let global = default_opts();
let result = resolve_formatting(&global, None);
assert_eq!(result, global);
}
#[test]
fn resolve_partial_override() {
let global = default_opts();
let ov = LineFormatOverride {
notation: Some(Notation::Scientific),
decimal_places: Some(4),
..Default::default()
};
let result = resolve_formatting(&global, Some(&ov));
assert_eq!(result.notation, Notation::Scientific);
assert_eq!(result.decimal_places, 4);
// Inherited
assert_eq!(result.thousands_separator, ThousandsSeparator::Comma);
assert_eq!(result.currency_symbol, "");
assert_eq!(result.currency_position, CurrencyPosition::Prefix);
}
#[test]
fn resolve_full_override() {
let global = default_opts();
let ov = LineFormatOverride {
decimal_places: Some(5),
notation: Some(Notation::SI),
thousands_separator: Some(ThousandsSeparator::Space),
currency_symbol: Some("\u{00a3}".to_string()), // £
currency_position: Some(CurrencyPosition::Suffix),
};
let result = resolve_formatting(&global, Some(&ov));
assert_eq!(result.decimal_places, 5);
assert_eq!(result.notation, Notation::SI);
assert_eq!(result.thousands_separator, ThousandsSeparator::Space);
assert_eq!(result.currency_symbol, "\u{00a3}");
assert_eq!(result.currency_position, CurrencyPosition::Suffix);
}
#[test]
fn resolve_empty_override_inherits_global() {
let global = default_opts();
let ov = LineFormatOverride::default();
let result = resolve_formatting(&global, Some(&ov));
assert_eq!(result, global);
}
}

View File

@@ -0,0 +1,290 @@
//! Clipboard value formatting for CalcPad.
//!
//! Ported from the cross-platform spec in Epic 10-2 (Click-to-Copy Answer).
//! The UI layer decides *when* to copy (single-click vs double-click); this
//! module decides *what* text to produce from a `CalcResult`.
//!
//! # Copy modes
//!
//! | Gesture | What gets copied | Example |
//! |------------- |------------------------------|------------------|
//! | Single-click | Raw numeric value only | `11.023` |
//! | Double-click | Display value (with unit) | `11.023 lbs` |
//!
//! The engine provides both strings so the platform layer never needs to
//! inspect the result internals.
use crate::types::{CalcResult, CalcValue};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// Pair of clipboard strings derived from a calculation result.
///
/// The UI layer selects which one to copy based on the user gesture.
#[derive(Debug, Clone, PartialEq)]
pub struct ClipboardValues {
/// Raw numeric value as a string (no unit, no currency symbol).
/// Used on single-click.
pub raw_value: String,
/// Human-readable display string (may include unit or currency symbol).
/// Used on double-click.
pub display_value: String,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Extract clipboard-ready strings from a `CalcResult`.
///
/// Returns `None` for error results (nothing useful to copy).
///
/// # Examples
///
/// ```
/// use calcpad_engine::types::CalcResult;
/// use calcpad_engine::formatting::clipboard::clipboard_values;
/// use calcpad_engine::span::Span;
///
/// let r = CalcResult::number(42.0, Span::new(0, 2));
/// let cv = clipboard_values(&r).unwrap();
/// assert_eq!(cv.raw_value, "42");
/// assert_eq!(cv.display_value, "42");
///
/// let r = CalcResult::unit_value(11.023, "lbs", Span::new(0, 5));
/// let cv = clipboard_values(&r).unwrap();
/// assert_eq!(cv.raw_value, "11.023");
/// assert_eq!(cv.display_value, "11.023 lbs");
/// ```
pub fn clipboard_values(result: &CalcResult) -> Option<ClipboardValues> {
match &result.value {
CalcValue::Number { value } => {
let raw = format_raw(*value);
Some(ClipboardValues {
raw_value: raw.clone(),
display_value: raw,
})
}
CalcValue::UnitValue { value, unit } => {
let raw = format_raw(*value);
let display = format!("{} {}", raw, unit);
Some(ClipboardValues {
raw_value: raw,
display_value: display,
})
}
CalcValue::CurrencyValue { amount, currency } => {
let raw = format_raw(*amount);
// Display uses the result's pre-formatted string which includes
// the currency symbol. Fall back to `raw currency` if display is
// empty (shouldn't happen in practice).
let display = if result.metadata.display.is_empty() {
format!("{} {}", raw, currency)
} else {
result.metadata.display.clone()
};
Some(ClipboardValues {
raw_value: raw,
display_value: display,
})
}
CalcValue::DateTime { date } => Some(ClipboardValues {
raw_value: date.clone(),
display_value: date.clone(),
}),
CalcValue::TimeDelta { days, description } => Some(ClipboardValues {
raw_value: days.to_string(),
display_value: description.clone(),
}),
CalcValue::Boolean { value } => {
let s = value.to_string();
Some(ClipboardValues {
raw_value: s.clone(),
display_value: s,
})
}
CalcValue::Error { .. } => None,
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Format a raw numeric value for clipboard. Integers are printed without
/// a decimal point; floats keep their natural representation.
fn format_raw(val: f64) -> String {
if val == val.floor() && val.abs() < 1e15 {
format!("{}", val as i64)
} else {
format!("{}", val)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::span::Span;
use crate::types::CalcResult;
fn sp() -> Span {
Span::new(0, 1)
}
// ── Numbers ──────────────────────────────────────────────────────
#[test]
fn number_integer() {
let r = CalcResult::number(42.0, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "42");
assert_eq!(cv.display_value, "42");
}
#[test]
fn number_decimal() {
let r = CalcResult::number(3.14, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "3.14");
assert_eq!(cv.display_value, "3.14");
}
#[test]
fn number_negative() {
let r = CalcResult::number(-100.0, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "-100");
assert_eq!(cv.display_value, "-100");
}
#[test]
fn number_zero() {
let r = CalcResult::number(0.0, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "0");
assert_eq!(cv.display_value, "0");
}
// ── Unit values ──────────────────────────────────────────────────
#[test]
fn unit_value_single_click_raw() {
let r = CalcResult::unit_value(11.023, "lbs", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "11.023");
}
#[test]
fn unit_value_double_click_display() {
let r = CalcResult::unit_value(11.023, "lbs", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.display_value, "11.023 lbs");
}
#[test]
fn unit_value_integer_amount() {
let r = CalcResult::unit_value(5.0, "kg", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "5");
assert_eq!(cv.display_value, "5 kg");
}
// ── Currency values ──────────────────────────────────────────────
#[test]
fn currency_raw_is_numeric() {
let r = CalcResult::currency_value(1234.56, "USD", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "1234.56");
}
#[test]
fn currency_display_has_symbol() {
let r = CalcResult::currency_value(1234.56, "USD", sp());
let cv = clipboard_values(&r).unwrap();
// Display comes from CalcResult::currency_value which formats as "$1234.56"
assert!(cv.display_value.contains("1234.56"));
}
#[test]
fn currency_euro() {
let r = CalcResult::currency_value(99.99, "EUR", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "99.99");
assert!(cv.display_value.contains("99.99"));
}
// ── DateTime ─────────────────────────────────────────────────────
#[test]
fn datetime_both_same() {
use chrono::NaiveDate;
let r = CalcResult::datetime(
NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
sp(),
);
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "2024-01-15");
assert_eq!(cv.display_value, "2024-01-15");
}
// ── TimeDelta ────────────────────────────────────────────────────
#[test]
fn time_delta() {
let r = CalcResult::time_delta(30, "30 days", sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "30");
assert_eq!(cv.display_value, "30 days");
}
// ── Boolean ──────────────────────────────────────────────────────
#[test]
fn boolean_true() {
let r = CalcResult::boolean(true, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "true");
assert_eq!(cv.display_value, "true");
}
#[test]
fn boolean_false() {
let r = CalcResult::boolean(false, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, "false");
assert_eq!(cv.display_value, "false");
}
// ── Errors ───────────────────────────────────────────────────────
#[test]
fn error_returns_none() {
let r = CalcResult::error("Division by zero", sp());
assert!(clipboard_values(&r).is_none());
}
// ── Spec alignment: raw vs display divergence ────────────────────
#[test]
fn raw_and_display_differ_for_units() {
let r = CalcResult::unit_value(42.5, "km", sp());
let cv = clipboard_values(&r).unwrap();
assert_ne!(cv.raw_value, cv.display_value);
assert_eq!(cv.raw_value, "42.5");
assert_eq!(cv.display_value, "42.5 km");
}
#[test]
fn raw_and_display_same_for_plain_number() {
let r = CalcResult::number(42.0, sp());
let cv = clipboard_values(&r).unwrap();
assert_eq!(cv.raw_value, cv.display_value);
}
}

View File

@@ -0,0 +1,471 @@
//! Line-type detection for CalcPad's notepad UX.
//!
//! Classifies raw input lines into semantic types that the UI layer uses
//! to decide rendering (styling, gutter icons, whether to show an answer).
//!
//! Ported from the cross-platform spec in Epic 10-1 (Headers, Comments &
//! Labels). The **lexer** already produces `TokenKind::Comment` and
//! `TokenKind::Text` tokens, and `variables::aggregators::is_heading` detects
//! heading lines. This module provides a **higher-level** classification that
//! the UI layer can consume directly, without touching tokens.
//!
//! # Design note — no duplication
//!
//! Comment detection (`//`) is intentionally compatible with the lexer's
//! full-line comment check (see `lexer.rs` lines 28-40). Heading detection
//! uses the same rules as `variables::aggregators::is_heading` (1-6 `#`
//! characters followed by a space) but is implemented locally to avoid a
//! module dependency and to also extract the heading level and text. This
//! module adds *label detection* (`Label: expr`) and the unified `LineType`
//! enum that were not previously available in the engine.
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/// The semantic type of a line in a CalcPad sheet.
///
/// Determined purely from the text content. The order of classification is:
///
/// 1. Blank / whitespace-only
/// 2. Heading (`# ...`, `## ...`, up to `######`)
/// 3. Comment (`// ...`)
/// 4. Label with expression (`Label: expr`)
/// 5. Expression (anything else that might evaluate)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum LineType {
/// Empty or whitespace-only line.
Blank,
/// Heading line. Contains the heading text (without leading `#` characters
/// and surrounding whitespace). `level` is 1--6.
Heading { level: u8, text: String },
/// Comment line. Contains the comment body (without leading `//`).
Comment { text: String },
/// Labeled expression. `label` includes the trailing colon
/// (e.g. `"Rent:"`), `expression` is the text after the colon.
Label { label: String, expression: String },
/// A calculable expression (or text that the evaluator should attempt).
Expression,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Classify a single line of input into its [`LineType`].
///
/// This is a **pure function** — it depends only on the text content and
/// performs no evaluation.
///
/// # Examples
///
/// ```
/// use calcpad_engine::formatting::line_types::{classify_line, LineType};
///
/// assert_eq!(classify_line(""), LineType::Blank);
/// assert_eq!(
/// classify_line("# Budget"),
/// LineType::Heading { level: 1, text: "Budget".to_string() },
/// );
/// assert_eq!(
/// classify_line("// a note"),
/// LineType::Comment { text: " a note".to_string() },
/// );
/// assert_eq!(
/// classify_line("Rent: 1500"),
/// LineType::Label {
/// label: "Rent:".to_string(),
/// expression: "1500".to_string(),
/// },
/// );
/// assert_eq!(classify_line("2 + 3"), LineType::Expression);
/// ```
pub fn classify_line(input: &str) -> LineType {
let trimmed = input.trim();
// 1. Blank
if trimmed.is_empty() {
return LineType::Blank;
}
// 2. Heading — delegate to the existing aggregators utility so we don't
// duplicate the `#` logic. Re-implement the level + text extraction
// here since `is_heading` only returns bool.
if let Some(heading) = detect_heading(trimmed) {
return heading;
}
// 3. Comment
if trimmed.starts_with("//") {
let text = trimmed[2..].to_string();
return LineType::Comment { text };
}
// 4. Label
if let Some(label_type) = detect_label(trimmed) {
return label_type;
}
// 5. Expression (fallback)
LineType::Expression
}
/// Returns `true` if the line is non-calculable (blank, heading, or comment).
///
/// Useful for quickly deciding whether to show an answer column entry.
pub fn is_non_calculable(input: &str) -> bool {
matches!(
classify_line(input),
LineType::Blank | LineType::Heading { .. } | LineType::Comment { .. }
)
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Detect a markdown-style heading (`# Title` through `###### Title`).
fn detect_heading(trimmed: &str) -> Option<LineType> {
let bytes = trimmed.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' {
return None;
}
let mut level: u8 = 0;
let mut i = 0;
while i < bytes.len() && bytes[i] == b'#' {
level += 1;
i += 1;
}
// Max 6 levels, and must be followed by a space or end of string.
// A `#` followed immediately by a digit (e.g. `#1`) is a line reference,
// not a heading.
if level == 0 || level > 6 {
return None;
}
if i < bytes.len() && bytes[i] != b' ' {
return None;
}
let text = if i < bytes.len() {
trimmed[i..].trim().to_string()
} else {
String::new()
};
Some(LineType::Heading { level, text })
}
/// Detect a label pattern: `Label: expression`.
///
/// A label colon must:
/// - Not be at the very end of the string
/// - Have non-empty text before it that contains at least one alphabetic char
/// (to distinguish from time expressions like `12:30`)
/// - Have non-empty content after it
fn detect_label(trimmed: &str) -> Option<LineType> {
for (i, ch) in trimmed.char_indices() {
if ch == ':' {
let before = trimmed[..i].trim();
let after = trimmed[i + 1..].trim();
if !before.is_empty()
&& !after.is_empty()
&& before.chars().any(|c| c.is_alphabetic())
{
// Include the colon in the label (matches 10-1 spec).
let label = format!("{}:", before);
return Some(LineType::Label {
label,
expression: after.to_string(),
});
}
}
}
None
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// ── Blank ────────────────────────────────────────────────────────
#[test]
fn blank_empty() {
assert_eq!(classify_line(""), LineType::Blank);
}
#[test]
fn blank_spaces() {
assert_eq!(classify_line(" "), LineType::Blank);
}
#[test]
fn blank_tab() {
assert_eq!(classify_line("\t"), LineType::Blank);
}
// ── Headings ─────────────────────────────────────────────────────
#[test]
fn heading_h1() {
assert_eq!(
classify_line("# Monthly Budget"),
LineType::Heading { level: 1, text: "Monthly Budget".to_string() },
);
}
#[test]
fn heading_h2() {
assert_eq!(
classify_line("## Expenses"),
LineType::Heading { level: 2, text: "Expenses".to_string() },
);
}
#[test]
fn heading_h3_indented() {
assert_eq!(
classify_line(" ### Indented"),
LineType::Heading { level: 3, text: "Indented".to_string() },
);
}
#[test]
fn heading_no_space_after_hash_is_not_heading() {
// `#Section` without space is not a heading (could be a reference).
// This aligns with the aggregators::is_heading behaviour.
assert_ne!(
classify_line("#Section"),
LineType::Heading { level: 1, text: "Section".to_string() },
);
}
#[test]
fn heading_hash_only() {
// A single `# ` with trailing space should be heading with empty text.
// `#` alone followed by nothing — also heading with empty text.
assert_eq!(
classify_line("# "),
LineType::Heading { level: 1, text: String::new() },
);
}
#[test]
fn heading_not_line_ref() {
// `#1` is a line reference, not a heading.
assert_eq!(classify_line("#1 * 2"), LineType::Expression);
}
#[test]
fn heading_six_levels() {
assert_eq!(
classify_line("###### Deep"),
LineType::Heading { level: 6, text: "Deep".to_string() },
);
}
#[test]
fn heading_too_many_hashes() {
// 7 hashes is not a valid heading.
assert_eq!(classify_line("####### TooDeep"), LineType::Expression);
}
#[test]
fn heading_expression_not_evaluated() {
assert_eq!(
classify_line("# 2 + 3"),
LineType::Heading { level: 1, text: "2 + 3".to_string() },
);
}
// ── Comments ─────────────────────────────────────────────────────
#[test]
fn comment_basic() {
assert_eq!(
classify_line("// This is a note"),
LineType::Comment { text: " This is a note".to_string() },
);
}
#[test]
fn comment_no_space() {
assert_eq!(
classify_line("//note"),
LineType::Comment { text: "note".to_string() },
);
}
#[test]
fn comment_empty() {
assert_eq!(
classify_line("//"),
LineType::Comment { text: String::new() },
);
}
#[test]
fn comment_not_evaluated() {
assert_eq!(
classify_line("// 100 * 2"),
LineType::Comment { text: " 100 * 2".to_string() },
);
}
#[test]
fn comment_indented() {
assert_eq!(
classify_line(" // indented comment"),
LineType::Comment { text: " indented comment".to_string() },
);
}
// ── Labels ───────────────────────────────────────────────────────
#[test]
fn label_simple() {
assert_eq!(
classify_line("Rent: 1500"),
LineType::Label {
label: "Rent:".to_string(),
expression: "1500".to_string(),
},
);
}
#[test]
fn label_with_math() {
assert_eq!(
classify_line("Total: 100 + 200 + 300"),
LineType::Label {
label: "Total:".to_string(),
expression: "100 + 200 + 300".to_string(),
},
);
}
#[test]
fn label_multiword() {
assert_eq!(
classify_line("Monthly Rent: 1500"),
LineType::Label {
label: "Monthly Rent:".to_string(),
expression: "1500".to_string(),
},
);
}
#[test]
fn label_colon_at_end_is_not_label() {
// Nothing after the colon — not a label.
assert_eq!(classify_line("Rent:"), LineType::Expression);
}
#[test]
fn no_label_for_time_expression() {
// `12:30` has no alpha chars before the colon, so it's not a label.
assert_eq!(classify_line("12:30"), LineType::Expression);
}
// ── Expression ───────────────────────────────────────────────────
#[test]
fn expression_simple() {
assert_eq!(classify_line("2 + 3"), LineType::Expression);
}
#[test]
fn expression_complex() {
assert_eq!(classify_line("(10 + 5) * 2"), LineType::Expression);
}
#[test]
fn expression_single_number() {
assert_eq!(classify_line("42"), LineType::Expression);
}
// ── is_non_calculable ────────────────────────────────────────────
#[test]
fn non_calculable_blank() {
assert!(is_non_calculable(""));
assert!(is_non_calculable(" "));
}
#[test]
fn non_calculable_heading() {
assert!(is_non_calculable("# Title"));
}
#[test]
fn non_calculable_comment() {
assert!(is_non_calculable("// note"));
}
#[test]
fn calculable_expression() {
assert!(!is_non_calculable("2 + 3"));
}
#[test]
fn calculable_label() {
assert!(!is_non_calculable("Price: 42"));
}
// ── Deterministic parsing (cross-platform consistency) ───────────
#[test]
fn deterministic_classification() {
let inputs = vec![
"# Budget",
"// notes here",
"Rent: 1500",
"2 + 3",
"",
];
let results_a: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
let results_b: Vec<_> = inputs.iter().map(|l| classify_line(l)).collect();
assert_eq!(results_a, results_b);
}
#[test]
fn mixed_sheet_scenario() {
let lines = vec![
"# Monthly Budget",
"// Income section",
"Salary: 5000",
"Bonus: 500",
"",
"# Expenses",
"Rent: 1500",
"Food: 300 + 200",
"// Utilities estimated",
"Utilities: 150",
"",
"5000 + 500 - 1500 - 500 - 150",
];
let types: Vec<_> = lines.iter().map(|l| classify_line(l)).collect();
assert!(matches!(&types[0], LineType::Heading { level: 1, text } if text == "Monthly Budget"));
assert!(matches!(&types[1], LineType::Comment { .. }));
assert!(matches!(&types[2], LineType::Label { label, .. } if label == "Salary:"));
assert!(matches!(&types[3], LineType::Label { label, .. } if label == "Bonus:"));
assert_eq!(types[4], LineType::Blank);
assert!(matches!(&types[5], LineType::Heading { level: 1, text } if text == "Expenses"));
assert!(matches!(&types[6], LineType::Label { label, .. } if label == "Rent:"));
assert!(matches!(&types[7], LineType::Label { label, expression } if label == "Food:" && expression == "300 + 200"));
assert!(matches!(&types[8], LineType::Comment { .. }));
assert!(matches!(&types[9], LineType::Label { label, .. } if label == "Utilities:"));
assert_eq!(types[10], LineType::Blank);
assert_eq!(types[11], LineType::Expression);
}
}

View File

@@ -0,0 +1,34 @@
//! Cross-platform UX formatting for CalcPad.
//!
//! This module extracts the notepad UX specs from Epics 10-1, 10-2, and 10-4
//! into the engine so that every platform (macOS, Windows, Web) renders
//! identical results.
//!
//! # Sub-modules
//!
//! * [`answer_format`] — Number formatting for the answer column: standard,
//! scientific, and SI notation with decimal places (0--10), thousands
//! separators, and currency symbol placement.
//! *(Epic 10-4: Answer Column Formatting)*
//!
//! * [`line_types`] — Line-type classification (blank, heading, comment,
//! label, expression) so the UI knows how to style each line and whether
//! to show an answer.
//! *(Epic 10-1: Headers, Comments & Labels)*
//!
//! * [`clipboard`] — Clipboard value extraction from a `CalcResult`:
//! raw numeric string (single-click) vs display string with unit
//! (double-click).
//! *(Epic 10-2: Click-to-Copy Answer)*
pub mod answer_format;
pub mod clipboard;
pub mod line_types;
// Re-export the most commonly used items for convenience.
pub use answer_format::{
format_number, resolve_formatting, CurrencyPosition, FormattingOptions, LineFormatOverride,
Notation, ThousandsSeparator,
};
pub use clipboard::{clipboard_values, ClipboardValues};
pub use line_types::{classify_line, is_non_calculable, LineType};

View File

@@ -4,12 +4,14 @@ pub mod currency;
pub mod datetime;
pub mod error;
pub mod ffi;
pub mod formatting;
pub mod functions;
pub mod interpreter;
pub mod lexer;
pub mod number;
pub mod parser;
pub mod pipeline;
pub mod plugins;
pub mod sheet_context;
pub mod span;
pub mod token;

View File

@@ -0,0 +1,437 @@
//! Plugin API: the `CalcPadPlugin` trait and supporting types.
//!
//! Developers implement [`CalcPadPlugin`] to extend calcpad-engine with
//! custom functions, units, and variables. The trait is object-safe so
//! plugins can be loaded dynamically at runtime.
use std::collections::HashMap;
use std::sync::Arc;
// ---------------------------------------------------------------------------
// Plugin Value type (simplified, f64-based)
// ---------------------------------------------------------------------------
/// A value exchanged between the engine and plugins.
///
/// Plugin functions receive arguments as `PluginValue` and return one.
/// When the user writes `80kg`, the plugin receives
/// `PluginValue { number: 80.0, unit: Some("kg") }`.
#[derive(Clone, Debug, PartialEq)]
pub struct PluginValue {
pub number: f64,
pub unit: Option<String>,
}
impl PluginValue {
/// Create a plain numeric value (no unit).
pub fn new(number: f64) -> Self {
Self { number, unit: None }
}
/// Create a value with an attached unit string.
pub fn with_unit(number: f64, unit: impl Into<String>) -> Self {
Self {
number,
unit: Some(unit.into()),
}
}
}
// ---------------------------------------------------------------------------
// Plugin function signature
// ---------------------------------------------------------------------------
/// A plugin function: takes a slice of `PluginValue` args and returns
/// a `PluginValue` or an error message.
pub type PluginFn = Arc<dyn Fn(&[PluginValue]) -> Result<PluginValue, String> + Send + Sync>;
// ---------------------------------------------------------------------------
// Per-category registries (passed to the plugin during registration)
// ---------------------------------------------------------------------------
/// Registry that a plugin fills with custom functions.
pub struct PluginFunctionRegistry {
pub(crate) functions: HashMap<String, PluginFn>,
}
impl PluginFunctionRegistry {
pub fn new() -> Self {
Self {
functions: HashMap::new(),
}
}
/// Register a named function.
pub fn register(
&mut self,
name: impl Into<String>,
func: impl Fn(&[PluginValue]) -> Result<PluginValue, String> + Send + Sync + 'static,
) {
self.functions.insert(name.into(), Arc::new(func));
}
/// Iterate over registered function names.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.functions.keys().map(|s| s.as_str())
}
}
impl Default for PluginFunctionRegistry {
fn default() -> Self {
Self::new()
}
}
/// Registry that a plugin fills with custom units.
///
/// Each unit is stored as a conversion factor relative to some base.
/// For example, `"lb"` with factor `0.453592` means `80 lb` evaluates
/// to `80 * 0.453592 = 36.287` in the base unit (kg).
pub struct PluginUnitRegistry {
pub(crate) units: HashMap<String, f64>,
}
impl PluginUnitRegistry {
pub fn new() -> Self {
Self {
units: HashMap::new(),
}
}
/// Register a unit with its conversion factor.
pub fn register(&mut self, name: impl Into<String>, factor: f64) {
self.units.insert(name.into(), factor);
}
/// Iterate over registered unit names.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.units.keys().map(|s| s.as_str())
}
}
impl Default for PluginUnitRegistry {
fn default() -> Self {
Self::new()
}
}
/// Registry that a plugin fills with custom variables (constants).
pub struct PluginVariableRegistry {
pub(crate) variables: HashMap<String, f64>,
}
impl PluginVariableRegistry {
pub fn new() -> Self {
Self {
variables: HashMap::new(),
}
}
/// Register a named constant.
pub fn register(&mut self, name: impl Into<String>, value: f64) {
self.variables.insert(name.into(), value);
}
/// Iterate over registered variable names.
pub fn names(&self) -> impl Iterator<Item = &str> {
self.variables.keys().map(|s| s.as_str())
}
}
impl Default for PluginVariableRegistry {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Plugin lifecycle
// ---------------------------------------------------------------------------
/// Errors that can occur during plugin initialisation.
#[derive(Clone, Debug, PartialEq)]
pub struct PluginError {
pub plugin_name: String,
pub message: String,
}
impl std::fmt::Display for PluginError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[plugin '{}'] {}", self.plugin_name, self.message)
}
}
impl std::error::Error for PluginError {}
/// A warning generated during plugin loading (e.g., name conflicts with
/// built-in functions).
#[derive(Clone, Debug, PartialEq)]
pub struct PluginWarning {
pub plugin_name: String,
pub message: String,
}
impl std::fmt::Display for PluginWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[plugin '{}'] {}", self.plugin_name, self.message)
}
}
// ---------------------------------------------------------------------------
// The CalcPadPlugin trait
// ---------------------------------------------------------------------------
/// The trait that all plugins implement to extend CalcPad.
///
/// # Lifecycle
///
/// 1. **`init()`** — Called once when the plugin is loaded. Perform any
/// one-time setup here. Return `Err` to abort loading.
/// 2. **`register_functions()`** / **`register_units()`** /
/// **`register_variables()`** — Called after a successful `init()` to
/// collect the plugin's contributions.
/// 3. **`shutdown()`** — Called when the plugin is unloaded or the engine
/// is torn down. Clean up resources here.
///
/// # Example
///
/// ```rust
/// use calcpad_engine::plugins::api::*;
///
/// struct BmiPlugin;
///
/// impl CalcPadPlugin for BmiPlugin {
/// fn name(&self) -> &str { "bmi" }
///
/// fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
/// reg.register("bmi", |args| {
/// if args.len() != 2 {
/// return Err("bmi() requires 2 arguments: weight, height".into());
/// }
/// let weight = args[0].number;
/// let height = args[1].number;
/// if height == 0.0 {
/// return Err("height cannot be zero".into());
/// }
/// Ok(PluginValue::new(weight / (height * height)))
/// });
/// }
/// }
/// ```
pub trait CalcPadPlugin: Send + Sync {
/// A short, unique name used for identification and conflict reporting.
fn name(&self) -> &str;
/// Called once when the plugin is loaded.
///
/// Return `Err` to abort loading; the error will be surfaced to the user.
fn init(&self) -> Result<(), String> {
Ok(())
}
/// Register custom functions with the engine.
fn register_functions(&self, _registry: &mut PluginFunctionRegistry) {}
/// Register custom units with the engine.
fn register_units(&self, _registry: &mut PluginUnitRegistry) {}
/// Register custom variables / constants with the engine.
fn register_variables(&self, _registry: &mut PluginVariableRegistry) {}
/// Called when the plugin is unloaded. Clean up any resources here.
fn shutdown(&self) {}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_value_plain() {
let v = PluginValue::new(42.0);
assert_eq!(v.number, 42.0);
assert!(v.unit.is_none());
}
#[test]
fn test_plugin_value_with_unit() {
let v = PluginValue::with_unit(80.0, "kg");
assert_eq!(v.number, 80.0);
assert_eq!(v.unit.as_deref(), Some("kg"));
}
#[test]
fn test_function_registry_register_and_call() {
let mut reg = PluginFunctionRegistry::new();
reg.register("double", |args| {
Ok(PluginValue::new(args[0].number * 2.0))
});
let func = reg.functions.get("double").unwrap();
let result = func(&[PluginValue::new(21.0)]).unwrap();
assert_eq!(result.number, 42.0);
}
#[test]
fn test_function_registry_names() {
let mut reg = PluginFunctionRegistry::new();
reg.register("foo", |_| Ok(PluginValue::new(0.0)));
reg.register("bar", |_| Ok(PluginValue::new(0.0)));
let mut names: Vec<&str> = reg.names().collect();
names.sort();
assert_eq!(names, vec!["bar", "foo"]);
}
#[test]
fn test_unit_registry() {
let mut reg = PluginUnitRegistry::new();
reg.register("kg", 1.0);
reg.register("lb", 0.453592);
assert_eq!(reg.units.get("kg"), Some(&1.0));
assert_eq!(reg.units.get("lb"), Some(&0.453592));
}
#[test]
fn test_unit_registry_names() {
let mut reg = PluginUnitRegistry::new();
reg.register("m", 1.0);
reg.register("ft", 0.3048);
let mut names: Vec<&str> = reg.names().collect();
names.sort();
assert_eq!(names, vec!["ft", "m"]);
}
#[test]
fn test_variable_registry() {
let mut reg = PluginVariableRegistry::new();
reg.register("pi", std::f64::consts::PI);
assert_eq!(reg.variables.get("pi"), Some(&std::f64::consts::PI));
}
#[test]
fn test_plugin_trait_full_implementation() {
struct TestPlugin;
impl CalcPadPlugin for TestPlugin {
fn name(&self) -> &str { "test" }
fn init(&self) -> Result<(), String> { Ok(()) }
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
reg.register("square", |args| {
if args.len() != 1 {
return Err("square() requires 1 argument".into());
}
Ok(PluginValue::new(args[0].number * args[0].number))
});
}
fn register_units(&self, reg: &mut PluginUnitRegistry) {
reg.register("cm", 0.01);
}
fn register_variables(&self, reg: &mut PluginVariableRegistry) {
reg.register("tau", std::f64::consts::TAU);
}
fn shutdown(&self) {}
}
let plugin = TestPlugin;
assert_eq!(plugin.name(), "test");
assert!(plugin.init().is_ok());
let mut freg = PluginFunctionRegistry::new();
plugin.register_functions(&mut freg);
let func = freg.functions.get("square").unwrap();
assert_eq!(func(&[PluginValue::new(5.0)]).unwrap().number, 25.0);
let mut ureg = PluginUnitRegistry::new();
plugin.register_units(&mut ureg);
assert_eq!(ureg.units.get("cm"), Some(&0.01));
let mut vreg = PluginVariableRegistry::new();
plugin.register_variables(&mut vreg);
assert_eq!(vreg.variables.get("tau"), Some(&std::f64::consts::TAU));
plugin.shutdown();
}
#[test]
fn test_plugin_default_methods_are_noops() {
struct MinimalPlugin;
impl CalcPadPlugin for MinimalPlugin {
fn name(&self) -> &str { "minimal" }
}
let p = MinimalPlugin;
assert!(p.init().is_ok());
// Should not panic:
let mut freg = PluginFunctionRegistry::new();
p.register_functions(&mut freg);
assert_eq!(freg.functions.len(), 0);
p.shutdown();
}
#[test]
fn test_plugin_init_can_fail() {
struct FailPlugin;
impl CalcPadPlugin for FailPlugin {
fn name(&self) -> &str { "fail" }
fn init(&self) -> Result<(), String> {
Err("missing required config".into())
}
}
let p = FailPlugin;
let err = p.init().unwrap_err();
assert_eq!(err, "missing required config");
}
#[test]
fn test_plugin_warning_display() {
let w = PluginWarning {
plugin_name: "myplugin".to_string(),
message: "function 'sin' conflicts with built-in".to_string(),
};
assert_eq!(
w.to_string(),
"[plugin 'myplugin'] function 'sin' conflicts with built-in"
);
}
#[test]
fn test_plugin_error_display() {
let e = PluginError {
plugin_name: "bad".to_string(),
message: "init failed".to_string(),
};
assert_eq!(e.to_string(), "[plugin 'bad'] init failed");
}
#[test]
fn test_function_returns_error() {
let mut reg = PluginFunctionRegistry::new();
reg.register("safe_div", |args| {
if args.len() != 2 {
return Err("safe_div() requires 2 args".into());
}
if args[1].number == 0.0 {
return Err("division by zero".into());
}
Ok(PluginValue::new(args[0].number / args[1].number))
});
let func = reg.functions.get("safe_div").unwrap();
assert_eq!(func(&[PluginValue::new(10.0), PluginValue::new(2.0)]).unwrap().number, 5.0);
let err = func(&[PluginValue::new(1.0), PluginValue::new(0.0)]).unwrap_err();
assert!(err.contains("division by zero"));
}
#[test]
fn test_plugin_fn_is_send_sync() {
// Compile-time check: PluginFn must be Send + Sync
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PluginFn>();
}
}

View File

@@ -0,0 +1,63 @@
//! Plugin / extension system for calcpad-engine.
//!
//! This module provides a trait-based plugin API, a registry for managing
//! loaded plugins, and a Rhai scripting integration for lightweight custom
//! functions.
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────┐
//! │ PluginRegistry │
//! │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
//! │ │ Plugin A │ │ Plugin B │ │ ScriptPlugin │ │
//! │ │ (Rust) │ │ (Rust) │ │ (.rhai) │ │
//! │ └─────┬─────┘ └─────┬─────┘ └──────┬────┘ │
//! │ │ │ │ │
//! │ └──────────────┴───────────────┘ │
//! │ CalcPadPlugin trait │
//! │ functions: HashMap<String, PluginFn> │
//! │ units: HashMap<String, f64> │
//! │ variables: HashMap<String, f64> │
//! └─────────────────────────────────────────────────────┘
//! ```
//!
//! # Quick start
//!
//! ```rust
//! use calcpad_engine::plugins::{
//! CalcPadPlugin, PluginFunctionRegistry, PluginValue, PluginRegistry,
//! };
//!
//! struct MyPlugin;
//!
//! impl CalcPadPlugin for MyPlugin {
//! fn name(&self) -> &str { "my_plugin" }
//!
//! fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
//! reg.register("double", |args| {
//! if args.len() != 1 {
//! return Err("double() requires 1 argument".into());
//! }
//! Ok(PluginValue::new(args[0].number * 2.0))
//! });
//! }
//! }
//!
//! let mut registry = PluginRegistry::new();
//! registry.add(MyPlugin).unwrap();
//! let result = registry.call_function("double", &[PluginValue::new(21.0)]).unwrap();
//! assert_eq!(result.number, 42.0);
//! ```
pub mod api;
pub mod registry;
pub mod scripting;
// Re-export the most commonly used types at the `plugins` level.
pub use api::{
CalcPadPlugin, PluginError, PluginFn, PluginFunctionRegistry,
PluginUnitRegistry, PluginValue, PluginVariableRegistry, PluginWarning,
};
pub use registry::PluginRegistry;
pub use scripting::{load_scripts_from_dir, ScriptError, ScriptLoadResult, ScriptPlugin};

View File

@@ -0,0 +1,485 @@
//! Plugin registry: loads, manages, and queries plugins.
//!
//! [`PluginRegistry`] is the central coordinator that:
//! - Accepts plugins (via [`add`] / [`add_boxed`])
//! - Calls their lifecycle hooks (init, registration, shutdown)
//! - Merges their contributions (functions, units, variables)
//! - Detects and reports naming conflicts with built-in symbols
use std::collections::HashMap;
use super::api::{
CalcPadPlugin, PluginError, PluginFn, PluginFunctionRegistry, PluginUnitRegistry,
PluginValue, PluginVariableRegistry, PluginWarning,
};
/// Names that are reserved by the engine's built-in function set.
/// Plugin functions with any of these names will be skipped, and a
/// warning will be emitted instead.
const BUILTIN_FUNCTION_NAMES: &[&str] = &[
"sin", "cos", "tan", "asin", "acos", "atan",
"sinh", "cosh", "tanh",
"sqrt", "abs", "ceil", "floor", "round",
"ln", "log", "log2", "log10",
"exp", "pow", "min", "max",
"factorial", "nPr", "nCr",
"gcd", "lcm", "sum", "avg", "average",
"compound_interest", "mortgage_payment",
"tc_to_frames", "frames_to_tc",
];
fn is_builtin_function(name: &str) -> bool {
BUILTIN_FUNCTION_NAMES.contains(&name)
}
/// Result of loading one or more plugins into the registry.
#[derive(Debug, Default)]
pub struct LoadResult {
/// Number of plugins that loaded successfully.
pub loaded: usize,
/// Errors from plugins that failed to load.
pub errors: Vec<PluginError>,
/// Non-fatal warnings (e.g., naming conflicts).
pub warnings: Vec<PluginWarning>,
}
/// Manages the full set of loaded plugins and their merged contributions.
pub struct PluginRegistry {
/// Loaded plugin instances (kept alive for shutdown).
plugins: Vec<Box<dyn CalcPadPlugin>>,
/// Merged function map: name -> callable.
functions: HashMap<String, PluginFn>,
/// Merged unit map: name -> conversion factor.
units: HashMap<String, f64>,
/// Merged variable map: name -> value.
variables: HashMap<String, f64>,
/// Accumulated warnings from all load operations.
warnings: Vec<PluginWarning>,
}
impl PluginRegistry {
/// Create an empty registry.
pub fn new() -> Self {
Self {
plugins: Vec::new(),
functions: HashMap::new(),
units: HashMap::new(),
variables: HashMap::new(),
warnings: Vec::new(),
}
}
/// Add a plugin (by value) to the registry.
///
/// Calls `init()`, then the three `register_*()` methods.
/// On init failure the plugin is **not** added and the error is returned.
pub fn add<P: CalcPadPlugin + 'static>(&mut self, plugin: P) -> Result<(), PluginError> {
self.add_boxed(Box::new(plugin))
}
/// Add a boxed plugin to the registry.
pub fn add_boxed(&mut self, plugin: Box<dyn CalcPadPlugin>) -> Result<(), PluginError> {
let name = plugin.name().to_string();
// Lifecycle: init
plugin.init().map_err(|msg| PluginError {
plugin_name: name.clone(),
message: msg,
})?;
// Collect functions
let mut freg = PluginFunctionRegistry::new();
plugin.register_functions(&mut freg);
for (fname, func) in freg.functions {
if is_builtin_function(&fname) {
self.warnings.push(PluginWarning {
plugin_name: name.clone(),
message: format!(
"function '{}' conflicts with built-in -- built-in takes precedence",
fname
),
});
} else if self.functions.contains_key(&fname) {
self.warnings.push(PluginWarning {
plugin_name: name.clone(),
message: format!(
"function '{}' already registered by another plugin -- overwritten",
fname
),
});
self.functions.insert(fname, func);
} else {
self.functions.insert(fname, func);
}
}
// Collect units
let mut ureg = PluginUnitRegistry::new();
plugin.register_units(&mut ureg);
for (uname, factor) in ureg.units {
if self.units.contains_key(&uname) {
self.warnings.push(PluginWarning {
plugin_name: name.clone(),
message: format!(
"unit '{}' already registered -- overwritten",
uname
),
});
}
self.units.insert(uname, factor);
}
// Collect variables
let mut vreg = PluginVariableRegistry::new();
plugin.register_variables(&mut vreg);
for (vname, value) in vreg.variables {
if self.variables.contains_key(&vname) {
self.warnings.push(PluginWarning {
plugin_name: name.clone(),
message: format!(
"variable '{}' already registered -- overwritten",
vname
),
});
}
self.variables.insert(vname, value);
}
self.plugins.push(plugin);
Ok(())
}
// -------------------------------------------------------------------
// Queries
// -------------------------------------------------------------------
/// Number of loaded plugins.
pub fn plugin_count(&self) -> usize {
self.plugins.len()
}
/// Names of all loaded plugins.
pub fn plugin_names(&self) -> Vec<&str> {
self.plugins.iter().map(|p| p.name()).collect()
}
/// Look up a plugin-provided function by name.
pub fn get_function(&self, name: &str) -> Option<&PluginFn> {
self.functions.get(name)
}
/// Call a plugin function by name.
pub fn call_function(
&self,
name: &str,
args: &[PluginValue],
) -> Result<PluginValue, String> {
let func = self
.functions
.get(name)
.ok_or_else(|| format!("unknown plugin function: {}", name))?;
func(args)
}
/// Check whether a plugin function with the given name exists.
pub fn has_function(&self, name: &str) -> bool {
self.functions.contains_key(name)
}
/// All registered plugin function names (sorted).
pub fn function_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
/// Look up a plugin-provided unit's conversion factor.
pub fn get_unit_factor(&self, name: &str) -> Option<f64> {
self.units.get(name).copied()
}
/// Check whether a plugin unit with the given name exists.
pub fn has_unit(&self, name: &str) -> bool {
self.units.contains_key(name)
}
/// All registered plugin unit names (sorted).
pub fn unit_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.units.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
/// Look up a plugin-provided variable's value.
pub fn get_variable(&self, name: &str) -> Option<f64> {
self.variables.get(name).copied()
}
/// Check whether a plugin variable with the given name exists.
pub fn has_variable(&self, name: &str) -> bool {
self.variables.contains_key(name)
}
/// All registered plugin variable names (sorted).
pub fn variable_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.variables.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
/// Warnings accumulated during loading.
pub fn warnings(&self) -> &[PluginWarning] {
&self.warnings
}
// -------------------------------------------------------------------
// Lifecycle: shutdown
// -------------------------------------------------------------------
/// Shut down all loaded plugins (calls `shutdown()` on each).
pub fn shutdown_all(&self) {
for plugin in &self.plugins {
plugin.shutdown();
}
}
}
impl Default for PluginRegistry {
fn default() -> Self {
Self::new()
}
}
impl Drop for PluginRegistry {
fn drop(&mut self) {
self.shutdown_all();
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::api::*;
// -- helpers --
struct MathPlugin;
impl CalcPadPlugin for MathPlugin {
fn name(&self) -> &str { "math_extras" }
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
reg.register("square", |args| {
if args.len() != 1 {
return Err("square() requires 1 argument".into());
}
Ok(PluginValue::new(args[0].number * args[0].number))
});
reg.register("cube", |args| {
if args.len() != 1 {
return Err("cube() requires 1 argument".into());
}
let n = args[0].number;
Ok(PluginValue::new(n * n * n))
});
}
fn register_variables(&self, reg: &mut PluginVariableRegistry) {
reg.register("golden_ratio", 1.618033988749);
}
}
struct UnitsPlugin;
impl CalcPadPlugin for UnitsPlugin {
fn name(&self) -> &str { "custom_units" }
fn register_units(&self, reg: &mut PluginUnitRegistry) {
reg.register("stone", 6.35029);
reg.register("furlong", 201.168);
}
}
struct FailingPlugin;
impl CalcPadPlugin for FailingPlugin {
fn name(&self) -> &str { "failing" }
fn init(&self) -> Result<(), String> {
Err("cannot connect to service".into())
}
}
struct ConflictPlugin;
impl CalcPadPlugin for ConflictPlugin {
fn name(&self) -> &str { "conflict" }
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
// "sin" is a built-in
reg.register("sin", |_| Ok(PluginValue::new(0.0)));
// "my_func" is not
reg.register("my_func", |_| Ok(PluginValue::new(99.0)));
}
}
// -- tests --
#[test]
fn test_empty_registry() {
let reg = PluginRegistry::new();
assert_eq!(reg.plugin_count(), 0);
assert!(reg.function_names().is_empty());
assert!(reg.unit_names().is_empty());
assert!(reg.variable_names().is_empty());
assert!(reg.warnings().is_empty());
}
#[test]
fn test_add_plugin_functions_and_variables() {
let mut reg = PluginRegistry::new();
reg.add(MathPlugin).unwrap();
assert_eq!(reg.plugin_count(), 1);
assert_eq!(reg.plugin_names(), vec!["math_extras"]);
// Functions
assert!(reg.has_function("square"));
assert!(reg.has_function("cube"));
assert!(!reg.has_function("nonexistent"));
let result = reg.call_function("square", &[PluginValue::new(7.0)]).unwrap();
assert_eq!(result.number, 49.0);
let result = reg.call_function("cube", &[PluginValue::new(3.0)]).unwrap();
assert_eq!(result.number, 27.0);
// Variables
assert!(reg.has_variable("golden_ratio"));
let gr = reg.get_variable("golden_ratio").unwrap();
assert!((gr - 1.618033988749).abs() < 1e-10);
}
#[test]
fn test_add_plugin_units() {
let mut reg = PluginRegistry::new();
reg.add(UnitsPlugin).unwrap();
assert!(reg.has_unit("stone"));
assert!(reg.has_unit("furlong"));
assert_eq!(reg.get_unit_factor("stone"), Some(6.35029));
}
#[test]
fn test_multiple_plugins() {
let mut reg = PluginRegistry::new();
reg.add(MathPlugin).unwrap();
reg.add(UnitsPlugin).unwrap();
assert_eq!(reg.plugin_count(), 2);
assert!(reg.has_function("square"));
assert!(reg.has_unit("stone"));
}
#[test]
fn test_plugin_init_failure_does_not_add() {
let mut reg = PluginRegistry::new();
let err = reg.add(FailingPlugin).unwrap_err();
assert_eq!(err.plugin_name, "failing");
assert!(err.message.contains("cannot connect"));
assert_eq!(reg.plugin_count(), 0);
}
#[test]
fn test_builtin_conflict_generates_warning() {
let mut reg = PluginRegistry::new();
reg.add(ConflictPlugin).unwrap();
// "sin" should NOT be registered (built-in conflict)
assert!(!reg.has_function("sin"));
// "my_func" should be registered
assert!(reg.has_function("my_func"));
assert_eq!(reg.warnings().len(), 1);
assert!(reg.warnings()[0].message.contains("sin"));
assert!(reg.warnings()[0].message.contains("built-in"));
}
#[test]
fn test_duplicate_function_name_across_plugins() {
struct PluginA;
impl CalcPadPlugin for PluginA {
fn name(&self) -> &str { "a" }
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
reg.register("shared_fn", |_| Ok(PluginValue::new(1.0)));
}
}
struct PluginB;
impl CalcPadPlugin for PluginB {
fn name(&self) -> &str { "b" }
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
reg.register("shared_fn", |_| Ok(PluginValue::new(2.0)));
}
}
let mut reg = PluginRegistry::new();
reg.add(PluginA).unwrap();
reg.add(PluginB).unwrap();
// The second plugin's version should overwrite
let result = reg.call_function("shared_fn", &[]).unwrap();
assert_eq!(result.number, 2.0);
// A warning should have been emitted
assert_eq!(reg.warnings().len(), 1);
assert!(reg.warnings()[0].message.contains("overwritten"));
}
#[test]
fn test_call_unknown_function_returns_error() {
let reg = PluginRegistry::new();
let err = reg.call_function("nope", &[]).unwrap_err();
assert!(err.contains("unknown plugin function"));
}
#[test]
fn test_function_names_sorted() {
let mut reg = PluginRegistry::new();
struct SortPlugin;
impl CalcPadPlugin for SortPlugin {
fn name(&self) -> &str { "sort" }
fn register_functions(&self, r: &mut PluginFunctionRegistry) {
r.register("zebra", |_| Ok(PluginValue::new(0.0)));
r.register("alpha", |_| Ok(PluginValue::new(0.0)));
r.register("mid", |_| Ok(PluginValue::new(0.0)));
}
}
reg.add(SortPlugin).unwrap();
assert_eq!(reg.function_names(), vec!["alpha", "mid", "zebra"]);
}
#[test]
fn test_shutdown_is_called() {
use std::sync::atomic::{AtomicBool, Ordering};
static SHUT_DOWN: AtomicBool = AtomicBool::new(false);
struct ShutdownPlugin;
impl CalcPadPlugin for ShutdownPlugin {
fn name(&self) -> &str { "shutdown_test" }
fn shutdown(&self) {
SHUT_DOWN.store(true, Ordering::SeqCst);
}
}
{
let mut reg = PluginRegistry::new();
reg.add(ShutdownPlugin).unwrap();
reg.shutdown_all();
assert!(SHUT_DOWN.load(Ordering::SeqCst));
}
}
#[test]
fn test_default_trait() {
let reg = PluginRegistry::default();
assert_eq!(reg.plugin_count(), 0);
}
}

View File

@@ -0,0 +1,555 @@
//! Rhai scripting integration for lightweight custom functions.
//!
//! Users can write `.rhai` script files that define functions. This module
//! compiles those scripts in a sandboxed engine and wraps each
//! script-defined function as a [`CalcPadPlugin`] for seamless integration
//! with the plugin registry.
//!
//! # Current status
//!
//! The Rhai integration is **stubbed**. When the `rhai` crate is added as
//! a dependency (with the `sync` feature) the stubs in this file should be
//! replaced with the real Rhai engine calls. The public API and types are
//! already designed and tested; only the compile/call internals are pending.
//!
//! To enable in the future, add to `Cargo.toml`:
//!
//! ```toml
//! rhai = { version = "1", features = ["sync"] }
//! ```
//!
//! # Sandbox guarantees (when fully enabled)
//!
//! - `Engine::new_raw()` — no standard library, no I/O
//! - `eval` symbol disabled
//! - Operation limit: 1 000 000
//! - Call-depth limit: 64
//! - Expression-depth limit: 64
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::api::{
CalcPadPlugin, PluginFn, PluginFunctionRegistry, PluginUnitRegistry,
PluginValue, PluginVariableRegistry,
};
// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------
/// Error from loading or compiling a Rhai script.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptError {
pub file: PathBuf,
pub line: Option<usize>,
pub message: String,
}
impl std::fmt::Display for ScriptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(line) = self.line {
write!(f, "{}: line {}: {}", self.file.display(), line, self.message)
} else {
write!(f, "{}: {}", self.file.display(), self.message)
}
}
}
impl std::error::Error for ScriptError {}
// ---------------------------------------------------------------------------
// Built-in name reservation
// ---------------------------------------------------------------------------
const BUILTIN_NAMES: &[&str] = &[
"sin", "cos", "tan", "asin", "acos", "atan",
"sinh", "cosh", "tanh",
"sqrt", "abs", "ceil", "floor", "round",
"ln", "log", "log2", "log10",
"exp", "pow", "min", "max",
"factorial", "nPr", "nCr",
"gcd", "lcm",
];
fn is_builtin_name(name: &str) -> bool {
BUILTIN_NAMES.contains(&name)
}
// ---------------------------------------------------------------------------
// ScriptPlugin
// ---------------------------------------------------------------------------
/// A plugin whose functions were loaded from `.rhai` script files.
///
/// This wraps compiled script functions as standard [`CalcPadPlugin`]
/// contributions so they integrate seamlessly with the
/// [`PluginRegistry`](super::registry::PluginRegistry).
pub struct ScriptPlugin {
name: String,
functions: HashMap<String, PluginFn>,
}
impl ScriptPlugin {
/// Create a script plugin directly from pre-built function closures.
///
/// This is the primary constructor used by [`load_scripts_from_dir`] and
/// is also useful for testing.
pub fn new(name: impl Into<String>, functions: HashMap<String, PluginFn>) -> Self {
Self {
name: name.into(),
functions,
}
}
/// Number of functions this script plugin provides.
pub fn function_count(&self) -> usize {
self.functions.len()
}
/// Names of all functions provided by this plugin.
pub fn function_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.functions.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
}
impl CalcPadPlugin for ScriptPlugin {
fn name(&self) -> &str {
&self.name
}
fn register_functions(&self, reg: &mut PluginFunctionRegistry) {
for (name, func) in &self.functions {
let f = Arc::clone(func);
reg.register(name.clone(), move |args: &[PluginValue]| f(args));
}
}
// Scripts currently only provide functions (units and variables would
// require a richer scripting protocol).
fn register_units(&self, _reg: &mut PluginUnitRegistry) {}
fn register_variables(&self, _reg: &mut PluginVariableRegistry) {}
}
// ---------------------------------------------------------------------------
// Load result
// ---------------------------------------------------------------------------
/// Result of loading `.rhai` scripts from a directory.
pub struct ScriptLoadResult {
/// The merged plugin, or `None` if no functions were loaded.
pub plugin: Option<ScriptPlugin>,
/// Errors from scripts that failed to parse / execute.
pub errors: Vec<ScriptError>,
/// Non-fatal warnings (e.g., built-in name conflicts).
pub warnings: Vec<String>,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Load all `.rhai` scripts from `dir`, compile them, and return a
/// [`ScriptLoadResult`].
///
/// Scripts that fail to parse are reported in `errors` but do not prevent
/// other scripts from loading. Functions whose names conflict with
/// built-in engine names are skipped (reported in `warnings`).
///
/// **Current implementation is stubbed** — it scans for `.rhai` files to
/// validate the directory but does not compile them (Rhai is not yet a
/// dependency). Each discovered function-definition line is noted but
/// returns a stub error at call time.
pub fn load_scripts_from_dir(dir: &Path) -> ScriptLoadResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let mut functions: HashMap<String, PluginFn> = HashMap::new();
if !dir.exists() || !dir.is_dir() {
return ScriptLoadResult {
plugin: None,
errors,
warnings,
};
}
// Collect .rhai files
let mut rhai_files: Vec<PathBuf> = match std::fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map_or(false, |ext| ext == "rhai"))
.collect(),
Err(e) => {
errors.push(ScriptError {
file: dir.to_path_buf(),
line: None,
message: format!("failed to read directory: {}", e),
});
return ScriptLoadResult {
plugin: None,
errors,
warnings,
};
}
};
rhai_files.sort();
if rhai_files.is_empty() {
return ScriptLoadResult {
plugin: None,
errors,
warnings,
};
}
// Stub: parse files looking for `fn <name>(<args>)` patterns.
// Real implementation would use `rhai::Engine::compile()`.
for path in &rhai_files {
match std::fs::read_to_string(path) {
Ok(source) => {
let extracted = extract_stub_functions(&source);
for (name, param_count) in extracted {
if is_builtin_name(&name) {
warnings.push(format!(
"script '{}': function '{}' conflicts with built-in -- skipped",
path.file_name().unwrap_or_default().to_string_lossy(),
name,
));
continue;
}
let fname = name.clone();
let pc = param_count;
let plugin_fn: PluginFn = Arc::new(move |args: &[PluginValue]| {
if args.len() != pc {
return Err(format!(
"{}() expects {} argument{}, got {}",
fname,
pc,
if pc == 1 { "" } else { "s" },
args.len(),
));
}
// Stub: real impl would evaluate the AST via Rhai engine.
Err(format!(
"{}(): Rhai scripting engine is not yet enabled",
fname,
))
});
functions.insert(name, plugin_fn);
}
}
Err(e) => {
errors.push(ScriptError {
file: path.clone(),
line: None,
message: format!("failed to read file: {}", e),
});
}
}
}
let plugin = if functions.is_empty() {
None
} else {
Some(ScriptPlugin::new("rhai_scripts", functions))
};
ScriptLoadResult {
plugin,
errors,
warnings,
}
}
/// Naive extraction of `fn name(a, b, c)` definitions from Rhai source.
///
/// This is a stub parser that extracts function signatures without a full
/// Rhai compile step. It finds lines matching `fn <ident>(<params>)` and
/// returns `(name, param_count)` pairs.
fn extract_stub_functions(source: &str) -> Vec<(String, usize)> {
let mut results = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("fn ") {
continue;
}
// "fn double(x) { ... }" -> extract "double" and count params
let rest = trimmed[3..].trim();
if let Some(paren_open) = rest.find('(') {
let name = rest[..paren_open].trim().to_string();
if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
continue;
}
if let Some(paren_close) = rest.find(')') {
let params_str = rest[paren_open + 1..paren_close].trim();
let param_count = if params_str.is_empty() {
0
} else {
params_str.split(',').count()
};
results.push((name, param_count));
}
}
}
results
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Helper to create a temporary directory for test scripts.
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(suffix: &str) -> Self {
let path = std::env::temp_dir().join(format!(
"calcpad_scripting_test_{}_{}", std::process::id(), suffix
));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
Self { path }
}
fn write_file(&self, name: &str, content: &str) {
fs::write(self.path.join(name), content).unwrap();
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
// -- extract_stub_functions tests --
#[test]
fn test_extract_no_functions() {
let fns = extract_stub_functions("let x = 5;\nlet y = 10;");
assert!(fns.is_empty());
}
#[test]
fn test_extract_single_function() {
let fns = extract_stub_functions("fn double(x) { x * 2.0 }");
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].0, "double");
assert_eq!(fns[0].1, 1);
}
#[test]
fn test_extract_multiple_functions() {
let src = r#"
fn double(x) { x * 2.0 }
fn add(a, b) { a + b }
fn pi() { 3.14159 }
"#;
let fns = extract_stub_functions(src);
assert_eq!(fns.len(), 3);
assert_eq!(fns[0], ("double".into(), 1));
assert_eq!(fns[1], ("add".into(), 2));
assert_eq!(fns[2], ("pi".into(), 0));
}
#[test]
fn test_extract_ignores_non_fn_lines() {
let src = "// fn fake(x) {}\nfn real(x) { x }";
let fns = extract_stub_functions(src);
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].0, "real");
}
// -- load_scripts_from_dir tests --
#[test]
fn test_load_empty_dir() {
let tmp = TempDir::new("empty");
let result = load_scripts_from_dir(tmp.path());
assert!(result.plugin.is_none());
assert!(result.errors.is_empty());
}
#[test]
fn test_load_nonexistent_dir() {
let result = load_scripts_from_dir(Path::new("/tmp/nonexistent_calcpad_99999"));
assert!(result.plugin.is_none());
assert!(result.errors.is_empty());
}
#[test]
fn test_load_simple_function() {
let tmp = TempDir::new("simple");
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
let result = load_scripts_from_dir(tmp.path());
assert!(result.errors.is_empty());
assert!(result.warnings.is_empty());
let plugin = result.plugin.unwrap();
assert_eq!(plugin.function_count(), 1);
assert_eq!(plugin.function_names(), vec!["double"]);
}
#[test]
fn test_load_multiple_functions_one_file() {
let tmp = TempDir::new("multi_fn");
tmp.write_file("funcs.rhai", r#"
fn double(x) { x * 2.0 }
fn triple(x) { x * 3.0 }
fn add(a, b) { a + b }
"#);
let result = load_scripts_from_dir(tmp.path());
assert!(result.errors.is_empty());
let plugin = result.plugin.unwrap();
assert_eq!(plugin.function_count(), 3);
}
#[test]
fn test_load_multiple_files() {
let tmp = TempDir::new("multi_file");
tmp.write_file("a.rhai", "fn from_a(x) { x + 1.0 }");
tmp.write_file("b.rhai", "fn from_b(x) { x + 2.0 }");
let result = load_scripts_from_dir(tmp.path());
assert!(result.errors.is_empty());
let plugin = result.plugin.unwrap();
assert_eq!(plugin.function_count(), 2);
}
#[test]
fn test_non_rhai_files_ignored() {
let tmp = TempDir::new("ignore");
tmp.write_file("readme.txt", "not a script");
tmp.write_file("script.lua", "-- not rhai");
tmp.write_file("actual.rhai", "fn real_fn(x) { x }");
let result = load_scripts_from_dir(tmp.path());
assert!(result.errors.is_empty());
let plugin = result.plugin.unwrap();
assert_eq!(plugin.function_count(), 1);
assert_eq!(plugin.function_names(), vec!["real_fn"]);
}
#[test]
fn test_builtin_conflict_produces_warning() {
let tmp = TempDir::new("conflict");
tmp.write_file("math.rhai", r#"
fn sqrt(x) { x }
fn my_func(x) { x * 2.0 }
"#);
let result = load_scripts_from_dir(tmp.path());
let plugin = result.plugin.unwrap();
// sqrt should be skipped
assert_eq!(plugin.function_count(), 1);
assert_eq!(plugin.function_names(), vec!["my_func"]);
// warning about sqrt
assert_eq!(result.warnings.len(), 1);
assert!(result.warnings[0].contains("sqrt"));
assert!(result.warnings[0].contains("built-in"));
}
#[test]
fn test_multiple_builtin_conflicts() {
let tmp = TempDir::new("multi_conflict");
tmp.write_file("bad.rhai", r#"
fn sin(x) { x }
fn cos(x) { x }
fn safe_fn(x) { x }
"#);
let result = load_scripts_from_dir(tmp.path());
assert_eq!(result.warnings.len(), 2);
let plugin = result.plugin.unwrap();
assert_eq!(plugin.function_count(), 1);
assert_eq!(plugin.function_names(), vec!["safe_fn"]);
}
#[test]
fn test_stub_function_arity_check() {
let tmp = TempDir::new("arity");
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
let result = load_scripts_from_dir(tmp.path());
let plugin = result.plugin.unwrap();
// Register into a PluginFunctionRegistry so we can call it.
let mut freg = PluginFunctionRegistry::new();
plugin.register_functions(&mut freg);
let func = freg.functions.get("double").unwrap();
// Wrong arity should produce an error.
let err = func(&[PluginValue::new(1.0), PluginValue::new(2.0)]).unwrap_err();
assert!(err.contains("expects 1 argument"));
}
#[test]
fn test_stub_function_returns_engine_not_enabled() {
let tmp = TempDir::new("stub_err");
tmp.write_file("math.rhai", "fn double(x) { x * 2.0 }");
let result = load_scripts_from_dir(tmp.path());
let plugin = result.plugin.unwrap();
let mut freg = PluginFunctionRegistry::new();
plugin.register_functions(&mut freg);
let func = freg.functions.get("double").unwrap();
// Correct arity but stub should say engine is not enabled.
let err = func(&[PluginValue::new(5.0)]).unwrap_err();
assert!(err.contains("not yet enabled"));
}
#[test]
fn test_zero_arg_function_extracted() {
let fns = extract_stub_functions("fn pi() { 3.14159 }");
assert_eq!(fns.len(), 1);
assert_eq!(fns[0].0, "pi");
assert_eq!(fns[0].1, 0);
}
#[test]
fn test_script_plugin_implements_calcpad_plugin() {
let functions: HashMap<String, PluginFn> = HashMap::new();
let plugin = ScriptPlugin::new("test_scripts", functions);
// Verify it satisfies the trait
let p: &dyn CalcPadPlugin = &plugin;
assert_eq!(p.name(), "test_scripts");
}
#[test]
fn test_script_error_display_with_line() {
let e = ScriptError {
file: PathBuf::from("foo.rhai"),
line: Some(42),
message: "unexpected token".into(),
};
assert_eq!(e.to_string(), "foo.rhai: line 42: unexpected token");
}
#[test]
fn test_script_error_display_without_line() {
let e = ScriptError {
file: PathBuf::from("bar.rhai"),
line: None,
message: "file not found".into(),
};
assert_eq!(e.to_string(), "bar.rhai: file not found");
}
}

View File

@@ -0,0 +1,177 @@
//! Property-based fuzz tests for the evaluation engine.
//!
//! These use proptest to generate random inputs and verify invariants:
//! - The engine never panics on any input
//! - Algebraic properties (commutativity, identities) hold
//! - Division by zero always produces errors
use calcpad_engine::context::EvalContext;
use calcpad_engine::pipeline::eval_line;
use calcpad_engine::types::ResultType;
use proptest::prelude::*;
fn arb_small_int() -> impl Strategy<Value = i64> {
-10000i64..10000
}
fn arb_operator() -> impl Strategy<Value = &'static str> {
prop::sample::select(vec!["+", "-", "*"])
}
// =========================================================================
// No-panic guarantees: valid expressions
// =========================================================================
proptest! {
#[test]
fn valid_number_never_panics(n in arb_small_int()) {
let input = format!("{}", n);
let mut ctx = EvalContext::new();
let _result = eval_line(&input, &mut ctx);
}
#[test]
fn valid_binary_expr_never_panics(
a in arb_small_int(),
b in arb_small_int(),
op in arb_operator()
) {
let input = format!("{} {} {}", a, op, b);
let mut ctx = EvalContext::new();
let _result = eval_line(&input, &mut ctx);
}
#[test]
fn valid_complex_expr_never_panics(
a in arb_small_int(),
b in 1i64..1000,
c in arb_small_int(),
op1 in arb_operator(),
op2 in arb_operator()
) {
let input = format!("({} {} {}) {} {}", a, op1, b, op2, c);
let mut ctx = EvalContext::new();
let _result = eval_line(&input, &mut ctx);
}
}
// =========================================================================
// No-panic guarantees: malformed / garbage input
// =========================================================================
proptest! {
#[test]
fn malformed_garbage_never_panics(
s in "[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]{1,50}"
) {
let mut ctx = EvalContext::new();
let _result = eval_line(&s, &mut ctx);
}
#[test]
fn malformed_unmatched_parens_never_panics(
s in "\\({0,5}[0-9]{1,4}[+\\-*/]{0,2}[0-9]{0,4}\\){0,5}"
) {
let mut ctx = EvalContext::new();
let _result = eval_line(&s, &mut ctx);
}
#[test]
fn empty_and_whitespace_never_panics(s in "\\s{0,20}") {
let mut ctx = EvalContext::new();
let _result = eval_line(&s, &mut ctx);
}
#[test]
fn random_identifiers_never_panics(s in "[a-z]{1,10}") {
let mut ctx = EvalContext::new();
let _result = eval_line(&s, &mut ctx);
}
}
// =========================================================================
// Algebraic properties
// =========================================================================
proptest! {
#[test]
fn addition_commutativity(a in arb_small_int(), b in arb_small_int()) {
let mut ctx1 = EvalContext::new();
let mut ctx2 = EvalContext::new();
let r1 = eval_line(&format!("{} + {}", a, b), &mut ctx1);
let r2 = eval_line(&format!("{} + {}", b, a), &mut ctx2);
match (r1.result_type(), r2.result_type()) {
(ResultType::Number, ResultType::Number) => {
let v1 = r1.metadata.raw_value.unwrap();
let v2 = r2.metadata.raw_value.unwrap();
prop_assert!((v1 - v2).abs() < 1e-10,
"{} + {} = {} but {} + {} = {}", a, b, v1, b, a, v2);
}
_ => {
prop_assert_eq!(r1.result_type(), r2.result_type());
}
}
}
#[test]
fn multiplication_commutativity(a in arb_small_int(), b in arb_small_int()) {
let mut ctx1 = EvalContext::new();
let mut ctx2 = EvalContext::new();
let r1 = eval_line(&format!("{} * {}", a, b), &mut ctx1);
let r2 = eval_line(&format!("{} * {}", b, a), &mut ctx2);
match (r1.result_type(), r2.result_type()) {
(ResultType::Number, ResultType::Number) => {
let v1 = r1.metadata.raw_value.unwrap();
let v2 = r2.metadata.raw_value.unwrap();
prop_assert!((v1 - v2).abs() < 1e-6,
"{} * {} = {} but {} * {} = {}", a, b, v1, b, a, v2);
}
_ => {
prop_assert_eq!(r1.result_type(), r2.result_type());
}
}
}
}
// =========================================================================
// Division by zero
// =========================================================================
proptest! {
#[test]
fn division_by_zero_always_error(a in arb_small_int()) {
let mut ctx = EvalContext::new();
let r = eval_line(&format!("{} / 0", a), &mut ctx);
prop_assert_eq!(r.result_type(), ResultType::Error);
}
}
// =========================================================================
// Identity operations
// =========================================================================
proptest! {
#[test]
fn add_zero_identity(a in arb_small_int()) {
let mut ctx = EvalContext::new();
let r = eval_line(&format!("{} + 0", a), &mut ctx);
if r.result_type() == ResultType::Number {
let v = r.metadata.raw_value.unwrap();
prop_assert!((v - a as f64).abs() < 1e-10,
"{} + 0 should be {} but got {}", a, a, v);
}
}
#[test]
fn mul_one_identity(a in arb_small_int()) {
let mut ctx = EvalContext::new();
let r = eval_line(&format!("{} * 1", a), &mut ctx);
if r.result_type() == ResultType::Number {
let v = r.metadata.raw_value.unwrap();
prop_assert!((v - a as f64).abs() < 1e-10,
"{} * 1 should be {} but got {}", a, a, v);
}
}
}

View File

@@ -0,0 +1,313 @@
//! Integration tests covering real-world sheet scenarios.
//!
//! These exercise the full pipeline (lexer -> parser -> interpreter) through
//! the eval_line / eval_sheet / SheetContext public APIs, verifying end-to-end
//! behavior rather than individual module internals.
use calcpad_engine::context::EvalContext;
use calcpad_engine::pipeline::{eval_line, eval_sheet};
use calcpad_engine::types::ResultType;
use calcpad_engine::SheetContext;
// =========================================================================
// Variable assignment and reference across lines
// =========================================================================
#[test]
fn sheet_variable_assignment_and_reference() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&["price = 100", "quantity = 5", "total = price * quantity"],
&mut ctx,
);
assert_eq!(results.len(), 3);
assert_eq!(results[0].metadata.display, "100");
assert_eq!(results[1].metadata.display, "5");
assert_eq!(results[2].metadata.display, "500");
}
#[test]
fn sheet_variable_used_in_multiple_lines() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&[
"rate = 25",
"hours = 8",
"daily = rate * hours",
"weekly = daily * 5",
],
&mut ctx,
);
assert_eq!(results[2].metadata.display, "200");
assert_eq!(results[3].metadata.display, "1000");
}
// =========================================================================
// Chained dependencies
// =========================================================================
#[test]
fn sheet_chained_dependencies() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&["a = 10", "b = a + 5", "c = b * 2", "d = c - a"],
&mut ctx,
);
assert_eq!(results[0].metadata.display, "10");
assert_eq!(results[1].metadata.display, "15");
assert_eq!(results[2].metadata.display, "30");
assert_eq!(results[3].metadata.display, "20");
}
#[test]
fn sheet_deep_chain() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&[
"v1 = 1",
"v2 = v1 + 1",
"v3 = v2 + 1",
"v4 = v3 + 1",
"v5 = v4 + 1",
],
&mut ctx,
);
assert_eq!(results[4].metadata.display, "5");
}
// =========================================================================
// Forward references and undefined variables
// =========================================================================
#[test]
fn sheet_forward_reference_error() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["a = b + 1", "b = 10"], &mut ctx);
// eval_sheet processes sequentially; b isn't defined yet when line 0 runs
assert_eq!(results[0].result_type(), ResultType::Error);
assert_eq!(results[1].metadata.display, "10");
}
#[test]
fn sheet_undefined_variable_in_chain() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = undefined_var + 1"], &mut ctx);
assert_eq!(results[0].result_type(), ResultType::Error);
}
// =========================================================================
// Unit conversion across lines
// =========================================================================
#[test]
fn sheet_unit_arithmetic_same_unit() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["a = 5kg", "b = 3kg", "total = a + b"], &mut ctx);
assert_eq!(results[2].result_type(), ResultType::UnitValue);
assert_eq!(results[2].metadata.raw_value, Some(8.0));
}
// =========================================================================
// Mixed expressions: comments, empty lines, text
// =========================================================================
#[test]
fn sheet_with_comments() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&[
"// Budget calculation",
"income = 5000",
"expenses = 3000",
"savings = income - expenses",
],
&mut ctx,
);
assert_eq!(results[0].result_type(), ResultType::Error); // comment
assert_eq!(results[3].metadata.display, "2000");
}
#[test]
fn sheet_with_empty_lines() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = 10", "", "y = x + 5"], &mut ctx);
assert_eq!(results[0].metadata.display, "10");
assert_eq!(results[1].result_type(), ResultType::Error); // empty line
assert_eq!(results[2].metadata.display, "15");
}
#[test]
fn sheet_mixed_comments_and_calcs() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&[
"// Shopping list",
"apples = 3 * 2",
"// bananas are on sale",
"bananas = 5 * 1",
"total = apples + bananas",
],
&mut ctx,
);
assert_eq!(results[1].metadata.display, "6");
assert_eq!(results[3].metadata.display, "5");
assert_eq!(results[4].metadata.display, "11");
}
// =========================================================================
// Variable reassignment / shadowing
// =========================================================================
#[test]
fn sheet_variable_reassignment() {
let mut ctx = EvalContext::new();
let results = eval_sheet(&["x = 5", "y = x * 2", "x = 20", "z = x * 2"], &mut ctx);
assert_eq!(results[0].metadata.display, "5");
assert_eq!(results[1].metadata.display, "10");
assert_eq!(results[2].metadata.display, "20");
assert_eq!(results[3].metadata.display, "40"); // uses reassigned x=20
}
// =========================================================================
// Real-world calculation scenarios
// =========================================================================
#[test]
fn sheet_invoice_calculation() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&[
"// Invoice",
"subtotal = 1500",
"tax_rate = 8.5",
"tax = subtotal * tax_rate / 100",
"total = subtotal + tax",
],
&mut ctx,
);
assert_eq!(results[1].metadata.display, "1500");
assert_eq!(results[3].metadata.display, "127.5");
assert_eq!(results[4].metadata.display, "1627.5");
}
#[test]
fn sheet_tip_calculation() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&["bill = 85", "tip = bill + 20%", "per_person = tip / 4"],
&mut ctx,
);
assert_eq!(results[0].metadata.display, "85");
assert_eq!(results[1].metadata.display, "102");
assert!((results[2].metadata.raw_value.unwrap() - 25.5).abs() < 0.01);
}
#[test]
fn sheet_accumulator_pattern() {
let mut ctx = EvalContext::new();
let lines: Vec<String> = (0..20)
.map(|i| {
if i == 0 {
"acc = 0".to_string()
} else {
format!("acc = acc + {}", i)
}
})
.collect();
let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
let results = eval_sheet(&refs, &mut ctx);
// Sum of 1..19 = 190
let last = results.last().unwrap();
assert_eq!(last.metadata.raw_value, Some(190.0));
}
// =========================================================================
// Context isolation
// =========================================================================
#[test]
fn context_variables_independent() {
let mut ctx1 = EvalContext::new();
let mut ctx2 = EvalContext::new();
eval_line("x = 100", &mut ctx1);
let r = eval_line("x", &mut ctx2);
assert_eq!(r.result_type(), ResultType::Error); // x not defined in ctx2
}
// =========================================================================
// SheetContext: incremental evaluation
// =========================================================================
#[test]
fn sheet_context_incremental_independent_lines() {
let mut sheet = SheetContext::new();
sheet.set_line(0, "a = 10");
sheet.set_line(1, "b = 20");
sheet.set_line(2, "c = a + 5");
sheet.set_line(3, "d = b + 5");
let results = sheet.eval();
assert_eq!(results.len(), 4);
// Change only b: a and c should be unaffected
sheet.set_line(1, "b = 99");
let results = sheet.eval();
assert_eq!(results[0].metadata.raw_value, Some(10.0));
assert_eq!(results[1].metadata.raw_value, Some(99.0));
assert_eq!(results[2].metadata.raw_value, Some(15.0)); // a + 5, unchanged
assert_eq!(results[3].metadata.raw_value, Some(104.0)); // b + 5
}
#[test]
fn sheet_context_aggregator_invoice() {
let mut sheet = SheetContext::new();
sheet.set_line(0, "## Monthly Expenses");
sheet.set_line(1, "1200"); // rent
sheet.set_line(2, "150"); // utilities
sheet.set_line(3, "400"); // groceries
sheet.set_line(4, "subtotal");
sheet.set_line(5, "## One-Time Costs");
sheet.set_line(6, "500"); // furniture
sheet.set_line(7, "200"); // electronics
sheet.set_line(8, "subtotal");
sheet.set_line(9, "grand total");
let results = sheet.eval();
assert_eq!(results[4].metadata.raw_value, Some(1750.0));
assert_eq!(results[8].metadata.raw_value, Some(700.0));
assert_eq!(results[9].metadata.raw_value, Some(2450.0));
}
#[test]
fn sheet_context_prev_through_sections() {
let mut sheet = SheetContext::new();
sheet.set_line(0, "100");
sheet.set_line(1, "prev + 50"); // 150
sheet.set_line(2, "prev * 2"); // 300
let results = sheet.eval();
assert_eq!(results[0].metadata.raw_value, Some(100.0));
assert_eq!(results[1].metadata.raw_value, Some(150.0));
assert_eq!(results[2].metadata.raw_value, Some(300.0));
}
#[test]
fn sheet_context_comparison_result() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&["budget = 1000", "spent = 750", "budget - spent > 0"],
&mut ctx,
);
assert_eq!(results[2].result_type(), ResultType::Boolean);
assert_eq!(results[2].metadata.display, "true");
}
#[test]
fn sheet_context_percentage_discount() {
let mut ctx = EvalContext::new();
let results = eval_sheet(
&["original = $200", "discounted = $200 - 30%"],
&mut ctx,
);
assert_eq!(results[0].result_type(), ResultType::CurrencyValue);
assert_eq!(results[1].result_type(), ResultType::CurrencyValue);
assert!((results[1].metadata.raw_value.unwrap() - 140.0).abs() < 0.01);
}