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

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

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

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

View File

@@ -23,7 +23,8 @@
"Bash(git commit:*)",
"Bash(/Users/cassel/.cargo/bin/cargo test:*)",
"Bash(tee /tmp/test-output.txt)",
"Bash(echo \"EXIT: $?\")"
"Bash(echo \"EXIT: $?\")",
"Bash(/Users/cassel/.cargo/bin/cargo build:*)"
]
}
}

4145
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
[workspace]
members = [
"calcpad-engine",
"calcpad-cli",
"calcpad-wasm",
"calcpad-windows",
]
resolver = "2"

15
calcpad-cli/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "calcpad-cli"
version = "0.1.0"
edition = "2021"
description = "CLI tool for CalcPad — notepad-style calculator"
[[bin]]
name = "calcpad"
path = "src/main.rs"
[dependencies]
calcpad-engine = { path = "../calcpad-engine" }
clap = { version = "4", features = ["derive"] }
rustyline = "15"
serde_json = "1"

328
calcpad-cli/src/main.rs Normal file
View File

@@ -0,0 +1,328 @@
use std::io::{self, IsTerminal, Read};
use std::process::ExitCode;
use clap::{Parser, ValueEnum};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use calcpad_engine::{eval_line, eval_sheet, CalcResult, CalcValue, EvalContext, ResultType};
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Plain,
Json,
Csv,
}
#[derive(Parser, Debug)]
#[command(
name = "calcpad",
about = "CalcPad — notepad-style calculator",
long_about = "Evaluate mathematical expressions, unit conversions, and more.\n\n\
Examples:\n \
calcpad \"2 + 3 * 4\"\n \
calcpad \"5kg in lbs\"\n \
echo \"100 + 200\" | calcpad\n \
calcpad --repl"
)]
struct Cli {
/// Expression to evaluate (omit for stdin/pipe mode).
expression: Option<String>,
/// Start an interactive REPL session.
#[arg(long)]
repl: bool,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
format: OutputFormat,
}
// ---------------------------------------------------------------------------
// Result helpers
// ---------------------------------------------------------------------------
fn is_error(result: &CalcResult) -> bool {
result.metadata.result_type == ResultType::Error
}
fn is_empty_error(result: &CalcResult) -> bool {
// The engine returns CalcResult::error("empty input", ..) or
// CalcResult::error("no expression found", ..) for blank/comment lines.
if let CalcValue::Error { ref message, .. } = result.value {
message == "empty input" || message == "no expression found"
} else {
false
}
}
fn error_message(result: &CalcResult) -> Option<&str> {
if let CalcValue::Error { ref message, .. } = result.value {
Some(message)
} else {
None
}
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
fn print_result_json(result: &CalcResult) {
// CalcResult derives Serialize via the engine, so we can emit it directly.
match serde_json::to_string(result) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Error: failed to serialize result: {}", e),
}
}
fn print_results_json(results: &[CalcResult]) {
match serde_json::to_string_pretty(results) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Error: failed to serialize results: {}", e),
}
}
fn print_result_csv(result: &CalcResult, line_num: usize) {
if is_empty_error(result) {
println!("{},,,", line_num);
return;
}
// line,type,display,raw_value
let rtype = result.metadata.result_type;
let display = &result.metadata.display;
// Escape display for CSV (wrap in quotes if it contains comma/quote)
let escaped = csv_escape(display);
let raw = result
.metadata
.raw_value
.map(|v| v.to_string())
.unwrap_or_default();
println!("{},{},{},{}", line_num, rtype, escaped, raw);
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
// ---------------------------------------------------------------------------
// Modes
// ---------------------------------------------------------------------------
fn run_single_expression(expression: &str, format: OutputFormat) -> ExitCode {
let mut ctx = EvalContext::new();
let result = eval_line(expression, &mut ctx);
if is_empty_error(&result) {
return ExitCode::SUCCESS;
}
match format {
OutputFormat::Plain => {
if is_error(&result) {
if let Some(msg) = error_message(&result) {
eprintln!("Error: {}", msg);
}
return ExitCode::from(1);
}
println!("{}", result.metadata.display);
}
OutputFormat::Json => {
print_result_json(&result);
if is_error(&result) {
return ExitCode::from(1);
}
}
OutputFormat::Csv => {
println!("line,type,display,raw_value");
print_result_csv(&result, 1);
if is_error(&result) {
return ExitCode::from(1);
}
}
}
ExitCode::SUCCESS
}
fn run_stdin(format: OutputFormat) -> ExitCode {
let mut input = String::new();
if let Err(e) = io::stdin().read_to_string(&mut input) {
eprintln!("Error: failed to read stdin: {}", e);
return ExitCode::from(1);
}
let lines: Vec<&str> = input.lines().collect();
if lines.is_empty() {
return ExitCode::SUCCESS;
}
let mut ctx = EvalContext::new();
let results = eval_sheet(&lines, &mut ctx);
let mut has_error = false;
match format {
OutputFormat::Plain => {
for result in &results {
if is_empty_error(result) {
println!();
} else if is_error(result) {
if let Some(msg) = error_message(result) {
println!("Error: {}", msg);
}
has_error = true;
} else {
println!("{}", result.metadata.display);
}
}
}
OutputFormat::Json => {
print_results_json(&results);
has_error = results.iter().any(|r| is_error(r) && !is_empty_error(r));
}
OutputFormat::Csv => {
println!("line,type,display,raw_value");
for (i, result) in results.iter().enumerate() {
print_result_csv(result, i + 1);
if is_error(result) && !is_empty_error(result) {
has_error = true;
}
}
}
}
if has_error {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn run_repl(format: OutputFormat) -> ExitCode {
let mut rl = match DefaultEditor::new() {
Ok(editor) => editor,
Err(err) => {
eprintln!("Error: failed to initialize line editor: {}", err);
return ExitCode::from(1);
}
};
// Try to load history from ~/.calcpad_history
let history_path = dirs_history_path();
if let Some(ref path) = history_path {
let _ = rl.load_history(path);
}
let mut ctx = EvalContext::new();
loop {
match rl.readline("> ") {
Ok(line) => {
let trimmed = line.trim();
if is_exit_command(trimmed) {
break;
}
let _ = rl.add_history_entry(&line);
let result = eval_line(&line, &mut ctx);
if is_empty_error(&result) {
// Blank or comment — no output.
continue;
}
match format {
OutputFormat::Plain => {
if is_error(&result) {
if let Some(msg) = error_message(&result) {
eprintln!("Error: {}", msg);
}
} else {
println!("{}", result.metadata.display);
}
}
OutputFormat::Json => {
print_result_json(&result);
}
OutputFormat::Csv => {
// In REPL mode we don't print a CSV header each time;
// just emit one row per expression.
let rtype = result.metadata.result_type;
let display = csv_escape(&result.metadata.display);
let raw = result
.metadata
.raw_value
.map(|v| v.to_string())
.unwrap_or_default();
println!("{},{},{}", rtype, display, raw);
}
}
}
Err(ReadlineError::Eof) => break,
Err(ReadlineError::Interrupted) => break,
Err(err) => {
eprintln!("Error: {}", err);
return ExitCode::from(1);
}
}
}
// Save history
if let Some(ref path) = history_path {
let _ = rl.save_history(path);
}
ExitCode::SUCCESS
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn is_exit_command(input: &str) -> bool {
let t = input.trim();
t.eq_ignore_ascii_case("exit") || t.eq_ignore_ascii_case("quit")
}
fn dirs_history_path() -> Option<std::path::PathBuf> {
// Use $HOME/.calcpad_history if available
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".calcpad_history"))
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.repl {
return run_repl(cli.format);
}
if let Some(ref expr) = cli.expression {
return run_single_expression(expr, cli.format);
}
// No expression argument — check if stdin is piped / redirected.
if !io::stdin().is_terminal() {
return run_stdin(cli.format);
}
// Nothing provided — show help.
// Re-parse with --help to trigger clap's help output.
eprintln!("Usage: calcpad <EXPRESSION>");
eprintln!(" calcpad --repl");
eprintln!(" echo \"100 + 200\" | calcpad");
eprintln!();
eprintln!("Run 'calcpad --help' for full usage information.");
ExitCode::from(1)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
// swift-tools-version: 5.9
import PackageDescription
// Path to the Rust static library built by `cargo build --release`
let rustLibPath = "../calcpad-engine/target/release"
let package = Package(
name: "CalcPad",
platforms: [
.macOS(.v14)
],
targets: [
.systemLibrary(
name: "CCalcPadEngine",
path: "Sources/CCalcPadEngine"
),
.executableTarget(
name: "CalcPad",
dependencies: ["CCalcPadEngine"],
path: "Sources/CalcPad",
linkerSettings: [
.unsafeFlags(["-L\(rustLibPath)"]),
.linkedLibrary("calcpad_engine"),
]
),
.testTarget(
name: "CalcPadTests",
dependencies: ["CalcPad"],
path: "Tests/CalcPadTests",
linkerSettings: [
.unsafeFlags(["-L\(rustLibPath)"]),
.linkedLibrary("calcpad_engine"),
]
),
]
)

View File

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

View File

@@ -0,0 +1,5 @@
module CCalcPadEngine {
header "calcpad.h"
link "calcpad_engine"
export *
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
@main
struct CalcPadApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.defaultSize(width: 800, height: 600)
}
}

View File

@@ -0,0 +1,7 @@
import SwiftUI
struct ContentView: View {
var body: some View {
TwoColumnEditorView()
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
/// Protocol for a calculation engine that evaluates text expressions.
/// The primary implementation is RustCalculationEngine (FFI bridge to calcpad-engine).
/// StubCalculationEngine is provided for testing without the Rust library.
protocol CalculationEngine: Sendable {
/// Evaluate a single line expression and return the result string, or nil for blank/comment lines.
func evaluateLine(_ line: String) -> LineResult
/// Evaluate an entire sheet (multi-line text) and return results for each line.
func evaluateSheet(_ text: String) -> [LineResult]
}
/// Stub engine for testing that handles basic arithmetic (+, -, *, /).
/// Used when the Rust engine library is not available.
final class StubCalculationEngine: CalculationEngine {
func evaluateLine(_ line: String) -> LineResult {
evaluateSingleLine(line, lineNumber: 1)
}
func evaluateSheet(_ text: String) -> [LineResult] {
let lines = text.components(separatedBy: "\n")
return lines.enumerated().map { index, line in
evaluateSingleLine(line, lineNumber: index + 1)
}
}
private func evaluateSingleLine(_ line: String, lineNumber: Int) -> LineResult {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Blank lines produce no result
guard !trimmed.isEmpty else {
return LineResult(id: lineNumber, expression: line, result: nil, isError: false)
}
// Comment lines (starting with // or #) produce no result
if trimmed.hasPrefix("//") || trimmed.hasPrefix("#") {
return LineResult(id: lineNumber, expression: line, result: nil, isError: false)
}
// Convert integer literals to floating-point so division produces decimals.
// NSExpression does integer division for "7 / 2" -> 3, but we want 3.5.
let floatExpr = trimmed.replacingOccurrences(
of: #"\b(\d+)\b"#,
with: "$1.0",
options: .regularExpression
)
// Try to evaluate as a basic arithmetic expression
do {
let expr = NSExpression(format: floatExpr)
if let value = expr.expressionValue(with: nil, context: nil) as? NSNumber {
let doubleValue = value.doubleValue
let formatted = String(format: "%g", doubleValue)
return LineResult(id: lineNumber, expression: line, result: formatted, isError: false)
}
}
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
}
}

View File

@@ -0,0 +1,107 @@
import Foundation
/// JSON response from `calcpad_eval_line` wraps a single `CalcResult`.
struct FFIResponse: Decodable {
let schemaVersion: String
let result: FFICalcResult
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case result
}
}
/// JSON response from `calcpad_eval_sheet` wraps an array of `CalcResult`.
struct FFISheetResponse: Decodable {
let schemaVersion: String
let results: [FFICalcResult]
enum CodingKeys: String, CodingKey {
case schemaVersion = "schema_version"
case results
}
}
/// A complete calculation result returned by the Rust engine.
struct FFICalcResult: Decodable {
let value: FFICalcValue
let metadata: FFIResultMetadata
}
/// Metadata attached to every evaluation result.
struct FFIResultMetadata: Decodable {
let span: FFISpan
let resultType: String
let display: String
let rawValue: Double?
enum CodingKeys: String, CodingKey {
case span
case resultType = "result_type"
case display
case rawValue = "raw_value"
}
}
/// Source span (byte offsets).
struct FFISpan: Decodable {
let start: Int
let end: Int
}
/// Tagged union of possible calculation values.
/// Rust serializes with `#[serde(tag = "kind")]`.
enum FFICalcValue: Decodable {
case number(value: Double)
case unitValue(value: Double, unit: String)
case currencyValue(amount: Double, currency: String)
case dateTime(date: String)
case timeDelta(days: Int64, description: String)
case boolean(value: Bool)
case error(message: String)
enum CodingKeys: String, CodingKey {
case kind
case value, unit, amount, currency, date, days, description, message, span
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "Number":
let value = try container.decode(Double.self, forKey: .value)
self = .number(value: value)
case "UnitValue":
let value = try container.decode(Double.self, forKey: .value)
let unit = try container.decode(String.self, forKey: .unit)
self = .unitValue(value: value, unit: unit)
case "CurrencyValue":
let amount = try container.decode(Double.self, forKey: .amount)
let currency = try container.decode(String.self, forKey: .currency)
self = .currencyValue(amount: amount, currency: currency)
case "DateTime":
let date = try container.decode(String.self, forKey: .date)
self = .dateTime(date: date)
case "TimeDelta":
let days = try container.decode(Int64.self, forKey: .days)
let desc = try container.decode(String.self, forKey: .description)
self = .timeDelta(days: days, description: desc)
case "Boolean":
let value = try container.decode(Bool.self, forKey: .value)
self = .boolean(value: value)
case "Error":
let message = try container.decode(String.self, forKey: .message)
self = .error(message: message)
default:
self = .error(message: "Unknown result kind: \(kind)")
}
}
/// Whether this value represents an error from the Rust engine.
var isError: Bool {
if case .error = self { return true }
return false
}
}

View File

@@ -0,0 +1,92 @@
import CCalcPadEngine
import Foundation
/// Calculation engine backed by the Rust CalcPad engine via C FFI.
///
/// Thread safety: Each call creates its own `EvalContext` on the Rust side,
/// so concurrent calls from different threads are safe. The C FFI functions
/// are documented as safe to call from any thread.
final class RustCalculationEngine: CalculationEngine, @unchecked Sendable {
private let decoder = JSONDecoder()
func evaluateLine(_ line: String) -> LineResult {
evaluateSingleLine(line, lineNumber: 1)
}
func evaluateSheet(_ text: String) -> [LineResult] {
let lines = text.components(separatedBy: "\n")
// Build C string array for the FFI call
let cStrings = lines.map { strdup($0) }
defer { cStrings.forEach { free($0) } }
var pointers = cStrings.map { UnsafePointer<CChar>($0) }
let resultPtr = pointers.withUnsafeMutableBufferPointer { buffer -> UnsafeMutablePointer<CChar>? in
calcpad_eval_sheet(buffer.baseAddress, Int32(lines.count))
}
guard let resultPtr else {
return lines.enumerated().map { index, line in
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
}
}
defer { calcpad_free_result(resultPtr) }
let jsonString = String(cString: resultPtr)
guard let jsonData = jsonString.data(using: .utf8),
let response = try? decoder.decode(FFISheetResponse.self, from: jsonData) else {
return lines.enumerated().map { index, line in
LineResult(id: index + 1, expression: line, result: "Error", isError: true)
}
}
return response.results.enumerated().map { index, calcResult in
mapToLineResult(calcResult, expression: lines[index], lineNumber: index + 1)
}
}
// MARK: - Private
private func evaluateSingleLine(_ line: String, lineNumber: Int) -> LineResult {
let resultPtr = calcpad_eval_line(line)
guard let resultPtr else {
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
}
defer { calcpad_free_result(resultPtr) }
let jsonString = String(cString: resultPtr)
guard let jsonData = jsonString.data(using: .utf8),
let response = try? decoder.decode(FFIResponse.self, from: jsonData) else {
return LineResult(id: lineNumber, expression: line, result: "Error", isError: true)
}
return mapToLineResult(response.result, expression: line, lineNumber: lineNumber)
}
private func mapToLineResult(_ calcResult: FFICalcResult, expression: String, lineNumber: Int) -> LineResult {
switch calcResult.value {
case .error(let message):
// Blank/comment lines produce "empty expression" errors in the Rust engine.
// Map these to nil result (matching the protocol's convention for non-evaluable lines).
if isEmptyExpressionError(message) {
return LineResult(id: lineNumber, expression: expression, result: nil, isError: false)
}
return LineResult(id: lineNumber, expression: expression, result: "Error: \(message)", isError: true)
default:
return LineResult(id: lineNumber, expression: expression, result: calcResult.metadata.display, isError: false)
}
}
/// The Rust engine returns an error for blank lines, comments, and other
/// non-evaluable input. We detect these to return nil (matching protocol convention).
private func isEmptyExpressionError(_ message: String) -> Bool {
let lower = message.lowercased()
return lower.contains("empty") || lower.contains("no expression") || lower.contains("comment")
}
}

View File

@@ -0,0 +1,15 @@
import Foundation
/// Represents the evaluation result for a single line in the editor.
struct LineResult: Identifiable, Equatable {
let id: Int
let expression: String
let result: String?
let isError: Bool
/// Line number (1-based) corresponding to this result.
var lineNumber: Int { id }
/// A blank/comment line that produces no result.
var isEmpty: Bool { result == nil && !isError }
}

View File

@@ -0,0 +1,111 @@
import AppKit
import SwiftUI
/// Displays calculation results in a vertical column, one result per line,
/// aligned to match the corresponding editor lines.
/// Uses NSViewRepresentable wrapping NSScrollView + NSTextView for pixel-perfect
/// line height alignment with the editor.
struct AnswerColumnView: NSViewRepresentable {
let results: [LineResult]
@Binding var scrollOffset: CGFloat
var font: NSFont
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = false
scrollView.hasHorizontalScroller = false
scrollView.borderType = .noBorder
scrollView.drawsBackground = false
// Disable user scrolling scroll is driven by the editor
scrollView.verticalScrollElasticity = .none
scrollView.horizontalScrollElasticity = .none
let textView = NSTextView()
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = true
textView.usesFontPanel = false
textView.drawsBackground = false
textView.backgroundColor = .clear
// Match editor text container settings for alignment
textView.textContainer?.lineFragmentPadding = 4
textView.textContainerInset = NSSize(width: 8, height: 8)
// Disable line wrapping to match editor behavior
textView.isHorizontallyResizable = true
textView.textContainer?.widthTracksTextView = false
textView.textContainer?.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
scrollView.documentView = textView
context.coordinator.textView = textView
context.coordinator.scrollView = scrollView
updateContent(textView: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
updateContent(textView: textView)
// Sync scroll position from editor
let currentOffset = scrollView.contentView.bounds.origin.y
if abs(currentOffset - scrollOffset) > 0.5 {
scrollView.contentView.scroll(to: NSPoint(x: 0, y: scrollOffset))
scrollView.reflectScrolledClipView(scrollView.contentView)
}
}
private func updateContent(textView: NSTextView) {
let attributedString = NSMutableAttributedString()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
let resultColor = NSColor.secondaryLabelColor
let errorColor = NSColor.systemRed
for (index, lineResult) in results.enumerated() {
let displayText = lineResult.result ?? ""
let color = lineResult.isError ? errorColor : resultColor
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,
]
let line = NSAttributedString(string: displayText, attributes: attributes)
attributedString.append(line)
// Add newline between lines (but not after the last)
if index < results.count - 1 {
let newline = NSAttributedString(
string: "\n",
attributes: [.font: font]
)
attributedString.append(newline)
}
}
textView.textStorage?.setAttributedString(attributedString)
}
final class Coordinator {
weak var textView: NSTextView?
weak var scrollView: NSScrollView?
}
}

View File

@@ -0,0 +1,137 @@
import AppKit
import SwiftUI
/// A SwiftUI wrapper around NSTextView for the editor pane.
/// Uses NSViewRepresentable to bridge AppKit's NSTextView into SwiftUI,
/// providing line-level control, scroll position access, and performance
/// that SwiftUI's TextEditor cannot match.
struct EditorTextView: NSViewRepresentable {
@Binding var text: String
@Binding var scrollOffset: CGFloat
var font: NSFont
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = false
let textView = NSTextView()
textView.isEditable = true
textView.isSelectable = true
textView.allowsUndo = true
textView.isRichText = false
textView.usesFontPanel = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
// Disable line wrapping horizontal scroll instead
textView.isHorizontallyResizable = true
textView.textContainer?.widthTracksTextView = false
textView.textContainer?.containerSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
textView.maxSize = NSSize(
width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude
)
// Configure font and text color
textView.font = font
textView.textColor = .textColor
textView.backgroundColor = .clear
textView.drawsBackground = false
textView.insertionPointColor = .textColor
// Set the text
textView.string = text
// Configure the text container for consistent line spacing
textView.textContainer?.lineFragmentPadding = 4
textView.textContainerInset = NSSize(width: 8, height: 8)
scrollView.documentView = textView
context.coordinator.textView = textView
context.coordinator.scrollView = scrollView
// Observe text changes
textView.delegate = context.coordinator
// Observe scroll changes
scrollView.contentView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(
context.coordinator,
selector: #selector(Coordinator.scrollViewDidScroll(_:)),
name: NSView.boundsDidChangeNotification,
object: scrollView.contentView
)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? NSTextView else { return }
// Update font if changed
if textView.font != font {
textView.font = font
// Reapply font to entire text storage
let range = NSRange(location: 0, length: textView.textStorage?.length ?? 0)
textView.textStorage?.addAttribute(.font, value: font, range: range)
}
// Update text only if it actually changed (avoid feedback loops)
if textView.string != text {
let selectedRanges = textView.selectedRanges
textView.string = text
textView.selectedRanges = selectedRanges
}
// Update scroll position if driven externally
context.coordinator.isUpdatingScroll = true
let currentOffset = scrollView.contentView.bounds.origin.y
if abs(currentOffset - scrollOffset) > 0.5 {
scrollView.contentView.scroll(to: NSPoint(x: 0, y: scrollOffset))
scrollView.reflectScrolledClipView(scrollView.contentView)
}
context.coordinator.isUpdatingScroll = false
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: EditorTextView
weak var textView: NSTextView?
weak var scrollView: NSScrollView?
var isUpdatingScroll = false
init(_ parent: EditorTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
}
@objc func scrollViewDidScroll(_ notification: Notification) {
guard !isUpdatingScroll,
let scrollView = scrollView else { return }
let offset = scrollView.contentView.bounds.origin.y
DispatchQueue.main.async { [weak self] in
self?.parent.scrollOffset = offset
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
}

View File

@@ -0,0 +1,72 @@
import Combine
import SwiftUI
/// The main two-column editor layout: text editor on the left, results on the right.
/// Scrolling is synchronized between both columns.
struct TwoColumnEditorView: View {
@State private var text: String = ""
@State private var scrollOffset: CGFloat = 0
@State private var results: [LineResult] = []
@State private var evaluationTask: Task<Void, Never>?
/// Uses the Rust FFI engine. Falls back to StubCalculationEngine if the Rust
/// library is not linked (e.g., during UI-only development).
private let engine: CalculationEngine = RustCalculationEngine()
/// Debounce interval for re-evaluation after typing (seconds).
private let evaluationDebounce: TimeInterval = 0.05
/// Font that respects the user's accessibility / Dynamic Type settings.
private var editorFont: NSFont {
// Use the system's preferred monospaced font size, which scales with
// Accessibility > Display > Text Size in System Settings (macOS 14+).
let baseSize = NSFont.systemFontSize
// Scale with accessibility settings via the body text style size
let preferredSize = NSFont.preferredFont(forTextStyle: .body, options: [:]).pointSize
// Use the larger of system default or accessibility-preferred size
let size = max(baseSize, preferredSize)
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
var body: some View {
HSplitView {
// Left pane: Editor
EditorTextView(
text: $text,
scrollOffset: $scrollOffset,
font: editorFont
)
.frame(minWidth: 200)
// Divider is automatic with HSplitView
// Right pane: Answer column
AnswerColumnView(
results: results,
scrollOffset: $scrollOffset,
font: editorFont
)
.frame(minWidth: 120, idealWidth: 200)
}
.onChange(of: text) { _, newValue in
scheduleEvaluation(newValue)
}
.onAppear {
evaluateText(text)
}
}
/// Debounce evaluation so rapid typing doesn't cause excessive recalculation.
private func scheduleEvaluation(_ newText: String) {
evaluationTask?.cancel()
evaluationTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(Int(evaluationDebounce * 1000)))
guard !Task.isCancelled else { return }
evaluateText(newText)
}
}
private func evaluateText(_ newText: String) {
results = engine.evaluateSheet(newText)
}
}

View File

@@ -0,0 +1,227 @@
import Testing
import Foundation
@testable import CalcPad
@Suite("FFI JSON Model Decoding Tests")
struct FFIModelTests {
// MARK: - Single Line Response
@Test("Decode number result")
func decodeNumberResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "Number", "value": 42.0 },
"metadata": {
"span": { "start": 0, "end": 5 },
"result_type": "Number",
"display": "42",
"raw_value": 42.0
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
#expect(response.schemaVersion == "1.0")
#expect(response.result.metadata.display == "42")
#expect(response.result.metadata.resultType == "Number")
#expect(response.result.metadata.rawValue == 42.0)
if case .number(let value) = response.result.value {
#expect(value == 42.0)
} else {
Issue.record("Expected .number variant")
}
}
@Test("Decode unit value result")
func decodeUnitValueResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "UnitValue", "value": 5.0, "unit": "kg" },
"metadata": {
"span": { "start": 0, "end": 4 },
"result_type": "UnitValue",
"display": "5 kg",
"raw_value": 5.0
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
if case .unitValue(let value, let unit) = response.result.value {
#expect(value == 5.0)
#expect(unit == "kg")
} else {
Issue.record("Expected .unitValue variant")
}
}
@Test("Decode currency value result")
func decodeCurrencyValueResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "CurrencyValue", "amount": 19.99, "currency": "USD" },
"metadata": {
"span": { "start": 0, "end": 6 },
"result_type": "CurrencyValue",
"display": "$19.99",
"raw_value": 19.99
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
if case .currencyValue(let amount, let currency) = response.result.value {
#expect(amount == 19.99)
#expect(currency == "USD")
} else {
Issue.record("Expected .currencyValue variant")
}
}
@Test("Decode datetime result")
func decodeDateTimeResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "DateTime", "date": "2024-03-17" },
"metadata": {
"span": { "start": 0, "end": 10 },
"result_type": "DateTime",
"display": "2024-03-17",
"raw_value": null
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
if case .dateTime(let date) = response.result.value {
#expect(date == "2024-03-17")
} else {
Issue.record("Expected .dateTime variant")
}
#expect(response.result.metadata.rawValue == nil)
}
@Test("Decode time delta result")
func decodeTimeDeltaResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "TimeDelta", "days": 30, "description": "30 days" },
"metadata": {
"span": { "start": 0, "end": 8 },
"result_type": "TimeDelta",
"display": "30 days",
"raw_value": 30.0
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
if case .timeDelta(let days, let desc) = response.result.value {
#expect(days == 30)
#expect(desc == "30 days")
} else {
Issue.record("Expected .timeDelta variant")
}
}
@Test("Decode boolean result")
func decodeBooleanResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "Boolean", "value": true },
"metadata": {
"span": { "start": 0, "end": 5 },
"result_type": "Boolean",
"display": "true",
"raw_value": null
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
if case .boolean(let value) = response.result.value {
#expect(value == true)
} else {
Issue.record("Expected .boolean variant")
}
}
@Test("Decode error result")
func decodeErrorResult() throws {
let json = """
{
"schema_version": "1.0",
"result": {
"value": { "kind": "Error", "message": "unexpected token", "span": { "start": 0, "end": 3 } },
"metadata": {
"span": { "start": 0, "end": 3 },
"result_type": "Error",
"display": "Error: unexpected token",
"raw_value": null
}
}
}
"""
let response = try JSONDecoder().decode(FFIResponse.self, from: Data(json.utf8))
#expect(response.result.value.isError)
if case .error(let message) = response.result.value {
#expect(message == "unexpected token")
} else {
Issue.record("Expected .error variant")
}
}
// MARK: - Sheet Response
@Test("Decode sheet response with multiple results")
func decodeSheetResponse() throws {
let json = """
{
"schema_version": "1.0",
"results": [
{
"value": { "kind": "Number", "value": 4.0 },
"metadata": { "span": { "start": 0, "end": 5 }, "result_type": "Number", "display": "4", "raw_value": 4.0 }
},
{
"value": { "kind": "Error", "message": "empty expression", "span": { "start": 0, "end": 0 } },
"metadata": { "span": { "start": 0, "end": 0 }, "result_type": "Error", "display": "Error: empty expression", "raw_value": null }
},
{
"value": { "kind": "Number", "value": 30.0 },
"metadata": { "span": { "start": 0, "end": 6 }, "result_type": "Number", "display": "30", "raw_value": 30.0 }
}
]
}
"""
let response = try JSONDecoder().decode(FFISheetResponse.self, from: Data(json.utf8))
#expect(response.results.count == 3)
#expect(response.results[0].metadata.display == "4")
#expect(response.results[1].value.isError)
#expect(response.results[2].metadata.display == "30")
}
// MARK: - FFICalcValue.isError
@Test("isError returns false for non-error values")
func isErrorFalseForNonError() throws {
let json = """
{ "kind": "Number", "value": 42.0 }
"""
let value = try JSONDecoder().decode(FFICalcValue.self, from: Data(json.utf8))
#expect(value.isError == false)
}
}

