Skip to main content

max / makenotwork

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