Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,16 @@ fail-fast = false

[profile.ci.junit]
path = "junit.xml"

# Surface the conformance runners' stdout even when they pass.
#
# Each TSV runner prints how many of its rows it actually enforced and how many
# it skipped, and why (core#767: a row the runner skips looks enforced and
# asserts nothing). nextest captures a passing test's output by default, so
# without this the accounting is invisible in CI — the only place it matters.
# Scoped by binary *id*, not name: `binary(conformance)` matches that binary
# name in every package, so any crate adding a `tests/conformance.rs` would
# silently inherit this.
[[profile.ci.overrides]]
filter = 'binary_id(truecalc-core::conformance)'
success-output = 'final'
12 changes: 12 additions & 0 deletions crates/core/src/eval/coercion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ pub fn to_number(v: Value) -> Result<f64, Value> {
// Zoned instants have no naive numeric value; force an explicit TZSERIAL
// downcast rather than silently mixing naive/aware time.
Value::Zoned(_) => Err(Value::Error(ErrorKind::Value)),
// Arithmetic rejects a sparkline: `=SPARKLINE({1,2,3})+1` is `#VALUE!`
// (google.tsv). `N()` (0) and the aggregates (which skip it) do not come
// through here.
Value::Sparkline(_) => Err(Value::Error(ErrorKind::Value)),
}
}

Expand All @@ -49,6 +53,12 @@ pub fn to_string_val(v: Value) -> Result<String, Value> {
Value::Array(_) => Err(Value::Error(ErrorKind::Value)),
// Self-describing canonical RFC-9557 form so concatenation is lossless.
Value::Zoned(z) => Ok(z.to_rfc9557()),
// Text contexts are permissive: a sparkline reads as empty text
// (google.tsv: `LEN` is `0`, `LEFT` and `TEXT` and `TEXTJOIN` are `""`,
// `CONCATENATE(sparkline,"x")` is `"x"`, `EXACT(sparkline,"")` is
// `TRUE`). The `&` *operator* is the one carved-out exception and
// rejects it before reaching here — see `eval_binary`.
Value::Sparkline(_) => Ok(String::new()),
}
}