View File

@@ -0,0 +1,64 @@
import Testing
@testable import CalcPad
@Suite("Performance Tests")
struct PerformanceTests {
let engine = StubCalculationEngine()
@Test("Evaluate 1000+ line sheet completes in under 1 second")
func largeSheetEvaluation() async throws {
// Generate a 1500-line document mixing expressions, blanks, and comments
var lines: [String] = []
for i in 1...1500 {
switch i % 5 {
case 0: lines.append("") // blank
case 1: lines.append("// Line \(i)") // comment
case 2: lines.append("\(i) + \(i * 2)")
case 3: lines.append("\(i) * 3")
case 4: lines.append("\(i) / 7")
default: lines.append("\(i)")
}
}
let text = lines.joined(separator: "\n")
let start = ContinuousClock.now
let results = engine.evaluateSheet(text)
let elapsed = ContinuousClock.now - start
#expect(results.count == 1500)
#expect(elapsed < .seconds(1), "Sheet evaluation took \(elapsed), expected < 1 second")
}
@Test("Evaluate 5000 line sheet completes in under 3 seconds")
func veryLargeSheetEvaluation() async throws {
var lines: [String] = []
for i in 1...5000 {
lines.append("\(i) + \(i)")
}
let text = lines.joined(separator: "\n")
let start = ContinuousClock.now
let results = engine.evaluateSheet(text)
let elapsed = ContinuousClock.now - start
#expect(results.count == 5000)
#expect(elapsed < .seconds(3), "Sheet evaluation took \(elapsed), expected < 3 seconds")
}
@Test("LineResult array construction is efficient for large documents")
func lineResultConstruction() {
// Verify we get correct line numbering for large documents
let lines = (1...1000).map { "\($0) + 1" }
let text = lines.joined(separator: "\n")
let results = engine.evaluateSheet(text)
#expect(results.count == 1000)
#expect(results.first?.lineNumber == 1)
#expect(results.last?.lineNumber == 1000)
// Spot-check results
#expect(results[0].result == "2") // 1 + 1
#expect(results[99].result == "101") // 100 + 1
#expect(results[999].result == "1001") // 1000 + 1
}
}

