Skip to main content

max / makenotwork

13.5 KB · 380 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 ///
127 /// # Do not substitute the output again
128 ///
129 /// One pass, and the output is not rescanned. A template may therefore ask
130 /// for text that looks like a marker and get it back verbatim:
131 ///
132 /// ```text
133 /// {{ price | money("{{ other }}") }} -> {{ other }}16.00
134 /// ```
135 ///
136 /// That is the template author's literal string, correctly emitted. It is
137 /// only a hazard if something feeds the result back in, which would
138 /// evaluate a marker the first pass deliberately treated as data. **A
139 /// second pass over untrusted output is a template injection**, and the
140 /// same is true of any value in the table whose text contains `{{`.
141 ///
142 /// Stated here because it had never been stated anywhere, and a caller
143 /// building a two-stage pipeline would reasonably have assumed the
144 /// opposite.
145 pub fn substitute(&self, text: &str) -> Result<String, SubstError> {
146 // Markers are located by `parser::markers`, which shares the parser's
147 // own notion of a quoted string. It used to be a non-greedy regex,
148 // which did not, so a `}}` inside a filter's string argument ended the
149 // marker early and the tail was copied through as text. See
150 // `parser::markers` for the case and the reasoning.
151 //
152 // Skip matches inside inline code spans and fenced code blocks so that
153 // documentation showing literal `{{ … }}` template syntax (e.g. Tauri
154 // updater URL patterns) is preserved verbatim.
155 let code_ranges = code_spans::code_span_ranges(text);
156 let in_code = |start: usize| code_ranges.iter().any(|&(s, e)| start >= s && start < e);
157
158 let mut unresolved: Vec<String> = Vec::new();
159 let mut errors: Vec<String> = Vec::new();
160 let mut out = String::with_capacity(text.len());
161 let mut last = 0;
162
163 for m in parser::markers(text) {
164 out.push_str(&text[last..m.start]);
165 last = m.end;
166
167 let raw = &text[m.start..m.end];
168 if in_code(m.start) {
169 out.push_str(raw);
170 continue;
171 }
172
173 let body = m.body;
174 match self.resolve(body) {
175 Ok(Some(v)) => out.push_str(&v.to_string()),
176 Ok(None) => {
177 // Path not found in the table. Preserve marker; flag for caller.
178 unresolved.push(body.trim().to_string());
179 out.push_str(raw);
180 }
181 Err(e) => {
182 errors.push(format!("`{body}`: {e}"));
183 out.push_str(raw);
184 }
185 }
186 }
187 out.push_str(&text[last..]);
188
189 if !errors.is_empty() {
190 return Err(SubstError::Unresolved(errors));
191 }
192 if !unresolved.is_empty() {
193 unresolved.sort();
194 unresolved.dedup();
195 return Err(SubstError::Unresolved(unresolved));
196 }
197 Ok(out)
198 }
199
200 /// Resolve a single `{{ body }}` expression. Returns:
201 /// - `Ok(Some(v))` when the path resolved and all filters applied cleanly.
202 /// - `Ok(None)` when the path is missing from the table.
203 /// - `Err(_)` for parse errors, unknown filters, or filter failures.
204 fn resolve(&self, body: &str) -> Result<Option<Value>, String> {
205 let expr = parser::parse_expr(body).map_err(|e| e.to_string())?;
206 let Some(initial) = self.values.get(expr.path).cloned() else {
207 return Ok(None);
208 };
209 let mut value = initial;
210 for call in &expr.filters {
211 let filter = self
212 .filters
213 .get(call.name)
214 .ok_or_else(|| format!("unknown filter `{}`", call.name))?;
215 value = filter.apply(value, &call.args).map_err(|e| e.to_string())?;
216 }
217 Ok(Some(value))
218 }
219 }
220
221 #[cfg(test)]
222 mod tests {
223 use super::*;
224
225 fn sample() -> Substituter {
226 Substituter::new()
227 .with_value("expenses.F_monthly", Value::Int(580))
228 .with_value("stripe.percent", Value::Float(0.029))
229 .with_value("stripe.fixed", Value::Float(0.30))
230 .with_value("cohort.lock_duration", Value::String("lifetime".into()))
231 }
232
233 /// Regression, from the soak tier 2026-08-11
234 /// (GoingsOn `subst-substitute:4def8fda6141`).
235 ///
236 /// The old regex ended the marker at the `}}` inside the string argument,
237 /// so the parser was handed ` expenses.F_monthly | money("` and reported an
238 /// unclosed paren, while `") }}` was copied through as literal text.
239 #[test]
240 fn a_close_brace_inside_a_string_argument_is_not_the_end_of_the_marker() {
241 let out = sample()
242 .substitute(r#"{{ expenses.F_monthly | money("}}") }}"#)
243 .expect("parses as one marker");
244 assert_eq!(out, "}}580.00");
245 }
246
247 /// The other half of the same finding: with the marker now bounded
248 /// correctly, this is a fixed point.
249 #[test]
250 fn a_close_brace_in_a_string_argument_round_trips() {
251 let s = sample();
252 let once = s
253 .substitute(r#"{{ expenses.F_monthly | money("}}") }}"#)
254 .unwrap();
255 assert_eq!(s.substitute(&once).unwrap(), once);
256 }
257
258 /// A template may ask for text that looks like a marker, and gets it back
259 /// literally. Substitution is one pass and does not rescan its own output,
260 /// so this is the documented behaviour rather than a defect -- see the note
261 /// on [`Substituter::substitute`] about not re-substituting output.
262 #[test]
263 fn marker_text_inside_a_string_argument_is_emitted_literally() {
264 let out = sample()
265 .substitute(r#"{{ expenses.F_monthly | money("{{ x }}") }}"#)
266 .unwrap();
267 assert_eq!(out, "{{ x }}580.00");
268 }
269
270 #[test]
271 fn substitute_replaces_known_keys() {
272 let out = sample()
273 .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.")
274 .unwrap();
275 assert_eq!(out, "Fixed monthly costs are $580.");
276 }
277
278 #[test]
279 fn substitute_handles_whitespace_in_markers() {
280 let out = sample()
281 .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}")
282 .unwrap();
283 assert_eq!(out, "a=580 b=580");
284 }
285
286 #[test]
287 fn substitute_applies_percent_filter() {
288 let out = sample()
289 .substitute("Stripe charges {{ stripe.percent | percent }}.")
290 .unwrap();
291 assert_eq!(out, "Stripe charges 2.9%.");
292 }
293
294 #[test]
295 fn substitute_applies_money_filter() {
296 let out = sample()
297 .substitute("Flat fee: {{ stripe.fixed | money }}.")
298 .unwrap();
299 assert_eq!(out, "Flat fee: $0.30.");
300 }
301
302 #[test]
303 fn substitute_consumer_can_register_custom_filter() {
304 // Closure-based filter: format thousands as "N.Nk".
305 let s = sample().with_filter("kilo", |v: Value, _args: &[FilterArg]| {
306 let n = v
307 .as_f64()
308 .ok_or_else(|| FilterError::type_error("kilo", &v))?;
309 Ok(Value::String(format!("{:.1}k", n / 1000.0)))
310 });
311 let out = s
312 .substitute("Fixed: {{ expenses.F_monthly | kilo }}")
313 .unwrap();
314 assert_eq!(out, "Fixed: 0.6k");
315 }
316
317 #[test]
318 fn substitute_unknown_filter_reports_error() {
319 let err = sample()
320 .substitute("{{ expenses.F_monthly | nope }}")
321 .unwrap_err();
322 let SubstError::Unresolved(items) = err;
323 assert!(
324 items.iter().any(|m| m.contains("unknown filter")),
325 "{items:?}"
326 );
327 }
328
329 #[test]
330 fn substitute_filter_type_mismatch_reports_error() {
331 let err = sample()
332 .substitute("{{ cohort.lock_duration | money }}")
333 .unwrap_err();
334 let SubstError::Unresolved(items) = err;
335 assert!(!items.is_empty());
336 }
337
338 #[test]
339 fn substitute_skips_inline_code() {
340 let out = sample()
341 .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.")
342 .unwrap();
343 assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`.");
344 }
345
346 #[test]
347 fn substitute_skips_fenced_code_block() {
348 let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n";
349 let out = sample().substitute(input).unwrap();
350 assert!(out.contains("Value: 580"));
351 assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}");
352 }
353
354 #[test]
355 fn substitute_reports_unresolved_keys_sorted_and_deduped() {
356 let err = sample()
357 .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}")
358 .unwrap_err();
359 let SubstError::Unresolved(items) = err;
360 assert_eq!(
361 items,
362 vec!["also.missing".to_string(), "nope.absent".to_string()]
363 );
364 }
365
366 #[test]
367 fn chains_filters() {
368 let s = Substituter::new().with_value("x", Value::Float(26.4079));
369 let out = s.substitute("{{ x | round(2) | money }}").unwrap();
370 assert_eq!(out, "$26.41");
371 }
372
373 #[test]
374 fn get_and_keys_expose_the_table() {
375 let s = sample();
376 assert_eq!(s.get("expenses.F_monthly"), Some(&Value::Int(580)));
377 assert!(s.keys().any(|k| k == "stripe.percent"));
378 }
379 }
380