feat(engine): add units, currency, datetime, variables, and functions modules

Extracted and integrated unique feature modules from Epic 2-6 branches:

- units/: 200+ unit conversions across 14 categories, SI prefixes
  (nano-tera), CSS/screen units, binary vs decimal data, custom units
- currency/: fiat (180+ currencies, cached rates, offline fallback),
  crypto (63 coins, CoinGecko), symbol recognition, rate caching
- datetime/: date/time math, 150+ city timezone mappings (chrono-tz),
  business day calculations, unix timestamps, relative expressions
- variables/: line references (lineN, #N, prev/ans), section
  aggregators (sum/total/avg/min/max/count), subtotals, autocomplete
- functions/: trig, log, combinatorics, financial, rounding,
  list operations (min/max/gcd/lcm), video timecodes

585 tests passing across workspace.
This commit is contained in:
C. Cassel
2026-03-17 09:01:13 -04:00
committed by C. Cassel
parent 6a8fecd03e
commit 68fa54615a
41 changed files with 11601 additions and 12 deletions

View File

@@ -0,0 +1,425 @@
//! Section aggregators for CalcPad sheets.
//!
//! Provides aggregation keywords (`sum`, `total`, `subtotal`, `average`/`avg`,
//! `min`, `max`, `count`) that operate over a section of lines, and
//! `grand total` which sums all subtotal results.
//!
//! A **section** is bounded by:
//! - Headings (lines starting with `#` followed by a space, e.g. `## Budget`)
//! - Other aggregator lines
//! - Start of document
//!
//! Only lines with numeric results are included in aggregation. Comments,
//! blank lines, and error lines are skipped.
use crate::span::Span;
use crate::types::{CalcResult, CalcValue};
/// The kind of aggregation to perform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggregatorKind {
/// Sum of numeric values in the section. Also used for `total`.
Sum,
/// Distinct subtotal (tracked separately for grand total).
Subtotal,
/// Arithmetic mean of numeric values in the section.
Average,
/// Minimum numeric value in the section.
Min,
/// Maximum numeric value in the section.
Max,
/// Count of lines with numeric results in the section.
Count,
/// Sum of all subtotal values seen so far in the document.
GrandTotal,
}
/// Check if a trimmed line is a heading (e.g., `## Monthly Costs`).
pub fn is_heading(line: &str) -> bool {
let trimmed = line.trim();
// Match lines starting with one or more '#' followed by a space
let bytes = trimmed.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' {
return false;
}
let mut i = 0;
while i < bytes.len() && bytes[i] == b'#' {
i += 1;
}
// Must have at least one # and be followed by a space (or be only #s)
i > 0 && i <= 6 && (i >= bytes.len() || bytes[i] == b' ')
}
/// Detect if a trimmed line is a standalone aggregator keyword.
/// Returns the aggregator kind, or None if the line is not an aggregator.
pub fn detect_aggregator(line: &str) -> Option<AggregatorKind> {
let trimmed = line.trim().to_lowercase();
// Check for two-word "grand total" first
if trimmed == "grand total" {
return Some(AggregatorKind::GrandTotal);
}
match trimmed.as_str() {
"sum" => Some(AggregatorKind::Sum),
"total" => Some(AggregatorKind::Sum),
"subtotal" => Some(AggregatorKind::Subtotal),
"average" | "avg" => Some(AggregatorKind::Average),
"min" => Some(AggregatorKind::Min),
"max" => Some(AggregatorKind::Max),
"count" => Some(AggregatorKind::Count),
_ => None,
}
}
/// Check if a line is a section boundary (heading or aggregator).
pub fn is_section_boundary(line: &str) -> bool {
is_heading(line) || detect_aggregator(line).is_some()
}
/// Collect numeric values from the section above the given line index.
///
/// Walks backwards from the line before `line_index` (0-indexed) until hitting
/// a section boundary (heading, another aggregator, or start of document).
///
/// Only non-error results with extractable numeric values are included.
pub fn collect_section_values(
results: &[CalcResult],
sources: &[String],
line_index: usize,
) -> Vec<f64> {
let mut values = Vec::new();
if line_index == 0 {
return values;
}
for i in (0..line_index).rev() {
let source = &sources[i];
// Stop at headings
if is_heading(source) {
break;
}
// Stop at other aggregator lines
if detect_aggregator(source).is_some() {
break;
}
// Extract numeric value from result
if let Some(val) = extract_numeric_value(&results[i]) {
values.push(val);
}
}
// Reverse to document order (we collected bottom-up)
values.reverse();
values
}
/// Extract a numeric value from a CalcResult, if it has one.
fn extract_numeric_value(result: &CalcResult) -> Option<f64> {
match &result.value {
CalcValue::Number { value } => Some(*value),
CalcValue::UnitValue { value, .. } => Some(*value),
CalcValue::CurrencyValue { amount, .. } => Some(*amount),
_ => None,
}
}
/// Compute an aggregation over the given values.
pub fn compute_aggregation(kind: AggregatorKind, values: &[f64], span: Span) -> CalcResult {
match kind {
AggregatorKind::GrandTotal => {
// Grand total is handled separately (sums subtotals, not section values)
let sum: f64 = values.iter().sum();
CalcResult::number(sum, span)
}
_ => {
if values.is_empty() {
return CalcResult::number(0.0, span);
}
match kind {
AggregatorKind::Sum | AggregatorKind::Subtotal => {
let sum: f64 = values.iter().sum();
CalcResult::number(sum, span)
}
AggregatorKind::Average => {
let sum: f64 = values.iter().sum();
CalcResult::number(sum / values.len() as f64, span)
}
AggregatorKind::Min => {
let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
CalcResult::number(min, span)
}
AggregatorKind::Max => {
let max = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
CalcResult::number(max, span)
}
AggregatorKind::Count => {
CalcResult::number(values.len() as f64, span)
}
AggregatorKind::GrandTotal => unreachable!(),
}
}
}
}
/// Compute a grand total from a list of subtotal values.
pub fn compute_grand_total(subtotal_values: &[f64], span: Span) -> CalcResult {
let sum: f64 = subtotal_values.iter().sum();
CalcResult::number(sum, span)
}
#[cfg(test)]
mod tests {
use super::*;
// --- is_heading ---
#[test]
fn test_heading_h1() {
assert!(is_heading("# Title"));
}
#[test]
fn test_heading_h2() {
assert!(is_heading("## Subtitle"));
}
#[test]
fn test_heading_h3_with_whitespace() {
assert!(is_heading(" ### Indented Heading "));
}
#[test]
fn test_not_heading_hash_in_middle() {
assert!(!is_heading("this is #not a heading"));
}
#[test]
fn test_not_heading_hash_ref() {
assert!(!is_heading("#1 * 2"));
}
#[test]
fn test_not_heading_empty() {
assert!(!is_heading(""));
}
// --- detect_aggregator ---
#[test]
fn test_detect_sum() {
assert_eq!(detect_aggregator("sum"), Some(AggregatorKind::Sum));
}
#[test]
fn test_detect_total() {
assert_eq!(detect_aggregator("total"), Some(AggregatorKind::Sum));
}
#[test]
fn test_detect_subtotal() {
assert_eq!(detect_aggregator("subtotal"), Some(AggregatorKind::Subtotal));
}
#[test]
fn test_detect_average() {
assert_eq!(detect_aggregator("average"), Some(AggregatorKind::Average));
}
#[test]
fn test_detect_avg() {
assert_eq!(detect_aggregator("avg"), Some(AggregatorKind::Average));
}
#[test]
fn test_detect_min() {
assert_eq!(detect_aggregator("min"), Some(AggregatorKind::Min));
}
#[test]
fn test_detect_max() {
assert_eq!(detect_aggregator("max"), Some(AggregatorKind::Max));
}
#[test]
fn test_detect_count() {
assert_eq!(detect_aggregator("count"), Some(AggregatorKind::Count));
}
#[test]
fn test_detect_grand_total() {
assert_eq!(detect_aggregator("grand total"), Some(AggregatorKind::GrandTotal));
}
#[test]
fn test_detect_case_insensitive() {
assert_eq!(detect_aggregator(" SUM "), Some(AggregatorKind::Sum));
assert_eq!(detect_aggregator(" Grand Total "), Some(AggregatorKind::GrandTotal));
}
#[test]
fn test_detect_not_aggregator() {
assert_eq!(detect_aggregator("sum + 5"), None);
assert_eq!(detect_aggregator("total expense"), None);
assert_eq!(detect_aggregator("x = 5"), None);
}
// --- collect_section_values ---
#[test]
fn test_collect_section_basic() {
let results = vec![
CalcResult::number(10.0, Span::new(0, 2)),
CalcResult::number(20.0, Span::new(0, 2)),
CalcResult::number(30.0, Span::new(0, 2)),
];
let sources = vec!["10".to_string(), "20".to_string(), "30".to_string()];
let values = collect_section_values(&results, &sources, 3);
assert_eq!(values, vec![10.0, 20.0, 30.0]);
}
#[test]
fn test_collect_section_stops_at_heading() {
let results = vec![
CalcResult::number(10.0, Span::new(0, 2)),
CalcResult::error("heading", Span::new(0, 8)),
CalcResult::number(20.0, Span::new(0, 2)),
CalcResult::number(30.0, Span::new(0, 2)),
];
let sources = vec![
"10".to_string(),
"## Section".to_string(),
"20".to_string(),
"30".to_string(),
];
let values = collect_section_values(&results, &sources, 4);
assert_eq!(values, vec![20.0, 30.0]);
}
#[test]
fn test_collect_section_stops_at_aggregator() {
let results = vec![
CalcResult::number(10.0, Span::new(0, 2)),
CalcResult::number(20.0, Span::new(0, 2)),
CalcResult::number(30.0, Span::new(0, 3)), // sum result
CalcResult::number(40.0, Span::new(0, 2)),
CalcResult::number(50.0, Span::new(0, 2)),
];
let sources = vec![
"10".to_string(),
"20".to_string(),
"sum".to_string(),
"40".to_string(),
"50".to_string(),
];
let values = collect_section_values(&results, &sources, 5);
assert_eq!(values, vec![40.0, 50.0]);
}
#[test]
fn test_collect_section_skips_errors() {
let results = vec![
CalcResult::number(10.0, Span::new(0, 2)),
CalcResult::error("parse error", Span::new(0, 3)),
CalcResult::number(30.0, Span::new(0, 2)),
];
let sources = vec!["10".to_string(), "???".to_string(), "30".to_string()];
let values = collect_section_values(&results, &sources, 3);
assert_eq!(values, vec![10.0, 30.0]);
}
#[test]
fn test_collect_section_empty() {
let results = vec![
CalcResult::error("heading", Span::new(0, 8)),
];
let sources = vec!["## Section".to_string()];
let values = collect_section_values(&results, &sources, 1);
assert!(values.is_empty());
}
#[test]
fn test_collect_at_start() {
let values = collect_section_values(&[], &[], 0);
assert!(values.is_empty());
}
// --- compute_aggregation ---
#[test]
fn test_sum_aggregation() {
let values = vec![10.0, 20.0, 30.0, 40.0];
let result = compute_aggregation(AggregatorKind::Sum, &values, Span::new(0, 3));
assert_eq!(result.value, CalcValue::Number { value: 100.0 });
}
#[test]
fn test_subtotal_aggregation() {
let values = vec![10.0, 20.0, 30.0];
let result = compute_aggregation(AggregatorKind::Subtotal, &values, Span::new(0, 8));
assert_eq!(result.value, CalcValue::Number { value: 60.0 });
}
#[test]
fn test_average_aggregation() {
let values = vec![10.0, 20.0, 30.0];
let result = compute_aggregation(AggregatorKind::Average, &values, Span::new(0, 7));
assert_eq!(result.value, CalcValue::Number { value: 20.0 });
}
#[test]
fn test_min_aggregation() {
let values = vec![5.0, 12.0, 3.0, 8.0];
let result = compute_aggregation(AggregatorKind::Min, &values, Span::new(0, 3));
assert_eq!(result.value, CalcValue::Number { value: 3.0 });
}
#[test]
fn test_max_aggregation() {
let values = vec![5.0, 12.0, 3.0, 8.0];
let result = compute_aggregation(AggregatorKind::Max, &values, Span::new(0, 3));
assert_eq!(result.value, CalcValue::Number { value: 12.0 });
}
#[test]
fn test_count_aggregation() {
let values = vec![5.0, 12.0, 3.0, 8.0];
let result = compute_aggregation(AggregatorKind::Count, &values, Span::new(0, 5));
assert_eq!(result.value, CalcValue::Number { value: 4.0 });
}
#[test]
fn test_empty_section_returns_zero() {
let result = compute_aggregation(AggregatorKind::Sum, &[], Span::new(0, 3));
assert_eq!(result.value, CalcValue::Number { value: 0.0 });
let result = compute_aggregation(AggregatorKind::Average, &[], Span::new(0, 7));
assert_eq!(result.value, CalcValue::Number { value: 0.0 });
}
// --- compute_grand_total ---
#[test]
fn test_grand_total_two_sections() {
let subtotals = vec![300.0, 125.0];
let result = compute_grand_total(&subtotals, Span::new(0, 11));
assert_eq!(result.value, CalcValue::Number { value: 425.0 });
}
#[test]
fn test_grand_total_empty() {
let result = compute_grand_total(&[], Span::new(0, 11));
assert_eq!(result.value, CalcValue::Number { value: 0.0 });
}
#[test]
fn test_grand_total_includes_zero_subtotals() {
let subtotals = vec![300.0, 0.0, 125.0];
let result = compute_grand_total(&subtotals, Span::new(0, 11));
assert_eq!(result.value, CalcValue::Number { value: 425.0 });
}
}

