use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use anyhow::{bail, Context, Result};
use crate::calamine::{open_workbook, Data as CData, Reader, Xlsx};
use crate::directives::{parse_directive_cell, Direction, Directive};
use crate::styles::{self, NumFmtKind, TemplateStyles};
use crate::value::Value;
#[derive(Debug, Default, Clone)]
pub struct ConfigMeta {
pub values: HashMap<String, String>,
}
impl ConfigMeta {
pub fn get(&self, key: &str) -> Option<&str> {
self.values.get(key).map(String::as_str)
}
pub fn source_sheet(&self) -> Option<&str> {
self.get("source_sheet")
}
pub fn output_file_pattern(&self) -> Option<&str> {
self.get("output_file_pattern")
}
pub fn file_group_keys(&self) -> Vec<String> {
match self.output_file_pattern() {
Some(p) => extract_group_keys(p),
None => Vec::new(),
}
}
pub fn source_table(&self) -> SourceTable {
self.get("source_table")
.map(parse_source_table)
.unwrap_or(SourceTable::HeaderRow(1))
}
}
fn extract_group_keys(pattern: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut rest = pattern;
while let Some(open) = rest.find("{{") {
let after_open = &rest[open + 2..];
let close = match after_open.find("}}") {
Some(c) => c,
None => break,
};
let expr = after_open[..close].trim();
rest = &after_open[close + 2..];
let raw = expr
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.map(str::trim)
.unwrap_or(expr);
if raw.is_empty() {
continue;
}
if raw.starts_with('.') || raw.starts_with('_') {
continue;
}
if raw
.chars()
.any(|c| matches!(c, ' ' | '|' | '+' | '*' | '/' | '-' | '>' | '<' | '=' | '!' | '&' | '(' | ')' | '[' | ']' | ','))
{
continue;
}
if !keys.iter().any(|k: &String| k == raw) {
keys.push(raw.to_string());
}
}
keys
}
#[derive(Debug, Clone, PartialEq)]
pub enum SourceTable {
HeaderRow(usize),
Range {
first_row: usize, last_row: Option<usize>, first_col: usize, last_col: Option<usize>, },
}
fn parse_source_table(raw: &str) -> SourceTable {
let s = raw.trim();
if let Ok(n) = s.parse::<usize>() {
return SourceTable::HeaderRow(n.max(1));
}
if let Some((a, b)) = s.split_once(':') {
let lhs = parse_a1_part(a.trim());
let rhs = parse_a1_part(b.trim());
if let (Some((Some(r1), Some(c1))), Some((r2, c2))) = (lhs, rhs) {
let (first_row, last_row) = match r2 {
Some(r2) => (r1.min(r2), Some(r1.max(r2))),
None => (r1, None),
};
let (first_col, last_col) = match c2 {
Some(c2) => (c1.min(c2), Some(c1.max(c2))),
None => (c1, None),
};
return SourceTable::Range {
first_row,
last_row,
first_col,
last_col,
};
}
}
SourceTable::HeaderRow(1)
}
fn parse_a1_part(s: &str) -> Option<(Option<usize>, Option<usize>)> {
if s.is_empty() {
return None;
}
let bytes = s.as_bytes();
let mut i = 0;
let mut col = 0usize;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
let c = bytes[i].to_ascii_uppercase();
col = col * 26 + (c - b'A' + 1) as usize;
i += 1;
}
let col_opt = if col == 0 { None } else { Some(col) };
let row_opt = if i == bytes.len() {
None
} else {
let row: usize = std::str::from_utf8(&bytes[i..]).ok()?.parse().ok()?;
if row == 0 {
None
} else {
Some(row)
}
};
if col_opt.is_none() && row_opt.is_none() {
return None;
}
Some((row_opt, col_opt))
}
#[derive(Debug, Clone)]
pub enum CellSource {
Empty,
Literal(Value),
Template {
text: String,
num_fmt: NumFmtKind,
format_code: Option<String>,
style_idx: Option<usize>,
},
Subtotal {
aggregate: String,
field: String,
},
CellFormula {
text: String,
cached: Value,
format_code: Option<String>,
style_idx: Option<usize>,
},
}
impl CellSource {
pub fn is_template(&self) -> bool {
matches!(self, CellSource::Template { .. })
}
}
fn parse_subtotal_cell(text: &str) -> Option<(String, String)> {
let trimmed = text.trim();
let inner = trimmed.strip_prefix("{{")?.strip_suffix("}}")?;
let body = inner.trim().strip_prefix("@subtotal")?.trim();
let paren_open = body.find('(')?;
let fn_name = body[..paren_open].trim();
let after = &body[paren_open + 1..];
let paren_close = after.rfind(')')?;
let arg = after[..paren_close].trim();
let field = arg
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.map(str::trim)
.filter(|s| !s.is_empty())?;
Some((fn_name.to_ascii_uppercase(), field.to_string()))
}
#[derive(Debug, Clone)]
pub enum RowPlan {
Static(Vec<CellSource>),
ExpandDown {
cells: Vec<CellSource>,
directives: Vec<Directive>,
subtotal_rows: Vec<Vec<CellSource>>,
side_rows: Vec<Vec<CellSource>>,
col_range: Option<(usize, usize)>,
},
ExpandRight {
cells: Vec<CellSource>,
directives: Vec<Directive>,
},
}
#[derive(Debug, Clone)]
pub struct SheetPlan {
pub name: String,
pub rows: Vec<RowPlan>,
pub sub_blocks: Vec<SubBlock>,
pub n_cols: usize,
}
#[derive(Debug, Clone)]
pub struct SubBlock {
pub col_first: usize,
pub col_last: usize,
pub rows: Vec<RowPlan>,
}
#[derive(Debug, Clone)]
pub struct WorkbookPlan {
pub config: ConfigMeta,
pub sheets: Vec<SheetPlan>,
pub inputs: HashMap<String, Value>,
pub lists: HashMap<String, Vec<Value>>,
pub named_sources: HashMap<String, SourceDecl>,
}
#[derive(Debug, Clone)]
pub struct SourceDecl {
pub sheet: String,
pub table: SourceTable,
}
const RESERVED_SHEETS: &[&str] = &["__config__", "__inputs__", "__lists__", "__sources__"];
fn is_reserved_sheet(name: &str) -> bool {
RESERVED_SHEETS.contains(&name)
}
fn cell_is_template_text(s: &str) -> bool {
s.contains("{{")
}
fn compute_template_col_range(cells: &[CellSource]) -> Option<(usize, usize)> {
let mut first: Option<usize> = None;
let mut last: usize = 0;
for (i, c) in cells.iter().enumerate() {
let is_core = matches!(c, CellSource::Template { .. } | CellSource::Subtotal { .. });
if is_core {
first.get_or_insert(i);
last = i;
}
}
let (mut lo, mut hi) = (first?, last);
while hi + 1 < cells.len() {
match &cells[hi + 1] {
CellSource::CellFormula { .. } => hi += 1,
_ => break,
}
}
while lo > 0 {
match &cells[lo - 1] {
CellSource::CellFormula { .. } => lo -= 1,
_ => break,
}
}
Some((lo, hi))
}
fn cells_only_outside_range(cells: &[CellSource], range: (usize, usize)) -> bool {
let (lo, hi) = range;
let mut any_outside = false;
for (i, c) in cells.iter().enumerate() {
let inside = i >= lo && i <= hi;
match c {
CellSource::Empty => continue,
_ if inside => return false,
_ => any_outside = true,
}
}
any_outside
}
fn cells_isolate_outside(cells: &[CellSource], range: (usize, usize)) -> Vec<CellSource> {
let (lo, hi) = range;
cells
.iter()
.enumerate()
.map(|(i, c)| {
if i >= lo && i <= hi {
CellSource::Empty
} else {
c.clone()
}
})
.collect()
}
fn cells_isolate_inside(cells: &[CellSource], range: (usize, usize)) -> Vec<CellSource> {
let (lo, hi) = range;
cells
.iter()
.enumerate()
.map(|(i, c)| {
if i >= lo && i <= hi {
c.clone()
} else {
CellSource::Empty
}
})
.collect()
}
fn template_depends_on_source_row(s: &str, named_sources_to_exclude: &[&str]) -> bool {
let mut cleaned = s.to_string();
for ns in [
"__config__[",
"__inputs__[",
"__lists__[",
"__sources__[",
] {
cleaned = cleaned.replace(ns, "");
}
for name in named_sources_to_exclude {
let prefix = format!("{name}[");
cleaned = cleaned.replace(&prefix, "");
}
cleaned.contains('[')
}
pub fn parse_template(path: &Path) -> Result<WorkbookPlan> {
let styles = styles::parse_template_styles(path).unwrap_or_default();
let wb: Xlsx<_> = open_workbook(path)
.with_context(|| format!("open template workbook at {}", path.display()))?;
parse_template_inner(wb, styles, None)
}
pub fn parse_template_bytes(bytes: &[u8]) -> Result<WorkbookPlan> {
parse_template_bytes_with_manifest(bytes, None)
}
pub fn parse_template_bytes_with_manifest(
bytes: &[u8],
manifest: Option<&crate::manifest::StyleManifest>,
) -> Result<WorkbookPlan> {
let styles = styles::parse_template_styles_bytes(bytes).unwrap_or_default();
let cursor = std::io::Cursor::new(bytes.to_vec());
let wb: Xlsx<_> = Xlsx::new(cursor).context("open template workbook from bytes")?;
parse_template_inner(wb, styles, manifest)
}
fn parse_template_inner<R: std::io::Read + std::io::Seek>(
mut wb: Xlsx<R>,
styles: styles::TemplateStyles,
manifest: Option<&crate::manifest::StyleManifest>,
) -> Result<WorkbookPlan> {
let named_source_names: Vec<String> = if sheet_names_set(&wb).contains("__sources__") {
if let Ok(range) = wb.worksheet_range("__sources__") {
let (rows, cols) = range.get_size();
if rows >= 2 && cols >= 1 {
(1..rows)
.filter_map(|r| match range.get((r, 0)) {
Some(CData::String(s)) if !s.is_empty() => Some(s.clone()),
_ => None,
})
.collect()
} else {
Vec::new()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
let mut config = ConfigMeta::default();
let sheet_names = wb.sheet_names();
if sheet_names.iter().any(|n| n == "__config__") {
let range = wb
.worksheet_range("__config__")
.context("read __config__ sheet")?;
let (rows, cols) = range.get_size();
for r in 0..rows {
if cols < 2 {
break;
}
let key = match range.get((r, 0)) {
Some(CData::String(s)) if !s.is_empty() => s.clone(),
_ => continue,
};
let value = match range.get((r, 1)) {
Some(CData::String(s)) => s.clone(),
Some(CData::Float(f)) => format!("{f}"),
Some(CData::Int(i)) => format!("{i}"),
Some(CData::Bool(b)) => b.to_string(),
_ => String::new(),
};
config.values.insert(key, value);
}
}
let mut sheets = Vec::with_capacity(sheet_names.len());
for name in sheet_names {
if is_reserved_sheet(&name) {
continue;
}
let range = wb
.worksheet_range(&name)
.with_context(|| format!("read template sheet {name:?}"))?;
let formula_range = wb.worksheet_formula(&name).ok();
let (value_rows, value_cols) = range.get_size();
let value_start = range.start().unwrap_or((0, 0));
let (rows, cols) = if let Some(fr) = formula_range.as_ref() {
if let (Some(fr_start), Some(fr_end)) = (fr.start(), fr.end()) {
let needed_rows =
(fr_end.0 as i64 - value_start.0 as i64).max(value_rows as i64 - 1) + 1;
let needed_cols =
(fr_end.1 as i64 - value_start.1 as i64).max(value_cols as i64 - 1) + 1;
let _ = fr_start;
(needed_rows.max(0) as usize, needed_cols.max(0) as usize)
} else {
(value_rows, value_cols)
}
} else {
(value_rows, value_cols)
};
let mut block_ranges: Vec<(usize, usize)> = Vec::new();
for r in 0..rows {
for c in 0..cols {
if let Some(CData::String(s)) = range.get((r, c)) {
if let Some(dirs) = parse_directive_cell(s) {
for d in dirs {
if let Directive::Block { col_first, col_last } = d {
block_ranges.push((col_first, col_last));
}
}
}
}
}
}
block_ranges.sort();
block_ranges.dedup();
if !block_ranges.is_empty() {
let mut sub_blocks_out: Vec<SubBlock> = Vec::new();
for (col_first, col_last) in &block_ranges {
let sub_rows = build_row_plans_for_range(
&range,
formula_range.as_ref(),
&name,
rows,
*col_first,
*col_last,
&styles,
&named_source_names,
manifest,
)?;
sub_blocks_out.push(SubBlock {
col_first: *col_first,
col_last: *col_last,
rows: sub_rows,
});
}
sheets.push(SheetPlan {
name: name.clone(),
rows: Vec::new(),
sub_blocks: sub_blocks_out,
n_cols: cols,
});
continue;
}
let row_plans = build_row_plans_for_range(
&range,
formula_range.as_ref(),
&name,
rows,
0,
cols.saturating_sub(1),
&styles,
&named_source_names,
manifest,
)?;
sheets.push(SheetPlan {
name,
rows: row_plans,
sub_blocks: Vec::new(),
n_cols: cols,
});
}
if sheets.is_empty() {
bail!("template has no visible (non-reserved) sheets");
}
let mut inputs = HashMap::new();
if sheet_names_set(&wb).contains("__inputs__") {
if let Ok(range) = wb.worksheet_range("__inputs__") {
let (rows, cols) = range.get_size();
if rows >= 2 && cols >= 1 {
let mut headers: Vec<String> = Vec::new();
for c in 0..cols {
headers.push(match range.get((0, c)) {
Some(CData::String(s)) => s.clone(),
_ => String::new(),
});
}
let name_col = 0usize; let default_col = headers
.iter()
.position(|h| h.eq_ignore_ascii_case("default"));
if let Some(default_col) = default_col {
let mut input_ctx: HashMap<String, Value> = HashMap::new();
let config_map: HashMap<String, Value> = config
.values
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect();
input_ctx
.insert("__config__".to_string(), Value::Map(Arc::new(config_map)));
for r in 1..rows {
let name = match range.get((r, name_col)) {
Some(CData::String(s)) if !s.is_empty() => s.clone(),
_ => continue,
};
let raw = range
.get((r, default_col))
.map(Value::from_calamine)
.unwrap_or(Value::Empty);
let evaluated = if let Value::String(s) = &raw {
if s.contains("{{") {
crate::eval::eval_cell(s, &input_ctx).unwrap_or(raw.clone())
} else {
raw
}
} else {
raw
};
inputs.insert(name, evaluated);
}
}
}
}
}
let mut lists: HashMap<String, Vec<Value>> = HashMap::new();
if sheet_names_set(&wb).contains("__lists__") {
if let Ok(range) = wb.worksheet_range("__lists__") {
let (rows, cols) = range.get_size();
for c in 0..cols {
let header = match range.get((0, c)) {
Some(CData::String(s)) if !s.is_empty() => s.clone(),
_ => continue,
};
let mut values = Vec::new();
for r in 1..rows {
match range.get((r, c)) {
Some(CData::Empty) | None => break,
Some(other) => values.push(Value::from_calamine(other)),
}
}
lists.insert(header, values);
}
}
}
let mut named_sources: HashMap<String, SourceDecl> = HashMap::new();
if sheet_names_set(&wb).contains("__sources__") {
if let Ok(range) = wb.worksheet_range("__sources__") {
let (rows, cols) = range.get_size();
if rows >= 2 && cols >= 1 {
let mut headers: Vec<String> = Vec::with_capacity(cols);
for c in 0..cols {
headers.push(match range.get((0, c)) {
Some(CData::String(s)) => s.clone(),
_ => String::new(),
});
}
let name_col = 0usize;
let sheet_col = headers
.iter()
.position(|h| h.eq_ignore_ascii_case("sheet"));
let table_col = headers
.iter()
.position(|h| h.eq_ignore_ascii_case("table"));
for r in 1..rows {
let name = match range.get((r, name_col)) {
Some(CData::String(s)) if !s.is_empty() => s.clone(),
_ => continue,
};
let sheet = sheet_col
.and_then(|c| range.get((r, c)))
.and_then(|d| match d {
CData::String(s) if !s.is_empty() => Some(s.clone()),
_ => None,
})
.unwrap_or_else(|| name.clone());
let table_raw = table_col
.and_then(|c| range.get((r, c)))
.map(|d| match d {
CData::String(s) => s.clone(),
CData::Float(f) => format!("{f}"),
CData::Int(i) => format!("{i}"),
_ => String::new(),
})
.unwrap_or_default();
let table = if table_raw.is_empty() {
SourceTable::HeaderRow(1)
} else {
parse_source_table(&table_raw)
};
named_sources.insert(name, SourceDecl { sheet, table });
}
}
}
}
Ok(WorkbookPlan {
config,
sheets,
inputs,
lists,
named_sources,
})
}
fn sheet_names_set<R: std::io::Read + std::io::Seek>(
wb: &Xlsx<R>,
) -> std::collections::HashSet<String> {
wb.sheet_names().into_iter().collect()
}
fn build_row_plans_for_range(
range: &calamine::Range<CData>,
formulas: Option<&calamine::Range<String>>,
sheet_name: &str,
rows: usize,
col_first: usize,
col_last: usize,
styles: &TemplateStyles,
named_source_names: &[String],
manifest: Option<&crate::manifest::StyleManifest>,
) -> Result<Vec<RowPlan>> {
let value_start = range.start().unwrap_or((0, 0));
let formula_at = |r: usize, c: usize| -> Option<String> {
formulas.and_then(|fr| {
let abs = (value_start.0 + r as u32, value_start.1 + c as u32);
fr.get_value(abs).and_then(|s| {
if s.is_empty() {
None
} else {
Some(s.clone())
}
})
})
};
let mut row_plans: Vec<RowPlan> = Vec::with_capacity(rows);
let mut pending_direction = Direction::Down;
let mut pending_directives: Vec<Directive> = Vec::new();
let cols_in_range = col_last.saturating_sub(col_first) + 1;
for r in 0..rows {
let mut row_cells = Vec::with_capacity(cols_in_range);
let mut has_source_template = false;
let mut has_subtotal = false;
let mut directive_only = true;
let mut any_cell = false;
let active_source: Option<&str> = pending_directives.iter().find_map(|d| match d {
Directive::Source(n) => Some(n.as_str()),
_ => None,
});
let exclude_named: Vec<&str> = named_source_names
.iter()
.filter(|n| Some(n.as_str()) != active_source)
.map(|s| s.as_str())
.collect();
for c_off in 0..cols_in_range {
let c = col_first + c_off;
let formula_here = formula_at(r, c);
let cell = match range.get((r, c)) {
None | Some(CData::Empty) if formula_here.is_some() => {
any_cell = true;
directive_only = false;
let formula_text = formula_here.unwrap();
let format_code = styles.format_code(sheet_name, r as u32, c as u32);
let style_idx = manifest.and_then(|m| {
m.cells
.get(sheet_name)
.and_then(|map| map.get(&(r as u32, c as u32)).copied())
});
CellSource::CellFormula {
text: formula_text,
cached: Value::Empty,
format_code,
style_idx,
}
}
None | Some(CData::Empty) => CellSource::Empty,
Some(CData::String(s)) if cell_is_template_text(s) => {
any_cell = true;
if let Some((aggregate, field)) = parse_subtotal_cell(s) {
directive_only = false;
has_subtotal = true;
CellSource::Subtotal { aggregate, field }
} else if parse_directive_cell(s).is_some() {
CellSource::Empty
} else {
directive_only = false;
if template_depends_on_source_row(s, &exclude_named) {
has_source_template = true;
}
let format_code = styles.format_code(sheet_name, r as u32, c as u32);
let num_fmt = format_code
.as_deref()
.map(styles::classify_num_fmt)
.unwrap_or(NumFmtKind::General);
let style_idx = manifest.and_then(|m| {
m.cells
.get(sheet_name)
.and_then(|map| map.get(&(r as u32, c as u32)).copied())
});
CellSource::Template {
text: s.clone(),
num_fmt,
format_code,
style_idx,
}
}
}
Some(other) => {
any_cell = true;
directive_only = false;
if let Some(formula_text) = formula_here {
let format_code = styles.format_code(sheet_name, r as u32, c as u32);
let style_idx = manifest.and_then(|m| {
m.cells
.get(sheet_name)
.and_then(|map| map.get(&(r as u32, c as u32)).copied())
});
CellSource::CellFormula {
text: formula_text,
cached: Value::from_calamine(other),
format_code,
style_idx,
}
} else {
CellSource::Literal(Value::from_calamine(other))
}
}
};
row_cells.push(cell);
}
if any_cell && directive_only {
for c_off in 0..cols_in_range {
let c = col_first + c_off;
if let Some(CData::String(s)) = range.get((r, c)) {
if let Some(directives) = parse_directive_cell(s) {
for d in directives {
match d {
Directive::Repeat(dir) => pending_direction = dir,
Directive::Block { .. } => {} other => pending_directives.push(other),
}
}
}
}
}
continue;
}
if !has_source_template && !has_subtotal {
if let Some(RowPlan::ExpandDown {
col_range: Some(range_inner),
side_rows,
..
}) = row_plans.last_mut()
{
let range_inner = *range_inner;
if !any_cell {
side_rows.push(row_cells);
continue;
}
if cells_only_outside_range(&row_cells, range_inner) {
side_rows.push(row_cells);
continue;
}
let outside = cells_isolate_outside(&row_cells, range_inner);
let inside = cells_isolate_inside(&row_cells, range_inner);
let has_inside = inside.iter().any(|c| !matches!(c, CellSource::Empty));
let has_outside = outside.iter().any(|c| !matches!(c, CellSource::Empty));
if has_inside && has_outside {
side_rows.push(outside);
row_plans.push(RowPlan::Static(inside));
continue;
}
}
if !any_cell {
continue;
}
}
if has_subtotal && !has_source_template {
if let Some(RowPlan::ExpandDown { subtotal_rows, .. }) = row_plans.last_mut() {
subtotal_rows.push(row_cells);
continue;
}
}
let row_plan = if has_source_template {
let directives = std::mem::take(&mut pending_directives);
let col_range = compute_template_col_range(&row_cells);
let plan = match pending_direction {
Direction::Down => RowPlan::ExpandDown {
cells: row_cells,
directives,
subtotal_rows: Vec::new(),
side_rows: Vec::new(),
col_range,
},
Direction::Right => RowPlan::ExpandRight {
cells: row_cells,
directives,
},
};
pending_direction = Direction::Down;
plan
} else {
if let Some(RowPlan::ExpandDown {
col_range: Some(range_inner),
side_rows,
..
}) = row_plans.last_mut()
{
if cells_only_outside_range(&row_cells, *range_inner) {
side_rows.push(row_cells);
continue;
}
}
RowPlan::Static(row_cells)
};
row_plans.push(row_plan);
}
Ok(row_plans)
}
pub fn inputs_to_value(inputs: &HashMap<String, Value>) -> Value {
Value::Map(Arc::new(inputs.clone()))
}
pub fn lists_to_value(lists: &HashMap<String, Vec<Value>>) -> Value {
let inner: HashMap<String, Value> = lists
.iter()
.map(|(k, v)| (k.clone(), Value::List(Arc::new(v.clone()))))
.collect();
Value::Map(Arc::new(inner))
}