View File

@@ -0,0 +1,164 @@
import Testing
import Foundation
@testable import CalcPad
@Suite("RustCalculationEngine Tests")
struct RustEngineTests {
let engine = RustCalculationEngine()
// MARK: - AC: evaluateLine returns LineResult (not raw pointer)
@Test("evaluateLine 2+2 returns LineResult with result 4")
func evalLineBasicArithmetic() {
let result = engine.evaluateLine("2 + 2")
#expect(result.result == "4")
#expect(result.isError == false)
#expect(result.lineNumber == 1)
}
@Test("evaluateLine multiplication")
func evalLineMultiplication() {
let result = engine.evaluateLine("6 * 7")
#expect(result.result == "42")
#expect(result.isError == false)
}
@Test("evaluateLine subtraction")
func evalLineSubtraction() {
let result = engine.evaluateLine("10 - 4")
#expect(result.result == "6")
#expect(result.isError == false)
}
@Test("evaluateLine division")
func evalLineDivision() {
let result = engine.evaluateLine("15 / 3")
#expect(result.result == "5")
#expect(result.isError == false)
}
@Test("evaluateLine decimal result")
func evalLineDecimal() {
let result = engine.evaluateLine("7 / 2")
#expect(result.result == "3.5")
#expect(result.isError == false)
}
// MARK: - AC: evaluateSheet returns array of LineResult, one per line
@Test("evaluateSheet returns one result per line")
func evalSheetMultiLine() {
let text = "2 + 2\n\n10 * 3"
let results = engine.evaluateSheet(text)
#expect(results.count == 3)
#expect(results[0].lineNumber == 1)
#expect(results[0].result == "4")
#expect(results[1].lineNumber == 2)
#expect(results[1].result == nil) // blank line
#expect(results[2].lineNumber == 3)
#expect(results[2].result == "30")
}
@Test("evaluateSheet with comments and blanks")
func evalSheetMixed() {
let text = "// Title\n5 + 5\n\n// Section\n3 * 3"
let results = engine.evaluateSheet(text)
#expect(results.count == 5)
#expect(results[0].result == nil) // comment
#expect(results[0].isError == false)
#expect(results[1].result == "10")
#expect(results[2].result == nil) // blank
#expect(results[3].result == nil) // comment
#expect(results[4].result == "9")
}
// MARK: - AC: Error handling errors become LineResult, no crash
@Test("Malformed expression returns error LineResult, not crash")
func evalLineError() {
let result = engine.evaluateLine("2 + + 3")
#expect(result.isError == true)
#expect(result.result != nil) // has error message
#expect(result.result?.starts(with: "Error") == true)
}
@Test("Completely invalid input returns error, not crash")
func evalLineInvalidInput() {
let result = engine.evaluateLine("@#$%^&")
// Should not crash either error or some result
#expect(result.id == 1)
}
// MARK: - AC: Blank and comment lines
@Test("Blank line returns nil result")
func blankLine() {
let result = engine.evaluateLine("")
#expect(result.result == nil)
#expect(result.isError == false)
}
@Test("Comment with // returns nil result")
func commentLine() {
let result = engine.evaluateLine("// this is a comment")
#expect(result.result == nil)
#expect(result.isError == false)
}
@Test("# is not a comment in Rust engine — treated as identifier lookup")
func hashNotComment() {
let result = engine.evaluateLine("# header")
// The Rust engine does not treat # as a comment. The # is skipped and
// "header" is parsed as an identifier, resulting in an undefined variable error.
#expect(result.isError == true)
}
// MARK: - AC: Memory safety no leaks with repeated calls
@Test("Repeated evaluateLine calls don't leak (1000 iterations)")
func memoryStressLine() {
for i in 0..<1000 {
let result = engine.evaluateLine("\(i) + 1")
#expect(result.isError == false)
}
}
@Test("Repeated evaluateSheet calls don't leak (100 iterations)")
func memoryStressSheet() {
let text = (1...10).map { "\($0) * 2" }.joined(separator: "\n")
for _ in 0..<100 {
let results = engine.evaluateSheet(text)
#expect(results.count == 10)
}
}
// MARK: - AC: Thread safety concurrent calls
@Test("Concurrent evaluateLine calls from multiple threads")
func threadSafety() async {
await withTaskGroup(of: LineResult.self) { group in
for i in 0..<50 {
group.addTask {
engine.evaluateLine("\(i) + 1")
}
}
var count = 0
for await result in group {
#expect(result.isError == false)
count += 1
}
#expect(count == 50)
}
}
// MARK: - AC: Variables shared across sheet lines
@Test("evaluateSheet shares variables across lines")
func evalSheetVariables() {
let text = "x = 10\nx * 2"
let results = engine.evaluateSheet(text)
#expect(results.count == 2)
#expect(results[0].result == "10")
#expect(results[1].result == "20")
}
}