View File

@@ -0,0 +1,552 @@
//! Autocomplete provider for CalcPad.
//!
//! Provides completion suggestions for variables, functions, keywords, and units
//! based on the current cursor position and sheet content.
//!
//! This module is purely text-based — it does not depend on the evaluation engine.
//! It scans the sheet content for variable declarations and matches against
//! built-in registries of functions, keywords, and units.
use serde::{Deserialize, Serialize};
/// The kind of completion item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompletionKind {
/// A user-declared variable.
Variable,
/// A built-in math function.
Function,
/// An aggregator keyword (sum, total, etc.).
Keyword,
/// A unit suffix (kg, km, etc.).
Unit,
}
/// A single autocomplete suggestion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompletionItem {
/// Display label for the suggestion.
pub label: String,
/// Text to insert when the suggestion is accepted.
pub insert_text: String,
/// Category of the completion.
pub kind: CompletionKind,
/// Optional description/detail.
pub detail: Option<String>,
}
/// Context for computing autocomplete suggestions.
pub struct CompletionContext<'a> {
/// Current line text.
pub line: &'a str,
/// Cursor position within the line (0-indexed byte offset).
pub cursor: usize,
/// Full sheet content (all lines joined by newlines).
pub sheet_content: &'a str,
/// Current line number (1-indexed).
pub line_number: usize,
}
/// Result of an autocomplete query.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CompletionResult {
/// Matching completion items.
pub items: Vec<CompletionItem>,
/// The prefix being matched.
pub prefix: String,
/// Start position for text replacement in the line (byte offset).
pub replace_start: usize,
/// End position for text replacement in the line (byte offset).
pub replace_end: usize,
}
/// Info about the extracted prefix at the cursor.
struct PrefixInfo {
prefix: String,
start: usize,
end: usize,
is_unit_context: bool,
}
// --- Built-in registries ---
fn keyword_completions() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "sum".to_string(),
insert_text: "sum".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Sum of section values".to_string()),
},
CompletionItem {
label: "total".to_string(),
insert_text: "total".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Total of section values".to_string()),
},
CompletionItem {
label: "subtotal".to_string(),
insert_text: "subtotal".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Subtotal of section values".to_string()),
},
CompletionItem {
label: "average".to_string(),
insert_text: "average".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Average of section values".to_string()),
},
CompletionItem {
label: "count".to_string(),
insert_text: "count".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Count of section values".to_string()),
},
CompletionItem {
label: "prev".to_string(),
insert_text: "prev".to_string(),
kind: CompletionKind::Keyword,
detail: Some("Previous line result".to_string()),
},
]
}
fn function_completions() -> Vec<CompletionItem> {
vec![
CompletionItem {
label: "sqrt".to_string(),
insert_text: "sqrt(".to_string(),
kind: CompletionKind::Function,
detail: Some("Square root".to_string()),
},
CompletionItem {
label: "abs".to_string(),
insert_text: "abs(".to_string(),
kind: CompletionKind::Function,
detail: Some("Absolute value".to_string()),
},
CompletionItem {
label: "round".to_string(),
insert_text: "round(".to_string(),
kind: CompletionKind::Function,
detail: Some("Round to nearest integer".to_string()),
},
CompletionItem {
label: "floor".to_string(),
insert_text: "floor(".to_string(),
kind: CompletionKind::Function,
detail: Some("Round down".to_string()),
},
CompletionItem {
label: "ceil".to_string(),
insert_text: "ceil(".to_string(),
kind: CompletionKind::Function,
detail: Some("Round up".to_string()),
},
CompletionItem {
label: "log".to_string(),
insert_text: "log(".to_string(),
kind: CompletionKind::Function,
detail: Some("Base-10 logarithm".to_string()),
},
CompletionItem {
label: "ln".to_string(),
insert_text: "ln(".to_string(),
kind: CompletionKind::Function,
detail: Some("Natural logarithm".to_string()),
},
CompletionItem {
label: "sin".to_string(),
insert_text: "sin(".to_string(),
kind: CompletionKind::Function,
detail: Some("Sine".to_string()),
},
CompletionItem {
label: "cos".to_string(),
insert_text: "cos(".to_string(),
kind: CompletionKind::Function,
detail: Some("Cosine".to_string()),
},
CompletionItem {
label: "tan".to_string(),
insert_text: "tan(".to_string(),
kind: CompletionKind::Function,
detail: Some("Tangent".to_string()),
},
]
}
fn unit_completions() -> Vec<CompletionItem> {
vec![
// Mass
CompletionItem { label: "kg".to_string(), insert_text: "kg".to_string(), kind: CompletionKind::Unit, detail: Some("Kilograms".to_string()) },
CompletionItem { label: "lb".to_string(), insert_text: "lb".to_string(), kind: CompletionKind::Unit, detail: Some("Pounds".to_string()) },
CompletionItem { label: "oz".to_string(), insert_text: "oz".to_string(), kind: CompletionKind::Unit, detail: Some("Ounces".to_string()) },
CompletionItem { label: "mg".to_string(), insert_text: "mg".to_string(), kind: CompletionKind::Unit, detail: Some("Milligrams".to_string()) },
// Length
CompletionItem { label: "km".to_string(), insert_text: "km".to_string(), kind: CompletionKind::Unit, detail: Some("Kilometers".to_string()) },
CompletionItem { label: "mm".to_string(), insert_text: "mm".to_string(), kind: CompletionKind::Unit, detail: Some("Millimeters".to_string()) },
CompletionItem { label: "cm".to_string(), insert_text: "cm".to_string(), kind: CompletionKind::Unit, detail: Some("Centimeters".to_string()) },
CompletionItem { label: "ft".to_string(), insert_text: "ft".to_string(), kind: CompletionKind::Unit, detail: Some("Feet".to_string()) },
CompletionItem { label: "in".to_string(), insert_text: "in".to_string(), kind: CompletionKind::Unit, detail: Some("Inches".to_string()) },
// Volume
CompletionItem { label: "ml".to_string(), insert_text: "ml".to_string(), kind: CompletionKind::Unit, detail: Some("Milliliters".to_string()) },
// Data
CompletionItem { label: "kB".to_string(), insert_text: "kB".to_string(), kind: CompletionKind::Unit, detail: Some("Kilobytes".to_string()) },
CompletionItem { label: "MB".to_string(), insert_text: "MB".to_string(), kind: CompletionKind::Unit, detail: Some("Megabytes".to_string()) },
CompletionItem { label: "GB".to_string(), insert_text: "GB".to_string(), kind: CompletionKind::Unit, detail: Some("Gigabytes".to_string()) },
CompletionItem { label: "TB".to_string(), insert_text: "TB".to_string(), kind: CompletionKind::Unit, detail: Some("Terabytes".to_string()) },
// Time
CompletionItem { label: "ms".to_string(), insert_text: "ms".to_string(), kind: CompletionKind::Unit, detail: Some("Milliseconds".to_string()) },
CompletionItem { label: "hr".to_string(), insert_text: "hr".to_string(), kind: CompletionKind::Unit, detail: Some("Hours".to_string()) },
]
}
// --- Prefix extraction ---
/// Extract the identifier prefix at the cursor position.
///
/// Handles unit context detection: when letters immediately follow digits
/// (e.g., "50km"), the prefix is the letter portion ("km") and is_unit_context is true.
fn extract_prefix(line: &str, cursor: usize) -> Option<PrefixInfo> {
if cursor == 0 || cursor > line.len() {
return None;
}
let bytes = line.as_bytes();
// Walk backwards collecting word characters (alphanumeric + underscore)
let mut start = cursor;
while start > 0 && is_word_char(bytes[start - 1]) {
start -= 1;
}
if start == cursor {
return None;
}
let full_word = &line[start..cursor];
// If the word starts with a letter or underscore, it's a normal identifier prefix
if full_word.as_bytes()[0].is_ascii_alphabetic() || full_word.as_bytes()[0] == b'_' {
return Some(PrefixInfo {
prefix: full_word.to_string(),
start,
end: cursor,
is_unit_context: false,
});
}
// Word starts with digits — find where letters begin for unit context
let letter_start = full_word
.bytes()
.position(|b| b.is_ascii_alphabetic() || b == b'_');
match letter_start {
Some(offset) => {
let prefix = full_word[offset..].to_string();
let abs_start = start + offset;
Some(PrefixInfo {
prefix,
start: abs_start,
end: cursor,
is_unit_context: true,
})
}
None => None, // All digits, no completion
}
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
// --- Variable extraction ---
/// Extract declared variable names from the sheet content.
/// Scans each line for `identifier = expression` patterns.
/// Excludes the current line to avoid self-reference.
fn extract_variables(sheet_content: &str, current_line_number: usize) -> Vec<CompletionItem> {
let mut seen = std::collections::HashSet::new();
let mut items = Vec::new();
for (i, line) in sheet_content.lines().enumerate() {
let line_num = i + 1; // 1-indexed
if line_num == current_line_number {
continue;
}
let trimmed = line.trim();
if let Some(name) = extract_variable_name(trimmed) {
if !seen.contains(&name) {
seen.insert(name.clone());
items.push(CompletionItem {
label: name.clone(),
insert_text: name,
kind: CompletionKind::Variable,
detail: Some(format!("Variable (line {})", line_num)),
});
}
}
}
items
}
/// Extract the variable name from an assignment line.
/// Returns Some(name) if the line matches `identifier = ...`.
fn extract_variable_name(line: &str) -> Option<String> {
let bytes = line.as_bytes();
if bytes.is_empty() || (!bytes[0].is_ascii_alphabetic() && bytes[0] != b'_') {
return None;
}
let mut i = 0;
while i < bytes.len() && is_word_char(bytes[i]) {
i += 1;
}
let name = &line[..i];
// Skip whitespace
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
// Must be followed by '=' but not '=='
if i < bytes.len() && bytes[i] == b'=' {
if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
return None; // comparison, not assignment
}
return Some(name.to_string());
}
None
}
// --- Core completion function ---
/// Get autocomplete suggestions for the current cursor position.
///
/// Returns `None` if:
/// - The prefix is less than 2 characters
/// - No completions match the prefix
pub fn get_completions(context: &CompletionContext) -> Option<CompletionResult> {
let prefix_info = extract_prefix(context.line, context.cursor)?;
// Enforce 2+ character minimum threshold
if prefix_info.prefix.len() < 2 {
return None;
}
let lower_prefix = prefix_info.prefix.to_lowercase();
let candidates = if prefix_info.is_unit_context {
// Unit context: only suggest units
unit_completions()
} else {
// General context: suggest variables, functions, and keywords
let variables = extract_variables(context.sheet_content, context.line_number);
let mut all = variables;
all.extend(function_completions());
all.extend(keyword_completions());
all
};
// Filter by case-insensitive prefix match
let mut filtered: Vec<CompletionItem> = candidates
.into_iter()
.filter(|item| item.label.to_lowercase().starts_with(&lower_prefix))
.collect();
if filtered.is_empty() {
return None;
}
// Sort: exact case match first, then alphabetical
let prefix_clone = prefix_info.prefix.clone();
filtered.sort_by(|a, b| {
let a_exact = if a.label.starts_with(&prefix_clone) { 0 } else { 1 };
let b_exact = if b.label.starts_with(&prefix_clone) { 0 } else { 1 };
if a_exact != b_exact {
return a_exact.cmp(&b_exact);
}
a.label.cmp(&b.label)
});
Some(CompletionResult {
items: filtered,
prefix: prefix_info.prefix,
replace_start: prefix_info.start,
replace_end: prefix_info.end,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_context<'a>(
line: &'a str,
cursor: usize,
sheet: &'a str,
line_number: usize,
) -> CompletionContext<'a> {
CompletionContext {
line,
cursor,
sheet_content: sheet,
line_number,
}
}
// --- AC1: Variable suggestions for 2+ character prefix ---
#[test]
fn test_variable_suggestions() {
let sheet = "monthly_rent = 1250\nmonthly_insurance = 200\nmortgage_payment = 800\n";
let ctx = make_context("mo", 2, sheet, 4);
let result = get_completions(&ctx).unwrap();
assert_eq!(result.prefix, "mo");
assert!(result.items.len() >= 2);
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"monthly_rent"));
assert!(labels.contains(&"monthly_insurance"));
assert!(labels.contains(&"mortgage_payment"));
}
// --- AC4: Built-in function suggestions ---
#[test]
fn test_function_suggestions_sq() {
let ctx = make_context("sq", 2, "", 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"sqrt"));
}
// --- AC5: No suggestions for single character ---
#[test]
fn test_no_suggestions_single_char() {
let ctx = make_context("m", 1, "", 1);
let result = get_completions(&ctx);
assert!(result.is_none());
}
// --- AC6: No suggestions when nothing matches ---
#[test]
fn test_no_suggestions_no_match() {
let ctx = make_context("zzzz", 4, "", 1);
let result = get_completions(&ctx);
assert!(result.is_none());
}
// --- AC7: Unit context after number ---
#[test]
fn test_unit_context_after_number() {
let ctx = make_context("50km", 4, "", 1);
let result = get_completions(&ctx).unwrap();
assert!(result.items.iter().all(|i| i.kind == CompletionKind::Unit));
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"km"));
}
#[test]
fn test_unit_context_kg() {
let ctx = make_context("50kg", 4, "", 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"kg"));
}
// --- Edge cases ---
#[test]
fn test_empty_line() {
let ctx = make_context("", 0, "", 1);
let result = get_completions(&ctx);
assert!(result.is_none());
}
#[test]
fn test_cursor_at_start() {
let ctx = make_context("sum", 0, "", 1);
let result = get_completions(&ctx);
assert!(result.is_none());
}
#[test]
fn test_excludes_current_line_variables() {
let sheet = "my_var = 10\nmy_other = 20";
// Cursor is on line 1 typing "my" — should not suggest my_var from line 1
let ctx = make_context("my", 2, sheet, 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(!labels.contains(&"my_var")); // excluded: same line
assert!(labels.contains(&"my_other")); // included: different line
}
#[test]
fn test_keyword_suggestions() {
let ctx = make_context("su", 2, "", 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"sum"));
assert!(labels.contains(&"subtotal"));
}
#[test]
fn test_replace_range() {
let ctx = make_context("x + sq", 6, "", 1);
let result = get_completions(&ctx).unwrap();
assert_eq!(result.prefix, "sq");
assert_eq!(result.replace_start, 4);
assert_eq!(result.replace_end, 6);
}
#[test]
fn test_prev_suggestion() {
let ctx = make_context("pr", 2, "", 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"prev"));
}
#[test]
fn test_case_insensitive_matching() {
let ctx = make_context("SU", 2, "", 1);
let result = get_completions(&ctx).unwrap();
let labels: Vec<&str> = result.items.iter().map(|i| i.label.as_str()).collect();
assert!(labels.contains(&"sum"));
assert!(labels.contains(&"subtotal"));
}
// --- Variable extraction ---
#[test]
fn test_extract_variable_name_valid() {
assert_eq!(extract_variable_name("x = 5"), Some("x".to_string()));
assert_eq!(
extract_variable_name("tax_rate = 0.15"),
Some("tax_rate".to_string())
);
assert_eq!(
extract_variable_name("_temp = 100"),
Some("_temp".to_string())
);
assert_eq!(
extract_variable_name("item1 = 42"),
Some("item1".to_string())
);
}
#[test]
fn test_extract_variable_name_invalid() {
assert_eq!(extract_variable_name("5 + 3"), None);
assert_eq!(extract_variable_name("== 5"), None);
assert_eq!(extract_variable_name(""), None);
assert_eq!(extract_variable_name("x == 5"), None);
}
}

