Merge pull request #81 from fiddlerwoaroof/wave/core-formula-io

core/formula/io: a duplicate-section round trip, the WHERE word boundary, and a parameter that promised a filter
This commit is contained in:
Ed Langley
2026-09-20 21:31:04 -07:00
committed by GitHub
17 changed files with 319 additions and 49 deletions
+23
View File
@@ -39,6 +39,29 @@ differences are the whole content of the design (improvise-kdp):
for `SUM`/`COUNT`, which have an identity, and an error for
`AVG`/`MIN`/`MAX`, which do not.
### `evaluate_aggregated` takes `Aggregation`, not a list it cannot read
The parameter was `none_cats: &[String]`, and the body consulted it exactly
once, for emptiness — a boolean wearing a list's type, since the aggregation
itself runs over `key.0` through `matching_values`. It never filtered by that
list, in any revision: `git log -S` back to the commit that introduced
`Axis::None` shows the same `is_empty()` and nothing else. So the name promised
a filter the body has never performed, and the next caller to build a careful
list would have threaded it into a parameter that cannot use it.
`Aggregation::{Exact, OverHidden}` admits only the two states the body
distinguishes, and `Aggregation::of(none_cats)` performs the reduction once, at
the boundary that holds the list (improvise-5jm5,
`doctrine(an_invariant_is_made_unrepresentable_not_validated)` — the same answer
`prune_axis_items` and `gp-tree`'s `Row<T>` got). It is an enum rather than a
`bool` because the two states have names worth reading at the call site, and the
regression guard is the **signature**: the thing the ticket describes — two
different lists, same emptiness, same answer — is no longer constructible.
Note what does *not* change: `recompute_formulas` and `aggregate_raw` keep
`&[String]`, because they walk the elements (stripping each hidden coordinate
off a stem). Two parameters that looked alike were doing two different jobs.
### Non-numeric values reduce by unanimity, and the branch order is the rule
`aggregate_cell_values` checks the **all-numeric branch first**. If every value
+49 -1
View File
@@ -92,6 +92,28 @@ round-tripped from the day it exists.
What the *evaluator* does with it, and why a fitted tree is always wrapped in a
`SUM`, is `context/code/core.md` §13.
### `WHERE` carries the same boundary, and the mechanism has to be generatable
`where_clause` and `inline_where` are compound-atomic with `sp+` after the
keyword, exactly as `is_expr` is (improvise-r08u). Non-atomic, pest auto-skipped
`WHITESPACE` after `^"WHERE"`, so `SUM(Rev WHEREAS x = y)` read as a `WHERE` on
a category `AS` — a silently wrong filter where it should have been a syntax
error, the same class of defect `IS` had.
Two things decided the shape, and the second is the one worth carrying. A
negative lookahead `!bare_char` with `sp*` would have been *strictly* more
permissive — it keeps `WHERE|Region|=East`, which the old rule accepted — but
**the grammar-walking generator cannot honour a predicate**: it walks
`formula.pest` through `pest_meta` and emits `WHEREa=""`, text the grammar then
refuses, so `generated_formula_parses` goes red. A rule whose only guard is a
lookahead is a rule the generator can violate. `sp+` is generatable, matches
`IS`, and costs exactly one accepted spelling: a category may no longer abut the
keyword, so `WHERE |Region|` needs its space.
Note compound-atomicity *alone* closes the ticket's own reproducer, because
`AS x` cannot be one identifier once implicit whitespace is gone. `sp+` is what
closes the residue, `WHEREAS=y`, and the negative test names both.
## A formula target is unquoted, exactly as a reference is
`parse_formula` strips the pipes off the target the same way it does off an
@@ -158,6 +180,18 @@ format order-independent — a `## View:` may name a category defined further
down. `PCategory::items` is a `Vec` in file order because `IndexMap` insertion
order is load-bearing (gotcha 18), so pass 2 must replay the list as written.
Two `## Category:` sections naming one category are **one** category, and the
merge is a concatenation performed in pass 1 (`ParsedFile::push_category`,
improvise-349m). Merging in pass 2 — building each section in turn — was not
idempotent. A group is created either by the item that names it or by a `>`
line, and `build_category` creates its own section's item-groups *before* its
own `>` lines; so the first section's `>` groups landed ahead of the second
section's item-groups, while `format_md` writes one section with all the items
before all the `>` lines. The reparse then built the other order, and the two
`format_md` outputs differed. Concatenating first makes the built order the
written order by construction. Refusing a duplicate header was the alternative
and would have made a legal hand-written file an error for no gain.
### A section header takes its name as the whole line, totally
`header_name = { pipe_quoted ~ &NEWLINE | line_name }` (improvise-pczv). The
@@ -390,7 +424,21 @@ and the derived `_Month`/`_Quarter` categories inherit the error. What makes it
tolerable is that the wizard shows the detected format and lets the user change
it — the same argument two-digit years already rested on. It is pinned by a test
so that changing the answer is a decision somebody makes rather than a side
effect of reordering the table. Filed as **improvise-l8rh**.
effect of reordering the table.
**Ruled a known limit** (improvise-l8rh, the user, 2026-09-20). Two things the
ruling rests on. Changing the default to day-first only moves which half of the
world imports silently wrong; and the honest fix is larger than the heuristic —
an ambiguous column should *surface* its ambiguity rather than resolve it, which
changes `FieldProposal`'s shape and the wizard step that displays it, the way
`Dataset::warnings` returns a decision the user should hear about as data. Worth
doing deliberately, not as a patch.
What makes the limit reasonable to live with is that it is now *deterministic*:
`analyze_records` iterated a `HashSet` until improvise-7yx, so the ten samples
`detect_date_format` inspects were a different ten every run and a column whose
eleventh value ruled out `%m/%d/%Y` detected a different format run to run. The
ten-sample window itself is unchanged and is part of the limit.
### CSV parsing
+15 -9
View File
@@ -145,11 +145,18 @@ Escapes inside pipes: `\|` (literal pipe), `\\` (backslash), `\n` (newline).
This convention is shared between the `.improv` persistence format and the
formula parser's identifier syntax.
### Formula Tokenizer: Identifiers and Quoting
### Formula Identifiers and Quoting
**Bare identifiers** support multi-word names (e.g., `Total Revenue`) by
allowing spaces when followed by non-operator, non-keyword characters. Keywords
(`WHERE`, `SUM`, `AVG`, `MIN`, `MAX`, `COUNT`, `IF`) act as token boundaries.
**Bare identifiers** are `(ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")*`
— no spaces, the same shape `improv.pest`'s `bare_name` has. A multi-word name
such as `Total Revenue` must be pipe-quoted.
**Keywords need a word boundary after them, not before.** `IS`, `WHERE` and the
aggregate names are spelled `^"…"` in `formula.pest`, and `is_expr`/`where_clause`/
`inline_where` are compound-atomic with `sp+` so that `Region ISLAND` and
`SUM(Rev WHEREAS x = y)` are parse errors rather than silently-wrong filters. A
`SUM`/`IF` without parens falls through to `ref_expr`, which is why a bare
keyword still names an item; the writer quotes it anyway (`quote::quote_name`).
**Pipe-quoted identifiers** (`|...|`) allow any characters — including spaces,
keywords, and operators — inside the delimiters. Use pipes when a category or
@@ -162,11 +169,10 @@ item name collides with a keyword or contains special characters:
SUM(|Net Revenue| WHERE |Region Name| = |East Coast|)
```
Pipes produce `Token::Ident` (same as bare identifiers), so they work
everywhere an identifier is expected: expressions, aggregate arguments, WHERE
clause category names and filter values. Double-quoted strings (`"..."`)
remain `Token::Str` and are used only for WHERE filter values in the
`split_where` pre-parse step.
`identifier` is `pipe_quoted | bare_ident`, so a pipe-quoted name works
everywhere a bare one does: expressions, aggregate arguments, and a `WHERE`
clause's category name. A double-quoted string is a `filter_value` only — the
right-hand side of a `WHERE` or an `IS`, never an expression.
---
+14
View File
@@ -12,6 +12,20 @@ none.
## `model/types.rs`
### a_hidden_dimension_list_reduces_to_one_of_two_states
improvise-5jm5. `evaluate_aggregated` took `none_cats: &[String]` and consulted
it once, for emptiness — so two lists with different contents and the same
emptiness gave the same answer, while the name promised a filter. Nothing
misbehaved; the signature described something the body never did.
This test is the *smaller* half of the guard and says so. The real guard is the
signature: the defect the ticket describes is no longer constructible, exactly
as `Checked`'s constructor is the guard for the basis check rather than a test
that can fail. What this test does add is that `Aggregation::of` is the only
place the list is read, and that a list of two hidden dimensions is not somehow
a third state — the reduction is emptiness, and the enum is not a count.
### apply_agg_over_nothing_is_absent_for_every_function
An empty cell must read as *absent*, not as zero — otherwise every blank
+56 -7
View File
@@ -203,6 +203,33 @@ backend from quietly normalising or re-encoding what it is handed.
## `improvise-io/src/persistence/parse.rs`
### two_sections_naming_one_category_round_trip_to_a_stable_group_order
improvise-349m: `format_md(parse_md(text))` was not idempotent when a file
carried two `## Category:` sections with the same name — the two outputs
differed only in the order of the `>` group lines. It looked like a flake
because only `grammar_prop_tests::generated_file_roundtrips` saw it, and only
when the generator happened to emit a duplicate header; taking proptest's
minimal input and running it deterministically is what turned it into a bug.
The fixture is the smallest shape that shows it: a first section defining a
group by a `>` line only, and a second whose *item* names a different group that
also gets a `>` line. Both groups must have parents, or `format_md` writes only
one `>` line and the differing internal order is invisible in the text — which
is the trap when shrinking this fixture further. Fault injected: reverting
`ParsedFile::push_category` to a plain `Vec::push` fails it, and the ticket's own
minimal input was checked against the fix as well.
**Stability is only half of what the merge owes, and the first version of this
test could see only that half.** Adversarial review injected the other fault —
`Some(_existing) => {}`, which *drops* the duplicate section outright — and the
whole `improvise-io` suite stayed green at 2000 proptest cases while the
fixture's `- X[G2]` and `> G2[P2]` vanished from the document without a word.
Main's behaviour was unstable-but-lossless; a test that compares two printed
forms cannot tell that from stable-and-lossy, because both sides of the equality
lose the same thing. The three `contains` assertions run *before* the equality
for exactly that reason, and they fail under the drop.
### parse_md_formula_category_suffix_without_space
Bug improvise-zgb (b). The formulas walker located the `[Category]` suffix with
@@ -444,9 +471,10 @@ can override it. The same argument as the ambiguity below.
nothing in the data can tell them apart. The heuristic is first-match over
`DATE_FORMATS`, so the American reading wins — a European CSV imports with its
days and months swapped, silently, and the derived `_Month`/`_Quarter`
categories inherit the error. Filed as **improvise-l8rh**; pinned here so that
changing the answer is a decision somebody makes rather than a side effect of
reordering the table.
categories inherit the error. **improvise-l8rh ruled this a known limit** rather
than a defect, so this test is what makes changing the answer a decision
somebody makes rather than a side effect of reordering the table. The argument
is in `context/code/formula-and-io.md`.
The loop at the end is the precondition that makes this a statement about
*ambiguity* rather than about one format merely fitting: the other reading has
@@ -646,7 +674,28 @@ reopens the hole; that substitution is what this test was finally proved
against. The negative arm is paired with a positive one, or "reject everything"
would pass.
`^"WHERE"` has the identical hole and was deliberately **not** fixed here:
pre-existing, far less exposed (it sits in one fixed position after an
expression, where `IS` follows any identifier in expression position), and a
grammar change with its own round-trip risk. Filed as improvise-r08u.
`^"WHERE"` had the identical hole. It is closed now — see below.
### where_does_not_swallow_the_start_of_a_longer_word
improvise-r08u, the `WHERE` half of the same defect. `where_clause` and
`inline_where` were ordinary rules, so pest auto-inserted `WHITESPACE` after
`^"WHERE"` and `SUM(Rev WHEREAS x = y)` parsed as a `WHERE` on a category `AS`:
a silently wrong filter where it should have been a syntax error.
The test carries **two** negative shapes because the repair has two independent
halves, and this was found by injecting each separately. Making the rules
compound-atomic alone already refuses `WHEREAS x = y` — once implicit whitespace
is gone, `AS x` cannot be one identifier — so a test built only on the ticket's
reproducer passes over a rule still spelled `sp*`, and `WHEREAS=y` is what that
rule still reads as a filter. Faults injected: the original non-atomic rule
(fails on the first assertion) and `sp+` relaxed to `sp*` (fails on the second).
The positive arms are there so "refuse every WHERE" cannot pass.
### where_requires_a_space_after_the_keyword
The one spelling `sp+` costs, stated rather than discovered later:
`WHERE|Region Name|=|East Coast|` used to parse and now does not. `IS` has
required its space since improvise-4zl7, so this is the two keywords agreeing.
The paired positive arm — the same text with the space — is what stops it
degrading into "any WHERE with a quoted category fails".
@@ -1,3 +1,4 @@
use crate::model::Aggregation;
use crate::ui::effect::{self, Effect};
use super::core::{Cmd, CmdContext};
@@ -140,7 +141,9 @@ impl Cmd for YankCell {
"yank"
}
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
let value = ctx.model.evaluate_aggregated(&self.key, ctx.none_cats());
let value = ctx
.model
.evaluate_aggregated(&self.key, Aggregation::of(ctx.none_cats()));
vec![
Box::new(effect::SetYanked(value)),
effect::set_status("Yanked"),
@@ -1,3 +1,4 @@
use crate::model::Aggregation;
use crate::model::cell::CellValue;
use crate::ui::app::AppMode;
use crate::ui::effect::{self, Effect, Panel};
@@ -112,7 +113,10 @@ impl Cmd for SearchNavigate {
Some(k) => k,
None => return false,
};
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
let s = match ctx
.model
.evaluate_aggregated(&key, Aggregation::of(ctx.none_cats()))
{
Some(CellValue::Number(n)) => format!("{n}"),
Some(CellValue::Text(t)) => t,
Some(CellValue::Error(e)) => format!("ERR:{e}"),
+5 -3
View File
@@ -4,10 +4,10 @@
use std::collections::BTreeMap;
use improvise_core::format::{format_f64, format_value, parse_number_format};
use improvise_core::model::Model;
use improvise_core::model::category::MEASURE_CATEGORY;
use improvise_core::model::cell::CellValue;
use improvise_core::model::record::{AggregateHash, RecordHash};
use improvise_core::model::{Aggregation, Model};
use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol};
use improvise_core::view::{Axis, View};
use improvise_core::workbook::Workbook;
@@ -237,7 +237,9 @@ fn column_totals(
.map(|c| {
let total: f64 = (0..layout.row_count())
.filter_map(|r| layout.cell_key(r, c))
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
.map(|key| {
model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats))
})
.sum();
CellDisplay::number(format_f64(total, comma, decimals))
})
@@ -272,7 +274,7 @@ fn cell_display(
app.model_state
.workbook
.model
.evaluate_aggregated(&key, &layout.none_cats)
.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))
});
CellDisplay {
text: format_value(value.as_ref(), comma, decimals),
+2 -2
View File
@@ -6,7 +6,7 @@ use unicode_width::UnicodeWidthStr;
use improvise_protocol::header_level_widths;
use crate::format::format_f64;
use crate::model::Model;
use crate::model::{Aggregation, Model};
use crate::view::{AxisEntry, GridLayout};
/// Minimum column width — enough for short numbers/labels + 1 char gap.
@@ -62,7 +62,7 @@ pub fn compute_col_widths(
for (ci, wref) in widths.iter_mut().enumerate().take(n) {
let total: f64 = (0..layout.row_count())
.filter_map(|ri| layout.cell_key(ri, ci))
.map(|key| model.evaluate_aggregated_f64(&key, &layout.none_cats))
.map(|key| model.evaluate_aggregated_f64(&key, Aggregation::of(&layout.none_cats)))
.sum();
let s = format_f64(total, fmt_comma, fmt_decimals);
let w = s.width() as u16;
+1 -1
View File
@@ -4,4 +4,4 @@ pub mod record;
pub mod symbol;
pub mod types;
pub use types::Model;
pub use types::{Aggregation, Model};
+63 -16
View File
@@ -200,6 +200,25 @@ fn eval_is(context: &CellKey, filter: &Filter) -> f64 {
.is_some_and(|v| v == filter.item.as_str()) as i32 as f64
}
/// Whether a grid hides dimensions a cell key does not name. The emptiness of
/// the view's `Axis::None` list is the whole question — `context/code/core.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Aggregation {
/// The key names every dimension, so the cell is read as stored.
Exact,
/// Fold every cell the key does not name.
OverHidden,
}
impl Aggregation {
pub fn of(none_cats: &[String]) -> Self {
match none_cats.is_empty() {
true => Aggregation::Exact,
false => Aggregation::OverHidden,
}
}
}
/// Pure-data document model: categories, cells, formulas. It knows nothing
/// about views — see [`crate::workbook::Workbook`] and core.md §10.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -476,8 +495,8 @@ impl Model {
})
}
pub fn evaluate_aggregated(&self, key: &CellKey, none_cats: &[String]) -> Option<CellValue> {
if none_cats.is_empty() {
pub fn evaluate_aggregated(&self, key: &CellKey, agg: Aggregation) -> Option<CellValue> {
if agg == Aggregation::Exact {
return self.evaluate(key);
}
@@ -491,8 +510,8 @@ impl Model {
}
/// [`Self::evaluate_aggregated`] as `f64`, with `0.0` for an empty cell.
pub fn evaluate_aggregated_f64(&self, key: &CellKey, none_cats: &[String]) -> f64 {
self.evaluate_aggregated(key, none_cats)
pub fn evaluate_aggregated_f64(&self, key: &CellKey, agg: Aggregation) -> f64 {
self.evaluate_aggregated(key, agg)
.and_then(|v| v.as_f64())
.unwrap_or(0.0)
}
@@ -815,7 +834,7 @@ mod agg_helper_tests {
#[cfg(test)]
mod model_tests {
use super::Model;
use super::{Aggregation, Model};
use crate::model::cell::{CellKey, CellValue};
fn coord(pairs: &[(&str, &str)]) -> CellKey {
@@ -994,7 +1013,7 @@ mod model_tests {
// With Date hidden, it aggregates to SUM.
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&partial_key, &none_cats);
let result = m.evaluate_aggregated(&partial_key, Aggregation::of(&none_cats));
assert_eq!(result, Some(CellValue::Number(150.0)));
}
@@ -1031,6 +1050,19 @@ mod model_tests {
);
}
#[test]
fn a_hidden_dimension_list_reduces_to_one_of_two_states() {
assert_eq!(Aggregation::of(&[]), Aggregation::Exact);
assert_eq!(
Aggregation::of(&["Date".to_string()]),
Aggregation::OverHidden
);
assert_eq!(
Aggregation::of(&["Date".to_string(), "Payee".to_string()]),
Aggregation::OverHidden
);
}
#[test]
fn evaluate_aggregated_no_hidden_delegates_to_evaluate() {
let mut m = Model::new("Test");
@@ -1039,7 +1071,7 @@ mod model_tests {
m.set_cell(coord(&[("Region", "East")]), CellValue::Number(42.0));
let key = coord(&[("Region", "East")]);
assert_eq!(
m.evaluate_aggregated(&key, &[]),
m.evaluate_aggregated(&key, Aggregation::Exact),
Some(CellValue::Number(42.0))
);
}
@@ -1065,7 +1097,7 @@ mod model_tests {
fn status_of(m: &Model) -> Option<CellValue> {
m.evaluate_aggregated(
&coord(&[("Payee", "Acme"), ("_Measure", "Status")]),
&["Date".to_string()],
Aggregation::of(&["Date".to_string()]),
)
}
@@ -1156,14 +1188,14 @@ mod model_tests {
let key = coord(&[("Payee", "Acme"), ("_Measure", "Price")]);
let none_cats = vec!["Date".to_string()];
let result = m.evaluate_aggregated(&key, &none_cats);
let result = m.evaluate_aggregated(&key, Aggregation::of(&none_cats));
assert_eq!(result, Some(CellValue::Number(20.0))); // avg(10, 30) = 20
}
}
#[cfg(test)]
mod formula_tests {
use super::Model;
use super::{Aggregation, Model};
use crate::formula::parse_formula;
use crate::model::cell::{CellKey, CellValue};
@@ -1715,12 +1747,18 @@ mod formula_tests {
m.recompute_formulas(&none_cats);
// Equality says X is zero…
assert_eq!(
m.evaluate_aggregated(&coord(&[("_Measure", "IsZero")]), &none_cats),
m.evaluate_aggregated(
&coord(&[("_Measure", "IsZero")]),
Aggregation::of(&none_cats)
),
Some(CellValue::Number(1.0))
);
// …so dividing by X must be div/0 in the cached path too.
assert_eq!(
m.evaluate_aggregated(&coord(&[("_Measure", "Ratio")]), &none_cats),
m.evaluate_aggregated(
&coord(&[("_Measure", "Ratio")]),
Aggregation::of(&none_cats)
),
Some(CellValue::Error("div/0".into()))
);
}
@@ -2382,7 +2420,7 @@ mod route_then_aggregate_tests {
let uncached = m.evaluate(&north);
m.recompute_formulas(&none_cats);
let cached = m.evaluate_aggregated(&north, &none_cats);
let cached = m.evaluate_aggregated(&north, Aggregation::of(&none_cats));
assert_eq!(uncached, Some(CellValue::Number(11.0)));
assert_eq!(cached, uncached, "the two evaluators must not diverge");
@@ -2397,7 +2435,10 @@ mod route_then_aggregate_tests {
m.recompute_formulas(&none_cats);
for region in ["North", "South"] {
assert_eq!(
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
m.evaluate_aggregated(
&key(&[("Region", region), (MEASURE, "Pred")]),
Aggregation::of(&none_cats)
),
Some(CellValue::Number(11.0)),
"{region}"
);
@@ -2428,7 +2469,10 @@ mod route_then_aggregate_tests {
m.recompute_formulas(&none_cats);
for (region, expected) in [("North", 1.0), ("South", 0.0)] {
assert_eq!(
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
m.evaluate_aggregated(
&key(&[("Region", region), (MEASURE, "Pred")]),
Aggregation::of(&none_cats)
),
Some(CellValue::Number(expected)),
"{region}"
);
@@ -2444,7 +2488,10 @@ mod route_then_aggregate_tests {
m.recompute_formulas(&none_cats);
for (region, expected) in [("North", 14.0), ("South", 200.0)] {
assert_eq!(
m.evaluate_aggregated(&key(&[("Region", region), (MEASURE, "Pred")]), &none_cats),
m.evaluate_aggregated(
&key(&[("Region", region), (MEASURE, "Pred")]),
Aggregation::of(&none_cats)
),
Some(CellValue::Number(expected)),
"{region}"
);
+3 -3
View File
@@ -1,8 +1,8 @@
use std::rc::Rc;
use crate::model::Model;
use crate::model::category::{DIM_CATEGORY, INDEX_CATEGORY, MEASURE_CATEGORY};
use crate::model::cell::{CellKey, CellValue};
use crate::model::{Aggregation, Model};
use crate::view::{Axis, View};
/// The trailing column that creates a new measure: typing a name into it
@@ -436,7 +436,7 @@ impl GridLayout {
for (ci, cell) in row.iter_mut().enumerate() {
*cell = self
.cell_key(ri, ci)
.and_then(|k| model.evaluate_aggregated(&k, &self.none_cats))
.and_then(|k| model.evaluate_aggregated(&k, Aggregation::of(&self.none_cats)))
.is_some();
}
}
@@ -494,7 +494,7 @@ impl GridLayout {
self.records_display(row, col).unwrap_or_default()
} else {
self.cell_key(row, col)
.and_then(|key| model.evaluate_aggregated(&key, &self.none_cats))
.and_then(|key| model.evaluate_aggregated(&key, Aggregation::of(&self.none_cats)))
.map(|v| crate::format::format_value(Some(&v), fmt_comma, fmt_decimals))
.unwrap_or_default()
}
+4 -2
View File
@@ -20,7 +20,9 @@ formula = { SOI ~ target ~ "=" ~ expr ~ where_clause? ~ EOI }
// to `|Total Revenue|` name the same item.
target = { identifier }
where_clause = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
// Compound-atomic like `is_expr`, so `sp+` is the real separator: relax it and
// `WHEREAS x = y` reads as a WHERE on a category `AS`.
where_clause = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value }
// ---- expressions --------------------------------------------------------
@@ -64,7 +66,7 @@ paren_expr = { "(" ~ expr ~ ")" }
// Aggregates with optional inline WHERE filter inside the parens.
agg_call = { agg_func ~ "(" ~ expr ~ inline_where? ~ ")" }
agg_func = { ^"SUM" | ^"AVG" | ^"MIN" | ^"MAX" | ^"COUNT" }
inline_where = { ^"WHERE" ~ identifier ~ "=" ~ filter_value }
inline_where = ${ ^"WHERE" ~ sp+ ~ identifier ~ sp* ~ "=" ~ sp* ~ filter_value }
// IF(cond, then, else). Comparison is a standalone rule because comparison
// operators are not valid in general expressions — only inside an IF condition.
+29
View File
@@ -463,6 +463,35 @@ mod tests {
assert_eq!(filter.item, "East");
}
#[test]
fn where_does_not_swallow_the_start_of_a_longer_word() {
assert!(parse_formula("X = SUM(Rev WHEREAS x = y)", "Cat").is_err());
assert!(parse_formula("X = Revenue WHEREAS x = y", "Cat").is_err());
assert!(parse_formula("X = Revenue WHEREAS=y", "Cat").is_err());
assert!(parse_formula("X = SUM(Rev WHEREAS=y)", "Cat").is_err());
let inline = parse_formula("X = SUM(Rev WHERE Region = East)", "Cat").unwrap();
assert!(
matches!(&inline.expr, Expr::Agg(_, _, Some(_))),
"{:?}",
inline.expr
);
let trailing = parse_formula("X = Revenue WHERE Region = East", "Cat").unwrap();
assert_eq!(trailing.filter.as_ref().unwrap().category, "Region");
}
/// `sp+` is the boundary, so the keyword needs a space after it exactly as
/// `IS` does — a quoted category no longer abuts it.
#[test]
fn where_requires_a_space_after_the_keyword() {
assert!(parse_formula("X = Revenue WHERE|Region Name|=|East Coast|", "Cat").is_err());
let f = parse_formula("X = Revenue WHERE |Region Name|=|East Coast|", "Cat").unwrap();
let filter = f.filter.as_ref().unwrap();
assert_eq!(
(filter.category.as_str(), filter.item.as_str()),
("Region Name", "East Coast")
);
}
// ── IS, the coordinate predicate (improvise-4zl7) ───────────────────
#[test]
+39 -1
View File
@@ -50,6 +50,29 @@ struct ParsedFile {
views: Vec<PView>,
}
impl ParsedFile {
/// Two `## Category:` sections naming one category are one category. The
/// merge concatenates, so the built order is the order a writer emits.
fn push_category(&mut self, pc: PCategory) {
let PCategory {
name,
items,
group_parents,
} = pc;
match self.categories.iter_mut().find(|e| e.name == name) {
Some(existing) => {
existing.items.extend(items);
existing.group_parents.extend(group_parents);
}
None => self.categories.push(PCategory {
name,
items,
group_parents,
}),
}
}
}
// ── Helpers for walking the pest parse tree ──────────────────────────────────
/// Advance an iterator, returning an error if empty.
@@ -285,7 +308,7 @@ fn walk_file(file: Pair<'_, Rule>) -> Result<ParsedFile> {
Rule::initial_view => {
parsed.initial_view = Some(section_title(&mut pair.into_inner(), "initial_view")?);
}
Rule::category_section => parsed.categories.push(walk_category_section(pair)?),
Rule::category_section => parsed.push_category(walk_category_section(pair)?),
Rule::formulas_section => parsed.formulas.extend(walk_formulas_section(pair)?),
Rule::data_section => parsed.data.extend(walk_data_section(pair)?),
Rule::view_section => parsed.views.push(walk_view_section(pair)?),
@@ -1078,6 +1101,21 @@ mod parser_edge_cases {
assert!(!item_names.is_empty());
}
#[test]
fn two_sections_naming_one_category_round_trip_to_a_stable_group_order() {
let text = "v2025-04-09\n# Test\n## Category: C\n> G1[P1]\n\
## Category: C\n- X[G2]\n> G2[P2]\n";
let fmt = crate::persistence::format_md;
let printed = fmt(&parse_md(text).unwrap());
for owed in ["> G1[P1]", "> G2[P2]", "- X[G2]"] {
assert!(
printed.contains(owed),
"the merge dropped {owed}:\n{printed}"
);
}
assert_eq!(printed, fmt(&parse_md(&printed).unwrap()));
}
#[test]
fn parse_category_with_no_items() {
let text = "v2025-04-09\n# Test\n## Category: Empty\n## Category: Full\n- A\n";
+4 -1
View File
@@ -297,7 +297,10 @@ mod tests {
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
let layout = improvise_core::view::layout::GridLayout::new(&wb.model, wb.active_view());
let key = layout.cell_key(row, col)?;
wb.model.evaluate_aggregated(&key, &layout.none_cats)
wb.model.evaluate_aggregated(
&key,
improvise_core::model::Aggregation::of(&layout.none_cats),
)
}
/// `Storage::list` is how a browser offers a document picker, and it must
@@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
use improvise_command::ui::app::App;
use improvise_command::ui::reduce;
use improvise_core::model::Aggregation;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::view::Axis;
use improvise_core::view::layout::GridLayout;
@@ -190,7 +191,8 @@ fn the_worker_loads_projects_edits_and_persists_over_post_message() {
fn value_at(wb: &Workbook, row: usize, col: usize) -> Option<CellValue> {
let layout = GridLayout::new(&wb.model, wb.active_view());
let key = layout.cell_key(row, col)?;
wb.model.evaluate_aggregated(&key, &layout.none_cats)
wb.model
.evaluate_aggregated(&key, Aggregation::of(&layout.none_cats))
}
/// A second worker over the same backend sees what the first one saved: what a