View File

@@ -0,0 +1,115 @@
import Testing
@testable import CalcPad
@Suite("StubCalculationEngine Tests")
struct StubEngineTests {
let engine = StubCalculationEngine()
@Test("Blank lines produce nil result")
func blankLine() {
let result = engine.evaluateLine("")
#expect(result.result == nil)
#expect(result.isError == false)
#expect(result.isEmpty == true)
}
@Test("Whitespace-only lines produce nil result")
func whitespaceOnly() {
let result = engine.evaluateLine(" ")
#expect(result.result == nil)
#expect(result.isError == false)
}
@Test("Comment lines starting with // produce nil result")
func doubleSlashComment() {
let result = engine.evaluateLine("// this is a comment")
#expect(result.result == nil)
#expect(result.isError == false)
}
@Test("Comment lines starting with # produce nil result")
func hashComment() {
let result = engine.evaluateLine("# header")
#expect(result.result == nil)
#expect(result.isError == false)
}
@Test("Simple addition")
func addition() {
let result = engine.evaluateLine("2 + 3")
#expect(result.result == "5")
#expect(result.isError == false)
}
@Test("Simple subtraction")
func subtraction() {
let result = engine.evaluateLine("10 - 4")
#expect(result.result == "6")
#expect(result.isError == false)
}
@Test("Simple multiplication")
func multiplication() {
let result = engine.evaluateLine("6 * 7")
#expect(result.result == "42")
#expect(result.isError == false)
}
@Test("Simple division")
func division() {
let result = engine.evaluateLine("15 / 3")
#expect(result.result == "5")
#expect(result.isError == false)
}
@Test("Decimal result")
func decimalResult() {
let result = engine.evaluateLine("7 / 2")
#expect(result.result == "3.5")
#expect(result.isError == false)
}
@Test("evaluateSheet returns one result per line")
func evaluateSheet() {
let text = "2 + 2\n\n10 * 3"
let results = engine.evaluateSheet(text)
#expect(results.count == 3)
#expect(results[0].lineNumber == 1)
#expect(results[0].result == "4")
#expect(results[1].lineNumber == 2)
#expect(results[1].result == nil)
#expect(results[2].lineNumber == 3)
#expect(results[2].result == "30")
}
@Test("evaluateSheet with comments and blanks")
func evaluateSheetMixed() {
let text = "// Title\n5 + 5\n\n# Section\n3 * 3"
let results = engine.evaluateSheet(text)
#expect(results.count == 5)
#expect(results[0].result == nil) // comment
#expect(results[1].result == "10")
#expect(results[2].result == nil) // blank
#expect(results[3].result == nil) // comment
#expect(results[4].result == "9")
}
@Test("LineResult id equals line number")
func lineResultId() {
let result = LineResult(id: 5, expression: "2+2", result: "4", isError: false)
#expect(result.lineNumber == 5)
#expect(result.id == 5)
}
@Test("LineResult isEmpty for nil results")
func lineResultIsEmpty() {
let empty = LineResult(id: 1, expression: "", result: nil, isError: false)
#expect(empty.isEmpty == true)
let error = LineResult(id: 1, expression: "bad", result: nil, isError: true)
#expect(error.isEmpty == false)
let hasResult = LineResult(id: 1, expression: "2+2", result: "4", isError: false)
#expect(hasResult.isEmpty == false)
}
}

View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Build the Rust CalcPad engine for macOS.
#
# Produces a static library that the Swift Package Manager links against.
#
# Usage:
# ./build-rust.sh # Build release (default)
# ./build-rust.sh debug # Build debug
#
# Prerequisites:
# - Rust toolchain with targets: aarch64-apple-darwin, x86_64-apple-darwin
# - Install targets: rustup target add aarch64-apple-darwin x86_64-apple-darwin
set -euo pipefail
PROFILE="${1:-release}"
ENGINE_DIR="$(cd "$(dirname "$0")/../calcpad-engine" && pwd)"
OUTPUT_DIR="$ENGINE_DIR/target/$PROFILE"
if [ "$PROFILE" = "debug" ]; then
CARGO_FLAG=""
else
CARGO_FLAG="--release"
fi
echo "Building calcpad-engine ($PROFILE)..."
# Build for the current architecture
cd "$ENGINE_DIR"
cargo build $CARGO_FLAG
echo ""
echo "Build complete!"
echo "Static library: $OUTPUT_DIR/libcalcpad_engine.a"
echo ""
echo "To build and test the Swift package:"
echo " cd calcpad-macos && swift test"

24
calcpad-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

19
calcpad-web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="CalcPad - A modern notepad calculator powered by WebAssembly" />
<meta name="theme-color" content="#6366f1" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="CalcPad" />
<link rel="apple-touch-icon" href="/icons/icon-192.svg" />
<title>CalcPad</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
calcpad-web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "calcpad-web",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "CalcPad web app — React + CodeMirror 6 + WASM engine",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@lezer/highlight": "^1.2.1",
"codemirror": "^6.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
"typescript": "~5.9.3",
"vite": "^7.0.0",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.1.0"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#6366f1"/>
<text x="16" y="23" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="18" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="24" fill="#6366f1"/>
<text x="96" y="128" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="100" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 264 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#6366f1"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#6366f1"/>
<text x="256" y="340" text-anchor="middle" font-family="system-ui, sans-serif" font-weight="700" font-size="260" fill="white">=</text>
</svg>

After

Width:  |  Height:  |  Size: 257 B

89
calcpad-web/src/App.tsx Normal file
View File

@@ -0,0 +1,89 @@
/**
* CalcPad main application component.
*
* Two-column layout:
* Left: CodeMirror 6 editor with CalcPad syntax highlighting
* Right: Answer gutter (integrated into CodeMirror) + optional standalone AnswerColumn
*
* The WASM engine runs in a Web Worker. On each document change (debounced),
* the editor sends lines to the worker, which evaluates them and posts back
* results. Results are fed into the CodeMirror answer gutter extension.
*/
import { useCallback } from 'react'
import { CalcEditor } from './editor/CalcEditor.tsx'
import { useEngine } from './engine/useEngine.ts'
import { useOnlineStatus } from './hooks/useOnlineStatus.ts'
import { useInstallPrompt } from './hooks/useInstallPrompt.ts'
import { OfflineBanner } from './components/OfflineBanner.tsx'
import { InstallPrompt } from './components/InstallPrompt.tsx'
import './styles/app.css'
const INITIAL_DOC = `# CalcPad
// Basic arithmetic
2 + 3
10 * 4.5
100 / 7
// Variables
price = 49.99
quantity = 3
subtotal = price * quantity
// Percentages
tax = subtotal * 8%
total = subtotal + tax
// Functions
sqrt(144)
2 ^ 10
`
function App() {
const engine = useEngine()
const isOnline = useOnlineStatus()
const installPrompt = useInstallPrompt()
const handleDocChange = useCallback(
(lines: string[]) => {
engine.evalSheet(lines)
},
[engine.evalSheet],
)
return (
<div className="calcpad-app">
<OfflineBanner isOnline={isOnline} />
<header className="calcpad-header">
<h1>CalcPad</h1>
<p className="subtitle">Notepad Calculator</p>
<div className="header-status">
<span className={`status-dot ${engine.ready ? '' : 'loading'}`} />
<span>{engine.ready ? 'Engine ready' : 'Loading engine...'}</span>
</div>
</header>
<main className="calcpad-editor">
<div className="editor-pane">
<CalcEditor
initialDoc={INITIAL_DOC}
onDocChange={handleDocChange}
results={engine.results}
debounceMs={50}
/>
</div>
</main>
<InstallPrompt
promptEvent={installPrompt.promptEvent}
isInstalled={installPrompt.isInstalled}
onInstall={installPrompt.handleInstall}
onDismiss={installPrompt.handleDismiss}
/>
</div>
)
}
export default App