Expand All @@ -74,6 +84,8 @@ pub fn to_bool(v: Value) -> Result<bool, Value> {
Value::Empty => Err(Value::Error(ErrorKind::Value)),
// A zoned instant has no truthiness.
Value::Zoned(_) => Err(Value::Error(ErrorKind::Value)),
// A sparkline is falsy (google.tsv: `=IF(SPARKLINE({1,2,3}),1,2)` is 2).
Value::Sparkline(_) => Ok(false),
// Array condition: use the top-left (anchor) element — same as the
// unspilled-array collapse the workbook layer applies.
Value::Array(mut elems) => {
Expand Down
5 changes: 5 additions & 0 deletions crates/core/src/eval/functions/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,11 @@ fn compare_values_sort(a: &Value, b: &Value) -> std::cmp::Ordering {
(Value::Bool(x), Value::Bool(y)) => x.cmp(y),
// Zone-aware instants sort by the absolute instant.
(Value::Zoned(x), Value::Zoned(y)) => x.utc_nanos.cmp(&y.utc_nanos),
// Sparklines: only equality is observed (google.tsv), and no ordering
// between two different ones is — matching the `=`/`<`/`>` operators and
// their EQ/LT/GT aliases, which report no ordering relation either. The
// sort is stable, so sparklines keep their input order.
(Value::Sparkline(_), Value::Sparkline(_)) => std::cmp::Ordering::Equal,
_ => std::cmp::Ordering::Equal,
}
}
Expand Down
229 changes: 229 additions & 0 deletions crates/core/src/eval/functions/google/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
//! `SPARKLINE(data, [options])` — the in-cell chart function.
//!
//! The engine's job here is parsing and validation, not drawing: a successful
//! call produces a [`Value::Sparkline`] carrying the parsed, validated
//! [`SparklineSpec`], and the consumer renders it.
//!
//! ## What Google Sheets does (conformance fixtures, `google.tsv`)
//!
//! The result is a value kind of its own — `TYPE()` reports `128` (outside
//! `TYPE`'s documented set) and `ISERROR()` is `FALSE`.
//!
//! ## Coercion — where the answers live
//!
//! Three coercion seams carry most contexts, each a single blanket arm in
//! [`crate::eval::coercion`] that every caller of that seam inherits:
//!
//! | seam | a sparkline reads as |
//! |---|---|
//! | `to_number` | `#VALUE!` |
//! | `to_string_val` | `""` |
//! | `to_bool` | `false` |
//!
//! **That is not enough to predict any particular function.** Individual
//! functions carry hand-carved arms that override their seam — `N`, `TEXT`, the
//! `TO_*` family, the `&` operator, and the aggregates — and they are spread
//! across the codebase, so no list here can stay complete. (The aggregates are
//! uniform *as of the rows below*: every one of them skips a sparkline and
//! answers 0 when nothing else is in scope, `AVERAGE` alone answering `#DIV/0!`.
//! That uniformity is a fact about the probed set, not a rule to extend from —
//! `MINA` had no arm at all until a row asked for one.)
//!
//! The record is `tests/fixtures/google_sheets/google.tsv`, every row of it
//! observed in live Google Sheets and the only authority here. This function
//! contributes **114** of that file's **128** data rows (the other 14 are
//! ARRAYFORMULA's, and a 115th recorded row sits in `bugs.tsv` — see below).
//! `tests/sparkline.rs` asserts the rows the TSV runner skips.
//!
//! One recorded row is a known engine divergence rather than a passing case:
//! `=MIN(SPARKLINE({1,2,3}),{})` is `#REF!` in Sheets and `0` here, because
//! `MIN` has no empty-array rule at all (`=MIN({})` is `0` on main, while
//! `=MAX({})` is `#REF!`). That gap predates sparklines and is unrelated to
//! them, so it lives in `bugs.tsv` awaiting its own fix rather than being
//! patched from here.
//!
//! Why not reason it out instead: `DOLLAR(sparkline)` is `#VALUE!` while
//! `TO_DOLLARS(sparkline)` is `""`. Near-identical names, both format a number
//! as currency text, opposite answers.
//!
//! So: **if you need the answer for a function, find its row. If it has no row,
//! probe it.** Do not infer one from its seam or from a function that resembles
//! it.
//!
//! Validation splits into three distinct error classes:
//!
//! - `#N/A` — the arity/shape of `data`: no arguments, a scalar instead of a
//! range, or a single value.
//! - `#REF!` — structural malformation: an empty array, or an `options` array
//! that is not key/value pairs.
//! - `#VALUE!` — a bad option *value* (an unrecognised `charttype`).
//!
//! An unrecognised option **key** is *not* an error — Sheets ignores it, which
//! is what lets a workbook written against a newer option set still evaluate.
//! A genuine blank cell inside the source range is likewise fine: it renders.

use crate::display::display_number;
use crate::eval::functions::{check_arity, FunctionMeta, Registry};
use crate::types::{ErrorKind, SparklineChartType, SparklineSpec, SparklineValue, Value};

/// The `charttype` option key, lifted out of the generic option list.
const CHART_TYPE_KEY: &str = "charttype";

/// Convert one evaluated cell into a plotted point / option value.
///
/// `Zoned` and a nested sparkline have no Google Sheets analogue, so there is no
/// ground truth for them; they are rejected with `#VALUE!` rather than given an
/// invented projection. Those two arms are reachable only for a cell *inside*
/// an array — a sparkline handed straight to `data` never gets here, because
/// [`parse_data`]'s non-array check answers `#N/A` first (the same answer as any
/// other scalar `data` argument).
fn to_sparkline_value(v: &Value) -> Result<SparklineValue, Value> {
match v {
Value::Number(n) | Value::Date(n) => Ok(SparklineValue::number(*n)),
Value::Text(s) => Ok(SparklineValue::Text(s.clone())),
Value::Bool(b) => Ok(SparklineValue::Bool(*b)),
Value::Empty => Ok(SparklineValue::Blank),
Value::Error(_) | Value::ErrorMsg(_, _) => Err(v.clone()),
Value::Array(_) | Value::Zoned(_) | Value::Sparkline(_) => {
Err(Value::Error(ErrorKind::Value))
}
}
}

/// Flatten a (possibly row-nested) array into its cells, row-major.
fn flatten<'a>(v: &'a Value, out: &mut Vec<&'a Value>) {
match v {
Value::Array(elems) => elems.iter().for_each(|e| flatten(e, out)),
other => out.push(other),
}
}

