Skip to main content

max / makenotwork

11.0 KB · 321 lines History Blame Raw
1 //! A markdown-aware `{{ dotted.path | filter(args) }}` value substitution engine.
2 //!
3 //! <!-- wiki: subst-overview -->
4 //!
5 //! A [`Substituter`] holds a flat table of named [`Value`]s and a registry of
6 //! [`Filter`]s. [`Substituter::substitute`] replaces `{{ … }}` markers in a
7 //! string with the looked-up value, running any piped filters left-to-right.
8 //! Markers inside inline code spans and fenced code blocks are left verbatim,
9 //! so documentation showing literal template syntax survives untouched.
10 //!
11 //! The engine is domain-free: it knows nothing about where values come from.
12 //! Callers populate the table (from TOML, a struct, computed values, …) and
13 //! register domain filters as needed.
14 //!
15 //! ```ignore
16 //! let s = Substituter::new()
17 //! .with_value("price.basic", Value::Int(16))
18 //! .with_value("stripe.percent", Value::Float(0.029));
19 //! let out = s.substitute("Basic is ${{ price.basic }} ({{ stripe.percent | percent }} fee).")?;
20 //! assert_eq!(out, "Basic is $16 (2.9% fee).");
21 //! ```
22
23 mod code_spans;
24 mod filters;
25 mod parser;
26 mod value;
27
28 use std::collections::HashMap;
29 use std::fmt;
30
31 pub use code_spans::code_span_ranges;
32 pub use filters::{Filter, FilterArg, FilterError};
33 pub use value::Value;
34
35 /// Error returned by [`Substituter::substitute`].
36 #[derive(Debug)]
37 pub enum SubstError {
38 /// One or more placeholders could not be produced. Each entry is either a
39 /// missing path (`"derived.foo"`) or a formatted filter failure
40 /// (`` "`x | nope`: unknown filter `nope`" ``). The output markdown keeps
41 /// the offending markers in place so callers can grep for them.
42 Unresolved(Vec<String>),
43 }
44
45 impl fmt::Display for SubstError {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::Unresolved(items) => {
49 write!(f, "unresolved placeholders: {}", items.join(", "))
50 }
51 }
52 }
53 }
54
55 impl std::error::Error for SubstError {}
56
57 /// A value table plus a filter registry, ready to substitute `{{ … }}` markers.
58 pub struct Substituter {
59 values: HashMap<String, Value>,
60 filters: HashMap<String, Box<dyn Filter>>,
61 }
62
63 impl Default for Substituter {
64 fn default() -> Self {
65 Self::new()
66 }
67 }
68
69 impl Substituter {
70 /// A substituter with every built-in filter registered and an empty value
71 /// table. Populate the table with [`insert`](Self::insert) /
72 /// [`with_value`](Self::with_value).
73 pub fn new() -> Self {
74 let mut filters: HashMap<String, Box<dyn Filter>> = HashMap::new();
75 filters::register_builtins(&mut filters);
76 Self {
77 values: HashMap::new(),
78 filters,
79 }
80 }
81
82 /// Insert (or overwrite) a value in the table.
83 pub fn insert(&mut self, key: impl Into<String>, value: Value) {
84 self.values.insert(key.into(), value);
85 }
86
87 /// Builder form of [`insert`](Self::insert).
88 #[must_use]
89 pub fn with_value(mut self, key: impl Into<String>, value: Value) -> Self {
90 self.insert(key, value);
91 self
92 }
93
94 /// Register a custom filter. Overrides any built-in or previously
95 /// registered filter with the same name.
96 ///
97 /// ```ignore
98 /// let s = Substituter::new()
99 /// .with_filter("k", |v: Value, _args: &[FilterArg]| {
100 /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?;
101 /// Ok(Value::String(format!("{:.0}K", n / 1000.0)))
102 /// });
103 /// ```
104 #[must_use]
105 pub fn with_filter(mut self, name: impl Into<String>, filter: impl Filter + 'static) -> Self {
106 self.filters.insert(name.into(), Box::new(filter));
107 self
108 }
109
110 /// Look up a single value by key.
111 pub fn get(&self, key: &str) -> Option<&Value> {
112 self.values.get(key)
113 }
114
115 /// Iterate over every key in the table in arbitrary order.
116 pub fn keys(&self) -> impl Iterator<Item = &str> {
117 self.values.keys().map(String::as_str)
118 }
119
120 /// Substitute `{{ dotted.path | filter(args) }}` placeholders in `text`.
121 ///
122 /// Returns [`SubstError::Unresolved`] listing every key that could not be
123 /// resolved (or every filter that failed). The output is the text with all
124 /// resolved markers replaced; unresolved markers are left in place when an
125 /// error is returned, so callers can grep for them.
126 pub fn substitute(&self, text: &str) -> Result<String, SubstError> {
127 // Matches `{{ … }}` non-greedily — the inner body may contain spaces,
128 // pipes, parens, and quoted strings (e.g. `{{ x | money("$") }}`).
129 let re = regex_lite::Regex::new(r"\{\{(.*?)\}\}").expect("static regex");
130
131 // Skip matches inside inline code spans and fenced code blocks so that
132 // documentation showing literal `{{ … }}` template syntax (e.g. Tauri
133 // updater URL patterns) is preserved verbatim.
134 let code_ranges = code_spans::code_span_ranges(text);
135 let in_code = |start: usize| code_ranges.iter().any(|&(s, e)| start >= s && start < e);
136
137 let mut unresolved: Vec<String> = Vec::new();
138 let mut errors: Vec<String> = Vec::new();
139 let mut out = String::with_capacity(text.len());
140 let mut last = 0;
141
142 for m in re.find_iter(text) {
143 out.push_str(&text[last..m.start()]);
144 last = m.end();
145
146 if in_code(m.start()) {
147 out.push_str(m.as_str());
148 continue;
149 }
150
151 let body = re.captures(m.as_str()).unwrap().get(1).unwrap().as_str();
152 match self.resolve(body) {
153 Ok(Some(v)) => out.push_str(&v.to_string()),
154 Ok(None) => {
155 // Path not found in the table. Preserve marker; flag for caller.
156 unresolved.push(body.trim().to_string());
157 out.push_str(m.as_str());
158 }
159 Err(e) => {
160 errors.push(format!("`{body}`: {e}"));
161 out.push_str(m.as_str());
162 }
163 }
164 }
165 out.push_str(&text[last..]);
166
167 if !errors.is_empty() {
168 return Err(SubstError::Unresolved(errors));
169 }
170 if !unresolved.is_empty() {
171 unresolved.sort();
172 unresolved.dedup();
173 return Err(SubstError::Unresolved(unresolved));
174 }
175 Ok(out)
176 }
177
178 /// Resolve a single `{{ body }}` expression. Returns:
179 /// - `Ok(Some(v))` when the path resolved and all filters applied cleanly.
180 /// - `Ok(None)` when the path is missing from the table.
181 /// - `Err(_)` for parse errors, unknown filters, or filter failures.
182 fn resolve(&self, body: &str) -> Result<Option<Value>, String> {
183 let expr = parser::parse_expr(body).map_err(|e| e.to_string())?;
184 let Some(initial) = self.values.get(expr.path).cloned() else {
185 return Ok(None);
186 };
187 let mut value = initial;
188 for call in &expr.filters {
189 let filter = self
190 .filters
191 .get(call.name)
192 .ok_or_else(|| format!("unknown filter `{}`", call.name))?;
193 value = filter.apply(value, &call.args).map_err(|e| e.to_string())?;
194 }
195 Ok(Some(value))
196 }
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202
203 fn sample() -> Substituter {
204 Substituter::new()
205 .with_value("expenses.F_monthly", Value::Int(580))
206 .with_value("stripe.percent", Value::Float(0.029))
207 .with_value("stripe.fixed", Value::Float(0.30))
208 .with_value("cohort.lock_duration", Value::String("lifetime".into()))
209 }
210
211 #[test]
212 fn substitute_replaces_known_keys() {
213 let out = sample()
214 .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.")
215 .unwrap();
216 assert_eq!(out, "Fixed monthly costs are $580.");
217 }
218
219 #[test]
220 fn substitute_handles_whitespace_in_markers() {
221 let out = sample()
222 .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}")
223 .unwrap();
224 assert_eq!(out, "a=580 b=580");
225 }
226
227 #[test]
228 fn substitute_applies_percent_filter() {
229 let out = sample()
230 .substitute("Stripe charges {{ stripe.percent | percent }}.")
231 .unwrap();
232 assert_eq!(out, "Stripe charges 2.9%.");
233 }
234
235 #[test]
236 fn substitute_applies_money_filter() {
237 let out = sample()
238 .substitute("Flat fee: {{ stripe.fixed | money }}.")
239 .unwrap();
240 assert_eq!(out, "Flat fee: $0.30.");
241 }
242
243 #[test]
244 fn substitute_consumer_can_register_custom_filter() {
245 // Closure-based filter: format thousands as "N.Nk".
246 let s = sample().with_filter("kilo", |v: Value, _args: &[FilterArg]| {
247 let n = v
248 .as_f64()
249 .ok_or_else(|| FilterError::type_error("kilo", &v))?;
250 Ok(Value::String(format!("{:.1}k", n / 1000.0)))
251 });
252 let out = s
253 .substitute("Fixed: {{ expenses.F_monthly | kilo }}")
254 .unwrap();
255 assert_eq!(out, "Fixed: 0.6k");
256 }
257
258 #[test]
259 fn substitute_unknown_filter_reports_error() {
260 let err = sample()
261 .substitute("{{ expenses.F_monthly | nope }}")
262 .unwrap_err();
263 let SubstError::Unresolved(items) = err;
264 assert!(
265 items.iter().any(|m| m.contains("unknown filter")),
266 "{items:?}"
267 );
268 }
269
270 #[test]
271 fn substitute_filter_type_mismatch_reports_error() {
272 let err = sample()
273 .substitute("{{ cohort.lock_duration | money }}")
274 .unwrap_err();
275 let SubstError::Unresolved(items) = err;
276 assert!(!items.is_empty());
277 }
278
279 #[test]
280 fn substitute_skips_inline_code() {
281 let out = sample()
282 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
283 .unwrap();
284 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
285 }
286
287 #[test]
288 fn substitute_skips_fenced_code_block() {
289 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
290 let out = sample().substitute(input).unwrap();
291 assert!(out.contains("Value: 580"));
292 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
293 }
294
295 #[test]
296 fn substitute_reports_unresolved_keys_sorted_and_deduped() {
297 let err = sample()
298 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
299 .unwrap_err();
300 let SubstError::Unresolved(items) = err;
301 assert_eq!(
302 items,
303 vec!["also.missing".to_string(), "nope.absent".to_string()]
304 );
305 }
306
307 #[test]
308 fn chains_filters() {
309 let s = Substituter::new().with_value("x", Value::Float(26.4079));
310 let out = s.substitute("{{ x | round(2) | money }}").unwrap();
311 assert_eq!(out, "$26.41");
312 }
313
314 #[test]
315 fn get_and_keys_expose_the_table() {
316 let s = sample();
317 assert_eq!(s.get("expenses.F_monthly"), Some(&Value::Int(580)));
318 assert!(s.keys().any(|k| k == "stripe.percent"));
319 }
320 }
321