View File

@@ -0,0 +1,49 @@
/**
* Right-side answer column that displays evaluation results
* as a standalone panel (alternative to the gutter-based display).
*
* This component renders results in a scrollable column synced
* to the editor's line height. Each line shows the display value
* from the engine, color-coded by result type.
*/
import type { EngineLineResult } from '../engine/types.ts'
import '../styles/answer-column.css'
export interface AnswerColumnProps {
results: EngineLineResult[]
}
function resultClassName(type: string): string {
switch (type) {
case 'number':
case 'unitValue':
case 'currencyValue':
case 'dateTime':
case 'timeDelta':
case 'boolean':
return 'answer-value'
case 'error':
return 'answer-error'
default:
return 'answer-empty'
}
}
export function AnswerColumn({ results }: AnswerColumnProps) {
return (
<div className="answer-column" aria-label="Calculation results" role="complementary">
{results.map((result, i) => (
<div
key={i}
className={`answer-line ${resultClassName(result.type)}`}
title={result.error ?? result.display}
>
{result.type === 'error'
? 'Error'
: result.display || '\u00A0'}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,45 @@
/**
* PWA install prompt shown when the browser supports app installation.
* Adapted from epic/9-3-pwa-support.
*/
import '../styles/install-prompt.css'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
interface InstallPromptProps {
promptEvent: BeforeInstallPromptEvent | null
isInstalled: boolean
onInstall: () => Promise<void>
onDismiss: () => void
}
export function InstallPrompt({
promptEvent,
isInstalled,
onInstall,
onDismiss,
}: InstallPromptProps) {
if (isInstalled || !promptEvent) return null
return (
<div className="install-prompt" role="complementary" aria-label="Install CalcPad">
<div className="install-content">
<p className="install-text">
Install CalcPad for offline access and a native app experience.
</p>
<div className="install-actions">
<button className="install-btn" onClick={onInstall}>
Install
</button>
<button className="install-dismiss" onClick={onDismiss}>
Not now
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
/**
* Banner shown when the user is offline.
* Adapted from epic/9-3-pwa-support.
*/
import '../styles/offline-banner.css'
interface OfflineBannerProps {
isOnline: boolean
}
export function OfflineBanner({ isOnline }: OfflineBannerProps) {
if (isOnline) return null
return (
<div className="offline-banner" role="status" aria-live="polite">
<span className="offline-icon" aria-hidden="true">
&#9679;
</span>
You are offline. Changes are saved locally.
</div>
)
}

View File

@@ -0,0 +1,224 @@
/**
* React wrapper around CodeMirror 6 for the CalcPad editor.
*
* Integrates the CalcPad language mode, answer gutter, error display,
* and debounced evaluation via the WASM engine Web Worker.
*/
import { useRef, useEffect, useCallback } from 'react'
import { EditorState } from '@codemirror/state'
import {
EditorView,
lineNumbers,
drawSelection,
highlightActiveLine,
keymap,
} from '@codemirror/view'
import {
defaultHighlightStyle,
syntaxHighlighting,
bracketMatching,
indentOnInput,
} from '@codemirror/language'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { calcpadLanguage } from './calcpad-language.ts'
import { answerGutterExtension, setAnswersEffect, type LineAnswer } from './answer-gutter.ts'
import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts'
import type { EngineLineResult } from '../engine/types.ts'
export interface CalcEditorProps {
/** Initial document content */
initialDoc?: string
/** Called when the document text changes (debounced internally) */
onDocChange?: (lines: string[]) => void
/** Engine evaluation results to display in the answer gutter */
results?: EngineLineResult[]
/** Debounce delay in ms before triggering onDocChange */
debounceMs?: number
}
/**
* CalcPad editor component built on CodeMirror 6.
* Handles syntax highlighting, line numbers, answer gutter,
* and error underlines.
*/
export function CalcEditor({
initialDoc = '',
onDocChange,
results,
debounceMs = 50,
}: CalcEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Stable callback ref for doc changes
const onDocChangeRef = useRef(onDocChange)
onDocChangeRef.current = onDocChange
const scheduleEval = useCallback((view: EditorView) => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
const doc = view.state.doc.toString()
const lines = doc.split('\n')
onDocChangeRef.current?.(lines)
}, debounceMs)
}, [debounceMs])
// Create editor on mount
useEffect(() => {
if (!containerRef.current) return
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged && viewRef.current) {
scheduleEval(viewRef.current)
}
})
const state = EditorState.create({
doc: initialDoc,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
bracketMatching(),
indentOnInput(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
syntaxHighlighting(defaultHighlightStyle),
calcpadLanguage(),
answerGutterExtension(),
errorDisplayExtension(),
updateListener,
calcpadEditorTheme,
],
})
const view = new EditorView({
state,
parent: containerRef.current,
})
viewRef.current = view
// Trigger initial evaluation
const doc = view.state.doc.toString()
onDocChangeRef.current?.(doc.split('\n'))
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
view.destroy()
viewRef.current = null
}
// initialDoc intentionally excluded — we only set it once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleEval])
// Push engine results into the answer gutter + error display
useEffect(() => {
const view = viewRef.current
if (!view || !results) return
const answers: LineAnswer[] = []
const errors: LineError[] = []
for (let i = 0; i < results.length; i++) {
const lineNum = i + 1
const result = results[i]
answers.push({ line: lineNum, result })
if (result.type === 'error' && result.error) {
// Map to document positions
if (lineNum <= view.state.doc.lines) {
const docLine = view.state.doc.line(lineNum)
errors.push({
from: docLine.from,
to: docLine.to,
message: result.error,
})
}
}
}
view.dispatch({
effects: [
setAnswersEffect.of(answers),
setErrorsEffect.of(errors),
],
})
}, [results])
return <div ref={containerRef} className="calc-editor" />
}
/**
* Base theme for the CalcPad editor.
*/
const calcpadEditorTheme = EditorView.baseTheme({
'&': {
height: '100%',
fontSize: '15px',
fontFamily: 'ui-monospace, Consolas, "Courier New", monospace',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
padding: '12px 0',
minHeight: '100%',
},
'.cm-line': {
padding: '0 16px',
lineHeight: '1.6',
},
'.cm-gutters': {
backgroundColor: 'transparent',
borderRight: 'none',
},
'.cm-lineNumbers .cm-gutterElement': {
padding: '0 8px 0 16px',
color: '#9ca3af',
fontSize: '13px',
minWidth: '32px',
},
'.cm-answer-gutter': {
minWidth: '140px',
textAlign: 'right',
paddingRight: '16px',
borderLeft: '1px solid #e5e4e7',
backgroundColor: '#f8f9fa',
fontFamily: 'ui-monospace, Consolas, monospace',
fontSize: '14px',
},
'&dark .cm-answer-gutter': {
borderLeft: '1px solid #2e303a',
backgroundColor: '#1a1b23',
},
'.cm-answer-value': {
color: '#6366f1',
fontWeight: '600',
},
'&dark .cm-answer-value': {
color: '#818cf8',
},
'.cm-answer-error': {
color: '#e53e3e',
fontStyle: 'italic',
},
'&dark .cm-answer-error': {
color: '#fc8181',
},
'.cm-activeLine': {
backgroundColor: 'rgba(99, 102, 241, 0.04)',
},
'&dark .cm-activeLine': {
backgroundColor: 'rgba(129, 140, 248, 0.06)',
},
'.cm-selectionBackground': {
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
},
'.cm-focused': {
outline: 'none',
},
})

View File

@@ -0,0 +1,104 @@
/**
* Custom gutter for displaying computed results alongside each line.
* Adapted from epic/9-2-codemirror-6-editor.
*
* The answer column is right-aligned and visually distinct from the input.
*/
import { GutterMarker, gutter } from '@codemirror/view'
import { StateField, StateEffect, type Extension } from '@codemirror/state'
import type { EngineLineResult } from '../engine/types.ts'
// --- State Effects ---
export interface LineAnswer {
line: number // 1-indexed line number
result: EngineLineResult | null
}
export const setAnswersEffect = StateEffect.define<LineAnswer[]>()
// --- Gutter Markers ---
class AnswerMarker extends GutterMarker {
constructor(
readonly text: string,
readonly isError: boolean,
) {
super()
}
override toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = this.isError ? 'cm-answer-error' : 'cm-answer-value'
span.textContent = this.text
return span
}
override eq(other: GutterMarker): boolean {
return (
other instanceof AnswerMarker &&
other.text === this.text &&
other.isError === this.isError
)
}
}
// --- State Field ---
export const answersField = StateField.define<Map<number, EngineLineResult | null>>({
create() {
return new Map()
},
update(answers, tr) {
for (const effect of tr.effects) {
if (effect.is(setAnswersEffect)) {
const newAnswers = new Map<number, EngineLineResult | null>()
for (const { line, result } of effect.value) {
newAnswers.set(line, result)
}
return newAnswers
}
}
return answers
},
})
// --- Gutter Extension ---
const DISPLAYABLE_TYPES = new Set(['number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean'])
export const answerGutter = gutter({
class: 'cm-answer-gutter',
lineMarker(view, line) {
const doc = view.state.doc
const lineNumber = doc.lineAt(line.from).number
const answers = view.state.field(answersField)
const result = answers.get(lineNumber)
if (!result) return null
if (result.type === 'error') {
return new AnswerMarker('Error', true)
}
if (DISPLAYABLE_TYPES.has(result.type) && result.display) {
return new AnswerMarker(result.display, false)
}
return null
},
lineMarkerChange(update) {
return update.transactions.some((tr) =>
tr.effects.some((e) => e.is(setAnswersEffect)),
)
},
})
/**
* Creates the answer gutter extension bundle.
*/
export function answerGutterExtension(): Extension {
return [answersField, answerGutter]
}

View File

@@ -0,0 +1,145 @@
/**
* CodeMirror 6 language extension for CalcPad syntax highlighting.
* Adapted from epic/9-2-codemirror-6-editor.
*
* Uses StreamLanguage to tokenize CalcPad input and map token types
* to CodeMirror highlight tags.
*/
import { StreamLanguage, type StringStream, LanguageSupport } from '@codemirror/language'
import { tags, type Tag } from '@lezer/highlight'
const KEYWORDS = new Set([
'if', 'then', 'else',
'in', 'to', 'as', 'of', 'per',
'sum', 'total', 'average', 'avg', 'min', 'max', 'count',
])
const BUILTIN_FUNCTIONS = new Set([
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
'log', 'ln', 'exp', 'sqrt', 'abs',
'floor', 'ceil', 'round',
])
const CONSTANTS = new Set(['pi', 'e'])
export interface CalcPadState {
inComment: boolean
}
export const calcpadStreamParser = {
startState(): CalcPadState {
return { inComment: false }
},
token(stream: StringStream, _state: CalcPadState): string | null {
// Start of line: check for heading
if (stream.sol() && stream.match(/^#{1,6}\s/)) {
stream.skipToEnd()
return 'heading'
}
// Comment
if (stream.match('//')) {
stream.skipToEnd()
return 'lineComment'
}
// Skip whitespace
if (stream.eatSpace()) return null
// Line reference: #N
if (stream.match(/^#\d+/)) {
return 'special'
}
// Line reference: lineN
if (stream.match(/^line\d+/i)) {
return 'special'
}
// Number (integer or decimal, with optional scientific notation)
if (stream.match(/^\d+(\.\d+)?([eE][+-]?\d+)?/)) {
return 'number'
}
// Assignment operator
if (stream.eat('=')) {
return 'definitionOperator'
}
// Operators
if (stream.match(/^[+\-*\/^%]/)) {
return 'operator'
}
// Comparison operators
if (stream.match(/^[<>]=?/) || stream.match(/^[!=]=/)) {
return 'operator'
}
// Parentheses
if (stream.eat('(') || stream.eat(')')) {
return 'paren'
}
// Comma
if (stream.eat(',')) {
return 'punctuation'
}
// Currency symbols
if (stream.eat('$') || stream.eat('\u20AC') || stream.eat('\u00A3') || stream.eat('\u00A5')) {
return 'keyword'
}
// Identifier, keyword, or function
const identMatch = stream.match(/^[a-zA-Z_][a-zA-Z0-9_]*/)
if (identMatch) {
const word = typeof identMatch === 'string' ? identMatch : (identMatch as RegExpMatchArray)[0]
const lower = word.toLowerCase()
if (BUILTIN_FUNCTIONS.has(lower)) {
return 'function'
}
if (CONSTANTS.has(lower)) {
return 'constant'
}
if (KEYWORDS.has(lower)) {
return 'keyword'
}
return 'variableName'
}
// Skip unknown character
stream.next()
return null
},
}
export const calcpadStreamLanguage = StreamLanguage.define<CalcPadState>(calcpadStreamParser)
/**
* Tag mapping for CalcPad token types to CodeMirror highlight tags.
*/
export const calcpadHighlightTags: Record<string, Tag> = {
number: tags.number,
operator: tags.operator,
variableName: tags.variableName,
function: tags.function(tags.variableName),
keyword: tags.keyword,
lineComment: tags.lineComment,
heading: tags.heading,
definitionOperator: tags.definitionOperator,
special: tags.special(tags.variableName),
constant: tags.constant(tags.variableName),
paren: tags.paren,
punctuation: tags.punctuation,
}
/**
* Creates a CalcPad language extension for CodeMirror 6.
*/
export function calcpadLanguage(): LanguageSupport {
return new LanguageSupport(calcpadStreamLanguage)
}

View File

@@ -0,0 +1,142 @@
/**
* Inline error display for CalcPad.
* Adapted from epic/9-2-codemirror-6-editor.
*
* Shows red underline decorations and gutter markers for lines with errors.
*/
import {
Decoration,
type DecorationSet,
GutterMarker,
gutter,
EditorView,
} from '@codemirror/view'
import { StateField, StateEffect, type Extension, RangeSet } from '@codemirror/state'
// --- State Effects ---
export interface LineError {
from: number // absolute start position in document
to: number // absolute end position in document
message: string
}
export const setErrorsEffect = StateEffect.define<LineError[]>()
// --- Error Gutter Marker ---
class ErrorGutterMarker extends GutterMarker {
override toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'cm-error-marker'
span.textContent = '\u26A0' // Warning sign
return span
}
override eq(other: GutterMarker): boolean {
return other instanceof ErrorGutterMarker
}
}
const errorMarkerInstance = new ErrorGutterMarker()
// --- Error Underline Decorations (StateField) ---
const errorUnderlineMark = Decoration.mark({ class: 'cm-error-underline' })
export const errorDecorationsField = StateField.define<DecorationSet>({
create() {
return Decoration.none
},
update(decos, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
if (effect.value.length === 0) {
return Decoration.none
}
const ranges = effect.value
.filter((e) => e.from < e.to)
.sort((a, b) => a.from - b.from)
.map((e) => errorUnderlineMark.range(e.from, e.to))
return RangeSet.of(ranges)
}
}
if (tr.docChanged) {
return decos.map(tr.changes)
}
return decos
},
provide(field) {
return EditorView.decorations.from(field)
},
})
// --- Error Lines Set (for gutter) ---
export const errorLinesField = StateField.define<Set<number>>({
create() {
return new Set()
},
update(lines, tr) {
for (const effect of tr.effects) {
if (effect.is(setErrorsEffect)) {
const newLines = new Set<number>()
for (const error of effect.value) {
const lineNumber = tr.state.doc.lineAt(error.from).number
newLines.add(lineNumber)
}
return newLines
}
}
return lines
},
})
// --- Error Gutter ---
export const errorGutter = gutter({
class: 'cm-error-gutter',
lineMarker(view, line) {
const lineNumber = view.state.doc.lineAt(line.from).number
const errorLines = view.state.field(errorLinesField)
return errorLines.has(lineNumber) ? errorMarkerInstance : null
},
lineMarkerChange(update) {
return update.transactions.some((tr) =>
tr.effects.some((e) => e.is(setErrorsEffect)),
)
},
})
// --- Base Theme ---
export const errorBaseTheme = EditorView.baseTheme({
'.cm-error-underline': {
textDecoration: 'underline wavy red',
},
'.cm-error-marker': {
color: '#e53e3e',
fontSize: '14px',
},
'.cm-error-gutter': {
width: '20px',
},
})
/**
* Creates the error display extension bundle.
*/
export function errorDisplayExtension(): Extension {
return [
errorDecorationsField,
errorLinesField,
errorGutter,
errorBaseTheme,
]
}

