diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index d31a99722..ce757c2ac 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -352,6 +352,9 @@ impl<'h> Evaluator<'h> { } fn unary(&mut self, op: UnOp, x: &Expr) -> Ev { + if let UnOp::Trim(mode) = op { + return self.trim_ref(mode, x); + } if op == UnOp::Spill { return match self.eval(x) { Ev::R(areas) => match areas.as_slice() { @@ -386,7 +389,7 @@ impl<'h> Evaluator<'h> { UnOp::Neg => Value::number(-n), UnOp::Plus => Value::number(n), UnOp::Percent => Value::number(n / 100.0), - UnOp::At | UnOp::Spill => Value::number(n), + UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n), }, op == UnOp::Plus, )) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 69290685e..59736294e 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -882,3 +882,42 @@ fn visit_range_declines_when_a_formula_in_the_block_spills_into_it() { assert_eq!(walked, None, "walked a block holding a spill it can't see"); } + +/// Trim references evaluate as `TRIMRANGE(range, code, code)`. +#[test] +fn trim_references() { + let mut t = T::new(); + t.set("C3", "1"); + t.set("D5", "2"); + t.set("A20", "5"); + t.set("A21", "6"); + let cases = [ + ("J1", "=ROWS(A1.:.F10)", "=ROWS(TRIMRANGE(A1:F10,3,3))", 3.0), + ("J2", "=COLUMNS(A1.:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,3,3))", 2.0), + ("J3", "=ROWS(A1:.F10)", "=ROWS(TRIMRANGE(A1:F10,2,2))", 5.0), + ("J4", "=COLUMNS(A1:.F10)", "=COLUMNS(TRIMRANGE(A1:F10,2,2))", 4.0), + ("J5", "=ROWS(A1.:F10)", "=ROWS(TRIMRANGE(A1:F10,1,1))", 8.0), + ("J6", "=MIN(ROW(A1.:F10))", "=MIN(ROW(TRIMRANGE(A1:F10,1,1)))", 3.0), + ("J7", "=SUM(A:.A)", "=SUM(TRIMRANGE(A:A,2,2))", 11.0), + ("J8", "=ROWS(A:.A)", "=ROWS(TRIMRANGE(A:A,2,2))", 21.0), + ("J9", "=ROWS(A.:.A)", "=ROWS(TRIMRANGE(A:A,3,3))", 2.0), + ("J10", "=ROWS(20.:.30)", "=ROWS(TRIMRANGE(20:30,3,3))", 2.0), + ]; + for (at, f, _, _) in cases { + t.set(at, f); + } + for (i, (at, f, same, want)) in cases.iter().enumerate() { + assert_eq!(t.num(at), *want, "{f}"); + let k = format!("K{}", i + 1); + t.set(&k, same); + assert_eq!(t.num(&k), *want, "{same}"); + } + // The trimmed block spills and follows edits to the full range. + t.set("L1", "=C1.:.F10"); + assert_eq!((t.num("L1"), t.num("M3")), (1.0, 2.0)); + t.set("A22", "4"); + assert_eq!((t.num("J7"), t.num("J8")), (15.0, 22.0)); + // Nothing but blanks. + t.set("K20", "=SUM(Q1:.Q9)"); + assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); +} diff --git a/crates/calc/src/trimrange.rs b/crates/calc/src/trimrange.rs index 4810277ca..f95020e2e 100644 --- a/crates/calc/src/trimrange.rs +++ b/crates/calc/src/trimrange.rs @@ -2,7 +2,7 @@ //! documented semantics: trim_rows / trim_cols 0 none, 1 leading, 2 trailing, 3 both). use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; -use gridcraft_formula::Expr; +use gridcraft_formula::{Expr, TrimMode}; use crate::eval::{Area, Ev, Evaluator, MAX_CELLS}; @@ -46,6 +46,20 @@ impl Evaluator<'_> { } } + /// A trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10`: `TRIMRANGE(range, code, code)`. + pub(crate) fn trim_ref(&mut self, mode: TrimMode, x: &Expr) -> Ev { + let flags = match mode { + TrimMode::Leading => (true, false), + TrimMode::Trailing => (false, true), + TrimMode::All => (true, true), + }; + let ev = self.eval(x); + match self.trim_ev(ev, flags, flags) { + Ok(ev) => ev, + Err(e) => Ev::V(Value::Error(e)), + } + } + fn trim_mode(&mut self, args: &[Expr], i: usize) -> Result<(bool, bool), CellError> { match args.get(i) { None | Some(Expr::Missing) => Ok((true, true)), @@ -63,8 +77,13 @@ impl Evaluator<'_> { }; let rows = self.trim_mode(args, 1)?; let cols = self.trim_mode(args, 2)?; + let ev = self.eval(first); + self.trim_ev(ev, rows, cols) + } + + fn trim_ev(&mut self, ev: Ev, rows: (bool, bool), cols: (bool, bool)) -> Result { let none = rows == (false, false) && cols == (false, false); - match self.eval(first) { + match ev { Ev::R(areas) => { let [a] = areas.as_slice() else { return Err(CellError::Value) }; if none { diff --git a/crates/formula/src/ast.rs b/crates/formula/src/ast.rs index 39f251154..9b04140cc 100644 --- a/crates/formula/src/ast.rs +++ b/crates/formula/src/ast.rs @@ -91,6 +91,38 @@ pub enum UnOp { /// Spill range operator `A1#`: the whole spill range of the dynamic array anchored at the /// cell (`ANCHORARRAY` in files). Spill, + /// Trim reference `A1.:E10` / `A1:.E10` / `A1.:.E10` around a range: the range without its + /// blank leading, trailing or outer rows and columns (`TRIMRANGE` with both trim codes set). + Trim(TrimMode), +} + +/// Which blank edges a trim reference drops. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum TrimMode { + /// `.:` — leading blank rows and columns. + Leading, + /// `:.` — trailing blank rows and columns. + Trailing, + /// `.:.` — both. + All, +} + +impl TrimMode { + pub fn symbol(self) -> &'static str { + match self { + TrimMode::Leading => ".:", + TrimMode::Trailing => ":.", + TrimMode::All => ".:.", + } + } + /// The equivalent `TRIMRANGE` trim_rows / trim_cols code. + pub fn code(self) -> u8 { + match self { + TrimMode::Leading => 1, + TrimMode::Trailing => 2, + TrimMode::All => 3, + } + } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index a4013335e..4c41d3609 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -4,6 +4,8 @@ use gridcraft_core::CellError; +use crate::ast::TrimMode; + #[derive(Clone, Debug, PartialEq)] pub enum Tok { Number(f64), @@ -27,6 +29,8 @@ pub enum Tok { Comma, Semicolon, Colon, + /// Trim reference range operator `.:`, `:.` or `.:.`. + Trim(TrimMode), Space, Eof, } @@ -49,6 +53,20 @@ fn is_word_char(c: char) -> bool { c.is_alphanumeric() || matches!(c, '_' | '.' | '$' | '\\' | '?') } +/// `A1`, `$A$1`, `A`, `$A`, `1`, `$1`: a word that can end a range, so `A1.:` is a trim +/// reference and not a name ending in a dot. +fn is_range_end(w: &str) -> bool { + let s = w.strip_prefix('$').unwrap_or(w); + let (letters, rest) = s.split_at(s.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(s.len())); + let digits = if letters.is_empty() { rest } else { rest.strip_prefix('$').unwrap_or(rest) }; + letters.len() <= 3 && if digits.is_empty() { !letters.is_empty() && rest.is_empty() } else { digits.bytes().all(|b| b.is_ascii_digit()) } +} + +/// Whether `c` can start the second end of a range after `:.` (`A1:.E10`, `A:.$C`, `1:.5`). +fn starts_range_end(c: Option) -> bool { + c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$') +} + /// Tokenizes formula text (without the leading `=`). pub fn tokenize(src: &str) -> Result, LexError> { tokenize_locale(src, crate::FormulaLocale::En) @@ -178,6 +196,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { + // Trim reference `A1.:E10` / `A1.:.E10` (the word before stopped at the dot). + if ch(i + 2) == Some('.') && starts_range_end(ch(i + 3)) { + push(&mut out, Tok::Trim(TrimMode::All), i + 3); + i += 3; + } else { + push(&mut out, Tok::Trim(TrimMode::Leading), i + 2); + i += 2; + } + } c if c.is_ascii_digit() || (c == locale.decimal_separator() && ch(i + 1).is_some_and(|d| d.is_ascii_digit())) => { // Number, unless it is a row range like 1:3 (handled by parser via Word) — numbers // followed by ':' and digits are row refs: lex as Word. @@ -185,12 +213,22 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result Result { - push(&mut out, Tok::Colon, i + 1); - i += 1; + if ch(i + 1) == Some('.') && starts_range_end(ch(i + 2)) { + push(&mut out, Tok::Trim(TrimMode::Trailing), i + 2); + i += 2; + } else { + push(&mut out, Tok::Colon, i + 1); + i += 1; + } } '+' | '-' | '*' | '/' | '^' | '&' | '%' | '@' | '=' => { let op = match c { @@ -315,6 +358,11 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result 1 && w.ends_with('.') && ch(j) == Some(':') && is_range_end(w.get(..w.len() - 1).unwrap_or("")) { + // `A1.:E10`: the dot belongs to the trim operator, lexed next. + let w = w.get(..w.len() - 1).unwrap_or("").to_string(); + push(&mut out, Tok::Word(w), j - 1); + i = j - 1; } else { push(&mut out, Tok::Word(w), j); i = j; diff --git a/crates/formula/src/lib.rs b/crates/formula/src/lib.rs index c40433251..e193c3fb4 100644 --- a/crates/formula/src/lib.rs +++ b/crates/formula/src/lib.rs @@ -10,7 +10,7 @@ pub mod lexer; pub mod parser; pub mod printer; -pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp}; +pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp}; pub use input::{FormulaLocale, parse_input, print_input}; pub use parser::{ParseError, normalize_function_name, parse}; pub use printer::{print, print_r1c1, quote_sheet}; diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index ee2354e63..4fd3b7bb2 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -174,6 +174,17 @@ impl Parser { lhs = Expr::Invoke(Box::new(lhs), args); continue; } + if let Tok::Trim(mode) = *self.peek() { + // A trim reference is the range operator with a modifier: same binding power. + let bp = BinOp::Range.precedence(); + if bp < min_bp { + break; + } + self.next(); + let rhs = self.expr(bp + 1, allow_union)?; + lhs = Expr::Unary(UnOp::Trim(mode), Box::new(combine(BinOp::Range, lhs, rhs))); + continue; + } let Some(op) = self.binop(allow_union) else { break }; let bp = op.precedence(); if bp < min_bp { @@ -243,6 +254,19 @@ impl Parser { { return Ok(Expr::Unary(UnOp::At, Box::new(x))); } + // Files may spell a trim reference as a function call (`_xlfn._TRO_ALL(A1:E10)`). + let trim = match name.as_str() { + "_TRO_LEADING" => Some(TrimMode::Leading), + "_TRO_TRAILING" => Some(TrimMode::Trailing), + "_TRO_ALL" => Some(TrimMode::All), + _ => None, + }; + if let Some(mode) = trim + && args.len() == 1 + && let Some(x) = args.pop() + { + return Ok(Expr::Unary(UnOp::Trim(mode), Box::new(x))); + } Ok(Expr::Call(name, args)) } Tok::Sheet(a, b) => { @@ -260,27 +284,42 @@ impl Parser { if let Tok::Struct(_) = self.peek() && word_is_cell(&w).is_none() { + let pos = self.pos(); let Tok::Struct(body) = self.next() else { return Err(self.err("internal")) }; - return Ok(Expr::Struct(parse_struct(&w, &body))); + return parse_struct(&w, &body).map(Expr::Struct).map_err(|msg| ParseError { msg, pos }); } self.reference_from_word(SheetSel::Current, &w) } - Tok::Struct(body) => Ok(Expr::Struct(parse_struct("", &body))), + Tok::Struct(body) => parse_struct("", &body).map(Expr::Struct).map_err(|msg| self.err(msg)), t => Err(self.err(format!("unexpected {}", describe(&t)))), } } - /// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name. + /// A word after an optional sheet: a cell, a column/row range (`A:C`, `3:5`) or a name. A + /// trim operator (`A1:.E10`, `A:.A`) in place of the colon wraps the range in + /// [`UnOp::Trim`]. fn reference_from_word(&mut self, sheet: SheetSel, w: &str) -> Result { + let trim = match self.peek() { + Tok::Colon => Some(None), + Tok::Trim(mode) => Some(Some(*mode)), + _ => None, + }; + let wrap = |kind: RefKind, sheet: SheetSel| { + let r = Expr::Ref(Reference { sheet, kind }); + match trim { + Some(Some(mode)) => Expr::Unary(UnOp::Trim(mode), Box::new(r)), + _ => r, + } + }; if let Some(a) = word_is_cell(w) { // Cell, possibly `A1:B2` (merged here so the sheet applies to both ends). - if *self.peek() == Tok::Colon + if trim.is_some() && let Tok::Word(w2) = self.peek_at(1) && let Some(b) = word_is_cell(w2) { self.next(); self.next(); - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Range(a, b) })); + return Ok(wrap(RefKind::Range(a, b), sheet)); } let cell = Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }); if *self.peek() == Tok::Op("#") { @@ -294,18 +333,18 @@ impl Parser { Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")), _ => None, }; - if *self.peek() == Tok::Colon + if trim.is_some() && let Some(w2) = second { if let (Some((c0, a0)), Some((c1, a1))) = (word_is_cols(w), word_is_cols(&w2)) { self.next(); self.next(); - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Cols(c0, a0, c1, a1) })); + return Ok(wrap(RefKind::Cols(c0, a0, c1, a1), sheet)); } if let (Some((r0, a0)), Some((r1, a1))) = (word_is_row(w), word_is_row(&w2)) { self.next(); self.next(); - return Ok(Expr::Ref(Reference { sheet, kind: RefKind::Rows(r0, a0, r1, a1) })); + return Ok(wrap(RefKind::Rows(r0, a0, r1, a1), sheet)); } } if sheet != SheetSel::Current { @@ -372,8 +411,10 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr { Expr::Binary(op, Box::new(lhs), Box::new(rhs)) } -/// Parses the inside of `Table[...]`. -pub fn parse_struct(table: &str, body: &str) -> StructRef { +/// Parses the inside of `Table[...]`. Text between the bracketed items may only be the `,` and +/// `:` separators, except one unbracketed column after `[#This Row],` (`[[#This Row],Units]`, +/// how some writers spell `[@Units]`); anything else is an error rather than dropped. +pub fn parse_struct(table: &str, body: &str) -> Result { let mut specifiers = Vec::new(); let mut cols: Vec = Vec::new(); let body = body.trim(); @@ -401,24 +442,41 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef { _ => None, } }; + let unexpected = |gap: &str| format!("unexpected `{gap}` in structured reference"); + // A column name may hold `[` and `]` only when escaped with `'`. + let column = |s: &str| -> Result { + let mut esc = false; + for c in s.chars() { + match c { + _ if esc => esc = false, + '\'' => esc = true, + '[' | ']' => return Err(unexpected(s)), + _ => {} + } + } + Ok(unescape(s)) + }; if !body.starts_with('[') { // Simple: [Col], [@Col], [#Headers], [@] if let Some(rest) = body.strip_prefix('@') { specifiers.push(StructItem::ThisRow); - let rest = rest.trim().trim_start_matches('[').trim_end_matches(']'); + let rest = rest.trim(); + let rest = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')).unwrap_or(rest); if !rest.is_empty() { - cols.push(unescape(rest)); + cols.push(column(rest)?); } } else if let Some(i) = item(body) { specifiers.push(i); } else if !body.is_empty() { - cols.push(unescape(body)); + cols.push(column(body)?); } } else { - // [[#Headers],[Col1]:[Col3]] or [@[Col 1]] + // [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1] let mut parts = Vec::new(); let mut depth = 0; let mut cur = String::new(); + // Text between bracketed items. + let mut gap = String::new(); let mut esc = false; for c in body.chars() { if esc { @@ -427,17 +485,26 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef { continue; } match c { - '\'' => { + '\'' if depth > 0 => { cur.push(c); esc = true; } '[' => { + if depth == 0 { + if !matches!(gap.trim(), "" | "," | ":") { + return Err(unexpected(gap.trim())); + } + gap.clear(); + } depth += 1; if depth > 1 { cur.push(c); } } ']' => { + if depth == 0 { + return Err(unexpected("]")); + } depth -= 1; if depth > 0 { cur.push(c); @@ -445,33 +512,35 @@ pub fn parse_struct(table: &str, body: &str) -> StructRef { parts.push(std::mem::take(&mut cur)); } } - ',' if depth == 0 => {} - ':' if depth == 0 => parts.push(":".into()), _ if depth > 0 => cur.push(c), - _ => {} + _ => gap.push(c), } } - let mut range_next = false; + if depth > 0 { + return Err("unclosed `[` in structured reference".to_string()); + } for p in parts { - if p == ":" { - range_next = true; - continue; - } if let Some(i) = item(&p) { specifiers.push(i); - } else if range_next { - cols.push(unescape(&p)); - range_next = false; } else { - cols.push(unescape(&p)); + cols.push(column(&p)?); } } - if body.starts_with("@") { - specifiers.push(StructItem::ThisRow); + let tail = gap.trim(); + if !tail.is_empty() { + let bare = tail.strip_prefix(',').map(str::trim).unwrap_or(""); + let simple = !bare.is_empty() && !bare.chars().any(|c| matches!(c, '[' | ']' | '#' | '\'' | ',' | ':')); + if !(simple && specifiers == [StructItem::ThisRow] && cols.is_empty()) { + return Err(unexpected(tail)); + } + cols.push(bare.to_string()); } } + if cols.len() > 2 { + return Err("a structured reference names at most two columns".to_string()); + } let mut it = cols.into_iter(); let col_start = it.next(); let col_end = it.next(); - StructRef { table: table.to_string(), specifiers, col_start, col_end } + Ok(StructRef { table: table.to_string(), specifiers, col_start, col_end }) } diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 12e785f95..a63599bb4 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -39,12 +39,17 @@ fn anchor_a1(a: &Anchor) -> String { } pub fn reference_a1(r: &Reference) -> String { + range_a1(r, ":") +} + +/// A1 text with `sep` between the two ends of a range (`:` or a trim operator). +fn range_a1(r: &Reference, sep: &str) -> String { let body = match &r.kind { RefKind::Cell(a) => anchor_a1(a), - RefKind::Range(a, b) => format!("{}:{}", anchor_a1(a), anchor_a1(b)), - RefKind::Rows(r0, a0, r1, a1) => format!("{}{}:{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1), + RefKind::Range(a, b) => format!("{}{sep}{}", anchor_a1(a), anchor_a1(b)), + RefKind::Rows(r0, a0, r1, a1) => format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, r0 + 1, if *a1 { "$" } else { "" }, r1 + 1), RefKind::Cols(c0, a0, c1, a1) => { - format!("{}{}:{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1)) + format!("{}{}{sep}{}{}", if *a0 { "$" } else { "" }, col_to_letters(*c0), if *a1 { "$" } else { "" }, col_to_letters(*c1)) } }; format!("{}{}", sheet_prefix(&r.sheet), body) @@ -64,16 +69,22 @@ fn anchor_r1c1(a: &Anchor, at: CellRef) -> String { } pub fn reference_r1c1(r: &Reference, at: CellRef) -> String { + range_r1c1(r, at, ":") +} + +/// R1C1 text with `sep` between the two ends of a range; a trim operator never collapses +/// `R1:R1` to `R1`. +fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String { let body = match &r.kind { RefKind::Cell(a) => anchor_r1c1(a, at), - RefKind::Range(a, b) => format!("{}:{}", anchor_r1c1(a, at), anchor_r1c1(b, at)), + RefKind::Range(a, b) => format!("{}{sep}{}", anchor_r1c1(a, at), anchor_r1c1(b, at)), RefKind::Rows(r0, a0, r1, a1) => { let (x, y) = (r1c1_part('R', *r0, *a0, at.row), r1c1_part('R', *r1, *a1, at.row)); - if x == y { x } else { format!("{x}:{y}") } + if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") } } RefKind::Cols(c0, a0, c1, a1) => { let (x, y) = (r1c1_part('C', *c0, *a0, at.col), r1c1_part('C', *c1, *a1, at.col)); - if x == y { x } else { format!("{x}:{y}") } + if x == y && sep == ":" { x } else { format!("{x}{sep}{y}") } } }; format!("{}{}", sheet_prefix(&r.sheet), body) @@ -193,6 +204,25 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { s.push('#'); } } + UnOp::Trim(mode) => match &**x { + Expr::Ref(r) if !matches!(r.kind, RefKind::Cell(_)) => match r1c1 { + Some(at) => s.push_str(&range_r1c1(r, at, mode.symbol())), + None => s.push_str(&range_a1(r, mode.symbol())), + }, + Expr::Binary(BinOp::Range, a, b) => { + write_expr(s, a, r1c1); + s.push_str(mode.symbol()); + write_expr(s, b, r1c1); + } + // A reference that became #REF! stays `#REF!`. + Expr::Error(_) => write_expr(s, x, r1c1), + // No operator form (e.g. a range shrunk to one cell): the equivalent call. + other => { + s.push_str("TRIMRANGE("); + write_expr(s, other, r1c1); + let _ = write!(s, ",{0},{0})", mode.code()); + } + }, }, Expr::Binary(op, a, b) => { write_expr(s, a, r1c1); diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 78dd7eb6e..d3dd4c231 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -220,3 +220,84 @@ fn spans() { let a1 = toks.iter().find(|t| t.tok == lexer::Tok::Word("A1".into())).unwrap(); assert_eq!((a1.start, a1.end), (4, 6)); } + +#[test] +fn unbracketed_this_row_column() { + // Writers such as xlsxwriter spell `[@Units]` as `[[#This Row],Units]`. + let p = |f: &str| parse(f).unwrap_or_else(|e| panic!("{f}: {e}")); + let Expr::Binary(BinOp::Mul, a, _) = p("[[#This Row],Units]*[[#This Row],Price]") else { panic!() }; + let Expr::Struct(s) = *a else { panic!() }; + assert_eq!((s.specifiers.as_slice(), s.col_start.as_deref()), ([StructItem::ThisRow].as_slice(), Some("Units"))); + assert_eq!(rt("[[#This Row],Units]*[[#This Row],Price]"), "[@Units]*[@Price]"); + assert_eq!(rt("Sales[[#This Row],Units]"), "Sales[@Units]"); + assert_eq!(rt("Sales[[#This Row], Units ]"), "Sales[@Units]"); + assert_eq!(rt("[[#This Row],Units]+[@[Unit Price]]+Sales[[#This Row],[Qty]]"), "[@Units]+[@[Unit Price]]+Sales[@Qty]"); + // Unexpected text between the brackets is an error, never silently dropped. + for bad in [ + "[[#This Row]x]", + "[[#This Row],[A]]x", + "[[#Data],Col]", + "[[#This Row],A:B]", + "[x[#This Row]]", + "[[#This Row],]x", + "Sales[[#All],[A]]junk", + "Sales[[A],[B],[C]]", + "Sales[@[A]x]", + ] { + assert!(parse(bad).is_err(), "{bad} should fail"); + } +} + +#[test] +fn trim_references() { + for f in [ + "SUM(A1:.E10)", + "SUM(A1.:E10)", + "SUM(A1.:.E10)", + "SUM(A:.A)", + "SUM($A.:.$C)", + "ROWS(1:.5)", + "ROWS($1.:$5)", + "ROWS(1.:.5)", + "Sheet2!$A$1:.B9", + "Q1.2026!A1.:.B2", + "'Q1 2026'!A1:.B2", + "SUM(A1:.A9,B1.:B9)", + "A1:.A9*2", + ] { + assert_eq!(rt(f), f, "round trip of {f}"); + } + let Expr::Call(_, args) = parse("SUM(A1:.E10)").unwrap() else { panic!() }; + assert!(matches!(args.as_slice(), [Expr::Unary(UnOp::Trim(TrimMode::Trailing), r)] if matches!(**r, Expr::Ref(_)))); + // Decimals, row ranges and dotted names keep their meaning. + assert_eq!(parse("1.5+.5").unwrap(), Expr::Binary(BinOp::Add, Box::new(Expr::Number(1.5)), Box::new(Expr::Number(0.5)))); + for f in ["SUM(1:5)", "Q1.2026!A1:B2", "my.name+1", "SUM(A1:B2)*1.5", "1.:5"] { + assert_eq!(rt(f), f); + } + assert_eq!(rt("'Q1.2026'!A1:B2"), "Q1.2026!A1:B2"); + assert!(matches!(parse("Q1.2026!A1:B2").unwrap(), Expr::Ref(_))); + // File call forms. + assert_eq!(rt("SUM(_xlfn._TRO_TRAILING(A1:E10))"), "SUM(A1:.E10)"); + assert_eq!(rt("_xlfn._TRO_LEADING(A:A)"), "A.:A"); + assert_eq!(rt("_xlfn._TRO_ALL(Sheet1!A1:B2)"), "Sheet1!A1.:.B2"); + // Not a range: printed as the equivalent call. + assert_eq!(rt("_xlfn._TRO_ALL(B2)"), "TRIMRANGE(B2,3,3)"); + for bad in ["A1.:", ".:A1", "A1:.:B2", "A1.:.:B2"] { + assert!(parse(bad).is_err(), "{bad} should fail"); + } + // R1C1 keeps the operator. + assert_eq!(print_r1c1(&parse("SUM(A1:.B2)+ROWS(2.:2)").unwrap(), gridcraft_core::CellRef::new(0, 0)), "SUM(RC:.R[1]C[1])+ROWS(R[1].:R[1])"); +} + +#[test] +fn trim_references_adjust() { + let ins = Edit::Insert { axis: Axis::Rows, at: 2, count: 2 }; + let e = parse("SUM(A1:.A5)+SUM(B:.B)+ROWS(2.:.4)").unwrap(); + assert_eq!(print(&adjust(e, "S", "S", &ins)), "SUM(A1:.A7)+SUM(B:.B)+ROWS(2.:.6)"); + let del = Edit::Delete { axis: Axis::Cols, at: 0, count: 1 }; + let e = parse("SUM(A1.:C1)+SUM(A:.A)").unwrap(); + assert_eq!(print(&adjust(e, "S", "S", &del)), "SUM(A1.:B1)+SUM(#REF!)"); + assert_eq!(print(&shift_relative(parse("SUM(A1.:.B2)").unwrap(), 1, 1)), "SUM(B2.:.C3)"); + let refs: Vec = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect(); + assert_eq!(refs, ["Sheet2!A1:B5"]); +} diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index dd18ca36b..767f1346a 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -213,7 +213,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, { let mut end = toks[j].end; let mut range = RangeRef::cell(c); - if let (Some(Tok::Colon), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok)) + if let (Some(Tok::Colon | Tok::Trim(_)), Some(Tok::Word(w2))) = (toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok)) && let Some(c2) = CellRef::parse(w2) { range = RangeRef::new(c, c2); diff --git a/crates/xlsx/src/array_formula.rs b/crates/xlsx/src/array_formula.rs index d49533e71..caa5e8e9f 100644 --- a/crates/xlsx/src/array_formula.rs +++ b/crates/xlsx/src/array_formula.rs @@ -83,7 +83,8 @@ impl Scan<'_> { let child = self.expr(x, sheet, array_context, depth + 1); match op { UnOp::At => Shape { array: false, affected: child.affected }, - UnOp::Spill => Shape { array: true, affected: child.affected }, + // A trim reference is a reference operator, like `:`. + UnOp::Spill | UnOp::Trim(_) => Shape { array: true, affected: child.affected }, _ => Shape { array: child.array, affected: child.affected || (child.array && !array_context) }, } } diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index c48341462..1526498eb 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -80,6 +80,13 @@ fn expr_to_file(e: Expr) -> String { Expr::Unary(gridcraft_formula::UnOp::Spill, r) => Expr::Call("_xlfn.ANCHORARRAY".into(), vec![*r]), // Nor an `@` operator: Excel writes `@x` as `_xlfn.SINGLE(x)`. Expr::Unary(gridcraft_formula::UnOp::At, x) => Expr::Call("_xlfn.SINGLE".into(), vec![*x]), + // Nor trim-reference operators: written as the equivalent `TRIMRANGE(range,n,n)`, which + // every Excel with trim references reads. (Excel's own file spelling of `A1:.E10` is + // not publicly documented; the `_xlfn._TRO_*` call forms are accepted on read.) + Expr::Unary(gridcraft_formula::UnOp::Trim(mode), x) => { + let code = Expr::Number(f64::from(mode.code())); + Expr::Call("_xlfn.TRIMRANGE".into(), vec![*x, code.clone(), code]) + } other => other, }); gridcraft_formula::print(&e) @@ -126,6 +133,18 @@ mod tests { assert_eq!(from_file("SUM(_xlfn.SINGLE(A1:A3)*B1:B3)"), "SUM(@A1:A3*B1:B3)"); } + #[test] + fn trim_references() { + assert_eq!(to_file(&Formula::new("SUM(A1:.E10)+SUM(Data!A.:A)")), "SUM(_xlfn.TRIMRANGE(A1:E10,2,2))+SUM(_xlfn.TRIMRANGE(Data!A:A,1,1))"); + assert_eq!(text_to_file("=ROWS(1.:.5)"), "ROWS(_xlfn.TRIMRANGE(1:5,3,3))"); + assert_eq!(from_file("SUM(_xlfn._TRO_TRAILING(A1:E10))+_xlfn._TRO_ALL($A:$B)"), "SUM(A1:.E10)+$A.:.$B"); + } + + #[test] + fn unbracketed_this_row_column() { + assert_eq!(from_file("Sales[[#This Row],Units]*[[#This Row],Price]"), "Sales[@Units]*[@Price]"); + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); diff --git a/docs/function-parity.md b/docs/function-parity.md index 941273cfa..85249c6aa 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -60,10 +60,6 @@ PIVOTBY, REGEXTEST…) are all present. | FORECAST.ETS family (4) | yes | 4–6 h | AAA exponential smoothing with seasonality detection; needed for Forecast Sheet too | | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | yes | 2–4 h | Odd first/last period bond maths | | GETPIVOTDATA | yes | 2–3 h | Needs the pivot engine's result cache; also the "Generate GetPivotData" option | -| TRIMRANGE, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators | -| TRIMRANGE, PERCENTOF | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators | -| PERCENTOF, BAHTTEXT | yes | 1–2 h | | -| Trim references (`A1.:.E10`, `A1:.E10`, `A1.:E10`) | yes | 1–2 h | TRIMRANGE works; the operators need lexer support (`.` is a name character) | | IMAGE | yes | 3–5 h | Pictures in cells (see PR #105); fetching URLs needs a privacy decision | | CUBE* (7), WEBSERVICE, FILTERXML | partly | 4–8 h | FILTERXML can work offline; CUBE needs an OLAP/data-model source | | TRANSLATE, DETECTLANGUAGE, STOCKHISTORY, COPILOT, PY | no (cloud) | — | Microsoft cloud services; open equivalents only by owner decision | @@ -75,10 +71,11 @@ PIVOTBY, REGEXTEST…) are all present. |---|---| | A1 and R1C1 references, absolute/relative/mixed, F4 cycling | done | | 3-D references (`Sheet1:Sheet3!A1`) | done; endpoint-deletion bug (#140) | -| Structured references (`Table1[Col]`, `[#Totals]`, `@`) | done | +| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done | | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | +| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | @@ -111,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds |---|---|---| | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | +| 2026-10-11 | minor | Trim-reference operators (`:.`, `.:`, `.:.`) and unbracketed `[[#This Row],Col]` done; dropped the stale "Scope of what's missing" rows for TRIMRANGE, PERCENTOF, BAHTTEXT and the trim references | | 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working | | 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working | | 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working | diff --git a/docs/gaps.md b/docs/gaps.md index db71522bc..7b89a9182 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -115,7 +115,6 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. - 20 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 17–29 h. - 21 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. -- 21 missing, 9 stubs; trim-reference operators (`A1:.E10`) not parsed; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. [function-parity.md](function-parity.md) @@ -160,6 +159,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| | 2026-10-11 | minor | §6: iterative calculation for circular references landed (Maximum Iterations, Maximum Change) | +| 2026-10-11 | minor | §15: trim-reference operators (`A1:.E10`, `A1.:E10`, `A1.:.E10`) parse and evaluate | | 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) | | 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) | | 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) |