mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
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:
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user