Skip to main content

harn_vm/stdlib/template/
filters.rs

1//! The prompt-template filter registry.
2//!
3//! [`FILTERS`] is the only list of filters that exists. `apply_filter`
4//! dispatches through it and tooling (editor completion, hover) reads
5//! it, so a filter cannot be offered to an author and then rejected by
6//! the engine, and a filter cannot be implemented without becoming
7//! discoverable.
8//!
9//! Arity checking and error positioning happen here, once, so each
10//! filter body only has to say what went wrong.
11
12use crate::value::{string_char_count, VmValue};
13
14use super::error::TemplateError;
15use super::render::{display_value, truthy};
16
17/// A filter body. Arity has already been checked against the filter's
18/// declared parameters; the returned message is positioned by the
19/// caller.
20type FilterBody = fn(&VmValue, &[VmValue]) -> Result<VmValue, String>;
21
22/// One filter's contract: what authors write after `|`, which arguments
23/// follow the `:`, and what it does.
24pub struct Filter {
25    /// The name written after `|`.
26    pub name: &'static str,
27    /// Names of the arguments that follow `:`, in order. Anything past
28    /// `required` is optional.
29    pub params: &'static [&'static str],
30    /// How many of `params` must be supplied.
31    pub required: usize,
32    /// One line describing what the filter does.
33    pub summary: &'static str,
34    body: FilterBody,
35}
36
37impl Filter {
38    /// How the filter is written, e.g. `join: separator` or
39    /// `indent: width[, indent_first]`.
40    pub fn signature(&self) -> String {
41        if self.params.is_empty() {
42            return self.name.to_string();
43        }
44        let mut out = format!("{}: ", self.name);
45        for (index, param) in self.params.iter().enumerate() {
46            // The bracket opens before the separator, so what is left
47            // when the optional arguments are dropped still reads as a
48            // valid call.
49            if index == self.required {
50                out.push('[');
51            }
52            if index > 0 {
53                out.push_str(", ");
54            }
55            out.push_str(param);
56        }
57        if self.required < self.params.len() {
58            out.push(']');
59        }
60        out
61    }
62}
63
64/// Every filter the engine can apply.
65pub static FILTERS: &[Filter] = &[
66    Filter {
67        name: "upper",
68        params: &[],
69        required: 0,
70        summary: "Uppercase the value.",
71        body: |v, _| Ok(str_value(display_value(v).to_uppercase())),
72    },
73    Filter {
74        name: "lower",
75        params: &[],
76        required: 0,
77        summary: "Lowercase the value.",
78        body: |v, _| Ok(str_value(display_value(v).to_lowercase())),
79    },
80    Filter {
81        name: "trim",
82        params: &[],
83        required: 0,
84        summary: "Strip leading and trailing whitespace.",
85        body: |v, _| Ok(str_value(display_value(v).trim().to_string())),
86    },
87    Filter {
88        name: "capitalize",
89        params: &[],
90        required: 0,
91        summary: "Uppercase the first character and lowercase the rest.",
92        body: |v, _| Ok(str_value(capitalize(&display_value(v)))),
93    },
94    Filter {
95        name: "title",
96        params: &[],
97        required: 0,
98        summary: "Uppercase the first character of every word.",
99        body: |v, _| Ok(str_value(title_case(&display_value(v)))),
100    },
101    Filter {
102        name: "length",
103        params: &[],
104        required: 0,
105        summary: "Number of characters, items, or entries.",
106        body: |v, _| length(v),
107    },
108    Filter {
109        name: "first",
110        params: &[],
111        required: 0,
112        summary: "First item of a list or set, or first character of a string.",
113        body: |v, _| Ok(first(v)),
114    },
115    Filter {
116        name: "last",
117        params: &[],
118        required: 0,
119        summary: "Last item of a list or set, or last character of a string.",
120        body: |v, _| Ok(last(v)),
121    },
122    Filter {
123        name: "reverse",
124        params: &[],
125        required: 0,
126        summary: "Reverse a list or string. Other values pass through unchanged.",
127        body: |v, _| Ok(reverse(v)),
128    },
129    Filter {
130        name: "join",
131        params: &["separator"],
132        required: 1,
133        summary: "Join a list or set into a string with the given separator.",
134        body: |v, args| join(v, &args[0]),
135    },
136    Filter {
137        name: "default",
138        params: &["fallback"],
139        required: 1,
140        summary: "Substitute the fallback when the value is falsy.",
141        body: |v, args| {
142            Ok(if truthy(v) {
143                v.clone()
144            } else {
145                args[0].clone()
146            })
147        },
148    },
149    Filter {
150        name: "json",
151        params: &["pretty"],
152        required: 0,
153        summary: "Serialize the value as JSON, optionally pretty-printed.",
154        body: |v, args| json(v, args.first().map(truthy).unwrap_or(false)),
155    },
156    Filter {
157        name: "indent",
158        params: &["width", "indent_first"],
159        required: 1,
160        summary: "Indent every line by `width` spaces, skipping the first line unless \
161                  `indent_first` is true.",
162        body: |v, args| indent(v, &args[0], args.get(1).map(truthy).unwrap_or(false)),
163    },
164    Filter {
165        name: "lines",
166        params: &[],
167        required: 0,
168        summary: "Split the value into a list of lines.",
169        body: |v, _| Ok(lines(v)),
170    },
171    Filter {
172        name: "escape_md",
173        params: &[],
174        required: 0,
175        summary: "Backslash-escape Markdown punctuation.",
176        body: |v, _| Ok(str_value(escape_md(&display_value(v)))),
177    },
178    Filter {
179        name: "replace",
180        params: &["from", "to"],
181        required: 2,
182        summary: "Replace every occurrence of `from` with `to`.",
183        body: |v, args| {
184            let s = display_value(v);
185            let from = display_value(&args[0]);
186            let to = display_value(&args[1]);
187            Ok(str_value(s.replace(&from, &to)))
188        },
189    },
190];
191
192/// The filter named `name`, if the engine has one.
193pub fn lookup(name: &str) -> Option<&'static Filter> {
194    FILTERS.iter().find(|filter| filter.name == name)
195}
196
197pub(super) fn apply_filter(
198    name: &str,
199    v: &VmValue,
200    args: &[VmValue],
201    line: usize,
202    col: usize,
203) -> Result<VmValue, TemplateError> {
204    let Some(filter) = lookup(name) else {
205        return Err(TemplateError::new(
206            line,
207            col,
208            format!("unknown filter `{name}`"),
209        ));
210    };
211    if args.len() < filter.required || args.len() > filter.params.len() {
212        return Err(TemplateError::new(
213            line,
214            col,
215            format!("filter `{name}` got wrong number of arguments"),
216        ));
217    }
218    (filter.body)(v, args).map_err(|message| TemplateError::new(line, col, message))
219}
220
221fn str_value(s: String) -> VmValue {
222    VmValue::String(arcstr::ArcStr::from(s))
223}
224
225fn capitalize(s: &str) -> String {
226    let mut out = String::with_capacity(s.len());
227    let mut chars = s.chars();
228    if let Some(c) = chars.next() {
229        out.extend(c.to_uppercase());
230    }
231    for c in chars {
232        out.extend(c.to_lowercase());
233    }
234    out
235}
236
237fn title_case(s: &str) -> String {
238    let mut out = String::with_capacity(s.len());
239    let mut at_start = true;
240    for c in s.chars() {
241        if c.is_whitespace() {
242            at_start = true;
243            out.push(c);
244        } else if at_start {
245            out.extend(c.to_uppercase());
246            at_start = false;
247        } else {
248            out.extend(c.to_lowercase());
249        }
250    }
251    out
252}
253
254fn length(v: &VmValue) -> Result<VmValue, String> {
255    let n: i64 = match v {
256        VmValue::String(s) => string_char_count(s) as i64,
257        VmValue::List(items) => items.len() as i64,
258        VmValue::Set(items) => items.len() as i64,
259        VmValue::Dict(d) => d.len() as i64,
260        VmValue::Range(r) => r.len(),
261        VmValue::Nil => 0,
262        other => return Err(format!("`length` not defined for {}", other.type_name())),
263    };
264    Ok(VmValue::Int(n))
265}
266
267fn first(v: &VmValue) -> VmValue {
268    match v {
269        VmValue::List(items) => items.first().cloned().unwrap_or(VmValue::Nil),
270        VmValue::Set(set) => set.items().first().cloned().unwrap_or(VmValue::Nil),
271        VmValue::String(s) => s
272            .chars()
273            .next()
274            .map(|c| str_value(c.to_string()))
275            .unwrap_or(VmValue::Nil),
276        _ => VmValue::Nil,
277    }
278}
279
280fn last(v: &VmValue) -> VmValue {
281    match v {
282        VmValue::List(items) => items.last().cloned().unwrap_or(VmValue::Nil),
283        VmValue::Set(set) => set.items().last().cloned().unwrap_or(VmValue::Nil),
284        VmValue::String(s) => s
285            .chars()
286            .last()
287            .map(|c| str_value(c.to_string()))
288            .unwrap_or(VmValue::Nil),
289        _ => VmValue::Nil,
290    }
291}
292
293fn reverse(v: &VmValue) -> VmValue {
294    match v {
295        VmValue::List(items) => {
296            let mut out: Vec<VmValue> = items.as_ref().clone();
297            out.reverse();
298            VmValue::List(std::sync::Arc::new(out))
299        }
300        VmValue::String(s) => str_value(s.chars().rev().collect::<String>()),
301        _ => v.clone(),
302    }
303}
304
305fn join(v: &VmValue, separator: &VmValue) -> Result<VmValue, String> {
306    let sep = display_value(separator);
307    let parts: Vec<String> = match v {
308        VmValue::List(items) => items.iter().map(display_value).collect(),
309        VmValue::Set(items) => items.iter().map(display_value).collect(),
310        VmValue::String(s) => return Ok(VmValue::String(s.clone())),
311        _ => return Err(format!("`join` requires a list (got {})", v.type_name())),
312    };
313    Ok(str_value(parts.join(&sep)))
314}
315
316fn json(v: &VmValue, pretty: bool) -> Result<VmValue, String> {
317    let jv = crate::llm::helpers::vm_value_to_json(v);
318    let s = if pretty {
319        serde_json::to_string_pretty(&jv)
320    } else {
321        serde_json::to_string(&jv)
322    }
323    .map_err(|e| format!("json serialization: {e}"))?;
324    Ok(str_value(s))
325}
326
327fn indent(v: &VmValue, width: &VmValue, indent_first: bool) -> Result<VmValue, String> {
328    let VmValue::Int(n) = width else {
329        return Err("`indent` requires an integer width".to_string());
330    };
331    let n = (*n).max(0) as usize;
332    let pad: String = " ".repeat(n);
333    let s = display_value(v);
334    let mut out = String::with_capacity(s.len() + n * 4);
335    for (i, line) in s.split('\n').enumerate() {
336        if i > 0 {
337            out.push('\n');
338        }
339        if !line.is_empty() && (i > 0 || indent_first) {
340            out.push_str(&pad);
341        }
342        out.push_str(line);
343    }
344    Ok(str_value(out))
345}
346
347fn lines(v: &VmValue) -> VmValue {
348    let s = display_value(v);
349    let list: Vec<VmValue> = s.split('\n').map(|p| str_value(p.to_string())).collect();
350    VmValue::List(std::sync::Arc::new(list))
351}
352
353fn escape_md(s: &str) -> String {
354    let mut out = String::with_capacity(s.len() + 8);
355    for c in s.chars() {
356        match c {
357            '\\' | '`' | '*' | '_' | '{' | '}' | '[' | ']' | '(' | ')' | '#' | '+' | '-' | '.'
358            | '!' | '|' | '<' | '>' => {
359                out.push('\\');
360                out.push(c);
361            }
362            _ => out.push(c),
363        }
364    }
365    out
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{apply_filter, lookup, FILTERS};
371    use crate::value::VmValue;
372
373    #[test]
374    fn filter_names_are_unique() {
375        let mut names: Vec<&str> = FILTERS.iter().map(|filter| filter.name).collect();
376        let before = names.len();
377        names.sort_unstable();
378        names.dedup();
379        assert_eq!(before, names.len(), "duplicate filter name in the table");
380    }
381
382    #[test]
383    fn required_arguments_are_a_prefix_of_the_parameter_list() {
384        for filter in FILTERS {
385            assert!(
386                filter.required <= filter.params.len(),
387                "`{}` requires more arguments than it declares",
388                filter.name
389            );
390        }
391    }
392
393    #[test]
394    fn signatures_mark_optional_arguments() {
395        assert_eq!(lookup("upper").unwrap().signature(), "upper");
396        assert_eq!(lookup("join").unwrap().signature(), "join: separator");
397        assert_eq!(lookup("replace").unwrap().signature(), "replace: from, to");
398        assert_eq!(lookup("json").unwrap().signature(), "json: [pretty]");
399        assert_eq!(
400            lookup("indent").unwrap().signature(),
401            "indent: width[, indent_first]"
402        );
403    }
404
405    #[test]
406    fn unknown_names_are_not_filters() {
407        assert!(lookup("endif").is_none());
408        assert!(lookup("").is_none());
409    }
410
411    /// `apply_filter` dispatches through the table rather than a parallel
412    /// `match`, so "declared" and "implemented" are the same set by
413    /// construction. This pins that: every declared filter is reachable,
414    /// and a name that is not declared is rejected as unknown.
415    #[test]
416    fn dispatch_covers_exactly_the_declared_filters() {
417        for filter in FILTERS {
418            let args = vec![VmValue::Nil; filter.required];
419            // A filter may still reject `nil` on its merits; what must
420            // never happen is the engine not knowing the name at all.
421            if let Err(error) = apply_filter(filter.name, &VmValue::Nil, &args, 1, 1) {
422                assert!(
423                    !error.kind.contains("unknown filter"),
424                    "`{}` is declared but not dispatched",
425                    filter.name
426                );
427            }
428        }
429
430        let error = apply_filter("uppercase", &VmValue::Nil, &[], 1, 1)
431            .expect_err("an undeclared name is not a filter");
432        assert!(error.kind.contains("unknown filter"), "got {}", error.kind);
433    }
434}