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:
425
calcpad-engine/src/variables/aggregators.rs
Normal file
425
calcpad-engine/src/variables/aggregators.rs
Normal 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 });
|
||||
}
|
||||
}
|
||||
552
calcpad-engine/src/variables/autocomplete.rs
Normal file
552
calcpad-engine/src/variables/autocomplete.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
calcpad-engine/src/variables/mod.rs
Normal file
39
calcpad-engine/src/variables/mod.rs
Normal 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,
|
||||
};
|
||||
365
calcpad-engine/src/variables/references.rs
Normal file
365
calcpad-engine/src/variables/references.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user