Skip to main content

max / makenotwork

2.6 KB · 67 lines History Blame Raw
1 # subst
2
3 A markdown-aware `{{ dotted.path | filter(args) }}` value-substitution engine.
4
5 Split out of [docengine]../docengine on 2026-07-25, along with
6 [mnw-assumptions]../mnw-assumptions, which is its only consumer today. The engine is
7 domain-free: it knows nothing about where values come from. Callers populate a table and
8 register whatever filters they need.
9
10 ```rust
11 use subst::{Substituter, Value};
12
13 let s = Substituter::new()
14 .with_value("price.basic", Value::Int(16))
15 .with_value("stripe.percent", Value::Float(0.029));
16
17 let out = s.substitute("Basic is ${{ price.basic }} ({{ stripe.percent | percent }} fee).")?;
18 assert_eq!(out, "Basic is $16 (2.9% fee).");
19 ```
20
21 ## Markers
22
23 A marker is `{{ path (| filter (args)?)* }}`. The path is a dotted key looked up verbatim
24 in the table; filters run left-to-right, each taking the previous filter's output.
25 Arguments are integer, float, or quoted string literals (single or double):
26 `{{ x | round(2) | money("$") }}`.
27
28 Markers inside inline code spans and fenced code blocks are **left verbatim**, so
29 documentation showing literal template syntax survives untouched. That is the whole reason
30 the pass is markdown-aware; everything else about it is plain text substitution.
31
32 ## Values and filters
33
34 `Value` is `Int(i64) | Float(f64) | String(String)`. Built-in filters:
35
36 | Filter | Effect |
37 |--------|--------|
38 | `int` | Truncate toward zero, render as an integer |
39 | `ceil` / `floor` / `round(n?)` | Round up / down / to `n` decimal places (0..=10, default 0) |
40 | `money(symbol?)` | Two decimals behind a symbol, `$` unless given: `0.3` becomes `$0.30` |
41 | `percent(n?)` | Multiply by 100, `n` decimals (default 1), trailing `%`: `0.029` becomes `2.9%` |
42 | `upper` / `lower` | ASCII case conversion |
43
44 Register a custom filter with `with_filter(name, f)`. `Filter` is a single-method trait
45 (`apply(Value, &[FilterArg]) -> Result<Value, FilterError>`) with a blanket impl over
46 `Fn`, so a closure works without writing a struct. A custom filter overrides a built-in of
47 the same name.
48
49 ## Errors
50
51 `substitute` returns `SubstError::Unresolved(Vec<String>)` listing every path that was
52 missing from the table, or every filter that failed, formatted for a log line. Unresolved
53 markers are left in place in the returned string, so a caller can grep the output rather
54 than diff it against the input to find them.
55
56 ## Dependencies
57
58 `regex-lite`, and nothing else.
59
60 The `code_span_ranges` helper is duplicated from docengine rather than shared: docengine's
61 `mentions` feature needs its own copy, and depending on docengine here would invert the
62 split this crate exists to make.
63
64 ## License
65
66 MIT.
67