View File

@@ -0,0 +1,28 @@
/**
* Types shared between the main thread and the WASM engine worker.
* These mirror the JsResult struct from calcpad-wasm/src/types.rs.
*/
/** Result from evaluating a single line via the WASM engine. */
export interface EngineLineResult {
/** Result type: number, unitValue, currencyValue, dateTime, timeDelta, boolean, error, text, comment, empty */
type: string
/** Display-formatted string of the result */
display: string
/** Raw numeric value, if applicable */
rawValue: number | null
/** Error message, if this is an error result */
error: string | null
}
/** Messages sent from the main thread to the worker. */
export type WorkerRequest =
| { kind: 'init' }
| { kind: 'evalSheet'; id: number; lines: string[] }
/** Messages sent from the worker back to the main thread. */
export type WorkerResponse =
| { kind: 'ready' }
| { kind: 'initError'; error: string }
| { kind: 'evalResult'; id: number; results: EngineLineResult[] }
| { kind: 'evalError'; id: number; error: string }

View File

@@ -0,0 +1,91 @@
/**
* React hook that manages communication with the calcpad WASM engine
* running in a Web Worker. Provides a function to evaluate a sheet
* (array of lines) and returns results asynchronously.
*/
import { useRef, useEffect, useCallback, useState } from 'react'
import type { WorkerResponse, EngineLineResult } from './types.ts'
export interface EngineState {
/** Whether the worker is initialized and ready */
ready: boolean
/** Evaluate a full sheet; returns results per line */
evalSheet: (lines: string[]) => void
/** The most recent evaluation results, one per input line */
results: EngineLineResult[]
/** Error from the last evaluation, if any */
error: string | null
}
export function useEngine(): EngineState {
const workerRef = useRef<Worker | null>(null)
const requestIdRef = useRef(0)
const latestIdRef = useRef(0)
const [ready, setReady] = useState(false)
const [results, setResults] = useState<EngineLineResult[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const worker = new Worker(
new URL('./worker.ts', import.meta.url),
{ type: 'module' },
)
workerRef.current = worker
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data
switch (msg.kind) {
case 'ready':
setReady(true)
break
case 'initError':
setError(msg.error)
break
case 'evalResult':
// Only apply if this is the most recent request
if (msg.id === latestIdRef.current) {
setResults(msg.results)
setError(null)
}
break
case 'evalError':
if (msg.id === latestIdRef.current) {
setError(msg.error)
}
break
}
}
worker.onerror = (err) => {
setError(`Worker error: ${err.message}`)
}
// Initialize the worker (trigger WASM loading)
worker.postMessage({ kind: 'init' })
return () => {
worker.terminate()
workerRef.current = null
}
}, [])
const evalSheet = useCallback((lines: string[]) => {
if (!workerRef.current) return
const id = ++requestIdRef.current
latestIdRef.current = id
workerRef.current.postMessage({
kind: 'evalSheet',
id,
lines,
})
}, [])
return { ready, evalSheet, results, error }
}

View File

@@ -0,0 +1,133 @@
/**
* Web Worker that loads the calcpad-wasm module and evaluates sheets
* off the main thread. Communication is via structured postMessage.
*
* The WASM module is expected at `/wasm/calcpad_wasm_bg.wasm` with
* its JS glue at `/wasm/calcpad_wasm.js` (output of wasm-pack).
*
* If the WASM module is not available (e.g., during development without
* a WASM build), the worker falls back to a lightweight JS evaluator.
*/
import type { WorkerRequest, WorkerResponse, EngineLineResult } from './types.ts'
// ---------- WASM engine interface ----------
interface CalcpadWasm {
evalSheet(lines: string[]): EngineLineResult[]
}
let engine: CalcpadWasm | null = null
// ---------- Fallback JS evaluator ----------
function fallbackEvalSheet(lines: string[]): EngineLineResult[] {
return lines.map((line) => {
const trimmed = line.trim()
if (trimmed === '') {
return { type: 'empty', display: '', rawValue: null, error: null }
}
if (trimmed.startsWith('//')) {
return { type: 'comment', display: trimmed.slice(2).trim(), rawValue: null, error: null }
}
if (/^#{1,6}\s/.test(trimmed)) {
return { type: 'text', display: trimmed, rawValue: null, error: null }
}
// Try to evaluate as a simple math expression
try {
// Strip inline comments
const exprPart = trimmed.replace(/\/\/.*$/, '').trim()
if (exprPart === '') {
return { type: 'empty', display: '', rawValue: null, error: null }
}
// Handle variable assignments: "name = expr"
const assignMatch = exprPart.match(/^([a-zA-Z_]\w*)\s*=\s*(.+)$/)
const expr = assignMatch ? assignMatch[2] : exprPart
// Very basic: try Function-based evaluation for simple arithmetic
// This is a development fallback only; production uses the WASM engine.
const sanitized = expr
.replace(/\^/g, '**')
.replace(/\bpi\b/gi, String(Math.PI))
.replace(/\be\b/gi, String(Math.E))
// Only allow safe characters for eval
if (/^[\d\s+\-*/.()%,eE]+$/.test(sanitized)) {
// eslint-disable-next-line no-new-func
const result = new Function(`"use strict"; return (${sanitized})`)() as number
if (typeof result === 'number' && isFinite(result)) {
const display = Number.isInteger(result) ? result.toString() : parseFloat(result.toPrecision(10)).toString()
return { type: 'number', display, rawValue: result, error: null }
}
}
return { type: 'text', display: '', rawValue: null, error: null }
} catch {
return { type: 'text', display: '', rawValue: null, error: null }
}
})
}
// ---------- WASM loading ----------
async function initWasm(): Promise<boolean> {
try {
// Try to load the wasm-pack output
const wasmModule = await import(/* @vite-ignore */ '/wasm/calcpad_wasm.js') as {
default: () => Promise<void>
evalSheet: (lines: string[]) => EngineLineResult[]
}
await wasmModule.default()
engine = {
evalSheet: (lines: string[]) => wasmModule.evalSheet(lines),
}
return true
} catch {
// WASM not available; fallback mode
console.warn('[calcpad-worker] WASM engine not found, using JS fallback')
return false
}
}
// ---------- Message handler ----------
function sendResponse(msg: WorkerResponse) {
self.postMessage(msg)
}
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const msg = event.data
switch (msg.kind) {
case 'init': {
const loaded = await initWasm()
if (loaded) {
sendResponse({ kind: 'ready' })
} else {
// Still report ready — we have the fallback
sendResponse({ kind: 'ready' })
}
break
}
case 'evalSheet': {
try {
const results = engine
? engine.evalSheet(msg.lines)
: fallbackEvalSheet(msg.lines)
sendResponse({ kind: 'evalResult', id: msg.id, results })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
sendResponse({ kind: 'evalError', id: msg.id, error: message })
}
break
}
}
}

