8d3230e685
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
1833 lines
67 KiB
Rust
1833 lines
67 KiB
Rust
//! Parsing the `.improv` text format: a tree-walk over the pest parse tree, in
|
|
//! two passes so sections may appear in any order — `context/code/formula-and-io.md`.
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use pest::Parser;
|
|
use pest::iterators::{Pair, Pairs};
|
|
use pest_derive::Parser;
|
|
|
|
use crate::formula::{AggFunc, parse_formula};
|
|
use crate::model::category::{CategoryKind, Group};
|
|
use crate::model::cell::{CellKey, CellValue};
|
|
use crate::view::{Axis, GroupFold};
|
|
use crate::workbook::Workbook;
|
|
|
|
use crate::formula::quote::{quote_name, unescape_pipe_body};
|
|
|
|
#[derive(Parser)]
|
|
#[grammar = "persistence/improv.pest"]
|
|
struct ImprovParser;
|
|
|
|
// ── Intermediate collectors ──────────────────────────────────────────────────
|
|
|
|
/// One `## Category:` section as pass 1 read it.
|
|
struct PCategory {
|
|
name: String,
|
|
/// `(item name, owning group)`, in file order — `IndexMap` insertion order
|
|
/// is load-bearing, so pass 2 must replay this list as written.
|
|
items: Vec<(String, Option<String>)>,
|
|
group_parents: Vec<(String, String)>,
|
|
/// What a `kind:` line said, `Regular` where none did. Absence is not a
|
|
/// claim, so merging two sections keeps the stated one — context/code/formula-and-io.md.
|
|
kind: CategoryKind,
|
|
}
|
|
|
|
/// One `## View:` section as pass 1 read it.
|
|
struct PView {
|
|
name: String,
|
|
axes: Vec<(String, Axis)>,
|
|
page_selections: Vec<(String, String)>,
|
|
format: String,
|
|
prune_empty: bool,
|
|
hidden: Vec<(String, String)>,
|
|
folds: Vec<PFold>,
|
|
}
|
|
|
|
/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category
|
|
/// form, which only `expanded` has.
|
|
struct PFold {
|
|
category: String,
|
|
group: Option<String>,
|
|
fold: GroupFold,
|
|
}
|
|
|
|
/// A file the grammar accepts and a model cannot hold (improvise-felo.6).
|
|
/// Typed, so the generator's property can tell it from a parse failure.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Refusal {
|
|
/// `> A[B]` and `> B[A]`: a hierarchy must be a forest.
|
|
HierarchyCycle {
|
|
category: String,
|
|
groups: Vec<String>,
|
|
},
|
|
/// One view gives one group (or one category's depth) two different folds.
|
|
FoldConflict {
|
|
view: String,
|
|
category: String,
|
|
group: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl std::fmt::Display for Refusal {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Refusal::HierarchyCycle { category, groups } => write!(
|
|
f,
|
|
"category {}: the group parents form a cycle through {}",
|
|
quote_name(category),
|
|
groups
|
|
.iter()
|
|
.map(|g| quote_name(g))
|
|
.collect::<Vec<_>>()
|
|
.join(" > ")
|
|
),
|
|
Refusal::FoldConflict {
|
|
view,
|
|
category,
|
|
group: Some(group),
|
|
} => write!(
|
|
f,
|
|
"view {}: group {}/{} is both expanded and collapsed, or expanded to two depths",
|
|
quote_name(view),
|
|
quote_name(category),
|
|
quote_name(group)
|
|
),
|
|
Refusal::FoldConflict {
|
|
view,
|
|
category,
|
|
group: None,
|
|
} => write!(
|
|
f,
|
|
"view {}: category {} is expanded to two depths",
|
|
quote_name(view),
|
|
quote_name(category)
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for Refusal {}
|
|
|
|
/// Everything pass 1 collects, before any of it is resolved against the model.
|
|
#[derive(Default)]
|
|
struct ParsedFile {
|
|
model_name: Option<String>,
|
|
initial_view: Option<String>,
|
|
categories: Vec<PCategory>,
|
|
formulas: Vec<(String, String)>,
|
|
measure_agg: Vec<(String, AggFunc)>,
|
|
data: Vec<(CellKey, CellValue)>,
|
|
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,
|
|
kind,
|
|
} = pc;
|
|
match self.categories.iter_mut().find(|e| e.name == name) {
|
|
Some(existing) => {
|
|
existing.items.extend(items);
|
|
existing.group_parents.extend(group_parents);
|
|
if kind != CategoryKind::Regular {
|
|
existing.kind = kind;
|
|
}
|
|
}
|
|
None => self.categories.push(PCategory {
|
|
name,
|
|
items,
|
|
group_parents,
|
|
kind,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Helpers for walking the pest parse tree ──────────────────────────────────
|
|
|
|
/// Advance an iterator, returning an error if empty.
|
|
fn next<'a>(pairs: &mut Pairs<'a, Rule>, ctx: &str) -> Result<Pair<'a, Rule>> {
|
|
pairs
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("Expected child in {ctx}"))
|
|
}
|
|
|
|
/// Extract the first child's text content, trimmed.
|
|
fn first_str(pair: Pair<'_, Rule>) -> Result<String> {
|
|
Ok(next(&mut pair.into_inner(), "first_str")?
|
|
.as_str()
|
|
.trim()
|
|
.to_string())
|
|
}
|
|
|
|
/// A `name` (`pipe_quoted | bare_name`) as the string it denotes.
|
|
fn extract_name(pair: Pair<'_, Rule>) -> Result<String> {
|
|
match pair.as_rule() {
|
|
Rule::bare_name => Ok(pair.as_str().to_string()),
|
|
Rule::pipe_quoted => {
|
|
let inner = next(&mut pair.into_inner(), "pipe_quoted")?;
|
|
Ok(unescape_pipe_body(inner.as_str()))
|
|
}
|
|
_ => Ok(pair.as_str().to_string()),
|
|
}
|
|
}
|
|
|
|
/// Extract two names from a pair's children.
|
|
fn extract_name_pair(pair: Pair<'_, Rule>) -> Result<(String, String)> {
|
|
let ctx = format!("{:?}", pair.as_rule());
|
|
let mut parts = pair.into_inner();
|
|
let a = extract_name(next(&mut parts, &ctx)?)?;
|
|
let b = extract_name(next(&mut parts, &ctx)?)?;
|
|
Ok((a, b))
|
|
}
|
|
|
|
/// A `header_name` as the string it denotes: the whole line, trimmed, or the
|
|
/// pipe-quoted spelling `format.rs::quote_header` chose (improvise-pczv).
|
|
fn header_name(pair: Pair<'_, Rule>) -> Result<String> {
|
|
let inner = next(&mut pair.into_inner(), "header_name")?;
|
|
match inner.as_rule() {
|
|
Rule::line_name => Ok(inner.as_str().trim().to_string()),
|
|
_ => extract_name(inner),
|
|
}
|
|
}
|
|
|
|
/// The header name at the front of a section's children — the title after
|
|
/// `## Category:` / `## View:`, or the whole of `# ` / `Initial View:`.
|
|
fn section_title(inner: &mut Pairs<'_, Rule>, ctx: &str) -> Result<String> {
|
|
header_name(next(inner, ctx)?)
|
|
}
|
|
|
|
/// A token matched by the grammar's `number` rule, as a cell value. If the two
|
|
/// ever drift the mismatch must surface — never silently store a wrong number.
|
|
fn parse_number_cell(s: &str) -> CellValue {
|
|
s.parse()
|
|
.map(CellValue::Number)
|
|
.unwrap_or_else(|_| CellValue::Error(format!("invalid number: {s}")))
|
|
}
|
|
|
|
// ── Pass 1: one walker per section ───────────────────────────────────────────
|
|
|
|
/// Run the grammar over `text`, rendering a pest error as a located message.
|
|
fn parse_tree(text: &str) -> Result<Pair<'_, Rule>> {
|
|
ImprovParser::parse(Rule::file, text)
|
|
.map_err(|e| {
|
|
use pest::error::LineColLocation;
|
|
let (line, col) = match e.line_col {
|
|
LineColLocation::Pos(p) | LineColLocation::Span(p, _) => p,
|
|
};
|
|
let line_text = text.lines().nth(line - 1).unwrap_or("").trim_end();
|
|
anyhow::anyhow!(
|
|
"Parse error at line {line}, column {col}: {line_text}\n ({})",
|
|
e.variant.message()
|
|
)
|
|
})?
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("Empty parse result"))
|
|
}
|
|
|
|
/// `## Category: Name` plus its item lists, grouped items and `>` hierarchy.
|
|
fn walk_category_section(pair: Pair<'_, Rule>) -> Result<PCategory> {
|
|
let mut inner = pair.into_inner();
|
|
let mut pc = PCategory {
|
|
name: section_title(&mut inner, "category_section")?,
|
|
items: Vec::new(),
|
|
group_parents: Vec::new(),
|
|
kind: CategoryKind::Regular,
|
|
};
|
|
for entry in inner {
|
|
match entry.as_rule() {
|
|
Rule::kind_line => {
|
|
let kw = next(&mut entry.into_inner(), "kind_line")?;
|
|
pc.kind = parse_kind_kw(kw.as_str())?;
|
|
}
|
|
Rule::item_list => {
|
|
for name_pair in entry.into_inner() {
|
|
pc.items.push((extract_name(name_pair)?, None));
|
|
}
|
|
}
|
|
Rule::grouped_item => {
|
|
let (name, group) = extract_name_pair(entry)?;
|
|
pc.items.push((name, Some(group)));
|
|
}
|
|
Rule::group_hierarchy => {
|
|
pc.group_parents.push(extract_name_pair(entry)?);
|
|
}
|
|
other => bail!("unhandled rule in category_section: {other:?}"),
|
|
}
|
|
}
|
|
Ok(pc)
|
|
}
|
|
|
|
/// The on-disk keyword for a category kind, exhaustively — the reader's half
|
|
/// of the `kind: label` line `format.rs` writes.
|
|
fn parse_kind_kw(kind: &str) -> Result<CategoryKind> {
|
|
match kind {
|
|
"label" => Ok(CategoryKind::Label),
|
|
_ => bail!("Unknown category kind: {kind}"),
|
|
}
|
|
}
|
|
|
|
/// The on-disk keyword for an aggregation, exhaustively — the reader's half of
|
|
/// `format.rs::agg_keyword`.
|
|
fn parse_agg_kind(kind: &str) -> Result<AggFunc> {
|
|
match kind {
|
|
"sum" => Ok(AggFunc::Sum),
|
|
"avg" => Ok(AggFunc::Avg),
|
|
"min" => Ok(AggFunc::Min),
|
|
"max" => Ok(AggFunc::Max),
|
|
"count" => Ok(AggFunc::Count),
|
|
_ => bail!("Unknown aggregation: {kind}"),
|
|
}
|
|
}
|
|
|
|
/// `## Aggregation`: `- Measure: avg` lines as `(item, AggFunc)` pairs.
|
|
fn walk_aggregation_section(pair: Pair<'_, Rule>) -> Result<Vec<(String, AggFunc)>> {
|
|
let mut out = Vec::new();
|
|
for line in pair.into_inner() {
|
|
if line.as_rule() == Rule::aggregation_line {
|
|
let mut parts = line.into_inner();
|
|
let name = extract_name(next(&mut parts, "aggregation_line")?)?;
|
|
let kind = next(&mut parts, "aggregation_line")?;
|
|
out.push((name, parse_agg_kind(kind.as_str())?));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// `## Formulas`: `(raw text, target category)` pairs. A line with no
|
|
/// `[Category]` suffix targets `_Measure`.
|
|
fn walk_formulas_section(pair: Pair<'_, Rule>) -> Result<Vec<(String, String)>> {
|
|
let mut out = Vec::new();
|
|
for fl in pair.into_inner() {
|
|
if fl.as_rule() == Rule::formula_line {
|
|
let mut parts = fl.into_inner();
|
|
let raw = next(&mut parts, "formula_line")?
|
|
.as_str()
|
|
.trim()
|
|
.to_string();
|
|
let category = match parts.next() {
|
|
Some(name_pair) => extract_name(name_pair)?,
|
|
None => "_Measure".to_string(),
|
|
};
|
|
if raw.contains('=') {
|
|
out.push((raw, category));
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The right-hand side of a data line.
|
|
fn walk_cell_value(value_pair: Pair<'_, Rule>) -> Result<CellValue> {
|
|
let value = match value_pair.as_rule() {
|
|
Rule::number => parse_number_cell(value_pair.as_str()),
|
|
Rule::pipe_quoted => {
|
|
let inner = next(&mut value_pair.into_inner(), "pipe_quoted")?;
|
|
CellValue::Text(unescape_pipe_body(inner.as_str()))
|
|
}
|
|
Rule::bare_value => {
|
|
let s = value_pair.as_str().trim();
|
|
match crate::model::cell::parse_nonfinite(s) {
|
|
Some(n) => CellValue::Number(n),
|
|
None => CellValue::Text(s.to_string()),
|
|
}
|
|
}
|
|
_ => CellValue::Text(value_pair.as_str().to_string()),
|
|
};
|
|
Ok(value)
|
|
}
|
|
|
|
/// `## Data`: `Cat=Item, … = value` lines as keyed cells.
|
|
fn walk_data_section(pair: Pair<'_, Rule>) -> Result<Vec<(CellKey, CellValue)>> {
|
|
let mut out = Vec::new();
|
|
for dl in pair.into_inner() {
|
|
if dl.as_rule() == Rule::data_line {
|
|
let mut dl_inner = dl.into_inner();
|
|
let first = next(&mut dl_inner, "data_line")?;
|
|
// `coord_list` is optional in the grammar: a cell with no
|
|
// coordinates is the scalar cell, spelled `= 42` (improvise-pnf5).
|
|
let (coords, value_pair) = match first.as_rule() {
|
|
Rule::coord_list => (walk_coord_list(first)?, next(&mut dl_inner, "data_line")?),
|
|
_ => (Vec::new(), first),
|
|
};
|
|
out.push((CellKey::new(coords), walk_cell_value(value_pair)?));
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The `Cat=Item, …` prefix of a data line.
|
|
fn walk_coord_list(coord_list: Pair<'_, Rule>) -> Result<Vec<(String, String)>> {
|
|
coord_list
|
|
.into_inner()
|
|
.filter(|p| p.as_rule() == Rule::coord)
|
|
.map(extract_name_pair)
|
|
.collect()
|
|
}
|
|
|
|
/// The keyword after `Category:` on an axis line.
|
|
fn parse_axis_kind(kind_str: &str) -> Result<Axis> {
|
|
match kind_str {
|
|
"row" => Ok(Axis::Row),
|
|
"column" => Ok(Axis::Column),
|
|
"page" => Ok(Axis::Page),
|
|
"filter" => Ok(Axis::Filter),
|
|
"none" => Ok(Axis::None),
|
|
_ => bail!("Unknown axis kind: {kind_str}"),
|
|
}
|
|
}
|
|
|
|
/// One axis line: the category, its axis, and — for the axes that constrain
|
|
/// data — the selected item that follows the comma.
|
|
fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> {
|
|
let mut parts = entry.into_inner();
|
|
let cat = extract_name(next(&mut parts, "axis cat")?)?;
|
|
let axis = parse_axis_kind(next(&mut parts, "axis kind")?.as_str())?;
|
|
pv.axes.push((cat.clone(), axis));
|
|
if axis.filters_data()
|
|
&& let Some(sel_pair) = parts.next()
|
|
{
|
|
pv.page_selections.push((cat, extract_name(sel_pair)?));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `expanded(N): Cat` or `expanded(N): Cat/Group`; a bare `expanded:` is N = 1.
|
|
fn walk_expanded_line(entry: Pair<'_, Rule>) -> Result<PFold> {
|
|
let mut depth = std::num::NonZeroUsize::MIN;
|
|
let mut names = Vec::new();
|
|
for part in entry.into_inner() {
|
|
match part.as_rule() {
|
|
Rule::fold_depth => {
|
|
let digits = first_str(part)?;
|
|
depth = digits
|
|
.parse()
|
|
.with_context(|| format!("expanded({digits}): depth out of range"))?;
|
|
}
|
|
_ => names.push(extract_name(part)?),
|
|
}
|
|
}
|
|
let mut names = names.into_iter();
|
|
let category = names.next().context("expanded_line: no category")?;
|
|
Ok(PFold {
|
|
category,
|
|
group: names.next(),
|
|
fold: GroupFold::Expanded(depth),
|
|
})
|
|
}
|
|
|
|
/// `## View: Name` plus its axis, format, hidden, collapsed and expanded lines.
|
|
fn walk_view_section(pair: Pair<'_, Rule>) -> Result<PView> {
|
|
let mut inner = pair.into_inner();
|
|
let mut pv = PView {
|
|
name: section_title(&mut inner, "view_section")?,
|
|
axes: Vec::new(),
|
|
page_selections: Vec::new(),
|
|
format: String::new(),
|
|
prune_empty: false,
|
|
hidden: Vec::new(),
|
|
folds: Vec::new(),
|
|
};
|
|
for entry in inner {
|
|
match entry.as_rule() {
|
|
Rule::axis_line => walk_axis_line(entry, &mut pv)?,
|
|
Rule::format_line => pv.format = first_str(entry)?,
|
|
Rule::prune_line => pv.prune_empty = first_str(entry)? == "true",
|
|
Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?),
|
|
Rule::collapsed_line => {
|
|
let (category, group) = extract_name_pair(entry)?;
|
|
pv.folds.push(PFold {
|
|
category,
|
|
group: Some(group),
|
|
fold: GroupFold::Collapsed,
|
|
});
|
|
}
|
|
Rule::expanded_line => pv.folds.push(walk_expanded_line(entry)?),
|
|
other => bail!("unhandled rule in view_section: {other:?}"),
|
|
}
|
|
}
|
|
Ok(pv)
|
|
}
|
|
|
|
/// Walk the whole file, dispatching each top-level section to its walker.
|
|
fn walk_file(file: Pair<'_, Rule>) -> Result<ParsedFile> {
|
|
let mut parsed = ParsedFile::default();
|
|
for pair in file.into_inner() {
|
|
match pair.as_rule() {
|
|
Rule::version_line | Rule::EOI => {}
|
|
Rule::model_name => {
|
|
parsed.model_name = Some(section_title(&mut pair.into_inner(), "model_name")?);
|
|
}
|
|
Rule::initial_view => {
|
|
parsed.initial_view = Some(section_title(&mut pair.into_inner(), "initial_view")?);
|
|
}
|
|
Rule::category_section => parsed.push_category(walk_category_section(pair)?),
|
|
Rule::formulas_section => parsed.formulas.extend(walk_formulas_section(pair)?),
|
|
Rule::aggregation_section => parsed.measure_agg.extend(walk_aggregation_section(pair)?),
|
|
Rule::data_section => parsed.data.extend(walk_data_section(pair)?),
|
|
Rule::view_section => parsed.views.push(walk_view_section(pair)?),
|
|
other => bail!("unhandled rule in file: {other:?}"),
|
|
}
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
// ── Pass 2: build the Workbook ───────────────────────────────────────────────
|
|
|
|
/// Register one category's items and groups, in file order.
|
|
fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> {
|
|
match pc.kind {
|
|
CategoryKind::Label => wb.add_label_category(&pc.name)?,
|
|
_ => wb.add_category(&pc.name)?,
|
|
};
|
|
let cat = wb
|
|
.model
|
|
.category_mut(&pc.name)
|
|
.ok_or_else(|| anyhow::anyhow!("Category '{}' not found after add", pc.name))?;
|
|
for (item_name, group) in &pc.items {
|
|
match group {
|
|
Some(g) => {
|
|
cat.add_item_in_group(item_name, g);
|
|
if !cat.groups.iter().any(|e| &e.name == g) {
|
|
cat.add_group(Group::new(g));
|
|
}
|
|
}
|
|
None => {
|
|
cat.add_item(item_name);
|
|
}
|
|
}
|
|
}
|
|
for (group_name, parent) in &pc.group_parents {
|
|
match cat.groups.iter_mut().find(|g| &g.name == group_name) {
|
|
Some(g) => g.parent = Some(parent.clone()),
|
|
None => cat.add_group(Group::new(group_name).with_parent(parent)),
|
|
}
|
|
}
|
|
if let Some(cycle) = cat.hierarchy_cycle() {
|
|
return Err(Refusal::HierarchyCycle {
|
|
category: pc.name.clone(),
|
|
groups: cycle.into_iter().map(String::from).collect(),
|
|
}
|
|
.into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Create the view if it is new, then apply its axes, selections and flags.
|
|
fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> {
|
|
if !wb.views.contains_key(&pv.name) {
|
|
wb.create_view(&pv.name);
|
|
}
|
|
let view = wb
|
|
.views
|
|
.get_mut(&pv.name)
|
|
.ok_or_else(|| anyhow::anyhow!("View '{}' not found after create", pv.name))?;
|
|
for (cat, axis) in &pv.axes {
|
|
view.set_axis(cat, *axis);
|
|
}
|
|
for (cat, sel) in &pv.page_selections {
|
|
view.set_page_selection(cat, sel);
|
|
}
|
|
if !pv.format.is_empty() {
|
|
view.number_format = pv.format.clone();
|
|
}
|
|
view.prune_empty = pv.prune_empty;
|
|
for (cat, item) in &pv.hidden {
|
|
view.hide_item(cat, item);
|
|
}
|
|
for PFold {
|
|
category,
|
|
group,
|
|
fold,
|
|
} in &pv.folds
|
|
{
|
|
let conflict = || Refusal::FoldConflict {
|
|
view: pv.name.clone(),
|
|
category: category.clone(),
|
|
group: group.clone(),
|
|
};
|
|
match (group, fold) {
|
|
(Some(g), _) => match view.group_fold(category, g) {
|
|
Some(held) if held != *fold => return Err(conflict().into()),
|
|
_ => view.set_group_fold(category, g, *fold),
|
|
},
|
|
(None, GroupFold::Expanded(n)) => match view.expand_depth(category) {
|
|
0 => view.set_expand_depth(category, n.get()),
|
|
held if held != n.get() => return Err(conflict().into()),
|
|
_ => {}
|
|
},
|
|
(None, GroupFold::Collapsed) => bail!("a category-wide collapse has no spelling"),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `Workbook::new` seeds a `Default` view and `on_category_added` fills it, so
|
|
/// a file naming its own views gained a phantom one it never had (improvise-m4rv).
|
|
fn drop_unnamed_default(wb: &mut Workbook, named: &[&PView]) {
|
|
let default_is_a_phantom =
|
|
!named.is_empty() && !named.iter().any(|pv| pv.name == crate::view::DEFAULT_VIEW);
|
|
if default_is_a_phantom {
|
|
wb.delete_view(crate::view::DEFAULT_VIEW).ok();
|
|
}
|
|
}
|
|
|
|
/// Turn pass 1's collectors into a [`Workbook`].
|
|
fn build_workbook(parsed: ParsedFile) -> Result<Workbook> {
|
|
let name = parsed
|
|
.model_name
|
|
.ok_or_else(|| anyhow::anyhow!("Missing model title (# Name)"))?;
|
|
let mut wb = Workbook::new(&name);
|
|
|
|
for pc in &parsed.categories {
|
|
build_category(&mut wb, pc)?;
|
|
}
|
|
|
|
// A file written before improvise-60z may carry the scratch views; drop
|
|
// them, and `initial_view` then falls through to the default.
|
|
let named: Vec<&PView> = parsed
|
|
.views
|
|
.iter()
|
|
.filter(|pv| !crate::view::VIRTUAL_VIEW_NAMES.contains(&pv.name.as_str()))
|
|
.collect();
|
|
for pv in &named {
|
|
build_view(&mut wb, pv)?;
|
|
}
|
|
drop_unnamed_default(&mut wb, &named);
|
|
|
|
for (item, agg) in parsed.measure_agg {
|
|
wb.model.measure_agg.insert(item, agg);
|
|
}
|
|
|
|
if let Some(iv) = &parsed.initial_view
|
|
&& wb.views.contains_key(iv)
|
|
{
|
|
wb.active_view = iv.clone();
|
|
}
|
|
|
|
for (raw, cat_name) in &parsed.formulas {
|
|
wb.model
|
|
.add_formula(parse_formula(raw, cat_name).with_context(|| format!("Formula: {raw}"))?);
|
|
}
|
|
for (key, value) in parsed.data {
|
|
wb.model.set_cell(key, value);
|
|
}
|
|
|
|
Ok(wb)
|
|
}
|
|
|
|
/// Parse the `.improv` format into a Workbook. Sections may appear in any
|
|
/// order; the two passes register categories before configuring views.
|
|
pub fn parse_md(text: &str) -> Result<Workbook> {
|
|
build_workbook(walk_file(parse_tree(text)?)?)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod grammar_coverage {
|
|
use super::Rule;
|
|
|
|
/// Which walker reads each grammar rule. A new rule in `improv.pest` adds a
|
|
/// `Rule` variant and this match stops compiling — improvise-4lgj.
|
|
fn walker_of(rule: Rule) -> &'static str {
|
|
match rule {
|
|
Rule::file | Rule::EOI => "walk_file",
|
|
Rule::version_line => "walk_file (skipped: the version is the literal)",
|
|
Rule::model_name | Rule::initial_view => "walk_file",
|
|
Rule::section => "silent: hoisted into file",
|
|
Rule::category_section => "walk_category_section",
|
|
Rule::item_list | Rule::grouped_item | Rule::group_hierarchy => "walk_category_section",
|
|
Rule::kind_line | Rule::kind_kw => "walk_category_section",
|
|
Rule::category_entry => "silent: hoisted into category_section",
|
|
Rule::formulas_section | Rule::formula_line | Rule::formula_raw => {
|
|
"walk_formulas_section"
|
|
}
|
|
Rule::formula_target => "silent: hoisted into formula_line",
|
|
Rule::aggregation_section | Rule::aggregation_line | Rule::agg_kind => {
|
|
"walk_aggregation_section"
|
|
}
|
|
Rule::data_section | Rule::data_line => "walk_data_section",
|
|
Rule::data_coords => "silent: hoisted into data_line",
|
|
Rule::coord_list | Rule::coord => "walk_coord_list",
|
|
Rule::cell_value => "silent: hoisted into data_line",
|
|
Rule::number | Rule::bare_value => "walk_cell_value",
|
|
Rule::view_section => "walk_view_section",
|
|
Rule::axis_line | Rule::axis_kind => "walk_axis_line",
|
|
Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section",
|
|
Rule::hidden_line | Rule::collapsed_line => "walk_view_section",
|
|
Rule::expanded_line | Rule::fold_depth | Rule::depth_number => "walk_expanded_line",
|
|
Rule::view_entry => "silent: hoisted into view_section",
|
|
Rule::name => "silent: hoisted into its parent",
|
|
Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name",
|
|
Rule::header_name | Rule::line_name => "header_name",
|
|
Rule::rest_of_line => "first_str",
|
|
Rule::blank_lines => "silent: whitespace",
|
|
}
|
|
}
|
|
|
|
/// The compile is the test: this call only type-checks while every `Rule`
|
|
/// variant is classified above. The assertions are a smoke check.
|
|
#[test]
|
|
fn every_grammar_rule_names_the_walker_that_reads_it() {
|
|
assert_eq!(walker_of(Rule::prune_line), "walk_view_section");
|
|
assert_eq!(walker_of(Rule::coord_list), "walk_coord_list");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::parse_md;
|
|
use crate::model::cell::CellValue;
|
|
use crate::persistence::test_helpers::coord;
|
|
use crate::view::Axis;
|
|
|
|
/// Bug improvise-zgb (b): `rfind(" [")` missed a suffix with no space and
|
|
/// the formula was silently retargeted. Evidence: the test-evidence doc.
|
|
#[test]
|
|
fn parse_md_formula_category_suffix_without_space() {
|
|
let text = "v2025-04-09\n# T\n\
|
|
## Category: Type\n\
|
|
- Food, Gas, Total\n\
|
|
## Formulas\n\
|
|
- Total = Food + Gas[Type]\n";
|
|
let m = parse_md(text).unwrap();
|
|
let f = &m.model.formulas()[0];
|
|
assert_eq!(f.raw, "Total = Food + Gas");
|
|
assert_eq!(f.target_category, "Type");
|
|
}
|
|
|
|
/// Companion to improvise-zgb: a trailing pipe-quoted name containing " ["
|
|
/// must not be mistaken for a category suffix.
|
|
#[test]
|
|
fn parse_md_formula_ending_in_bracketed_pipe_name_keeps_raw() {
|
|
let text = "v2025-04-09\n# T\n\
|
|
## Formulas\n\
|
|
- Total = Cost + |Revenue [USD]|\n";
|
|
let m = parse_md(text).unwrap();
|
|
let f = &m.model.formulas()[0];
|
|
assert_eq!(f.raw, "Total = Cost + |Revenue [USD]|");
|
|
assert_eq!(f.target_category, "_Measure");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_md_order_independent_view_before_categories() {
|
|
// A hand-edited file with the view section before the category sections.
|
|
// The parser must still produce correct axis assignments.
|
|
let text = "v2025-04-09\n# Test\n\
|
|
## View: Default\n\
|
|
Type: row\n\
|
|
Month: column\n\
|
|
## Category: Type\n\
|
|
- Food\n\
|
|
## Category: Month\n\
|
|
- Jan\n";
|
|
let m = parse_md(text).unwrap();
|
|
assert_eq!(m.active_view().axis_of("Type"), Axis::Row);
|
|
assert_eq!(m.active_view().axis_of("Month"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_md_order_independent_new_view_before_categories() {
|
|
let text = "v2025-04-09\n# Test\n\
|
|
## View: Transposed\n\
|
|
Type: column\n\
|
|
Month: row\n\
|
|
## View: Default\n\
|
|
Type: row\n\
|
|
Month: column\n\
|
|
## Category: Type\n\
|
|
- Food\n\
|
|
## Category: Month\n\
|
|
- Jan\n";
|
|
let m = parse_md(text).unwrap();
|
|
let transposed = m.views.get("Transposed").unwrap();
|
|
assert_eq!(transposed.axis_of("Type"), Axis::Column);
|
|
assert_eq!(transposed.axis_of("Month"), Axis::Row);
|
|
let default = m.views.get("Default").unwrap();
|
|
assert_eq!(default.axis_of("Type"), Axis::Row);
|
|
assert_eq!(default.axis_of("Month"), Axis::Column);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_md_order_independent_data_before_categories() {
|
|
let text = "v2025-04-09\n# Test\n\
|
|
## Data\n\
|
|
Month=Jan, Type=Food = 42\n\
|
|
## Category: Type\n\
|
|
- Food\n\
|
|
## Category: Month\n\
|
|
- Jan\n\
|
|
## View: Default\n\
|
|
Type: row\n\
|
|
Month: column\n";
|
|
let m = parse_md(text).unwrap();
|
|
assert_eq!(
|
|
m.model
|
|
.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
|
|
Some(&CellValue::Number(42.0))
|
|
);
|
|
}
|
|
|
|
// ── parse_md: comments and blank lines ──────────────────────────────
|
|
|
|
#[test]
|
|
fn parse_md_ignores_blank_and_comment_lines() {
|
|
let text = r#"v2025-04-09
|
|
# Test Model
|
|
|
|
## Category: Type
|
|
- Food, Gas
|
|
|
|
## Data
|
|
Type=Food = 42
|
|
"#;
|
|
let m = parse_md(text).unwrap();
|
|
assert_eq!(m.model.name, "Test Model");
|
|
assert!(m.model.category("Type").is_some());
|
|
}
|
|
}
|
|
|
|
// ── Property-based parser tests ──────────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod parser_prop_tests {
|
|
use super::parse_md;
|
|
use crate::model::cell::CellValue;
|
|
use crate::persistence::format_md;
|
|
use crate::persistence::test_helpers::coord;
|
|
use crate::workbook::Workbook;
|
|
use proptest::prelude::*;
|
|
|
|
/// Safe identifier: won't collide with format grammar.
|
|
fn safe_ident() -> impl Strategy<Value = String> {
|
|
"[A-Za-z][A-Za-z0-9_]{0,9}"
|
|
}
|
|
|
|
/// Names that stress the section-header line (improvise-pczv): edge
|
|
/// whitespace, line breaks, pipes, and text that looks like a marker.
|
|
fn awkward_name() -> impl Strategy<Value = String> {
|
|
prop_oneof![
|
|
"[A-Za-z ]{0,8}",
|
|
"[ \n\r|]{1,3}[A-Za-z]{0,5}[ \n\r|]{0,3}",
|
|
r"\|[A-Za-z]{0,5}\|",
|
|
"[A-Za-z]{0,4}\n## Data",
|
|
"[A-Za-z]{0,4}\n## Category: X",
|
|
]
|
|
}
|
|
|
|
/// A workbook that already holds categories, items and cells — a
|
|
/// default-constructed one cannot show a coordinate losing its category.
|
|
fn populated_model() -> Workbook {
|
|
let mut m = Workbook::new("Budget");
|
|
m.add_category("Type").unwrap();
|
|
for item in ["Food", "Gas"] {
|
|
m.add_item("Type", item).unwrap();
|
|
}
|
|
m.model
|
|
.set_cell(coord(&[("Type", "Food")]), CellValue::Number(42.0));
|
|
m
|
|
}
|
|
|
|
/// Text values designed to exercise parser edge cases.
|
|
fn tricky_text() -> impl Strategy<Value = String> {
|
|
prop_oneof![
|
|
"[a-zA-Z0-9 ]{0,20}",
|
|
"[a-zA-Z]+(, [a-zA-Z]+)*",
|
|
Just(r#"say "hi""#.to_string()),
|
|
Just("\"".to_string()),
|
|
Just("\"\"".to_string()),
|
|
Just("a = b".to_string()),
|
|
Just(" = ".to_string()),
|
|
Just("# not a header".to_string()),
|
|
Just("[bracketed]".to_string()),
|
|
// printable ASCII range
|
|
"[ -~]{0,30}",
|
|
Just("".to_string()),
|
|
]
|
|
}
|
|
|
|
fn cell_value() -> impl Strategy<Value = CellValue> {
|
|
prop_oneof![
|
|
prop::num::f64::NORMAL
|
|
.prop_filter("finite", |f| f.is_finite())
|
|
.prop_map(CellValue::Number),
|
|
(-1000i64..1000).prop_map(|n| CellValue::Number(n as f64)),
|
|
tricky_text().prop_map(CellValue::Text),
|
|
]
|
|
}
|
|
|
|
fn arbitrary_model() -> impl Strategy<Value = Workbook> {
|
|
let items1 = prop::collection::hash_set(safe_ident(), 1..=4);
|
|
let items2 = prop::collection::hash_set(safe_ident(), 1..=4);
|
|
let values = prop::collection::vec(cell_value(), 1..=8);
|
|
|
|
(safe_ident(), items1, items2, values).prop_map(|(name, items1, items2, values)| {
|
|
let mut m = Workbook::new(&name);
|
|
m.add_category("CatA").unwrap();
|
|
m.add_category("CatB").unwrap();
|
|
|
|
let items1: Vec<_> = items1.into_iter().collect();
|
|
let items2: Vec<_> = items2.into_iter().collect();
|
|
|
|
for item in &items1 {
|
|
m.add_item("CatA", item).unwrap();
|
|
}
|
|
for item in &items2 {
|
|
m.add_item("CatB", item).unwrap();
|
|
}
|
|
|
|
for (i, value) in values.into_iter().enumerate() {
|
|
let a = &items1[i % items1.len()];
|
|
let b = &items2[i % items2.len()];
|
|
m.model.set_cell(coord(&[("CatA", a), ("CatB", b)]), value);
|
|
}
|
|
|
|
m
|
|
})
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|
|
|
/// improvise-pczv as a property rather than a list of clauses.
|
|
/// Evidence: `context/test-evidence/formula-and-io.md`.
|
|
#[test]
|
|
fn roundtrip_preserves_an_awkward_category_name(name in awkward_name()) {
|
|
let mut m = populated_model();
|
|
m.add_category(&name).unwrap();
|
|
m.add_item(&name, "East").unwrap();
|
|
m.model.set_cell(coord(&[(&name, "East")]), CellValue::Number(7.0));
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text)
|
|
.map_err(|e| TestCaseError::fail(format!("{name:?} did not reload: {e}\n{text}")))?;
|
|
prop_assert!(
|
|
loaded.model.category(&name).is_some(),
|
|
"{:?} came back as {:?}\n{}",
|
|
name,
|
|
loaded.model.categories.keys().collect::<Vec<_>>(),
|
|
text
|
|
);
|
|
prop_assert_eq!(
|
|
loaded.model.get_cell(&coord(&[(&name, "East")])),
|
|
Some(&CellValue::Number(7.0)),
|
|
"the data coordinate lost its category\n{}", text
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_preserves_model_name(name in safe_ident()) {
|
|
let m = Workbook::new(&name);
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
prop_assert_eq!(loaded.model.name, name);
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_preserves_categories_and_items(
|
|
items1 in prop::collection::hash_set(safe_ident(), 1..=5),
|
|
items2 in prop::collection::hash_set(safe_ident(), 1..=5),
|
|
) {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Alpha").unwrap();
|
|
m.add_category("Beta").unwrap();
|
|
for item in &items1 {
|
|
m.add_item("Alpha", item).unwrap();
|
|
}
|
|
for item in &items2 {
|
|
m.add_item("Beta", item).unwrap();
|
|
}
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
|
|
let loaded_alpha: std::collections::HashSet<String> = loaded
|
|
.model.category("Alpha").unwrap()
|
|
.items.values().map(|i| i.name.clone()).collect();
|
|
let loaded_beta: std::collections::HashSet<String> = loaded
|
|
.model.category("Beta").unwrap()
|
|
.items.values().map(|i| i.name.clone()).collect();
|
|
|
|
prop_assert_eq!(loaded_alpha, items1);
|
|
prop_assert_eq!(loaded_beta, items2);
|
|
}
|
|
|
|
/// Double round-trip: format → parse → format should be idempotent.
|
|
#[test]
|
|
fn double_roundtrip_is_idempotent(model in arbitrary_model()) {
|
|
let text1 = format_md(&model);
|
|
let loaded = parse_md(&text1).unwrap();
|
|
let text2 = format_md(&loaded);
|
|
prop_assert_eq!(&text1, &text2,
|
|
"format→parse→format was not idempotent");
|
|
}
|
|
|
|
/// Tricky text values survive round-trip as cell values.
|
|
#[test]
|
|
fn tricky_text_value_roundtrips(text_val in tricky_text()) {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Dim").unwrap();
|
|
m.add_item("Dim", "A").unwrap();
|
|
m.add_category("Msr").unwrap();
|
|
m.add_item("Msr", "V").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("Dim", "A"), ("Msr", "V")]),
|
|
CellValue::Text(text_val.clone()),
|
|
);
|
|
|
|
let formatted = format_md(&m);
|
|
let loaded = parse_md(&formatted).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("Dim", "A"), ("Msr", "V")]));
|
|
prop_assert_eq!(
|
|
got,
|
|
Some(&CellValue::Text(text_val.clone())),
|
|
"Text value {:?} did not round-trip.\n{}",
|
|
text_val, formatted
|
|
);
|
|
}
|
|
|
|
/// Cell count is preserved across round-trip.
|
|
#[test]
|
|
fn roundtrip_preserves_cell_count(model in arbitrary_model()) {
|
|
let original_count = model.model.data.iter_cells().count();
|
|
let text = format_md(&model);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let loaded_count = loaded.model.data.iter_cells().count();
|
|
prop_assert_eq!(original_count, loaded_count,
|
|
"Cell count changed after round-trip");
|
|
}
|
|
|
|
/// Item names with special characters (brackets, backslashes) round-trip.
|
|
#[test]
|
|
fn tricky_item_names_roundtrip(
|
|
name in prop_oneof![
|
|
"[a-zA-Z]{1,8}",
|
|
Just("[bracketed]".to_string()),
|
|
Just("a\\b".to_string()),
|
|
Just("x [y]".to_string()),
|
|
Just("\\[escaped\\]".to_string()),
|
|
Just("name[0]".to_string()),
|
|
"[ -~]{1,15}",
|
|
]
|
|
) {
|
|
// Item names must not be empty/whitespace or start with markdown syntax
|
|
prop_assume!(!name.is_empty());
|
|
prop_assume!(name.trim() == name); // no leading/trailing whitespace
|
|
prop_assume!(!name.starts_with('#'));
|
|
prop_assume!(!name.starts_with('>'));
|
|
prop_assume!(!name.starts_with('-'));
|
|
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Cat").unwrap();
|
|
m.add_item("Cat", &name).unwrap();
|
|
m.add_category("Dim").unwrap();
|
|
m.add_item("Dim", "X").unwrap();
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let cat = loaded.model.category("Cat").unwrap();
|
|
let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect();
|
|
prop_assert!(item_names.contains(&name.as_str()),
|
|
"Item name {:?} did not round-trip.\nGot: {:?}\n{}",
|
|
name, item_names, text);
|
|
}
|
|
|
|
/// Category names with special characters used in data coordinates.
|
|
#[test]
|
|
fn tricky_category_names_in_data_roundtrip(
|
|
cat_name in prop_oneof![
|
|
"[a-zA-Z]{1,8}",
|
|
Just("Type, Sub".to_string()),
|
|
Just("A=B".to_string()),
|
|
Just("Cat`Name".to_string()),
|
|
Just("Income, Gross".to_string()),
|
|
]
|
|
) {
|
|
prop_assume!(!cat_name.is_empty());
|
|
prop_assume!(!cat_name.starts_with('#'));
|
|
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category(&cat_name).unwrap();
|
|
m.add_item(&cat_name, "X").unwrap();
|
|
m.add_category("Other").unwrap();
|
|
m.add_item("Other", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[(&cat_name, "X"), ("Other", "Y")]),
|
|
CellValue::Number(42.0),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[(&cat_name, "X"), ("Other", "Y")]));
|
|
prop_assert_eq!(got, Some(&CellValue::Number(42.0)),
|
|
"Category name {:?} broke data round-trip.\n{}",
|
|
cat_name, text);
|
|
}
|
|
|
|
/// Item names with special characters used in data coordinates.
|
|
#[test]
|
|
fn tricky_item_names_in_data_roundtrip(
|
|
item_name in prop_oneof![
|
|
"[a-zA-Z]{1,8}",
|
|
Just("A=B".to_string()),
|
|
Just("X, Y".to_string()),
|
|
Just("Item`s".to_string()),
|
|
]
|
|
) {
|
|
prop_assume!(!item_name.is_empty());
|
|
prop_assume!(!item_name.starts_with('#'));
|
|
prop_assume!(!item_name.starts_with('-'));
|
|
prop_assume!(!item_name.starts_with('>'));
|
|
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Cat").unwrap();
|
|
m.add_item("Cat", &item_name).unwrap();
|
|
m.add_category("Dim").unwrap();
|
|
m.add_item("Dim", "V").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("Cat", &item_name), ("Dim", "V")]),
|
|
CellValue::Number(99.0),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("Cat", &item_name), ("Dim", "V")]));
|
|
prop_assert_eq!(got, Some(&CellValue::Number(99.0)),
|
|
"Item name {:?} broke data round-trip.\n{}",
|
|
item_name, text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Additional parser edge-case tests ────────────────────────────────────────
|
|
|
|
#[cfg(test)]
|
|
mod parser_edge_cases {
|
|
use super::parse_md;
|
|
use crate::model::category::Group;
|
|
use crate::model::cell::CellValue;
|
|
use crate::persistence::format_md;
|
|
use crate::persistence::test_helpers::coord;
|
|
use crate::workbook::Workbook;
|
|
|
|
// ── Backtick quoting in coordinates ─────────────────────────────────
|
|
|
|
#[test]
|
|
fn backtick_in_category_name() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Cat`s").unwrap();
|
|
m.add_item("Cat`s", "A").unwrap();
|
|
m.add_category("Dim").unwrap();
|
|
m.add_item("Dim", "X").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("Cat`s", "A"), ("Dim", "X")]),
|
|
CellValue::Number(1.0),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert_eq!(
|
|
loaded
|
|
.model
|
|
.get_cell(&coord(&[("Cat`s", "A"), ("Dim", "X")])),
|
|
Some(&CellValue::Number(1.0)),
|
|
"Backtick in category name broke round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn item_name_with_both_equals_and_comma() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Cat").unwrap();
|
|
m.add_item("Cat", "a=1, b=2").unwrap();
|
|
m.add_category("Dim").unwrap();
|
|
m.add_item("Dim", "X").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("Cat", "a=1, b=2"), ("Dim", "X")]),
|
|
CellValue::Number(7.0),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert_eq!(
|
|
loaded
|
|
.model
|
|
.get_cell(&coord(&[("Cat", "a=1, b=2"), ("Dim", "X")])),
|
|
Some(&CellValue::Number(7.0)),
|
|
"Item with '=' and ', ' broke round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── View section edge cases ─────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn hidden_item_with_slash_in_name() {
|
|
// hidden: Cat/Item — but what if item name contains '/'?
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_item("Type", "A/B").unwrap();
|
|
m.active_view_mut().hide_item("Type", "A/B");
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
// This will likely fail — the parser splits on '/'
|
|
assert!(
|
|
loaded.active_view().is_hidden("Type", "A/B"),
|
|
"Hidden item with '/' in name was corrupted.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collapsed_group_with_slash_in_name() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.active_view_mut()
|
|
.set_group_fold("Type", "Q1/Q2", crate::view::GroupFold::Collapsed);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert!(
|
|
loaded.active_view().group_fold("Type", "Q1/Q2")
|
|
== Some(crate::view::GroupFold::Collapsed),
|
|
"Collapsed group with '/' in name was corrupted.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn view_name_ending_with_active_string() {
|
|
// View name "Not (active)" could be confused with the active marker
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_item("Type", "A").unwrap();
|
|
m.create_view("Not (active)");
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert!(
|
|
loaded.views.contains_key("Not (active)"),
|
|
"View name ending with '(active)' was misinterpreted.\nViews: {:?}\n{text}",
|
|
loaded.views.keys().collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
// ── Group hierarchy edge cases ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn group_name_with_brackets() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Month").unwrap();
|
|
m.add_item_in_group("Month", "Jan", "Q1 [2025]").unwrap();
|
|
m.model
|
|
.category_mut("Month")
|
|
.unwrap()
|
|
.add_group(Group::new("Q1 [2025]").with_parent("Year"));
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let cat = loaded.model.category("Month").unwrap();
|
|
let jan = cat.items.values().find(|i| i.name == "Jan");
|
|
assert!(jan.is_some(), "Jan item missing after round-trip.\n{text}");
|
|
assert_eq!(
|
|
jan.unwrap().group.as_deref(),
|
|
Some("Q1 [2025]"),
|
|
"Group name with brackets was corrupted.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn item_in_group_where_item_has_brackets() {
|
|
// Item "Data [raw]" in group "Input" — the item name has brackets
|
|
// AND the item has a group.
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("Type").unwrap();
|
|
m.add_item_in_group("Type", "Data [raw]", "Input").unwrap();
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let cat = loaded.model.category("Type").unwrap();
|
|
let item = cat.items.values().find(|i| i.name == "Data [raw]");
|
|
assert!(
|
|
item.is_some(),
|
|
"Item 'Data [raw]' with group not found.\nItems: {:?}\n{text}",
|
|
cat.items
|
|
.values()
|
|
.map(|i| (&i.name, &i.group))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(item.unwrap().group.as_deref(), Some("Input"));
|
|
}
|
|
|
|
// ── Malformed input resilience ──────────────────────────────────────
|
|
|
|
#[test]
|
|
fn parse_empty_string() {
|
|
let result = parse_md("");
|
|
assert!(
|
|
result.is_err() || result.unwrap().model.name.is_empty(),
|
|
"Empty input should either error or produce empty model"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_just_model_name() {
|
|
let m = parse_md("v2025-04-09\n# MyModel\n").unwrap();
|
|
assert_eq!(m.model.name, "MyModel");
|
|
}
|
|
|
|
/// Bug improvise-4yc: `unwrap_or(0.0)` fabricated a zero for any token the
|
|
/// grammar admitted but `f64` rejected. Evidence: the test-evidence doc.
|
|
#[test]
|
|
fn unparseable_number_token_yields_error_not_zero() {
|
|
// No token the current grammar admits fails f64 parsing, so exercise
|
|
// the helper directly with forms the grammar could drift toward.
|
|
for bad in ["3.14.15", "1.5e", "--5", ""] {
|
|
let got = super::parse_number_cell(bad);
|
|
match got {
|
|
CellValue::Error(msg) => {
|
|
assert!(
|
|
msg.contains(bad) || bad.is_empty(),
|
|
"error should name {bad:?}: {msg}"
|
|
)
|
|
}
|
|
other => panic!("{bad:?} must not produce {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// improvise-4yc through the grammar: a malformed numeric form routes to
|
|
/// `bare_value` or to a parse error, never to `Number(0.0)`.
|
|
#[test]
|
|
fn malformed_numbers_in_data_never_silently_zero() {
|
|
// "--5" fails the number rule entirely → bare_value → Text
|
|
let text = "v2025-04-09\n# T\n## Data\nType=Food = --5\n";
|
|
let m = parse_md(text).unwrap();
|
|
assert_eq!(
|
|
m.model.get_cell(&coord(&[("Type", "Food")])),
|
|
Some(&CellValue::Text("--5".into()))
|
|
);
|
|
|
|
// "3.14.15" — number matches the "3.14" prefix and commits, the rest
|
|
// fails → whole-file parse error whose message shows the line
|
|
let text = "v2025-04-09\n# T\n## Data\nType=Food = 3.14.15\n";
|
|
let err = parse_md(text).unwrap_err().to_string();
|
|
assert!(err.contains("3.14.15"), "error must name the text: {err}");
|
|
|
|
// "1e999" is admitted; f64 parsing saturates to +inf, not 0.0
|
|
let text = "v2025-04-09\n# T\n## Data\nType=Food = 1e999\n";
|
|
let m = parse_md(text).unwrap();
|
|
assert_eq!(
|
|
m.model.get_cell(&coord(&[("Type", "Food")])),
|
|
Some(&CellValue::Number(f64::INFINITY))
|
|
);
|
|
}
|
|
|
|
/// Bug improvise-6kj: a parse failure must name the line number and quote
|
|
/// the line, for somebody fixing a hand-edited file.
|
|
#[test]
|
|
fn parse_error_names_line_number_and_contents() {
|
|
let text = "v2025-04-09\n# T\n## Data\nType=Food = 3.14.15\n";
|
|
let err = parse_md(text).unwrap_err().to_string();
|
|
assert!(err.contains("line 4"), "missing line number: {err}");
|
|
assert!(
|
|
err.contains("Type=Food = 3.14.15"),
|
|
"missing line contents: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_data_without_value() {
|
|
// Malformed data line: no " = " separator — pest rejects it
|
|
let text = "v2025-04-09\n# Test\n## Data\nType=Food\n";
|
|
assert!(parse_md(text).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_data_with_empty_coords() {
|
|
// Data line with only value, no coordinates — pest rejects it
|
|
let text = "v2025-04-09\n# Test\n## Data\n = 42\n";
|
|
assert!(parse_md(text).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_duplicate_categories() {
|
|
let text = "v2025-04-09\n# Test\n## Category: Type\n- A\n## Category: Type\n- B\n";
|
|
let m = parse_md(text).unwrap();
|
|
let cat = m.model.category("Type").unwrap();
|
|
let item_names: Vec<&str> = cat.items.values().map(|i| i.name.as_str()).collect();
|
|
assert_eq!(
|
|
item_names,
|
|
["A", "B"],
|
|
"the merge dropped a section's items"
|
|
);
|
|
}
|
|
|
|
#[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";
|
|
let m = parse_md(text).unwrap();
|
|
assert!(m.model.category("Empty").is_some());
|
|
assert_eq!(m.model.category("Empty").unwrap().items.len(), 0);
|
|
assert_eq!(m.model.category("Full").unwrap().items.len(), 1);
|
|
}
|
|
|
|
// ── Number formatting edge cases ────────────────────────────────────
|
|
|
|
#[test]
|
|
fn number_negative_zero_roundtrips() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model
|
|
.set_cell(coord(&[("A", "X"), ("B", "Y")]), CellValue::Number(-0.0));
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
|
|
match got {
|
|
Some(CellValue::Number(n)) => assert!(n.abs() == 0.0),
|
|
other => panic!("Expected Number(0.0), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn number_very_large_roundtrips() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("A", "X"), ("B", "Y")]),
|
|
CellValue::Number(1.7976931348623157e308),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
|
|
assert_eq!(
|
|
got,
|
|
Some(&CellValue::Number(1.7976931348623157e308)),
|
|
"f64::MAX did not round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn number_very_small_roundtrips() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("A", "X"), ("B", "Y")]),
|
|
CellValue::Number(5e-324), // f64::MIN_POSITIVE subnormal
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
|
|
assert_eq!(
|
|
got,
|
|
Some(&CellValue::Number(5e-324)),
|
|
"Subnormal float did not round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn number_pi_roundtrips() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("A", "X"), ("B", "Y")]),
|
|
CellValue::Number(std::f64::consts::PI),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
let got = loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")]));
|
|
assert_eq!(
|
|
got,
|
|
Some(&CellValue::Number(std::f64::consts::PI)),
|
|
"PI did not round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Whitespace edge cases ───────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn model_name_with_leading_trailing_spaces() {
|
|
let text = "v2025-04-09\n# Spaced Model \n";
|
|
let m = parse_md(text).unwrap();
|
|
// `line_name` captures everything after "# "; we trim in the builder
|
|
assert_eq!(m.model.name, "Spaced Model");
|
|
}
|
|
|
|
#[test]
|
|
fn category_name_with_trailing_spaces() {
|
|
let text = "v2025-04-09\n# Test\n## Category: Trailing \n- Item\n";
|
|
let m = parse_md(text).unwrap();
|
|
// `line_name` includes trailing spaces; we trim in the builder
|
|
assert!(m.model.category("Trailing").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn data_line_with_extra_whitespace() {
|
|
// With the pest grammar, extra whitespace in data lines is rejected
|
|
let text = "v2025-04-09\n# Test\n## Category: T\n- A\n## Category: M\n- J\n## Data\n T=A , M=J = 42 \n";
|
|
// pest grammar is strict about whitespace — this should fail
|
|
assert!(parse_md(text).is_err());
|
|
}
|
|
|
|
// ── Three-category model ────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn three_categories_round_trip() {
|
|
let mut m = Workbook::new("3D");
|
|
for cat in ["Region", "Product", "Year"] {
|
|
m.add_category(cat).unwrap();
|
|
}
|
|
m.add_item("Region", "East").unwrap();
|
|
m.add_item("Region", "West").unwrap();
|
|
m.add_item("Product", "Widget").unwrap();
|
|
m.add_item("Year", "2025").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("Region", "East"), ("Product", "Widget"), ("Year", "2025")]),
|
|
CellValue::Number(1000.0),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert_eq!(
|
|
loaded.model.get_cell(&coord(&[
|
|
("Region", "East"),
|
|
("Product", "Widget"),
|
|
("Year", "2025")
|
|
])),
|
|
Some(&CellValue::Number(1000.0)),
|
|
"3-category cell did not round-trip.\n{text}"
|
|
);
|
|
}
|
|
|
|
// ── Text value with backslash ───────────────────────────────────────
|
|
|
|
#[test]
|
|
fn text_value_with_backslash() {
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("A", "X"), ("B", "Y")]),
|
|
CellValue::Text("C:\\Users\\file.txt".into()),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert_eq!(
|
|
loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])),
|
|
Some(&CellValue::Text("C:\\Users\\file.txt".into())),
|
|
"Backslash in text was corrupted.\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn text_value_with_backslash_n_literal() {
|
|
// The literal string "\n" (two chars) should not become a newline
|
|
let mut m = Workbook::new("Test");
|
|
m.add_category("A").unwrap();
|
|
m.add_item("A", "X").unwrap();
|
|
m.add_category("B").unwrap();
|
|
m.add_item("B", "Y").unwrap();
|
|
m.model.set_cell(
|
|
coord(&[("A", "X"), ("B", "Y")]),
|
|
CellValue::Text("literal \\n not newline".into()),
|
|
);
|
|
|
|
let text = format_md(&m);
|
|
let loaded = parse_md(&text).unwrap();
|
|
assert_eq!(
|
|
loaded.model.get_cell(&coord(&[("A", "X"), ("B", "Y")])),
|
|
Some(&CellValue::Text("literal \\n not newline".into())),
|
|
"Literal backslash-n was corrupted.\n{text}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Grammar-walking file generator: reads `improv.pest` at test time, so a new
|
|
// production needs generator coverage — `context/code/formula-and-io.md` ─────
|
|
|
|
#[cfg(test)]
|
|
mod generator {
|
|
use pest_meta::ast::{Expr, RuleType};
|
|
use pest_meta::parser;
|
|
use proptest::prelude::*;
|
|
use std::collections::HashMap;
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
/// Counts how often the grammar text is actually re-read; the guard below
|
|
/// is what keeps `grammar()` from becoming a per-case parse again.
|
|
static GRAMMAR_PARSES: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// The parsed grammar, read once for the process. Evidence:
|
|
/// `context/test-evidence/formula-and-io.md`.
|
|
fn grammar() -> &'static HashMap<String, (RuleType, Expr)> {
|
|
static RULES: OnceLock<HashMap<String, (RuleType, Expr)>> = OnceLock::new();
|
|
RULES.get_or_init(load_grammar)
|
|
}
|
|
|
|
/// Parse the grammar file and return rules keyed by name.
|
|
fn load_grammar() -> HashMap<String, (RuleType, Expr)> {
|
|
GRAMMAR_PARSES.fetch_add(1, Ordering::Relaxed);
|
|
let grammar = include_str!("improv.pest");
|
|
let pairs = parser::parse(parser::Rule::grammar_rules, grammar)
|
|
.unwrap_or_else(|e| panic!("Bad grammar: {e}"));
|
|
let rules = parser::consume_rules(pairs).unwrap_or_else(|e| panic!("{e:?}"));
|
|
rules
|
|
.into_iter()
|
|
.map(|r| (r.name.clone(), (r.ty, r.expr)))
|
|
.collect()
|
|
}
|
|
|
|
/// Recursive string generator driven by a pest `Expr`; `choices` is consumed
|
|
/// left-to-right, and running out picks the smallest alternative.
|
|
struct Gen<'g> {
|
|
rules: &'g HashMap<String, (RuleType, Expr)>,
|
|
choices: Vec<u8>,
|
|
pos: usize,
|
|
}
|
|
|
|
impl<'g> Gen<'g> {
|
|
fn new(rules: &'g HashMap<String, (RuleType, Expr)>, choices: Vec<u8>) -> Self {
|
|
Self {
|
|
rules,
|
|
choices,
|
|
pos: 0,
|
|
}
|
|
}
|
|
|
|
/// Consume one byte of entropy, defaulting to 0.
|
|
fn pick(&mut self) -> u8 {
|
|
let v = self.choices.get(self.pos).copied().unwrap_or(0);
|
|
self.pos += 1;
|
|
v
|
|
}
|
|
|
|
fn emit(&mut self, expr: &Expr, out: &mut String) {
|
|
match expr {
|
|
Expr::Str(s) => out.push_str(s),
|
|
Expr::Range(lo, hi) => {
|
|
let lo = lo.chars().next().unwrap() as u32;
|
|
let hi = hi.chars().next().unwrap() as u32;
|
|
let range = hi - lo + 1;
|
|
let ch = char::from_u32(lo + (self.pick() as u32 % range)).unwrap();
|
|
out.push(ch);
|
|
}
|
|
Expr::Ident(name) => {
|
|
// Built-in pest rules
|
|
match name.as_str() {
|
|
"ANY" => {
|
|
let ch = (b'a' + self.pick() % 26) as char;
|
|
out.push(ch);
|
|
}
|
|
"NEWLINE" => out.push('\n'),
|
|
"SOI" | "EOI" => {}
|
|
"ASCII_DIGIT" => {
|
|
let d = (b'0' + self.pick() % 10) as char;
|
|
out.push(d);
|
|
}
|
|
_ => {
|
|
// Look up user-defined rule
|
|
if let Some((_ty, expr)) = self.rules.get(name) {
|
|
self.emit(expr, out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Expr::Seq(a, b) => {
|
|
self.emit(a, out);
|
|
self.emit(b, out);
|
|
}
|
|
Expr::Choice(a, b) => {
|
|
// Collect all choices (right-associated)
|
|
let mut alts: Vec<&Expr> = vec![a.as_ref()];
|
|
let mut cur = b.as_ref();
|
|
while let Expr::Choice(l, r) = cur {
|
|
alts.push(l.as_ref());
|
|
cur = r.as_ref();
|
|
}
|
|
alts.push(cur);
|
|
let idx = self.pick() as usize % alts.len();
|
|
self.emit(alts[idx], out);
|
|
}
|
|
Expr::Opt(inner) => {
|
|
if !self.pick().is_multiple_of(3) {
|
|
// ~66% chance of emitting
|
|
self.emit(inner, out);
|
|
}
|
|
}
|
|
Expr::Rep(inner) => {
|
|
// 0..N repetitions
|
|
let count = self.pick() % 4;
|
|
for _ in 0..count {
|
|
self.emit(inner, out);
|
|
}
|
|
}
|
|
Expr::RepOnce(inner) => {
|
|
// 1..N repetitions
|
|
let count = 1 + self.pick() % 3;
|
|
for _ in 0..count {
|
|
self.emit(inner, out);
|
|
}
|
|
}
|
|
Expr::NegPred(_) | Expr::PosPred(_) => {
|
|
// Lookaheads don't produce output
|
|
}
|
|
_ => {
|
|
// Skip unsupported expressions
|
|
}
|
|
}
|
|
}
|
|
|
|
fn generate(&mut self, rule_name: &str) -> String {
|
|
let mut out = String::new();
|
|
if let Some((_ty, expr)) = self.rules.get(rule_name).cloned() {
|
|
self.emit(&expr, &mut out);
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Proptest strategy: generate a valid `.improv` file by walking the grammar.
|
|
pub fn improv_file() -> impl Strategy<Value = String> {
|
|
// Use random bytes as entropy for choices in the grammar walk
|
|
prop::collection::vec(any::<u8>(), 64..=256).prop_map(|choices| {
|
|
let mut g = Gen::new(grammar(), choices);
|
|
g.generate("file")
|
|
})
|
|
}
|
|
|
|
/// improvise-felo.6: the round-trip properties see `expanded(N):` lines only
|
|
/// if the walk emits them, depths included.
|
|
#[test]
|
|
fn the_generator_writes_expanded_lines_with_and_without_a_depth() {
|
|
use proptest::strategy::ValueTree;
|
|
use proptest::test_runner::TestRunner;
|
|
|
|
let mut runner = TestRunner::deterministic();
|
|
let strategy = improv_file();
|
|
let files: Vec<String> = (0..512)
|
|
.map(|_| strategy.new_tree(&mut runner).unwrap().current())
|
|
.collect();
|
|
let lines = || files.iter().flat_map(|f| f.lines());
|
|
assert!(lines().any(|l| l.starts_with("expanded(")), "a depth");
|
|
assert!(lines().any(|l| l.starts_with("expanded: ")), "a bare line");
|
|
assert!(lines().any(|l| l.starts_with("collapsed: ")), "a collapse");
|
|
}
|
|
|
|
/// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`.
|
|
#[test]
|
|
fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() {
|
|
use proptest::strategy::ValueTree;
|
|
use proptest::test_runner::TestRunner;
|
|
|
|
let mut runner = TestRunner::deterministic();
|
|
let strategy = improv_file();
|
|
for _ in 0..64 {
|
|
let _ = strategy.new_tree(&mut runner).unwrap().current();
|
|
}
|
|
assert_eq!(
|
|
GRAMMAR_PARSES.load(Ordering::Relaxed),
|
|
1,
|
|
"improv.pest was re-parsed per generated case"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod grammar_prop_tests {
|
|
use super::{Refusal, generator, parse_md};
|
|
|
|
use crate::persistence::format_md;
|
|
use proptest::prelude::*;
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig::with_cases(256))]
|
|
|
|
/// parse(generate()) — every generated file parses, or is refused for a
|
|
/// named reason the grammar cannot express (a `Refusal`, improvise-felo.6).
|
|
#[test]
|
|
fn generated_file_parses(file in generator::improv_file()) {
|
|
let result = parse_md(&file);
|
|
let refused = result.as_ref().err().is_some_and(|e| e.downcast_ref::<Refusal>().is_some());
|
|
prop_assert!(result.is_ok() || refused,
|
|
"Generated file failed to parse:\n{}\nError: {}",
|
|
file, result.unwrap_err());
|
|
}
|
|
|
|
/// parse(print(parse(generate()))) — round-trip through format is stable.
|
|
#[test]
|
|
fn generated_file_roundtrips(file in generator::improv_file()) {
|
|
let result1 = parse_md(&file);
|
|
// Skip inputs that don't parse (the grammar walk may produce
|
|
// degenerate inputs like empty model names)
|
|
prop_assume!(result1.is_ok());
|
|
let model1 = result1.unwrap();
|
|
let printed = format_md(&model1);
|
|
let model2_result = parse_md(&printed);
|
|
prop_assert!(model2_result.is_ok(),
|
|
"Re-formatted file failed to parse:\n{}\nError: {}",
|
|
printed, model2_result.unwrap_err());
|
|
|
|
let model2 = model2_result.unwrap();
|
|
|
|
// Model name preserved
|
|
prop_assert_eq!(&model1.model.name, &model2.model.name);
|
|
|
|
// Category count preserved
|
|
prop_assert_eq!(
|
|
model1.model.categories.len(),
|
|
model2.model.categories.len(),
|
|
"Category count changed"
|
|
);
|
|
|
|
// Cell count preserved
|
|
let count1 = model1.model.data.iter_cells().count();
|
|
let count2 = model2.model.data.iter_cells().count();
|
|
prop_assert_eq!(count1, count2,
|
|
"Cell count changed: {} → {}\nOriginal:\n{}\nRe-formatted:\n{}",
|
|
count1, count2, file, printed);
|
|
|
|
// Double round-trip: format(parse(format(parse(gen)))) == format(parse(gen))
|
|
let printed2 = format_md(&model2);
|
|
prop_assert_eq!(&printed, &printed2,
|
|
"format→parse→format not idempotent");
|
|
}
|
|
}
|
|
}
|