View File

@@ -0,0 +1,39 @@
//! Variables, line references, aggregators, and autocomplete for CalcPad.
//!
//! This module provides the features from Epic 5 (Variables, Line References &
//! Aggregators) that extend the CalcPad engine beyond simple per-line evaluation:
//!
//! - **Line references** (`line1`, `#1`): Reference the result of a specific line
//! by number, with renumbering support when lines are inserted/deleted and
//! circular reference detection.
//!
//! - **Aggregators** (`sum`, `total`, `subtotal`, `average`/`avg`, `min`, `max`,
//! `count`, `grand total`): Compute over a section of lines bounded by headings
//! or other aggregator lines.
//!
//! - **Autocomplete**: Provides completion suggestions for variables, functions,
//! keywords, and units based on prefix matching (2+ characters).
//!
//! Note: Variable declaration/usage (`x = 5`, then `x * 2`) and previous-line
//! references (`prev`, `ans`) are handled by the core engine modules:
//! - `context.rs` / `EvalContext` — stores variables and resolves `__prev`
//! - `sheet_context.rs` / `SheetContext` — manages multi-line evaluation with
//! dependency tracking, storing line results as `__line_N` variables
//! - `interpreter.rs` — evaluates `LineRef`, `PrevRef`, and `FunctionCall` AST nodes
//! - `lexer.rs` / `parser.rs` — tokenize and parse `lineN`, `#N`, `prev`, `ans`
pub mod aggregators;
pub mod autocomplete;
pub mod references;
// Re-export key types for convenience.
pub use aggregators::{
AggregatorKind, collect_section_values, compute_aggregation, compute_grand_total,
detect_aggregator, is_heading, is_section_boundary,
};
pub use autocomplete::{
get_completions, CompletionContext, CompletionItem, CompletionKind, CompletionResult,
};
pub use references::{
detect_circular_line_refs, extract_line_refs, renumber_after_delete, renumber_after_insert,
};