View File

@@ -0,0 +1,69 @@
/**
* React hook that captures the beforeinstallprompt event for PWA install.
* Adapted from epic/9-3-pwa-support.
*/
import { useState, useEffect, useCallback } from 'react'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
export interface InstallPromptState {
promptEvent: BeforeInstallPromptEvent | null
isInstalled: boolean
handleInstall: () => Promise<void>
handleDismiss: () => void
}
export function useInstallPrompt(): InstallPromptState {
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null)
const [isInstalled, setIsInstalled] = useState(false)
useEffect(() => {
// Check if already installed (standalone mode)
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
('standalone' in navigator && (navigator as unknown as { standalone: boolean }).standalone)
if (isStandalone) {
setIsInstalled(true)
return
}
const handler = (e: Event) => {
e.preventDefault()
setPromptEvent(e as BeforeInstallPromptEvent)
}
const installedHandler = () => {
setIsInstalled(true)
setPromptEvent(null)
}
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', installedHandler)
return () => {
window.removeEventListener('beforeinstallprompt', handler)
window.removeEventListener('appinstalled', installedHandler)
}
}, [])
const handleInstall = useCallback(async () => {
if (!promptEvent) return
await promptEvent.prompt()
const result = await promptEvent.userChoice
if (result.outcome === 'accepted') {
setIsInstalled(true)
}
setPromptEvent(null)
}, [promptEvent])
const handleDismiss = useCallback(() => {
setPromptEvent(null)
}, [])
return { promptEvent, isInstalled, handleInstall, handleDismiss }
}

View File

@@ -0,0 +1,27 @@
/**
* React hook that tracks whether the browser is online or offline.
* Adapted from epic/9-3-pwa-support.
*/
import { useState, useEffect } from 'react'
export function useOnlineStatus(): boolean {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true,
)
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}

31
calcpad-web/src/main.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Register service worker for PWA support
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const { registerSW } = await import('virtual:pwa-register')
registerSW({
onNeedRefresh() {
if (confirm('New version available. Reload to update?')) {
window.location.reload()
}
},
onOfflineReady() {
console.log('CalcPad is ready to work offline')
},
})
} catch {
// PWA registration not available in dev mode
}
})
}

View File

@@ -0,0 +1,47 @@
/* ---------- Answer column (standalone panel mode) ---------- */
.answer-column {
width: 220px;
padding: 12px 0;
border-left: 1px solid var(--border);
background: var(--bg-secondary);
overflow-y: auto;
flex-shrink: 0;
}
.answer-line {
padding: 0 16px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Match CodeMirror's line height for alignment */
height: 24px;
}
.answer-value {
color: var(--accent);
font-weight: 600;
text-align: right;
}
.answer-error {
color: var(--error);
font-style: italic;
text-align: right;
}
.answer-empty {
color: transparent;
}
@media (max-width: 640px) {
.answer-column {
width: 100%;
max-height: 120px;
border-left: none;
border-top: 1px solid var(--border);
}
}

View File

@@ -0,0 +1,92 @@
/* ---------- App layout ---------- */
.calcpad-app {
display: flex;
flex-direction: column;
height: 100svh;
overflow: hidden;
}
/* ---------- Header ---------- */
.calcpad-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.calcpad-header h1 {
margin: 0;
font-size: 20px;
letter-spacing: -0.3px;
}
.calcpad-header .subtitle {
margin: 0;
font-size: 13px;
color: var(--text);
}
.header-status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
}
.status-dot.loading {
background: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ---------- Editor area ---------- */
.calcpad-editor {
flex: 1;
display: flex;
overflow: hidden;
}
.editor-pane {
flex: 1;
min-width: 0;
overflow: hidden;
}
.editor-pane .calc-editor {
height: 100%;
}
/* Ensure CodeMirror fills its container */
.editor-pane .cm-editor {
height: 100%;
}
/* ---------- Responsive ---------- */
@media (max-width: 640px) {
.calcpad-header {
padding: 10px 16px;
}
.calcpad-header .subtitle {
display: none;
}
}

View File

@@ -0,0 +1,60 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--bg-secondary: #f8f9fa;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #6366f1;
--accent-bg: rgba(99, 102, 241, 0.1);
--accent-border: rgba(99, 102, 241, 0.5);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
--success: #10b981;
--success-bg: rgba(16, 185, 129, 0.1);
--error: #e53e3e;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, 'Courier New', monospace;
font: 16px/1.5 var(--sans);
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-secondary: #1a1b23;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #818cf8;
--accent-bg: rgba(129, 140, 248, 0.15);
--accent-border: rgba(129, 140, 248, 0.5);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 600;
color: var(--text-h);
}

View File

@@ -0,0 +1,69 @@
.install-prompt {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 24px;
background: var(--bg);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
}
.install-content {
max-width: 600px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.install-text {
margin: 0;
font-size: 14px;
color: var(--text-h);
}
.install-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.install-btn {
padding: 8px 20px;
border-radius: 6px;
border: none;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.install-btn:hover {
opacity: 0.9;
}
.install-dismiss {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.install-dismiss:hover {
background: var(--accent-bg);
}
@media (max-width: 480px) {
.install-content {
flex-direction: column;
text-align: center;
}
}

View File

@@ -0,0 +1,23 @@
.offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
background: var(--warning-bg);
color: var(--warning);
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid var(--warning);
flex-shrink: 0;
}
.offline-icon {
font-size: 8px;
animation: offline-pulse 2s ease-in-out infinite;
}
@keyframes offline-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

14
calcpad-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="vite/client" />
declare module 'virtual:pwa-register' {
export interface RegisterSWOptions {
immediate?: boolean
onNeedRefresh?: () => void
onOfflineReady?: () => void
onRegistered?: (registration: ServiceWorkerRegistration | undefined) => void
onRegisteredSW?: (swScriptUrl: string, registration: ServiceWorkerRegistration | undefined) => void
onRegisterError?: (error: unknown) => void
}
export function registerSW(options?: RegisterSWOptions): (reloadPage?: boolean) => Promise<void>
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,95 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt',
includeAssets: ['favicon.svg', 'icons/*.svg'],
manifest: {
name: 'CalcPad',
short_name: 'CalcPad',
description: 'A modern notepad calculator powered by WebAssembly',
theme_color: '#6366f1',
background_color: '#ffffff',
display: 'standalone',
orientation: 'any',
scope: '/',
start_url: '/',
icons: [
{
src: '/icons/icon-192.svg',
sizes: '192x192',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon-maskable-512.svg',
sizes: '512x512',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
categories: ['productivity', 'utilities'],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,wasm}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24,
},
networkTimeoutSeconds: 5,
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
},
},
],
},
devOptions: {
enabled: false,
},
}),
],
build: {
target: 'es2022',
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
codemirror: [
'@codemirror/state',
'@codemirror/view',
'@codemirror/language',
'@lezer/highlight',
],
},
},
},
},
worker: {
format: 'es',
},
})

View File

