fix: make .improv parser order-independent via two-pass approach
Root cause: set_axis silently ignores unregistered categories, so a view section appearing before its categories would produce wrong axis assignments when on_category_added later ran and assigned defaults. Fix: collect all raw data in pass 1, then build the model in the correct dependency order in pass 2 (categories → views → data/formulas). The file can now list sections in any order. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -144,170 +144,193 @@ pub fn format_md(model: &Model) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the markdown `.improv` format into a Model.
|
/// Parse the markdown `.improv` format into a Model.
|
||||||
|
///
|
||||||
|
/// Uses a two-pass approach so the file is order-independent:
|
||||||
|
/// pass 1 collects raw data, pass 2 builds the model with categories
|
||||||
|
/// registered before views are configured.
|
||||||
pub fn parse_md(text: &str) -> Result<Model> {
|
pub fn parse_md(text: &str) -> Result<Model> {
|
||||||
|
// ── Intermediate types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
struct PCategory {
|
||||||
|
name: String,
|
||||||
|
items: Vec<(String, Option<String>)>, // (name, group)
|
||||||
|
group_parents: Vec<(String, String)>, // (group, parent)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PView {
|
||||||
|
name: String,
|
||||||
|
is_active: bool,
|
||||||
|
axes: Vec<(String, Axis)>,
|
||||||
|
page_selections: Vec<(String, String)>,
|
||||||
|
format: String,
|
||||||
|
hidden: Vec<(String, String)>,
|
||||||
|
collapsed: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pass 1: collect ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(PartialEq)]
|
||||||
enum Section { None, Category, Formulas, Data, View }
|
enum Section { None, Category, Formulas, Data, View }
|
||||||
|
|
||||||
let mut model: Option<Model> = None;
|
let mut model_name: Option<String> = None;
|
||||||
let mut current_cat = String::new();
|
let mut categories: Vec<PCategory> = Vec::new();
|
||||||
let mut current_view = String::new();
|
let mut formulas: Vec<(String, String)> = Vec::new(); // (raw, category)
|
||||||
let mut active_view_name = String::new();
|
let mut data: Vec<(CellKey, CellValue)> = Vec::new();
|
||||||
|
let mut views: Vec<PView> = Vec::new();
|
||||||
let mut section = Section::None;
|
let mut section = Section::None;
|
||||||
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if trimmed.is_empty() { continue; }
|
if trimmed.is_empty() { continue; }
|
||||||
|
|
||||||
// Model title
|
|
||||||
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
|
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
|
||||||
model = Some(Model::new(trimmed[2..].trim()));
|
model_name = Some(trimmed[2..].trim().to_string());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let m = match model.as_mut() {
|
|
||||||
Some(m) => m,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Section headers
|
|
||||||
if let Some(rest) = trimmed.strip_prefix("## Category: ") {
|
if let Some(rest) = trimmed.strip_prefix("## Category: ") {
|
||||||
current_cat = rest.trim().to_string();
|
categories.push(PCategory { name: rest.trim().to_string(),
|
||||||
m.add_category(¤t_cat)?;
|
items: Vec::new(), group_parents: Vec::new() });
|
||||||
section = Section::Category;
|
section = Section::Category;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if trimmed == "## Formulas" {
|
if trimmed == "## Formulas" { section = Section::Formulas; continue; }
|
||||||
section = Section::Formulas;
|
if trimmed == "## Data" { section = Section::Data; continue; }
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if trimmed == "## Data" {
|
|
||||||
section = Section::Data;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(rest) = trimmed.strip_prefix("## View: ") {
|
if let Some(rest) = trimmed.strip_prefix("## View: ") {
|
||||||
let (name, is_active) = match rest.trim().strip_suffix(" (active)") {
|
let (name, is_active) = match rest.trim().strip_suffix(" (active)") {
|
||||||
Some(n) => (n.trim().to_string(), true),
|
Some(n) => (n.trim().to_string(), true),
|
||||||
None => (rest.trim().to_string(), false),
|
None => (rest.trim().to_string(), false),
|
||||||
};
|
};
|
||||||
if is_active { active_view_name = name.clone(); }
|
views.push(PView { name, is_active, axes: Vec::new(),
|
||||||
if !m.views.contains_key(&name) { m.create_view(&name); }
|
page_selections: Vec::new(), format: String::new(),
|
||||||
current_view = name;
|
hidden: Vec::new(), collapsed: Vec::new() });
|
||||||
section = Section::View;
|
section = Section::View;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if trimmed.starts_with("## ") { continue; }
|
if trimmed.starts_with("## ") { continue; }
|
||||||
|
|
||||||
match section {
|
match section {
|
||||||
Section::Category => parse_category_line(trimmed, ¤t_cat, m),
|
Section::Category => {
|
||||||
Section::Formulas => parse_formula_line(trimmed, m)?,
|
let Some(cat) = categories.last_mut() else { continue };
|
||||||
Section::Data => parse_data_line(trimmed, m),
|
if let Some(rest) = trimmed.strip_prefix("- ") {
|
||||||
Section::View => parse_view_line(trimmed, ¤t_view, m)?,
|
let (name, group) = parse_bracketed(rest);
|
||||||
|
cat.items.push((name.to_string(), group.map(str::to_string)));
|
||||||
|
} else if let Some(rest) = trimmed.strip_prefix("> ") {
|
||||||
|
let (group, parent) = parse_bracketed(rest);
|
||||||
|
if let Some(p) = parent {
|
||||||
|
cat.group_parents.push((group.to_string(), p.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section::Formulas => {
|
||||||
|
if let Some(rest) = trimmed.strip_prefix("- ") {
|
||||||
|
let (raw, cat) = parse_bracketed(rest);
|
||||||
|
if let Some(c) = cat {
|
||||||
|
formulas.push((raw.to_string(), c.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section::Data => {
|
||||||
|
let Some(sep) = trimmed.find(" = ") else { continue };
|
||||||
|
let coords: Vec<(String, String)> = trimmed[..sep].split(", ")
|
||||||
|
.filter_map(|p| { let (c, i) = p.split_once('=')?;
|
||||||
|
Some((c.trim().to_string(), i.trim().to_string())) })
|
||||||
|
.collect();
|
||||||
|
if coords.is_empty() { continue; }
|
||||||
|
let vs = trimmed[sep + 3..].trim();
|
||||||
|
let value = if let Some(s) = vs.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
||||||
|
CellValue::Text(s.to_string())
|
||||||
|
} else if let Ok(n) = vs.parse::<f64>() {
|
||||||
|
CellValue::Number(n)
|
||||||
|
} else {
|
||||||
|
CellValue::Text(vs.to_string())
|
||||||
|
};
|
||||||
|
data.push((CellKey::new(coords), value));
|
||||||
|
}
|
||||||
|
Section::View => {
|
||||||
|
let Some(view) = views.last_mut() else { continue };
|
||||||
|
if let Some(fmt) = trimmed.strip_prefix("format: ") {
|
||||||
|
view.format = fmt.trim().to_string();
|
||||||
|
} else if let Some(rest) = trimmed.strip_prefix("hidden: ") {
|
||||||
|
if let Some((c, i)) = rest.trim().split_once('/') {
|
||||||
|
view.hidden.push((c.trim().to_string(), i.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else if let Some(rest) = trimmed.strip_prefix("collapsed: ") {
|
||||||
|
if let Some((c, g)) = rest.trim().split_once('/') {
|
||||||
|
view.collapsed.push((c.trim().to_string(), g.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else if let Some(colon) = trimmed.find(": ") {
|
||||||
|
let cat = trimmed[..colon].trim();
|
||||||
|
let rest = trimmed[colon + 2..].trim();
|
||||||
|
if let Some(sel_rest) = rest.strip_prefix("page") {
|
||||||
|
view.axes.push((cat.to_string(), Axis::Page));
|
||||||
|
if let Some(sel) = sel_rest.strip_prefix(", ") {
|
||||||
|
view.page_selections.push((cat.to_string(), sel.trim().to_string()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let axis = match rest { "row" => Axis::Row, "column" => Axis::Column,
|
||||||
|
_ => continue };
|
||||||
|
view.axes.push((cat.to_string(), axis));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Section::None => {}
|
Section::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut m = model.ok_or_else(|| anyhow::anyhow!("Empty or invalid .improv file"))?;
|
// ── Pass 2: build ─────────────────────────────────────────────────────────
|
||||||
if !active_view_name.is_empty() && m.views.contains_key(&active_view_name) {
|
|
||||||
m.active_view = active_view_name;
|
|
||||||
}
|
|
||||||
Ok(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_category_line(line: &str, cat_name: &str, m: &mut Model) {
|
let name = model_name.ok_or_else(|| anyhow::anyhow!("Missing model title (# Name)"))?;
|
||||||
if let Some(rest) = line.strip_prefix("- ") {
|
let mut m = Model::new(&name);
|
||||||
// Item line: "- ItemName" or "- ItemName [GroupName]"
|
|
||||||
let (item_name, group) = parse_bracketed(rest);
|
// Categories first — registers them with all existing views via on_category_added
|
||||||
if let Some(c) = m.category_mut(cat_name) {
|
for pc in &categories {
|
||||||
|
m.add_category(&pc.name)?;
|
||||||
|
let cat = m.category_mut(&pc.name).unwrap();
|
||||||
|
for (item_name, group) in &pc.items {
|
||||||
match group {
|
match group {
|
||||||
Some(g) => {
|
Some(g) => {
|
||||||
c.add_item_in_group(item_name, g);
|
cat.add_item_in_group(item_name, g);
|
||||||
if !c.groups.iter().any(|existing| existing.name == g) {
|
if !cat.groups.iter().any(|e| &e.name == g) {
|
||||||
c.add_group(Group::new(g));
|
cat.add_group(Group::new(g));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => { c.add_item(item_name); }
|
None => { cat.add_item(item_name); }
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(rest) = line.strip_prefix("> ") {
|
|
||||||
// Group hierarchy line: "> GroupName [ParentName]"
|
|
||||||
let (group_name, parent) = parse_bracketed(rest);
|
|
||||||
if let Some(parent_name) = parent {
|
|
||||||
if let Some(c) = m.category_mut(cat_name) {
|
|
||||||
match c.groups.iter_mut().find(|g| g.name == group_name) {
|
|
||||||
Some(g) => g.parent = Some(parent_name.to_string()),
|
|
||||||
None => c.add_group(Group::new(group_name).with_parent(parent_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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_formula_line(line: &str, m: &mut Model) -> Result<()> {
|
// Views — all categories are now registered, so set_axis works correctly
|
||||||
if let Some(rest) = line.strip_prefix("- ") {
|
let mut active_view = String::new();
|
||||||
let (raw, cat) = parse_bracketed(rest);
|
for pv in &views {
|
||||||
if let Some(cat_name) = cat {
|
if pv.is_active { active_view = pv.name.clone(); }
|
||||||
let formula = parse_formula(raw, cat_name)
|
if !m.views.contains_key(&pv.name) { m.create_view(&pv.name); }
|
||||||
.with_context(|| format!("Formula: {raw}"))?;
|
let view = m.views.get_mut(&pv.name).unwrap();
|
||||||
m.add_formula(formula);
|
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(); }
|
||||||
|
for (cat, item) in &pv.hidden { view.hide_item(cat, item); }
|
||||||
|
for (cat, grp) in &pv.collapsed { view.toggle_group_collapse(cat, grp); }
|
||||||
}
|
}
|
||||||
}
|
if !active_view.is_empty() && m.views.contains_key(&active_view) {
|
||||||
Ok(())
|
m.active_view = active_view;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_data_line(line: &str, m: &mut Model) {
|
// Formulas and data can go in any order relative to each other
|
||||||
// "Cat1=Item1, Cat2=Item2 = value"
|
for (raw, cat_name) in &formulas {
|
||||||
let Some(sep) = line.find(" = ") else { return };
|
m.add_formula(parse_formula(raw, cat_name)
|
||||||
let coords_str = &line[..sep];
|
.with_context(|| format!("Formula: {raw}"))?);
|
||||||
let value_str = line[sep + 3..].trim();
|
}
|
||||||
let coords: Vec<(String, String)> = coords_str.split(", ")
|
for (key, value) in data {
|
||||||
.filter_map(|part| {
|
m.set_cell(key, value);
|
||||||
let (cat, item) = part.split_once('=')?;
|
|
||||||
Some((cat.trim().to_string(), item.trim().to_string()))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if coords.is_empty() { return; }
|
|
||||||
let value = if let Some(inner) = value_str.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
|
|
||||||
CellValue::Text(inner.to_string())
|
|
||||||
} else if let Ok(n) = value_str.parse::<f64>() {
|
|
||||||
CellValue::Number(n)
|
|
||||||
} else {
|
|
||||||
CellValue::Text(value_str.to_string())
|
|
||||||
};
|
|
||||||
m.set_cell(CellKey::new(coords), value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_view_line(line: &str, view_name: &str, m: &mut Model) -> Result<()> {
|
Ok(m)
|
||||||
let view = m.views.get_mut(view_name)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("View not found: {}", view_name))?;
|
|
||||||
|
|
||||||
if let Some(fmt) = line.strip_prefix("format: ") {
|
|
||||||
view.number_format = fmt.trim().to_string();
|
|
||||||
} else if let Some(rest) = line.strip_prefix("hidden: ") {
|
|
||||||
if let Some((cat, item)) = rest.trim().split_once('/') {
|
|
||||||
view.hide_item(cat.trim(), item.trim());
|
|
||||||
}
|
|
||||||
} else if let Some(rest) = line.strip_prefix("collapsed: ") {
|
|
||||||
if let Some((cat, group)) = rest.trim().split_once('/') {
|
|
||||||
view.toggle_group_collapse(cat.trim(), group.trim());
|
|
||||||
}
|
|
||||||
} else if let Some(colon) = line.find(": ") {
|
|
||||||
let cat = line[..colon].trim();
|
|
||||||
let rest = line[colon + 2..].trim();
|
|
||||||
if let Some(sel_rest) = rest.strip_prefix("page") {
|
|
||||||
view.set_axis(cat, Axis::Page);
|
|
||||||
if let Some(sel) = sel_rest.strip_prefix(", ") {
|
|
||||||
view.set_page_selection(cat, sel.trim());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let axis = match rest {
|
|
||||||
"row" => Axis::Row,
|
|
||||||
"column" => Axis::Column,
|
|
||||||
_ => return Ok(()),
|
|
||||||
};
|
|
||||||
view.set_axis(cat, axis);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split `"Name [Bracket]"` → `("Name", Some("Bracket"))` or `("Name", None)`.
|
/// Split `"Name [Bracket]"` → `("Name", Some("Bracket"))` or `("Name", None)`.
|
||||||
@ -585,6 +608,65 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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 = "# Test\n\
|
||||||
|
## View: Default (active)\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() {
|
||||||
|
// A non-Default view with swapped axes, declared before categories exist.
|
||||||
|
let text = "# Test\n\
|
||||||
|
## View: Transposed (active)\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 = "# Test\n\
|
||||||
|
## Data\n\
|
||||||
|
Month=Jan, Type=Food = 42\n\
|
||||||
|
## Category: Type\n\
|
||||||
|
- Food\n\
|
||||||
|
## Category: Month\n\
|
||||||
|
- Jan\n\
|
||||||
|
## View: Default (active)\n\
|
||||||
|
Type: row\n\
|
||||||
|
Month: column\n";
|
||||||
|
let m = parse_md(text).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
m.get_cell(&coord(&[("Month", "Jan"), ("Type", "Food")])),
|
||||||
|
Some(&CellValue::Number(42.0))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_dispatcher_detects_legacy_json_by_brace() {
|
fn load_dispatcher_detects_legacy_json_by_brace() {
|
||||||
// The load() function routes to JSON deserializer when text starts with '{'
|
// The load() function routes to JSON deserializer when text starts with '{'
|
||||||
|
|||||||
Reference in New Issue
Block a user