/// Parse and validate the `data` argument.
///
/// `#N/A` for a scalar or a single value, `#REF!` for an empty array.
fn parse_data(v: &Value) -> Result<Vec<SparklineValue>, Value> {
if !matches!(v, Value::Array(_)) {
return Err(Value::Error(ErrorKind::NA));
}
let mut cells = Vec::new();
flatten(v, &mut cells);
match cells.len() {
0 => return Err(Value::Error(ErrorKind::Ref)),
1 => return Err(Value::Error(ErrorKind::NA)),
_ => {}
}
cells.iter().map(|c| to_sparkline_value(c)).collect()
}

/// The option-key projection of a cell. Keys are matched case-insensitively,
/// so they are stored ASCII-lower-cased.
fn option_key(v: &Value) -> Result<String, Value> {
let key = match v {
Value::Text(s) => s.clone(),
Value::Number(n) | Value::Date(n) => display_number(*n),
Value::Bool(b) => (if *b { "TRUE" } else { "FALSE" }).to_string(),
Value::Empty => String::new(),
Value::Error(_) | Value::ErrorMsg(_, _) => return Err(v.clone()),
Value::Array(_) | Value::Zoned(_) | Value::Sparkline(_) => {
return Err(Value::Error(ErrorKind::Ref))
}
};
Ok(key.to_ascii_lowercase())
}

/// Split the `options` argument into key/value pairs.
///
/// Anything that is not a well-formed pair list — a scalar, an empty array, a
/// row that is not two cells wide, an odd-length flat array — is `#REF!`.
fn option_pairs(v: &Value) -> Result<Vec<(&Value, &Value)>, Value> {
let Value::Array(elems) = v else {
return Err(Value::Error(ErrorKind::Ref));
};
if elems.is_empty() {
return Err(Value::Error(ErrorKind::Ref));
}
let rows = elems.iter().filter(|e| matches!(e, Value::Array(_))).count();
if rows == elems.len() {
// `{"charttype","line";"color","red"}` — one key/value pair per row.
let mut pairs = Vec::with_capacity(elems.len());
for row in elems {
let Value::Array(cells) = row else { unreachable!() };
if cells.len() != 2 {
return Err(Value::Error(ErrorKind::Ref));
}
pairs.push((&cells[0], &cells[1]));
}
Ok(pairs)
} else if rows == 0 {
// `{"bogus","x"}` — a single flat row of key/value cells.
if elems.len() % 2 != 0 {
return Err(Value::Error(ErrorKind::Ref));
}
Ok(elems.chunks(2).map(|p| (&p[0], &p[1])).collect())
} else {
// Rows mixed with bare cells is not a pair list.
Err(Value::Error(ErrorKind::Ref))
}
}

/// `SPARKLINE(data, [options])` — build the render spec for an in-cell chart.
pub fn sparkline_fn(args: &[Value]) -> Value {
if let Some(err) = check_arity(args, 1, 2) {
return err;
}

let data = match parse_data(&args[0]) {
Ok(data) => data,
Err(e) => return e,
};

let mut chart_type = SparklineChartType::Line;
let mut options = Vec::new();
if let Some(raw_options) = args.get(1) {
let pairs = match option_pairs(raw_options) {
Ok(pairs) => pairs,
Err(e) => return e,
};
for (raw_key, raw_value) in pairs {
let key = match option_key(raw_key) {
Ok(key) => key,
Err(e) => return e,
};
let value = match to_sparkline_value(raw_value) {
Ok(value) => value,
Err(e) => return e,
};
if key == CHART_TYPE_KEY {
// A bad option *value* is `#VALUE!` (a bad option *key* is not
// an error at all — it lands in `options` untouched).
let SparklineValue::Text(ref name) = value else {
return Value::Error(ErrorKind::Value);
};
match SparklineChartType::parse(name) {
Some(t) => chart_type = t,
None => return Value::Error(ErrorKind::Value),
}
} else {
options.push((key, value));
}
}
}

Value::Sparkline(Box::new(SparklineSpec {
chart_type,
data,
options,
}))
}