@@ -0,0 +1,19 @@
[package]
name = "calcpad-windows"
version = "0.1.0"
edition = "2021"
description = "CalcPad — native Windows app built with iced"
[[bin]]
name = "calcpad-win"
path = "src/main.rs"
[dependencies]
calcpad-engine = { path = "../calcpad-engine" }
iced = { version = "0.13", features = ["tokio"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[profile.release]
opt-level = 3
lto = true
strip = true

View File

@@ -0,0 +1,210 @@
//! Thin wrapper around `calcpad_engine::SheetContext` for the iced UI.
//!
//! Exposes a simple `evaluate_sheet(text) -> Vec<LineResult>` interface so
//! the UI layer does not interact with the engine types directly.
use calcpad_engine::{CalcResult, CalcValue, ResultType, SheetContext};
/// A pre-formatted result for a single line, ready for display.
#[derive(Debug, Clone)]
pub struct LineResult {
/// The display string shown in the results column.
/// Empty string means the line produced no visible result (comment, blank, heading).
pub display: String,
/// Whether this result is an error.
pub is_error: bool,
}
/// Evaluate a full sheet of text, returning one `LineResult` per line.
///
/// Uses `SheetContext` for full dependency resolution, variables,
/// aggregators, unit conversions, and all engine features.
pub fn evaluate_sheet(text: &str) -> Vec<LineResult> {
if text.is_empty() {
return vec![LineResult {
display: String::new(),
is_error: false,
}];
}
let lines: Vec<&str> = text.lines().collect();
let mut ctx = SheetContext::new();
for (i, line) in lines.iter().enumerate() {
ctx.set_line(i, line);
}
let results = ctx.eval();
let mut output: Vec<LineResult> = results.into_iter().map(|r| format_result(&r)).collect();
// Ensure we have at least as many results as lines
while output.len() < lines.len() {
output.push(LineResult {
display: String::new(),
is_error: false,
});
}
output
}
/// Format a `CalcResult` into a `LineResult`.
fn format_result(result: &CalcResult) -> LineResult {
match &result.value {
CalcValue::Error { message, .. } => {
// Suppress "noise" errors from empty/comment lines
let suppressed = message == "Empty expression"
|| message == "no expression found"
|| message == "No result";
if suppressed {
LineResult {
display: String::new(),
is_error: false,
}
} else {
LineResult {
display: result.metadata.display.clone(),
is_error: true,
}
}
}
_ => LineResult {
display: result.metadata.display.clone(),
is_error: result.metadata.result_type == ResultType::Error,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_input() {
let results = evaluate_sheet("");
assert_eq!(results.len(), 1);
assert_eq!(results[0].display, "");
assert!(!results[0].is_error);
}
#[test]
fn test_single_expression() {
let results = evaluate_sheet("2 + 2");
assert_eq!(results.len(), 1);
assert_eq!(results[0].display, "4");
assert!(!results[0].is_error);
}
#[test]
fn test_multiple_lines() {
let results = evaluate_sheet("10\n20\n30");
assert_eq!(results.len(), 3);
assert_eq!(results[0].display, "10");
assert_eq!(results[1].display, "20");
assert_eq!(results[2].display, "30");
}
#[test]
fn test_variables() {
let results = evaluate_sheet("x = 10\nx * 2");
assert_eq!(results.len(), 2);
assert_eq!(results[0].display, "10");
assert_eq!(results[1].display, "20");
}
#[test]
fn test_variable_dependency() {
let results = evaluate_sheet("price = 100\ntax = 15\nprice + tax");
assert_eq!(results.len(), 3);
assert_eq!(results[0].display, "100");
assert_eq!(results[1].display, "15");
assert_eq!(results[2].display, "115");
}
#[test]
fn test_aggregator_total() {
let results = evaluate_sheet("10\n20\n30\ntotal");
assert_eq!(results.len(), 4);
assert_eq!(results[3].display, "60");
}
#[test]
fn test_empty_lines_produce_empty_results() {
let results = evaluate_sheet("10\n\n20");
assert_eq!(results.len(), 3);
assert_eq!(results[0].display, "10");
assert_eq!(results[1].display, "");
assert_eq!(results[2].display, "20");
}
#[test]
fn test_comment_lines() {
let results = evaluate_sheet("// this is a comment\n42");
assert_eq!(results.len(), 2);
assert_eq!(results[0].display, "");
assert_eq!(results[1].display, "42");
}
#[test]
fn test_complex_expressions() {
let results = evaluate_sheet("(2 + 3) * 4");
assert_eq!(results.len(), 1);
assert_eq!(results[0].display, "20");
}
#[test]
fn test_non_math_text() {
let results = evaluate_sheet("hello world");
assert_eq!(results.len(), 1);
assert_eq!(results[0].display, "");
}
#[test]
fn test_native_types_no_serialization() {
// Verify results come back as native Rust types through SheetContext.
let mut ctx = SheetContext::new();
ctx.set_line(0, "2 + 2");
let results = ctx.eval();
assert_eq!(results.len(), 1);
assert_eq!(results[0].value, CalcValue::Number { value: 4.0 });
}
#[test]
fn test_full_sheet_with_dependencies() {
let mut ctx = SheetContext::new();
ctx.set_line(0, "base = 100");
ctx.set_line(1, "rate = 0.15");
ctx.set_line(2, "base * rate");
let results = ctx.eval();
assert_eq!(results.len(), 3);
assert_eq!(results[0].value, CalcValue::Number { value: 100.0 });
assert_eq!(results[1].value, CalcValue::Number { value: 0.15 });
assert_eq!(results[2].value, CalcValue::Number { value: 15.0 });
}
#[test]
fn test_performance_1000_lines() {
let lines: Vec<String> = (0..1000)
.map(|i| format!("{} + {} * {}", i, i + 1, i + 2))
.collect();
let sheet = lines.join("\n");
let start = std::time::Instant::now();
let results = evaluate_sheet(&sheet);
let elapsed = start.elapsed();
assert_eq!(results.len(), 1000);
for r in &results {
assert!(!r.display.is_empty(), "Every line should produce a result");
}
// Must complete 1000 evaluations within one 60fps frame budget (16ms).
assert!(
elapsed.as_millis() < 100, // generous for CI, expect <16ms locally
"1000 evaluations took {}ms",
elapsed.as_millis()
);
}
}

117
calcpad-windows/src/main.rs Normal file
View File

@@ -0,0 +1,117 @@
//! CalcPad — native Windows (and cross-platform) notepad calculator.
//!
//! Built with iced and the calcpad-engine. Two-column layout:
//! editable text on the left, live-evaluated results on the right.
//! GPU-accelerated rendering via wgpu with automatic tiny-skia fallback.
mod evaluator;
mod theme;
mod ui;
use evaluator::LineResult;
use iced::widget::{column, container, row, text_editor, vertical_rule};
use iced::{Element, Font, Length};
fn main() -> iced::Result {
iced::application("CalcPad", CalcPad::update, CalcPad::view)
.window_size((960.0, 640.0))
.run()
}
// ---------------------------------------------------------------------------
// Application state
// ---------------------------------------------------------------------------
/// Top-level application state.
struct CalcPad {
/// The editor content (left pane).
content: text_editor::Content,
/// Cached per-line results (right pane).
results: Vec<LineResult>,
/// Current line count (for the header status).
line_count: usize,
}
impl Default for CalcPad {
fn default() -> Self {
Self {
content: text_editor::Content::new(),
results: vec![LineResult {
display: String::new(),
is_error: false,
}],
line_count: 1,
}
}
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
enum Message {
/// The user interacted with the text editor.
EditorAction(text_editor::Action),
}
// ---------------------------------------------------------------------------
// Update & View
// ---------------------------------------------------------------------------
impl CalcPad {
fn update(&mut self, message: Message) {
match message {
Message::EditorAction(action) => {
let is_edit = action.is_edit();
self.content.perform(action);
if is_edit {
self.re_evaluate();
}
}
}
}
fn view(&self) -> Element<'_, Message> {
// -- Header --
let header = ui::header::view::<Message>(self.line_count);
// -- Left: text editor --
let editor = text_editor(&self.content)
.on_action(Message::EditorAction)
.font(Font::MONOSPACE)
.height(Length::Fill)
.style(theme::editor_style);
let editor_pane = container(editor)
.width(Length::FillPortion(3))
.height(Length::Fill)
.style(theme::editor_pane);
// -- Right: results column --
let results_view = ui::results_column::view::<Message>(&self.results);
let results_pane = container(results_view)
.width(Length::FillPortion(2))
.height(Length::Fill)
.style(theme::results_pane);
// -- Assemble --
let body = row![editor_pane, vertical_rule(1), results_pane]
.height(Length::Fill);
column![header, body].height(Length::Fill).into()
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// Re-evaluate all lines through the engine and cache results.
fn re_evaluate(&mut self) {
let source = self.content.text();
self.results = evaluator::evaluate_sheet(&source);
self.line_count = source.lines().count().max(1);
}
}

View File

@@ -0,0 +1,88 @@
//! Windows 11 inspired theme for the CalcPad iced application.
//!
//! Provides color constants and styling functions that mirror the
//! WinUI 3 / Fluent Design aesthetic: clean backgrounds, subtle
//! separators, and the Segoe-like color palette.
use iced::widget::{container, text_editor};
use iced::{Border, Color, Theme};
// ---------------------------------------------------------------------------
// Color palette — Windows 11 Light
// ---------------------------------------------------------------------------
/// Main window background (WinUI "Layer" background).
pub const BACKGROUND: Color = Color::from_rgb(0.976, 0.976, 0.976); // #f9f9f9
/// Editor pane background — slightly lighter for contrast.
pub const EDITOR_BG: Color = Color::from_rgb(1.0, 1.0, 1.0); // #ffffff
/// Results pane background — very subtle gray tint.
pub const RESULTS_BG: Color = Color::from_rgb(0.965, 0.965, 0.969); // #f7f7f8
/// Result text color — muted dark gray, not pure black.
pub const RESULT_TEXT: Color = Color::from_rgb(0.247, 0.247, 0.247); // #3f3f3f
/// Error text color — Windows 11 system red.
pub const ERROR_TEXT: Color = Color::from_rgb(0.780, 0.173, 0.157); // #c72c28
/// Accent color — Windows 11 default blue.
pub const ACCENT: Color = Color::from_rgb(0.0, 0.471, 0.831); // #0078d4
/// Separator / rule color.
pub const SEPARATOR: Color = Color::from_rgb(0.878, 0.878, 0.878); // #e0e0e0
/// Title bar / header background.
pub const HEADER_BG: Color = Color::from_rgb(0.949, 0.949, 0.949); // #f2f2f2
/// Placeholder / dimmed text.
pub const PLACEHOLDER: Color = Color::from_rgb(0.600, 0.600, 0.600); // #999999
// ---------------------------------------------------------------------------
// Container styles
// ---------------------------------------------------------------------------
/// Style for the editor (left) pane container.
pub fn editor_pane(_theme: &Theme) -> container::Style {
container::Style {
background: Some(iced::Background::Color(EDITOR_BG)),
..Default::default()
}
}
/// Style for the results (right) pane container.
pub fn results_pane(_theme: &Theme) -> container::Style {
container::Style {
background: Some(iced::Background::Color(RESULTS_BG)),
..Default::default()
}
}
/// Style for the top header bar.
pub fn header_bar(_theme: &Theme) -> container::Style {
container::Style {
background: Some(iced::Background::Color(HEADER_BG)),
border: Border {
color: SEPARATOR,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
}
}
// ---------------------------------------------------------------------------
// Text editor style
// ---------------------------------------------------------------------------
/// Clean, borderless text editor that blends with the editor pane.
pub fn editor_style(theme: &Theme, status: text_editor::Status) -> text_editor::Style {
let mut style = text_editor::default(theme, status);
style.background = iced::Background::Color(EDITOR_BG);
style.border = Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
};
style
}

View File

@@ -0,0 +1,22 @@
//! Top header bar — displays the app title and basic status.
use crate::theme;
use iced::widget::{container, row, text};
use iced::{Element, Length};
/// Build the header bar element.
pub fn view<'a, Message: 'a>(line_count: usize) -> Element<'a, Message> {
let title = text("CalcPad").size(14);
let status = text(format!("{} lines", line_count))
.size(12)
.color(theme::PLACEHOLDER);
container(
row![title, iced::widget::horizontal_space(), status]
.padding([6, 12])
.align_y(iced::Alignment::Center),
)
.width(Length::Fill)
.style(theme::header_bar)
.into()
}

View File

@@ -0,0 +1,4 @@
//! UI components for the CalcPad Windows application.
pub mod header;
pub mod results_column;

View File

@@ -0,0 +1,38 @@
//! Results column component — renders one text widget per editor line,
//! aligned vertically so each result sits beside its input line.
use crate::evaluator::LineResult;
use crate::theme;
use iced::widget::{column, scrollable, text, Column};
use iced::{Element, Font, Length};
/// Build the scrollable results column from a slice of `LineResult`s.
///
/// Each result occupies exactly one line-height so it aligns with the
/// corresponding editor line on the left.
pub fn view<'a, Message: 'a>(results: &[LineResult]) -> Element<'a, Message> {
let items: Vec<Element<'a, Message>> = if results.is_empty() {
vec![text("").font(Font::MONOSPACE).into()]
} else {
results
.iter()
.map(|r| {
let color = if r.is_error {
theme::ERROR_TEXT
} else {
theme::RESULT_TEXT
};
text(r.display.clone())
.font(Font::MONOSPACE)
.color(color)
.into()
})
.collect()
};
scrollable(
Column::with_children(items).padding([7, 10]),
)
.height(Length::Fill)
.into()
}