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:
@@ -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
|
||||
|
||||
155
calcpad-engine/benches/eval_benchmark.rs
Normal file
155
calcpad-engine/benches/eval_benchmark.rs
Normal 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);
|
||||
666
calcpad-engine/src/formatting/answer_format.rs
Normal file
666
calcpad-engine/src/formatting/answer_format.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
290
calcpad-engine/src/formatting/clipboard.rs
Normal file
290
calcpad-engine/src/formatting/clipboard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
471
calcpad-engine/src/formatting/line_types.rs
Normal file
471
calcpad-engine/src/formatting/line_types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
34
calcpad-engine/src/formatting/mod.rs
Normal file
34
calcpad-engine/src/formatting/mod.rs
Normal 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};
|
||||
@@ -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;
|
||||
|
||||
437
calcpad-engine/src/plugins/api.rs
Normal file
437
calcpad-engine/src/plugins/api.rs
Normal 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>();
|
||||
}
|
||||
}
|
||||
63
calcpad-engine/src/plugins/mod.rs
Normal file
63
calcpad-engine/src/plugins/mod.rs
Normal 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};
|
||||
485
calcpad-engine/src/plugins/registry.rs
Normal file
485
calcpad-engine/src/plugins/registry.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
555
calcpad-engine/src/plugins/scripting.rs
Normal file
555
calcpad-engine/src/plugins/scripting.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
177
calcpad-engine/tests/proptest_fuzz.rs
Normal file
177
calcpad-engine/tests/proptest_fuzz.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
313
calcpad-engine/tests/sheet_scenarios.rs
Normal file
313
calcpad-engine/tests/sheet_scenarios.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user