pub fn register_google(registry: &mut Registry) {
registry.register_eager(
"SPARKLINE",
sparkline_fn,
FunctionMeta {
category: "google",
signature: "SPARKLINE(data, [options])",
description: "Miniature in-cell chart over a range, as a render spec",
},
);
}
8 changes: 7 additions & 1 deletion crates/core/src/eval/functions/logical/info/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub fn n_fn(args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
Value::Number(n) | Value::Date(n) => Value::Number(n),
Value::Bool(b) => Value::Number(if b { 1.0 } else { 0.0 }),
Value::Empty | Value::Text(_) | Value::Array(_) => Value::Number(0.0),
// `=N(SPARKLINE({1,2,3}))` is 0 (google.tsv) even though arithmetic on
// a sparkline is `#VALUE!` — the asymmetry is Sheets', not ours.
Value::Sparkline(_) => Value::Number(0.0),
Value::Zoned(_) => Value::Error(ErrorKind::Value),
Value::Error(_) | Value::ErrorMsg(_, _) => val,
}
Expand All @@ -72,6 +75,9 @@ pub fn type_fn(args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
Value::Array(_) => 64.0,
Value::Empty => 1.0, // Excel treats empty as number
Value::Zoned(_) => 1.0, // classify like Number/Date for the TYPE code
// 128 is outside TYPE's documented set (1/2/4/16/64): Sheets reports a
// sparkline as a value kind of its own (google.tsv).
Value::Sparkline(_) => 128.0,
};
Value::Number(code)
}
Expand Down Expand Up @@ -152,7 +158,7 @@ pub fn cell_fn(args: &[Expr], ctx: &mut EvalCtx<'_>) -> Value {
Value::Zoned(_) => Value::Text("n".to_string()),
Value::Error(e) => Value::Error(e),
Value::ErrorMsg(e, m) => Value::ErrorMsg(e, m),
Value::Array(_) => Value::Error(ErrorKind::NA),
Value::Array(_) | Value::Sparkline(_) => Value::Error(ErrorKind::NA),
}
}
"col" => {
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/eval/functions/math/average/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub fn average_fn(args: &[Value]) -> Value {
}
}
Value::Empty => {} // skip
// Aggregates skip a sparkline (google.tsv: SUM/MAX skip it).
Value::Sparkline(_) => {}
Value::Zoned(_) => return Value::Error(ErrorKind::Value),
Value::Error(_) | Value::ErrorMsg(_, _) => return arg.clone(),
Value::Array(elems) => {
Expand Down
10 changes: 10 additions & 0 deletions crates/core/src/eval/functions/math/countunique/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ enum UniqueKey {
Text(String), // case-sensitive per GS
Bool(bool),
ErrorVal(String), // GS: errors are counted as unique, not propagated
/// A sparkline, keyed by its whole parsed spec. `=` reports any two
/// sparklines equal, but COUNTUNIQUE does not — google.tsv records
/// `=COUNTUNIQUE(SPARKLINE({1,2,3}),SPARKLINE({9,9,9}))` as 2 and the same
/// call on two identical sparklines as 1, so uniqueness keys off something
/// deeper than the comparison operator does. Every option the spec kept
/// participates, recognised or not: an unrecognised key is not dropped, so
/// `{"bogus","x"}` and `{"bogus","y"}` count as two, exactly like two
/// different values of a recognised key would.
SparklineVal(String),
}

fn to_unique_key(v: &Value) -> Option<UniqueKey> {
Expand All @@ -22,6 +31,7 @@ fn to_unique_key(v: &Value) -> Option<UniqueKey> {
Value::ErrorMsg(e, _) => Some(UniqueKey::ErrorVal(format!("{e:?}"))),
Value::Date(_) | Value::Array(_) => None,
Value::Zoned(_) => None,
Value::Sparkline(spec) => Some(UniqueKey::SparklineVal(format!("{spec:?}"))),
}
}

Expand Down
Loading
Loading