//! A markdown-aware `{{ dotted.path | filter(args) }}` value substitution engine. //! //! //! //! A [`Substituter`] holds a flat table of named [`Value`]s and a registry of //! [`Filter`]s. [`Substituter::substitute`] replaces `{{ … }}` markers in a //! string with the looked-up value, running any piped filters left-to-right. //! Markers inside inline code spans and fenced code blocks are left verbatim, //! so documentation showing literal template syntax survives untouched. //! //! The engine is domain-free: it knows nothing about where values come from. //! Callers populate the table (from TOML, a struct, computed values, …) and //! register domain filters as needed. //! //! ```ignore //! let s = Substituter::new() //! .with_value("price.basic", Value::Int(16)) //! .with_value("stripe.percent", Value::Float(0.029)); //! let out = s.substitute("Basic is ${{ price.basic }} ({{ stripe.percent | percent }} fee).")?; //! assert_eq!(out, "Basic is $16 (2.9% fee)."); //! ``` mod code_spans; mod filters; mod parser; mod value; use std::collections::HashMap; use std::fmt; pub use code_spans::code_span_ranges; pub use filters::{Filter, FilterArg, FilterError}; pub use value::Value; /// Error returned by [`Substituter::substitute`]. #[derive(Debug)] pub enum SubstError { /// One or more placeholders could not be produced. Each entry is either a /// missing path (`"derived.foo"`) or a formatted filter failure /// (`` "`x | nope`: unknown filter `nope`" ``). The output markdown keeps /// the offending markers in place so callers can grep for them. Unresolved(Vec), } impl fmt::Display for SubstError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Unresolved(items) => { write!(f, "unresolved placeholders: {}", items.join(", ")) } } } } impl std::error::Error for SubstError {} /// A value table plus a filter registry, ready to substitute `{{ … }}` markers. pub struct Substituter { values: HashMap, filters: HashMap>, } impl Default for Substituter { fn default() -> Self { Self::new() } } impl Substituter { /// A substituter with every built-in filter registered and an empty value /// table. Populate the table with [`insert`](Self::insert) / /// [`with_value`](Self::with_value). pub fn new() -> Self { let mut filters: HashMap> = HashMap::new(); filters::register_builtins(&mut filters); Self { values: HashMap::new(), filters, } } /// Insert (or overwrite) a value in the table. pub fn insert(&mut self, key: impl Into, value: Value) { self.values.insert(key.into(), value); } /// Builder form of [`insert`](Self::insert). #[must_use] pub fn with_value(mut self, key: impl Into, value: Value) -> Self { self.insert(key, value); self } /// Register a custom filter. Overrides any built-in or previously /// registered filter with the same name. /// /// ```ignore /// let s = Substituter::new() /// .with_filter("k", |v: Value, _args: &[FilterArg]| { /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?; /// Ok(Value::String(format!("{:.0}K", n / 1000.0))) /// }); /// ``` #[must_use] pub fn with_filter(mut self, name: impl Into, filter: impl Filter + 'static) -> Self { self.filters.insert(name.into(), Box::new(filter)); self } /// Look up a single value by key. pub fn get(&self, key: &str) -> Option<&Value> { self.values.get(key) } /// Iterate over every key in the table in arbitrary order. pub fn keys(&self) -> impl Iterator { self.values.keys().map(String::as_str) } /// Substitute `{{ dotted.path | filter(args) }}` placeholders in `text`. /// /// Returns [`SubstError::Unresolved`] listing every key that could not be /// resolved (or every filter that failed). The output is the text with all /// resolved markers replaced; unresolved markers are left in place when an /// error is returned, so callers can grep for them. pub fn substitute(&self, text: &str) -> Result { // Matches `{{ … }}` non-greedily — the inner body may contain spaces, // pipes, parens, and quoted strings (e.g. `{{ x | money("$") }}`). let re = regex_lite::Regex::new(r"\{\{(.*?)\}\}").expect("static regex"); // Skip matches inside inline code spans and fenced code blocks so that // documentation showing literal `{{ … }}` template syntax (e.g. Tauri // updater URL patterns) is preserved verbatim. let code_ranges = code_spans::code_span_ranges(text); let in_code = |start: usize| code_ranges.iter().any(|&(s, e)| start >= s && start < e); let mut unresolved: Vec = Vec::new(); let mut errors: Vec = Vec::new(); let mut out = String::with_capacity(text.len()); let mut last = 0; for m in re.find_iter(text) { out.push_str(&text[last..m.start()]); last = m.end(); if in_code(m.start()) { out.push_str(m.as_str()); continue; } let body = re.captures(m.as_str()).unwrap().get(1).unwrap().as_str(); match self.resolve(body) { Ok(Some(v)) => out.push_str(&v.to_string()), Ok(None) => { // Path not found in the table. Preserve marker; flag for caller. unresolved.push(body.trim().to_string()); out.push_str(m.as_str()); } Err(e) => { errors.push(format!("`{body}`: {e}")); out.push_str(m.as_str()); } } } out.push_str(&text[last..]); if !errors.is_empty() { return Err(SubstError::Unresolved(errors)); } if !unresolved.is_empty() { unresolved.sort(); unresolved.dedup(); return Err(SubstError::Unresolved(unresolved)); } Ok(out) } /// Resolve a single `{{ body }}` expression. Returns: /// - `Ok(Some(v))` when the path resolved and all filters applied cleanly. /// - `Ok(None)` when the path is missing from the table. /// - `Err(_)` for parse errors, unknown filters, or filter failures. fn resolve(&self, body: &str) -> Result, String> { let expr = parser::parse_expr(body).map_err(|e| e.to_string())?; let Some(initial) = self.values.get(expr.path).cloned() else { return Ok(None); }; let mut value = initial; for call in &expr.filters { let filter = self .filters .get(call.name) .ok_or_else(|| format!("unknown filter `{}`", call.name))?; value = filter.apply(value, &call.args).map_err(|e| e.to_string())?; } Ok(Some(value)) } } #[cfg(test)] mod tests { use super::*; fn sample() -> Substituter { Substituter::new() .with_value("expenses.F_monthly", Value::Int(580)) .with_value("stripe.percent", Value::Float(0.029)) .with_value("stripe.fixed", Value::Float(0.30)) .with_value("cohort.lock_duration", Value::String("lifetime".into())) } #[test] fn substitute_replaces_known_keys() { let out = sample() .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.") .unwrap(); assert_eq!(out, "Fixed monthly costs are $580."); } #[test] fn substitute_handles_whitespace_in_markers() { let out = sample() .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}") .unwrap(); assert_eq!(out, "a=580 b=580"); } #[test] fn substitute_applies_percent_filter() { let out = sample() .substitute("Stripe charges {{ stripe.percent | percent }}.") .unwrap(); assert_eq!(out, "Stripe charges 2.9%."); } #[test] fn substitute_applies_money_filter() { let out = sample() .substitute("Flat fee: {{ stripe.fixed | money }}.") .unwrap(); assert_eq!(out, "Flat fee: $0.30."); } #[test] fn substitute_consumer_can_register_custom_filter() { // Closure-based filter: format thousands as "N.Nk". let s = sample().with_filter("kilo", |v: Value, _args: &[FilterArg]| { let n = v .as_f64() .ok_or_else(|| FilterError::type_error("kilo", &v))?; Ok(Value::String(format!("{:.1}k", n / 1000.0))) }); let out = s .substitute("Fixed: {{ expenses.F_monthly | kilo }}") .unwrap(); assert_eq!(out, "Fixed: 0.6k"); } #[test] fn substitute_unknown_filter_reports_error() { let err = sample() .substitute("{{ expenses.F_monthly | nope }}") .unwrap_err(); let SubstError::Unresolved(items) = err; assert!( items.iter().any(|m| m.contains("unknown filter")), "{items:?}" ); } #[test] fn substitute_filter_type_mismatch_reports_error() { let err = sample() .substitute("{{ cohort.lock_duration | money }}") .unwrap_err(); let SubstError::Unresolved(items) = err; assert!(!items.is_empty()); } #[test] fn substitute_skips_inline_code() { let out = sample() .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.") .unwrap(); assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`."); } #[test] fn substitute_skips_fenced_code_block() { let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n"; let out = sample().substitute(input).unwrap(); assert!(out.contains("Value: 580")); assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}"); } #[test] fn substitute_reports_unresolved_keys_sorted_and_deduped() { let err = sample() .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}") .unwrap_err(); let SubstError::Unresolved(items) = err; assert_eq!( items, vec!["also.missing".to_string(), "nope.absent".to_string()] ); } #[test] fn chains_filters() { let s = Substituter::new().with_value("x", Value::Float(26.4079)); let out = s.substitute("{{ x | round(2) | money }}").unwrap(); assert_eq!(out, "$26.41"); } #[test] fn get_and_keys_expose_the_table() { let s = sample(); assert_eq!(s.get("expenses.F_monthly"), Some(&Value::Int(580))); assert!(s.keys().any(|k| k == "stripe.percent")); } }