View File

@@ -0,0 +1,365 @@
//! Line reference support for CalcPad.
//!
//! Provides line references (`line1`, `#1`) that resolve to the result of a
//! specific line by number, and renumbering logic for when lines are inserted
//! or deleted.
//!
//! Line references are 1-indexed (matching what users see in the editor).
//! Internally they are stored in the EvalContext as `__line_N` variables.
use std::collections::HashSet;
/// Extract all line reference numbers from an expression string.
/// Recognizes both `lineN` and `#N` syntax (case-insensitive for "line").
pub fn extract_line_refs(input: &str) -> Vec<usize> {
let mut refs = Vec::new();
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
// Check for #N syntax
if bytes[i] == b'#' && i + 1 < len && bytes[i + 1].is_ascii_digit() {
i += 1;
let start = i;
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
if let Ok(n) = input[start..i].parse::<usize>() {
if !refs.contains(&n) {
refs.push(n);
}
}
continue;
}
// Check for lineN syntax (case-insensitive)
if i + 4 < len {
let word = &input[i..i + 4];
if word.eq_ignore_ascii_case("line") {
let after = i + 4;
if after < len && bytes[after].is_ascii_digit() {
// Check that the character before is not alphanumeric (word boundary)
if i == 0 || !bytes[i - 1].is_ascii_alphanumeric() {
let num_start = after;
let mut j = after;
while j < len && bytes[j].is_ascii_digit() {
j += 1;
}
// Check that the character after is not alphanumeric (word boundary)
if j >= len || !bytes[j].is_ascii_alphanumeric() {
if let Ok(n) = input[num_start..j].parse::<usize>() {
if !refs.contains(&n) {
refs.push(n);
}
}
}
i = j;
continue;
}
}
}
}
i += 1;
}
refs
}
/// Update line references in an expression string after a line insertion.
///
/// When a new line is inserted at position `insert_at` (1-indexed),
/// all references to lines at or after that position are incremented by 1.
pub fn renumber_after_insert(input: &str, insert_at: usize) -> String {
renumber_refs(input, |line_num| {
if line_num >= insert_at {
line_num + 1
} else {
line_num
}
})
}
/// Update line references in an expression string after a line deletion.
///
/// When a line is deleted at position `delete_at` (1-indexed),
/// references to the deleted line become 0 (invalid).
/// References to lines after the deleted one are decremented by 1.
pub fn renumber_after_delete(input: &str, delete_at: usize) -> String {
renumber_refs(input, |line_num| {
if line_num == delete_at {
0 // mark as invalid
} else if line_num > delete_at {
line_num - 1
} else {
line_num
}
})
}
/// Apply a renumbering function to all line references in an expression string.
/// Handles both `lineN` and `#N` syntax.
fn renumber_refs<F>(input: &str, transform: F) -> String
where
F: Fn(usize) -> usize,
{
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
// Check for #N syntax
if bytes[i] == b'#' && i + 1 < len && bytes[i + 1].is_ascii_digit() {
result.push('#');
i += 1;
let start = i;
while i < len && bytes[i].is_ascii_digit() {
i += 1;
}
if let Ok(n) = input[start..i].parse::<usize>() {
let new_n = transform(n);
result.push_str(&new_n.to_string());
} else {
result.push_str(&input[start..i]);
}
continue;
}
// Check for lineN syntax (case-insensitive)
if i + 4 < len {
let word = &input[i..i + 4];
if word.eq_ignore_ascii_case("line") {
let after = i + 4;
if after < len && bytes[after].is_ascii_digit() {
if i == 0 || !bytes[i - 1].is_ascii_alphanumeric() {
let prefix = &input[i..i + 4]; // preserve original case
let num_start = after;
let mut j = after;
while j < len && bytes[j].is_ascii_digit() {
j += 1;
}
if j >= len || !bytes[j].is_ascii_alphanumeric() {
if let Ok(n) = input[num_start..j].parse::<usize>() {
let new_n = transform(n);
result.push_str(prefix);
result.push_str(&new_n.to_string());
i = j;
continue;
}
}
}
}
}
}
// Safe: input is valid UTF-8, push the character
let ch = input[i..].chars().next().unwrap();
result.push(ch);
i += ch.len_utf8();
}
result
}
/// Detect circular line references given a set of lines and their line reference dependencies.
///
/// Returns the set of line numbers (1-indexed) that are involved in circular references.
pub fn detect_circular_line_refs(
line_refs: &[(usize, Vec<usize>)], // (line_number, referenced_lines)
) -> HashSet<usize> {
use std::collections::HashMap;
let mut adj: HashMap<usize, Vec<usize>> = HashMap::new();
for (line_num, refs) in line_refs {
adj.insert(*line_num, refs.clone());
}
let mut circular = HashSet::new();
#[derive(Clone, Copy, PartialEq)]
enum State {
Unvisited,
InProgress,
Done,
}
let mut state: HashMap<usize, State> = HashMap::new();
for (line_num, _) in line_refs {
state.insert(*line_num, State::Unvisited);
}
fn dfs(
node: usize,
adj: &HashMap<usize, Vec<usize>>,
state: &mut HashMap<usize, State>,
path: &mut Vec<usize>,
circular: &mut HashSet<usize>,
) {
if let Some(&s) = state.get(&node) {
if s == State::Done {
return;
}
if s == State::InProgress {
// Found a cycle — mark all nodes in the cycle
if let Some(start_idx) = path.iter().position(|&n| n == node) {
for &n in &path[start_idx..] {
circular.insert(n);
}
}
circular.insert(node);
return;
}
}
state.insert(node, State::InProgress);
path.push(node);
if let Some(deps) = adj.get(&node) {
for &dep in deps {
if adj.contains_key(&dep) {
dfs(dep, adj, state, path, circular);
}
}
}
path.pop();
state.insert(node, State::Done);
}
for (line_num, _) in line_refs {
if state.get(line_num) == Some(&State::Unvisited) {
let mut path = Vec::new();
dfs(*line_num, &adj, &mut state, &mut path, &mut circular);
}
}
circular
}
#[cfg(test)]
mod tests {
use super::*;
// --- extract_line_refs ---
#[test]
fn test_extract_hash_ref() {
assert_eq!(extract_line_refs("#1 * 2"), vec![1]);
}
#[test]
fn test_extract_line_ref() {
assert_eq!(extract_line_refs("line1 * 2"), vec![1]);
}
#[test]
fn test_extract_line_ref_case_insensitive() {
assert_eq!(extract_line_refs("Line3 + Line1"), vec![3, 1]);
}
#[test]
fn test_extract_multiple_refs() {
assert_eq!(extract_line_refs("#1 + #2 * line3"), vec![1, 2, 3]);
}
#[test]
fn test_extract_no_refs() {
assert_eq!(extract_line_refs("x + 5"), Vec::<usize>::new());
}
#[test]
fn test_extract_dedup() {
assert_eq!(extract_line_refs("#1 + #1"), vec![1]);
}
// --- renumber_after_insert ---
#[test]
fn test_renumber_insert_shifts_refs_at_or_after() {
assert_eq!(renumber_after_insert("#1 + #2", 1), "#2 + #3");
}
#[test]
fn test_renumber_insert_no_shift_before() {
assert_eq!(renumber_after_insert("#1 + #2", 3), "#1 + #2");
}
#[test]
fn test_renumber_insert_line_syntax() {
assert_eq!(renumber_after_insert("line2 * 3", 1), "line3 * 3");
}
#[test]
fn test_renumber_insert_mixed() {
assert_eq!(renumber_after_insert("#1 + line3", 2), "#1 + line4");
}
// --- renumber_after_delete ---
#[test]
fn test_renumber_delete_marks_deleted_as_zero() {
assert_eq!(renumber_after_delete("#2 * 3", 2), "#0 * 3");
}
#[test]
fn test_renumber_delete_shifts_after() {
assert_eq!(renumber_after_delete("#1 + #3", 2), "#1 + #2");
}
#[test]
fn test_renumber_delete_line_syntax() {
assert_eq!(renumber_after_delete("line3 + 5", 2), "line2 + 5");
}
#[test]
fn test_renumber_delete_no_change_before() {
assert_eq!(renumber_after_delete("#1 + #2", 5), "#1 + #2");
}
// --- detect_circular_line_refs ---
#[test]
fn test_no_cycles() {
let refs = vec![(1, vec![]), (2, vec![1]), (3, vec![1, 2])];
let circular = detect_circular_line_refs(&refs);
assert!(circular.is_empty());
}
#[test]
fn test_direct_cycle() {
let refs = vec![(1, vec![2]), (2, vec![1])];
let circular = detect_circular_line_refs(&refs);
assert!(circular.contains(&1));
assert!(circular.contains(&2));
}
#[test]
fn test_transitive_cycle() {
let refs = vec![(1, vec![2]), (2, vec![3]), (3, vec![1])];
let circular = detect_circular_line_refs(&refs);
assert!(circular.contains(&1));
assert!(circular.contains(&2));
assert!(circular.contains(&3));
}
#[test]
fn test_self_reference() {
let refs = vec![(1, vec![1])];
let circular = detect_circular_line_refs(&refs);
assert!(circular.contains(&1));
}
#[test]
fn test_partial_cycle() {
// Line 4 depends on line 2 which is in a cycle, but line 4 is not in the cycle itself
let refs = vec![(1, vec![2]), (2, vec![1]), (3, vec![]), (4, vec![2])];
let circular = detect_circular_line_refs(&refs);
assert!(circular.contains(&1));
assert!(circular.contains(&2));
assert!(!circular.contains(&3));
// Line 4 is not in the cycle (it just references a cyclic line)
assert!(!circular.contains(&4));
}
}