Skip to main content

xl3_core/
plan.rs

1//! Template plan: the parsed, evaluation-ready representation of a
2//! template workbook.
3//!
4//! Phase 1 P1-A scope:
5//! - parse the workbook's reserved `__config__` sheet into `ConfigMeta`
6//! - for every non-reserved, visible sheet, classify each row as either
7//!   a `RowPlan::Static` (copy as-is) or a `RowPlan::ExpandDown`
8//!   (repeat once per source row)
9//!
10//! Auto-detection (xl3 0.x default): a row is an expansion row iff
11//! any cell in that row contains `{{ ... }}`. Explicit `#block` /
12//! `@repeat` directives land in later milestones.
13
14use std::collections::HashMap;
15use std::path::Path;
16use std::sync::Arc;
17
18use anyhow::{bail, Context, Result};
19
20use crate::calamine::{open_workbook, Data as CData, Reader, Xlsx};
21use crate::directives::{parse_directive_cell, Direction, Directive};
22use crate::styles::{self, NumFmtKind, TemplateStyles};
23use crate::value::Value;
24
25#[derive(Debug, Default, Clone)]
26pub struct ConfigMeta {
27    pub values: HashMap<String, String>,
28}
29
30impl ConfigMeta {
31    pub fn get(&self, key: &str) -> Option<&str> {
32        self.values.get(key).map(String::as_str)
33    }
34    pub fn source_sheet(&self) -> Option<&str> {
35        self.get("source_sheet")
36    }
37    pub fn output_file_pattern(&self) -> Option<&str> {
38        self.get("output_file_pattern")
39    }
40    /// File-group keys extracted from `output_file_pattern` — mirrors
41    /// xl3 (TS)'s `extractGroupKeys`. Each `{{ ... }}` block whose
42    /// payload is a bare identifier (or `[Identifier]`) names a source
43    /// column the renderer should partition output files by.
44    /// Operators, function calls, namespaced refs (`__inputs__[…]`)
45    /// and identifiers starting with `.` or `_` are skipped.
46    pub fn file_group_keys(&self) -> Vec<String> {
47        match self.output_file_pattern() {
48            Some(p) => extract_group_keys(p),
49            None => Vec::new(),
50        }
51    }
52    /// Parse the `source_table` config value (xl3 evaluation.md
53    /// "Source Data Model"). Returns the default — first row as
54    /// header, data continues to the end of the sheet — when the
55    /// value is missing or unrecognised.
56    pub fn source_table(&self) -> SourceTable {
57        self.get("source_table")
58            .map(parse_source_table)
59            .unwrap_or(SourceTable::HeaderRow(1))
60    }
61}
62
63fn extract_group_keys(pattern: &str) -> Vec<String> {
64    let mut keys = Vec::new();
65    let mut rest = pattern;
66    while let Some(open) = rest.find("{{") {
67        let after_open = &rest[open + 2..];
68        let close = match after_open.find("}}") {
69            Some(c) => c,
70            None => break,
71        };
72        let expr = after_open[..close].trim();
73        rest = &after_open[close + 2..];
74        // Strip a `[...]` wrapper so `{{ [Department] }}` becomes
75        // `Department` — same shape xl3 (TS) emits.
76        let raw = expr
77            .strip_prefix('[')
78            .and_then(|s| s.strip_suffix(']'))
79            .map(str::trim)
80            .unwrap_or(expr);
81        if raw.is_empty() {
82            continue;
83        }
84        if raw.starts_with('.') || raw.starts_with('_') {
85            continue;
86        }
87        // Reject anything that looks like an expression rather than a
88        // bare column name.
89        if raw
90            .chars()
91            .any(|c| matches!(c, ' ' | '|' | '+' | '*' | '/' | '-' | '>' | '<' | '=' | '!' | '&' | '(' | ')' | '[' | ']' | ','))
92        {
93            continue;
94        }
95        if !keys.iter().any(|k: &String| k == raw) {
96            keys.push(raw.to_string());
97        }
98    }
99    keys
100}
101
102/// How the source sheet is interpreted.
103#[derive(Debug, Clone, PartialEq)]
104pub enum SourceTable {
105    /// `source_table: 1` (default) or `source_table: 3` — the named
106    /// row (1-based) is the header. Data rows continue until the end
107    /// of the sheet (modulo blank-row handling per ADR-0007).
108    HeaderRow(usize),
109    /// `source_table: B3:D4` (closed) or `B3:D` / `B3` (open-ended) —
110    /// the first row is the header, columns are constrained, and the
111    /// bottom row is either explicit (`last_row = Some(...)`) or
112    /// "until the end of the used range" (`None`). Likewise
113    /// `last_col` may be `None` to mean "until the rightmost used
114    /// column".
115    Range {
116        first_row: usize,         // 1-based
117        last_row: Option<usize>,  // 1-based, inclusive; None = open-ended
118        first_col: usize,         // 1-based
119        last_col: Option<usize>,  // 1-based, inclusive; None = open-ended
120    },
121}
122
123fn parse_source_table(raw: &str) -> SourceTable {
124    let s = raw.trim();
125    if let Ok(n) = s.parse::<usize>() {
126        return SourceTable::HeaderRow(n.max(1));
127    }
128    if let Some((a, b)) = s.split_once(':') {
129        let lhs = parse_a1_part(a.trim());
130        let rhs = parse_a1_part(b.trim());
131        if let (Some((Some(r1), Some(c1))), Some((r2, c2))) = (lhs, rhs) {
132            let (first_row, last_row) = match r2 {
133                Some(r2) => (r1.min(r2), Some(r1.max(r2))),
134                None => (r1, None),
135            };
136            let (first_col, last_col) = match c2 {
137                Some(c2) => (c1.min(c2), Some(c1.max(c2))),
138                None => (c1, None),
139            };
140            return SourceTable::Range {
141                first_row,
142                last_row,
143                first_col,
144                last_col,
145            };
146        }
147    }
148    SourceTable::HeaderRow(1)
149}
150
151/// Parse one half of an A1 range — accepts `B3` (cell), `B` (column
152/// only) or `3` (row only). Returns `(row?, col?)` with 1-based
153/// indices and `None` for whichever component wasn't present.
154fn parse_a1_part(s: &str) -> Option<(Option<usize>, Option<usize>)> {
155    if s.is_empty() {
156        return None;
157    }
158    let bytes = s.as_bytes();
159    let mut i = 0;
160    let mut col = 0usize;
161    while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
162        let c = bytes[i].to_ascii_uppercase();
163        col = col * 26 + (c - b'A' + 1) as usize;
164        i += 1;
165    }
166    let col_opt = if col == 0 { None } else { Some(col) };
167    let row_opt = if i == bytes.len() {
168        None
169    } else {
170        let row: usize = std::str::from_utf8(&bytes[i..]).ok()?.parse().ok()?;
171        if row == 0 {
172            None
173        } else {
174            Some(row)
175        }
176    };
177    if col_opt.is_none() && row_opt.is_none() {
178        return None;
179    }
180    Some((row_opt, col_opt))
181}
182
183#[derive(Debug, Clone)]
184pub enum CellSource {
185    Empty,
186    Literal(Value),
187    /// Contains at least one `{{ ... }}` expression block. `num_fmt`
188    /// is the classified numFmt of the underlying *template* cell,
189    /// used by ADR-0003 single-expression coercion at render time.
190    /// `format_code` is the raw numFmt string from the template (e.g.
191    /// `"0.00"` or `"yyyy-mm-dd"`) — preserved so the output writer
192    /// can emit the same display format on the rendered cell.
193    /// `style_idx` is the index into the host-supplied
194    /// `StyleManifest::styles` table, populated when the renderer
195    /// receives a manifest (Phase 2 Task 2.2). `None` when the host
196    /// didn't ship a manifest or the template cell wasn't styled.
197    Template {
198        text: String,
199        num_fmt: NumFmtKind,
200        format_code: Option<String>,
201        style_idx: Option<usize>,
202    },
203    /// `{{ @subtotal <FN>(<ColumnRef>) }}` — emitted at the end of
204    /// each group when the enclosing block has a `@group` directive.
205    /// `aggregate` is normalised to uppercase; `field` is the bare
206    /// column name (Phase-1 scope: no `Source[Field]` form).
207    Subtotal {
208        aggregate: String,
209        field: String,
210    },
211    /// A native Excel formula in a template cell. ADR-0021 (static
212    /// cell) and ADR-0046 (cell inside an expansion block): the
213    /// formula text is preserved verbatim — references are NOT
214    /// adjusted to match the cloned row's position. The `cached`
215    /// value is what calamine read from the template, used by Stage
216    /// 1 conformance comparison and by Excel until it recalculates.
217    CellFormula {
218        text: String,
219        cached: Value,
220        format_code: Option<String>,
221        style_idx: Option<usize>,
222    },
223}
224
225impl CellSource {
226    pub fn is_template(&self) -> bool {
227        matches!(self, CellSource::Template { .. })
228    }
229}
230
231/// Try to recognise a cell whose text is a single
232/// `{{ @subtotal <FN>(<ColumnRef>) }}` expression. Returns
233/// `(aggregate, field)` when the shape matches.
234fn parse_subtotal_cell(text: &str) -> Option<(String, String)> {
235    let trimmed = text.trim();
236    let inner = trimmed.strip_prefix("{{")?.strip_suffix("}}")?;
237    let body = inner.trim().strip_prefix("@subtotal")?.trim();
238    let paren_open = body.find('(')?;
239    let fn_name = body[..paren_open].trim();
240    let after = &body[paren_open + 1..];
241    let paren_close = after.rfind(')')?;
242    let arg = after[..paren_close].trim();
243    // Phase-1: only the bare `[Field]` form. `Source[Field]` is a
244    // future extension.
245    let field = arg
246        .strip_prefix('[')
247        .and_then(|s| s.strip_suffix(']'))
248        .map(str::trim)
249        .filter(|s| !s.is_empty())?;
250    Some((fn_name.to_ascii_uppercase(), field.to_string()))
251}
252
253#[derive(Debug, Clone)]
254pub enum RowPlan {
255    Static(Vec<CellSource>),
256    ExpandDown {
257        cells: Vec<CellSource>,
258        directives: Vec<Directive>,
259        /// Rows that follow the expansion row and contribute their
260        /// subtotal cells once per group (when `@group` is active).
261        /// Always empty when no `@group` directive is in scope.
262        subtotal_rows: Vec<Vec<CellSource>>,
263        /// Rows that follow the expansion row and contribute *side*
264        /// (outside-col-range) cells per ADR-0066. Each side row maps
265        /// onto the corresponding subsequent source-row position.
266        /// Always empty when no side cells were absorbed.
267        side_rows: Vec<Vec<CellSource>>,
268        /// Inclusive (first, last) column range that the expansion
269        /// templates occupy. Cells outside this range are "side"
270        /// cells that follow ADR-0066 column-scoped splice semantics.
271        /// `None` when there are no template cells (degenerate row).
272        col_range: Option<(usize, usize)>,
273    },
274    /// Same row, repeated *to the right* once per source row. The first
275    /// template cell in the row is the anchor — its column is the
276    /// starting column of the expanded run.
277    ExpandRight {
278        cells: Vec<CellSource>,
279        directives: Vec<Directive>,
280    },
281}
282
283#[derive(Debug, Clone)]
284pub struct SheetPlan {
285    pub name: String,
286    pub rows: Vec<RowPlan>,
287    /// Multi-block (`@block A:B` / `@block D:E`). When non-empty, the
288    /// renderer ignores `rows` and renders each `SubBlock` separately,
289    /// then merges by column range. ADR-0068 / 0069.
290    pub sub_blocks: Vec<SubBlock>,
291    /// Total column width of the sheet (used to size the merged
292    /// row-major buffer when sub_blocks are in play).
293    pub n_cols: usize,
294}
295
296/// One column-bounded block in a multi-block sheet. `col_first` /
297/// `col_last` are 0-based inclusive sheet columns. `rows` is the
298/// block's own RowPlan sequence — cells are indexed 0..=(col_last - col_first).
299#[derive(Debug, Clone)]
300pub struct SubBlock {
301    pub col_first: usize,
302    pub col_last: usize,
303    pub rows: Vec<RowPlan>,
304}
305
306#[derive(Debug, Clone)]
307pub struct WorkbookPlan {
308    pub config: ConfigMeta,
309    pub sheets: Vec<SheetPlan>,
310    /// Per-input default value from the `__inputs__` sheet, keyed by
311    /// input name. Host inputs (if any) override these at render time.
312    pub inputs: HashMap<String, Value>,
313    /// Named value lists from the `__lists__` sheet. Each column is a
314    /// list — header is the list name, cells below are the values.
315    /// Used by `@filter [Field] in __lists__[Name]`.
316    pub lists: HashMap<String, Vec<Value>>,
317    /// Named external data sources declared on `__sources__` (xl3
318    /// ADR-0012). Each entry says where the source lives in the data
319    /// workbook and how to interpret its layout.
320    pub named_sources: HashMap<String, SourceDecl>,
321}
322
323/// One row from the `__sources__` sheet — names a secondary source
324/// reachable via `SourceName[Column]` expressions.
325#[derive(Debug, Clone)]
326pub struct SourceDecl {
327    pub sheet: String,
328    pub table: SourceTable,
329}
330
331const RESERVED_SHEETS: &[&str] = &["__config__", "__inputs__", "__lists__", "__sources__"];
332
333fn is_reserved_sheet(name: &str) -> bool {
334    RESERVED_SHEETS.contains(&name)
335}
336
337fn cell_is_template_text(s: &str) -> bool {
338    // Same condition the TS implementation uses: a cell is a template
339    // cell iff it contains `{{`. We don't try to validate balance here;
340    // `eval::eval_cell` will surface malformed expressions.
341    s.contains("{{")
342}
343
344/// True iff the template text references a *source-row* field — i.e.
345/// a bare `[Column]` or `Source[Column]` reference that varies per
346/// source row. Reserved-namespace refs (`__inputs__[key]`,
347/// `__config__[key]`, `__lists__[key]`, `__sources__[key]`) do NOT
348/// count, because they're constants for the whole render.
349///
350/// This is the signal the planner uses to decide whether a row is an
351/// expansion row or a static-but-templated row. A row that only
352/// references reserved namespaces (e.g. `Report month: {{ __inputs__[month] }}`)
353/// is evaluated once, not once per source row.
354/// Inclusive `(first, last)` column range of the cells flagged as
355/// `Template { .. }` or `Subtotal { .. }`. The expansion engine
356/// rewrites only these columns when iterating source rows; columns
357/// outside the range follow the column-scoped splice rule in
358/// ADR-0066. Returns `None` if no template-bearing cells were found.
359fn compute_template_col_range(cells: &[CellSource]) -> Option<(usize, usize)> {
360    // First pass: locate the Template / Subtotal core columns. Those
361    // are unambiguously inside the expansion block.
362    let mut first: Option<usize> = None;
363    let mut last: usize = 0;
364    for (i, c) in cells.iter().enumerate() {
365        let is_core = matches!(c, CellSource::Template { .. } | CellSource::Subtotal { .. });
366        if is_core {
367            first.get_or_insert(i);
368            last = i;
369        }
370    }
371    let (mut lo, mut hi) = (first?, last);
372    // ADR-0046: a native Excel formula adjacent to the template
373    // columns is part of the per-iteration block (e.g. `[Item] |
374    // [Amount] | =B2*2`). One separated by an Empty cell is a side
375    // cell that belongs to the column-scoped splice instead (fixture
376    // 142 — templates in A/B, side `=SUM(B:B)` in E). The walk stops
377    // at the first Empty in either direction so cosmetic gaps stay
378    // outside the expansion.
379    while hi + 1 < cells.len() {
380        match &cells[hi + 1] {
381            CellSource::CellFormula { .. } => hi += 1,
382            _ => break,
383        }
384    }
385    while lo > 0 {
386        match &cells[lo - 1] {
387            CellSource::CellFormula { .. } => lo -= 1,
388            _ => break,
389        }
390    }
391    Some((lo, hi))
392}
393
394/// True iff every non-Empty cell in `cells` sits *outside* the given
395/// column range. Used to detect ADR-0066 "side rows" that the planner
396/// should absorb into the preceding ExpandDown.
397fn cells_only_outside_range(cells: &[CellSource], range: (usize, usize)) -> bool {
398    let (lo, hi) = range;
399    let mut any_outside = false;
400    for (i, c) in cells.iter().enumerate() {
401        let inside = i >= lo && i <= hi;
402        match c {
403            CellSource::Empty => continue,
404            _ if inside => return false,
405            _ => any_outside = true,
406        }
407    }
408    any_outside
409}
410
411fn cells_isolate_outside(cells: &[CellSource], range: (usize, usize)) -> Vec<CellSource> {
412    let (lo, hi) = range;
413    cells
414        .iter()
415        .enumerate()
416        .map(|(i, c)| {
417            if i >= lo && i <= hi {
418                CellSource::Empty
419            } else {
420                c.clone()
421            }
422        })
423        .collect()
424}
425
426fn cells_isolate_inside(cells: &[CellSource], range: (usize, usize)) -> Vec<CellSource> {
427    let (lo, hi) = range;
428    cells
429        .iter()
430        .enumerate()
431        .map(|(i, c)| {
432            if i >= lo && i <= hi {
433                c.clone()
434            } else {
435                CellSource::Empty
436            }
437        })
438        .collect()
439}
440
441fn template_depends_on_source_row(s: &str, named_sources_to_exclude: &[&str]) -> bool {
442    let mut cleaned = s.to_string();
443    for ns in [
444        "__config__[",
445        "__inputs__[",
446        "__lists__[",
447        "__sources__[",
448    ] {
449        cleaned = cleaned.replace(ns, "");
450    }
451    // Named-source references that don't belong to the *active* source
452    // are row-set refs (XLOOKUP / aggregate input), not per-row.
453    // Active-source refs (`<active>[Col]`) ARE per-row when `@source`
454    // is in scope — the caller passes in the non-active named-source
455    // names so they get stripped here.
456    for name in named_sources_to_exclude {
457        let prefix = format!("{name}[");
458        cleaned = cleaned.replace(&prefix, "");
459    }
460    cleaned.contains('[')
461}
462
463pub fn parse_template(path: &Path) -> Result<WorkbookPlan> {
464    let styles = styles::parse_template_styles(path).unwrap_or_default();
465    let wb: Xlsx<_> = open_workbook(path)
466        .with_context(|| format!("open template workbook at {}", path.display()))?;
467    parse_template_inner(wb, styles, None)
468}
469
470/// Variant that builds a `WorkbookPlan` from an in-memory template
471/// XLSX buffer (the WASM entry point's input shape).
472pub fn parse_template_bytes(bytes: &[u8]) -> Result<WorkbookPlan> {
473    parse_template_bytes_with_manifest(bytes, None)
474}
475
476/// Same as `parse_template_bytes`, but also accepts the host
477/// style manifest so the planner can stamp the matching style
478/// index onto each `CellSource::Template` up front (Phase 2 Task
479/// 2.2). `None` is identical to `parse_template_bytes`.
480pub fn parse_template_bytes_with_manifest(
481    bytes: &[u8],
482    manifest: Option<&crate::manifest::StyleManifest>,
483) -> Result<WorkbookPlan> {
484    let styles = styles::parse_template_styles_bytes(bytes).unwrap_or_default();
485    let cursor = std::io::Cursor::new(bytes.to_vec());
486    let wb: Xlsx<_> = Xlsx::new(cursor).context("open template workbook from bytes")?;
487    parse_template_inner(wb, styles, manifest)
488}
489
490fn parse_template_inner<R: std::io::Read + std::io::Seek>(
491    mut wb: Xlsx<R>,
492    styles: styles::TemplateStyles,
493    manifest: Option<&crate::manifest::StyleManifest>,
494) -> Result<WorkbookPlan> {
495    // First pass: collect named-source names so the row classifier can
496    // recognise `<Source>[Column]` as a row-set reference (not a per-
497    // source-row reference).
498    let named_source_names: Vec<String> = if sheet_names_set(&wb).contains("__sources__") {
499        if let Ok(range) = wb.worksheet_range("__sources__") {
500            let (rows, cols) = range.get_size();
501            if rows >= 2 && cols >= 1 {
502                (1..rows)
503                    .filter_map(|r| match range.get((r, 0)) {
504                        Some(CData::String(s)) if !s.is_empty() => Some(s.clone()),
505                        _ => None,
506                    })
507                    .collect()
508            } else {
509                Vec::new()
510            }
511        } else {
512            Vec::new()
513        }
514    } else {
515        Vec::new()
516    };
517
518    let mut config = ConfigMeta::default();
519    let sheet_names = wb.sheet_names();
520
521    // Read __config__ first (it may not exist; xl3 lets default behavior
522    // kick in then).
523    if sheet_names.iter().any(|n| n == "__config__") {
524        let range = wb
525            .worksheet_range("__config__")
526            .context("read __config__ sheet")?;
527        let (rows, cols) = range.get_size();
528        for r in 0..rows {
529            if cols < 2 {
530                break;
531            }
532            let key = match range.get((r, 0)) {
533                Some(CData::String(s)) if !s.is_empty() => s.clone(),
534                _ => continue,
535            };
536            let value = match range.get((r, 1)) {
537                Some(CData::String(s)) => s.clone(),
538                Some(CData::Float(f)) => format!("{f}"),
539                Some(CData::Int(i)) => format!("{i}"),
540                Some(CData::Bool(b)) => b.to_string(),
541                _ => String::new(),
542            };
543            config.values.insert(key, value);
544        }
545    }
546
547    let mut sheets = Vec::with_capacity(sheet_names.len());
548    for name in sheet_names {
549        if is_reserved_sheet(&name) {
550            continue;
551        }
552        let range = wb
553            .worksheet_range(&name)
554            .with_context(|| format!("read template sheet {name:?}"))?;
555        // Read formulas alongside cell values so native Excel
556        // formulas (ADR-0021 / ADR-0046) round-trip. `worksheet_formula`
557        // returns a `Range<String>` shaped over the formula-bearing
558        // cells only; calamine often picks a different `start` and
559        // `end` than the value range. Cells in the formula range can
560        // also live outside the value range when the formula is the
561        // only thing in that column (e.g. fixture 142 — value range
562        // stops at column D, formulas live in column E).
563        let formula_range = wb.worksheet_formula(&name).ok();
564        let (value_rows, value_cols) = range.get_size();
565        let value_start = range.start().unwrap_or((0, 0));
566        // Combined iteration bounds: the planner walks row/col
567        // indices relative to the value range; if a formula cell
568        // sits past the value range we widen the bounds so the
569        // formula isn't silently dropped. `rows` / `cols` end up
570        // expressed in value-range-relative units the rest of the
571        // builder already understands.
572        let (rows, cols) = if let Some(fr) = formula_range.as_ref() {
573            if let (Some(fr_start), Some(fr_end)) = (fr.start(), fr.end()) {
574                let needed_rows =
575                    (fr_end.0 as i64 - value_start.0 as i64).max(value_rows as i64 - 1) + 1;
576                let needed_cols =
577                    (fr_end.1 as i64 - value_start.1 as i64).max(value_cols as i64 - 1) + 1;
578                let _ = fr_start;
579                (needed_rows.max(0) as usize, needed_cols.max(0) as usize)
580            } else {
581                (value_rows, value_cols)
582            }
583        } else {
584            (value_rows, value_cols)
585        };
586
587        // ADR-0068/0069 multi-block detection. A `@block A:B` directive
588        // anywhere on the sheet declares a column-bounded sub-block.
589        // We pre-scan once so we know whether to drive the
590        // single-block (current) or multi-block path for this sheet.
591        let mut block_ranges: Vec<(usize, usize)> = Vec::new();
592        for r in 0..rows {
593            for c in 0..cols {
594                if let Some(CData::String(s)) = range.get((r, c)) {
595                    if let Some(dirs) = parse_directive_cell(s) {
596                        for d in dirs {
597                            if let Directive::Block { col_first, col_last } = d {
598                                block_ranges.push((col_first, col_last));
599                            }
600                        }
601                    }
602                }
603            }
604        }
605        block_ranges.sort();
606        block_ranges.dedup();
607
608        if !block_ranges.is_empty() {
609            let mut sub_blocks_out: Vec<SubBlock> = Vec::new();
610            for (col_first, col_last) in &block_ranges {
611                let sub_rows = build_row_plans_for_range(
612                    &range,
613                    formula_range.as_ref(),
614                    &name,
615                    rows,
616                    *col_first,
617                    *col_last,
618                    &styles,
619                    &named_source_names,
620                    manifest,
621                )?;
622                sub_blocks_out.push(SubBlock {
623                    col_first: *col_first,
624                    col_last: *col_last,
625                    rows: sub_rows,
626                });
627            }
628            sheets.push(SheetPlan {
629                name: name.clone(),
630                rows: Vec::new(),
631                sub_blocks: sub_blocks_out,
632                n_cols: cols,
633            });
634            continue;
635        }
636
637        let row_plans = build_row_plans_for_range(
638            &range,
639            formula_range.as_ref(),
640            &name,
641            rows,
642            0,
643            cols.saturating_sub(1),
644            &styles,
645            &named_source_names,
646            manifest,
647        )?;
648        sheets.push(SheetPlan {
649            name,
650            rows: row_plans,
651            sub_blocks: Vec::new(),
652            n_cols: cols,
653        });
654    }
655
656
657    // Sanity check that we picked up the bits we need.
658    if sheets.is_empty() {
659        bail!("template has no visible (non-reserved) sheets");
660    }
661
662    // Parse `__inputs__` defaults if present. xl3's spec gives the
663    // sheet `name | type | default | label | description | options ...`
664    // columns; we only need name → default for now.
665    let mut inputs = HashMap::new();
666    if sheet_names_set(&wb).contains("__inputs__") {
667        if let Ok(range) = wb.worksheet_range("__inputs__") {
668            let (rows, cols) = range.get_size();
669            if rows >= 2 && cols >= 1 {
670                let mut headers: Vec<String> = Vec::new();
671                for c in 0..cols {
672                    headers.push(match range.get((0, c)) {
673                        Some(CData::String(s)) => s.clone(),
674                        _ => String::new(),
675                    });
676                }
677                let name_col = 0usize; // xl3: first column is always the input name
678                let default_col = headers
679                    .iter()
680                    .position(|h| h.eq_ignore_ascii_case("default"));
681                if let Some(default_col) = default_col {
682                    // ADR-0050: __inputs__ default values may themselves
683                    // be XTL templates that reference __config__ and
684                    // pure scalar functions. Evaluate them with a
685                    // ctx-of-config so the resulting plan holds the
686                    // already-rendered defaults.
687                    let mut input_ctx: HashMap<String, Value> = HashMap::new();
688                    let config_map: HashMap<String, Value> = config
689                        .values
690                        .iter()
691                        .map(|(k, v)| (k.clone(), Value::String(v.clone())))
692                        .collect();
693                    input_ctx
694                        .insert("__config__".to_string(), Value::Map(Arc::new(config_map)));
695                    for r in 1..rows {
696                        let name = match range.get((r, name_col)) {
697                            Some(CData::String(s)) if !s.is_empty() => s.clone(),
698                            _ => continue,
699                        };
700                        let raw = range
701                            .get((r, default_col))
702                            .map(Value::from_calamine)
703                            .unwrap_or(Value::Empty);
704                        let evaluated = if let Value::String(s) = &raw {
705                            if s.contains("{{") {
706                                crate::eval::eval_cell(s, &input_ctx).unwrap_or(raw.clone())
707                            } else {
708                                raw
709                            }
710                        } else {
711                            raw
712                        };
713                        inputs.insert(name, evaluated);
714                    }
715                }
716            }
717        }
718    }
719
720    // Parse `__lists__` if present. Each column is a list (header is
721    // its name, values are the cells below until the first blank).
722    let mut lists: HashMap<String, Vec<Value>> = HashMap::new();
723    if sheet_names_set(&wb).contains("__lists__") {
724        if let Ok(range) = wb.worksheet_range("__lists__") {
725            let (rows, cols) = range.get_size();
726            for c in 0..cols {
727                let header = match range.get((0, c)) {
728                    Some(CData::String(s)) if !s.is_empty() => s.clone(),
729                    _ => continue,
730                };
731                let mut values = Vec::new();
732                for r in 1..rows {
733                    match range.get((r, c)) {
734                        Some(CData::Empty) | None => break,
735                        Some(other) => values.push(Value::from_calamine(other)),
736                    }
737                }
738                lists.insert(header, values);
739            }
740        }
741    }
742
743    // Parse `__sources__` if present. xl3's column convention is
744    // `name | sheet | table | description | …`. We only need name →
745    // (sheet, table). Other columns (description, etc.) are ignored
746    // for now.
747    let mut named_sources: HashMap<String, SourceDecl> = HashMap::new();
748    if sheet_names_set(&wb).contains("__sources__") {
749        if let Ok(range) = wb.worksheet_range("__sources__") {
750            let (rows, cols) = range.get_size();
751            if rows >= 2 && cols >= 1 {
752                let mut headers: Vec<String> = Vec::with_capacity(cols);
753                for c in 0..cols {
754                    headers.push(match range.get((0, c)) {
755                        Some(CData::String(s)) => s.clone(),
756                        _ => String::new(),
757                    });
758                }
759                let name_col = 0usize;
760                let sheet_col = headers
761                    .iter()
762                    .position(|h| h.eq_ignore_ascii_case("sheet"));
763                let table_col = headers
764                    .iter()
765                    .position(|h| h.eq_ignore_ascii_case("table"));
766                for r in 1..rows {
767                    let name = match range.get((r, name_col)) {
768                        Some(CData::String(s)) if !s.is_empty() => s.clone(),
769                        _ => continue,
770                    };
771                    let sheet = sheet_col
772                        .and_then(|c| range.get((r, c)))
773                        .and_then(|d| match d {
774                            CData::String(s) if !s.is_empty() => Some(s.clone()),
775                            _ => None,
776                        })
777                        .unwrap_or_else(|| name.clone());
778                    let table_raw = table_col
779                        .and_then(|c| range.get((r, c)))
780                        .map(|d| match d {
781                            CData::String(s) => s.clone(),
782                            CData::Float(f) => format!("{f}"),
783                            CData::Int(i) => format!("{i}"),
784                            _ => String::new(),
785                        })
786                        .unwrap_or_default();
787                    let table = if table_raw.is_empty() {
788                        SourceTable::HeaderRow(1)
789                    } else {
790                        parse_source_table(&table_raw)
791                    };
792                    named_sources.insert(name, SourceDecl { sheet, table });
793                }
794            }
795        }
796    }
797
798    Ok(WorkbookPlan {
799        config,
800        sheets,
801        inputs,
802        lists,
803        named_sources,
804    })
805}
806
807fn sheet_names_set<R: std::io::Read + std::io::Seek>(
808    wb: &Xlsx<R>,
809) -> std::collections::HashSet<String> {
810    wb.sheet_names().into_iter().collect()
811}
812
813/// Build the RowPlan sequence for the given column range of a sheet —
814/// re-used by both the single-block path and each sub-block of a
815/// multi-block sheet (ADR-0068/0069). Cells *outside* the range are
816/// not visited at all.
817fn build_row_plans_for_range(
818    range: &calamine::Range<CData>,
819    formulas: Option<&calamine::Range<String>>,
820    sheet_name: &str,
821    rows: usize,
822    col_first: usize,
823    col_last: usize,
824    styles: &TemplateStyles,
825    named_source_names: &[String],
826    manifest: Option<&crate::manifest::StyleManifest>,
827) -> Result<Vec<RowPlan>> {
828    // Lookup a non-empty formula text for (r, c) or None. Calamine
829    // returns the formula text without the leading "=" (e.g. `UPPER(A1)`).
830    // The (r, c) the outer loop passes us is relative to the value
831    // `range`. Both ranges declare independent `start` offsets, so we
832    // translate to absolute coordinates via the value range's start
833    // before consulting the formula range via `get_value` (absolute).
834    let value_start = range.start().unwrap_or((0, 0));
835    let formula_at = |r: usize, c: usize| -> Option<String> {
836        formulas.and_then(|fr| {
837            let abs = (value_start.0 + r as u32, value_start.1 + c as u32);
838            fr.get_value(abs).and_then(|s| {
839                if s.is_empty() {
840                    None
841                } else {
842                    Some(s.clone())
843                }
844            })
845        })
846    };
847    let mut row_plans: Vec<RowPlan> = Vec::with_capacity(rows);
848    let mut pending_direction = Direction::Down;
849    let mut pending_directives: Vec<Directive> = Vec::new();
850    let cols_in_range = col_last.saturating_sub(col_first) + 1;
851    for r in 0..rows {
852        let mut row_cells = Vec::with_capacity(cols_in_range);
853        let mut has_source_template = false;
854        let mut has_subtotal = false;
855        let mut directive_only = true;
856        let mut any_cell = false;
857        let active_source: Option<&str> = pending_directives.iter().find_map(|d| match d {
858            Directive::Source(n) => Some(n.as_str()),
859            _ => None,
860        });
861        let exclude_named: Vec<&str> = named_source_names
862            .iter()
863            .filter(|n| Some(n.as_str()) != active_source)
864            .map(|s| s.as_str())
865            .collect();
866        for c_off in 0..cols_in_range {
867            let c = col_first + c_off;
868            // Native Excel formula peek: calamine reads the cached
869            // result via `range.get`, which can be Empty when the
870            // formula's inputs reference cells we haven't filled yet
871            // (e.g. `=B2*2` over a template `{{ [Amount] }}`). Only
872            // skip ahead to the per-variant branches once we've
873            // confirmed there's no formula text at this position.
874            let formula_here = formula_at(r, c);
875            let cell = match range.get((r, c)) {
876                None | Some(CData::Empty) if formula_here.is_some() => {
877                    any_cell = true;
878                    directive_only = false;
879                    let formula_text = formula_here.unwrap();
880                    let format_code = styles.format_code(sheet_name, r as u32, c as u32);
881                    let style_idx = manifest.and_then(|m| {
882                        m.cells
883                            .get(sheet_name)
884                            .and_then(|map| map.get(&(r as u32, c as u32)).copied())
885                    });
886                    CellSource::CellFormula {
887                        text: formula_text,
888                        cached: Value::Empty,
889                        format_code,
890                        style_idx,
891                    }
892                }
893                None | Some(CData::Empty) => CellSource::Empty,
894                Some(CData::String(s)) if cell_is_template_text(s) => {
895                    any_cell = true;
896                    if let Some((aggregate, field)) = parse_subtotal_cell(s) {
897                        directive_only = false;
898                        has_subtotal = true;
899                        CellSource::Subtotal { aggregate, field }
900                    } else if parse_directive_cell(s).is_some() {
901                        CellSource::Empty
902                    } else {
903                        directive_only = false;
904                        if template_depends_on_source_row(s, &exclude_named) {
905                            has_source_template = true;
906                        }
907                        let format_code = styles.format_code(sheet_name, r as u32, c as u32);
908                        let num_fmt = format_code
909                            .as_deref()
910                            .map(styles::classify_num_fmt)
911                            .unwrap_or(NumFmtKind::General);
912                        let style_idx = manifest.and_then(|m| {
913                            m.cells
914                                .get(sheet_name)
915                                .and_then(|map| map.get(&(r as u32, c as u32)).copied())
916                        });
917                        CellSource::Template {
918                            text: s.clone(),
919                            num_fmt,
920                            format_code,
921                            style_idx,
922                        }
923                    }
924                }
925                Some(other) => {
926                    any_cell = true;
927                    directive_only = false;
928                    // ADR-0021: a native Excel formula in a static
929                    // template cell preserves its text. ADR-0046:
930                    // the same applies to formulas inside expansion
931                    // blocks — the text is cloned verbatim per row.
932                    if let Some(formula_text) = formula_here {
933                        let format_code = styles.format_code(sheet_name, r as u32, c as u32);
934                        let style_idx = manifest.and_then(|m| {
935                            m.cells
936                                .get(sheet_name)
937                                .and_then(|map| map.get(&(r as u32, c as u32)).copied())
938                        });
939                        CellSource::CellFormula {
940                            text: formula_text,
941                            cached: Value::from_calamine(other),
942                            format_code,
943                            style_idx,
944                        }
945                    } else {
946                        CellSource::Literal(Value::from_calamine(other))
947                    }
948                }
949            };
950            row_cells.push(cell);
951        }
952
953        if any_cell && directive_only {
954            for c_off in 0..cols_in_range {
955                let c = col_first + c_off;
956                if let Some(CData::String(s)) = range.get((r, c)) {
957                    if let Some(directives) = parse_directive_cell(s) {
958                        for d in directives {
959                            match d {
960                                Directive::Repeat(dir) => pending_direction = dir,
961                                Directive::Block { .. } => {} // already consumed by outer scan
962                                other => pending_directives.push(other),
963                            }
964                        }
965                    }
966                }
967            }
968            continue;
969        }
970
971        if !has_source_template && !has_subtotal {
972            if let Some(RowPlan::ExpandDown {
973                col_range: Some(range_inner),
974                side_rows,
975                ..
976            }) = row_plans.last_mut()
977            {
978                let range_inner = *range_inner;
979                if !any_cell {
980                    side_rows.push(row_cells);
981                    continue;
982                }
983                if cells_only_outside_range(&row_cells, range_inner) {
984                    side_rows.push(row_cells);
985                    continue;
986                }
987                let outside = cells_isolate_outside(&row_cells, range_inner);
988                let inside = cells_isolate_inside(&row_cells, range_inner);
989                let has_inside = inside.iter().any(|c| !matches!(c, CellSource::Empty));
990                let has_outside = outside.iter().any(|c| !matches!(c, CellSource::Empty));
991                if has_inside && has_outside {
992                    side_rows.push(outside);
993                    row_plans.push(RowPlan::Static(inside));
994                    continue;
995                }
996            }
997            if !any_cell {
998                continue;
999            }
1000        }
1001
1002        if has_subtotal && !has_source_template {
1003            if let Some(RowPlan::ExpandDown { subtotal_rows, .. }) = row_plans.last_mut() {
1004                subtotal_rows.push(row_cells);
1005                continue;
1006            }
1007        }
1008
1009        let row_plan = if has_source_template {
1010            let directives = std::mem::take(&mut pending_directives);
1011            let col_range = compute_template_col_range(&row_cells);
1012            let plan = match pending_direction {
1013                Direction::Down => RowPlan::ExpandDown {
1014                    cells: row_cells,
1015                    directives,
1016                    subtotal_rows: Vec::new(),
1017                    side_rows: Vec::new(),
1018                    col_range,
1019                },
1020                Direction::Right => RowPlan::ExpandRight {
1021                    cells: row_cells,
1022                    directives,
1023                },
1024            };
1025            pending_direction = Direction::Down;
1026            plan
1027        } else {
1028            if let Some(RowPlan::ExpandDown {
1029                col_range: Some(range_inner),
1030                side_rows,
1031                ..
1032            }) = row_plans.last_mut()
1033            {
1034                if cells_only_outside_range(&row_cells, *range_inner) {
1035                    side_rows.push(row_cells);
1036                    continue;
1037                }
1038            }
1039            RowPlan::Static(row_cells)
1040        };
1041        row_plans.push(row_plan);
1042    }
1043    Ok(row_plans)
1044}
1045
1046/// Wrap `inputs` as a single `Value::Map` so the evaluator can resolve
1047/// `__inputs__[key]` via the reserved-ref path without thinking about
1048/// where the value came from. Host overrides should be merged into the
1049/// `inputs` map *before* this call.
1050pub fn inputs_to_value(inputs: &HashMap<String, Value>) -> Value {
1051    Value::Map(Arc::new(inputs.clone()))
1052}
1053
1054/// Wrap `lists` as a `Value::Map` whose values are `Value::List` —
1055/// matches the `__lists__[Name]` lookup shape (namespace is a map of
1056/// name → list, and `<ns>[key]` resolves to a list).
1057pub fn lists_to_value(lists: &HashMap<String, Vec<Value>>) -> Value {
1058    let inner: HashMap<String, Value> = lists
1059        .iter()
1060        .map(|(k, v)| (k.clone(), Value::List(Arc::new(v.clone()))))
1061        .collect();
1062    Value::Map(Arc::new(inner))
1063}