Merge pull request #420 from neilcawse/fix/structref

Formulas: trim-reference operators (A1:.E10, A1.:E10, A1.:.E10); read unbracketed [[#This Row],Col]

# Conflicts:
#	docs/function-parity.md
#	docs/gaps.md
This commit is contained in:
Brandon Thomas
2026-10-11 18:05:40 -04:00
14 changed files with 393 additions and 54 deletions
+4 -1
View File
@@ -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,
))
+39
View File
@@ -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));
}
+21 -2
View File
@@ -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<Ev, CellError> {
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 {
+32
View File
@@ -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)]
+54 -6
View File
@@ -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<char>) -> bool {
c.is_some_and(|c| c.is_ascii_alphanumeric() || c == '$')
}
/// Tokenizes formula text (without the leading `=`).
pub fn tokenize(src: &str) -> Result<Vec<Token>, LexError> {
tokenize_locale(src, crate::FormulaLocale::En)
@@ -178,6 +196,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
push(&mut out, Tok::Struct(inner), j);
i = j;
}
'.' if ch(i + 1) == Some(':') => {
// 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<Vec<To
while ch(j).is_some_and(|d| d.is_ascii_digit()) {
j += 1;
}
let is_row_ref = ch(j) == Some(':') && {
let mut k = j + 1;
if ch(k) == Some('$') {
// Row ranges `1:3`, and the trim forms `1.:3`, `1:.3`, `1.:.3`.
let is_row_ref = {
let mut k = j;
if ch(k) == Some('.') && ch(k + 1) == Some(':') {
k += 1;
}
ch(k).is_some_and(|d| d.is_ascii_digit())
ch(k) == Some(':') && {
k += 1;
if ch(k) == Some('.') {
k += 1;
}
if ch(k) == Some('$') {
k += 1;
}
ch(k).is_some_and(|d| d.is_ascii_digit())
}
};
// A word like "1A" can't happen in valid formulas; numbers then.
if is_row_ref {
@@ -252,8 +290,13 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
i += 1;
}
':' => {
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<Vec<To
} else if w.eq_ignore_ascii_case("FALSE") && ch(j) != Some('[') {
push(&mut out, Tok::Bool(false), j);
i = j;
} else if w.len() > 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;
+1 -1
View File
@@ -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};
+99 -30
View File
@@ -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<Expr, ParseError> {
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<StructRef, String> {
let mut specifiers = Vec::new();
let mut cols: Vec<String> = 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<String, String> {
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 })
}
+36 -6
View File
@@ -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<CellRef>) {
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);
+81
View File
@@ -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<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
assert_eq!(refs, ["Sheet2!A1:B5"]);
}
+1 -1
View File
@@ -213,7 +213,7 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
{
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);
+2 -1
View File
@@ -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) },
}
}
+19
View File
@@ -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)");
+3 -5
View File
@@ -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 `<calcPr iterate iterateCount iterateDelta>`), 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 |
+1 -1
View File
@@ -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) |