Skip to main content

declint_core/
template.rs

1//! Message templates: `{match}` and named-capture placeholders with
2//! `{{` / `}}` escapes, parsed and validated at config-load time.
3
4/// One piece of a parsed [`Template`].
5#[derive(Debug, Clone, PartialEq, Eq)]
6enum Part {
7    /// Literal text, copied through verbatim.
8    Text(String),
9    /// A placeholder: `match` for the whole match, or a named capture group.
10    Placeholder(String),
11}
12
13/// A rule message with placeholders resolved per match.
14///
15/// Syntax:
16///
17/// * `{match}` — the text the rule matched.
18/// * `{name}` — the text of the named capture group `name`.
19/// * `{{` and `}}` — literal braces.
20///
21/// [`Template::parse`] rejects malformed placeholders, and
22/// [`Template::validate`] rejects names that the rule's regex has no
23/// capture group for — both at config-load time, never mid-lint.
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
25pub struct Template(Vec<Part>);
26
27impl Template {
28    /// Parses a template, rejecting malformed placeholders.
29    ///
30    /// A `{` must open a placeholder (`{match}`, `{name}`) or escape itself
31    /// (`{{`); a lone `}` is literal text.
32    pub fn parse(src: &str) -> Result<Self, String> {
33        let mut parts = Vec::new();
34        let mut text = String::new();
35        let mut chars = src.chars().peekable();
36        while let Some(c) = chars.next() {
37            match c {
38                '{' => match chars.peek() {
39                    Some('{') => {
40                        chars.next();
41                        text.push('{');
42                    }
43                    Some(&next) if next == '}' || next.is_ascii_alphanumeric() || next == '_' => {
44                        let mut name = String::new();
45                        loop {
46                            match chars.peek() {
47                                Some('}') => {
48                                    chars.next();
49                                    break;
50                                }
51                                // Names may also be capture numbers
52                                // (`{1}`, `{2}`, ...).
53                                Some(&n)
54                                    if name.is_empty()
55                                        && (n.is_ascii_alphanumeric() || n == '_') =>
56                                {
57                                    name.push(n);
58                                    chars.next();
59                                }
60                                Some(&n) if !name.is_empty() && n.is_ascii_alphanumeric() => {
61                                    name.push(n);
62                                    chars.next();
63                                }
64                                _ => break,
65                            }
66                        }
67                        if name.is_empty() {
68                            return Err(
69                                "expected a placeholder name after `{` (e.g. `{match}`), or \
70                                 `{{` for a literal brace"
71                                    .into(),
72                            );
73                        }
74                        if !text.is_empty() {
75                            parts.push(Part::Text(std::mem::take(&mut text)));
76                        }
77                        parts.push(Part::Placeholder(name));
78                    }
79                    Some(_) => {
80                        return Err(
81                            "expected a placeholder name after `{` (e.g. `{match}`), or \
82                             `{{` for a literal brace"
83                                .into(),
84                        )
85                    }
86                    None => return Err("unclosed `{` at the end of the template".into()),
87                },
88                '}' => {
89                    if chars.peek() == Some(&'}') {
90                        chars.next();
91                    }
92                    text.push('}');
93                }
94                _ => text.push(c),
95            }
96        }
97        if !text.is_empty() {
98            parts.push(Part::Text(text));
99        }
100        Ok(Self(parts))
101    }
102
103    /// Checks every placeholder against `regex`'s capture groups —
104    /// named groups by name, numbered groups by index (`{1}` needs a
105    /// first capture group).
106    pub fn validate(&self, regex: &regex::Regex) -> Result<(), String> {
107        for part in &self.0 {
108            if let Part::Placeholder(name) = part {
109                if name == "match"
110                    || regex.capture_names().flatten().any(|n| n == name)
111                    || name
112                        .parse::<usize>()
113                        .is_ok_and(|index| index >= 1 && index < regex.captures_len())
114                {
115                    continue;
116                }
117                return Err(format!(
118                    "placeholder `{{{name}}}` does not name a capture group of the rule's \
119                     pattern (known groups: {})",
120                    known_groups(regex),
121                ));
122            }
123        }
124        Ok(())
125    }
126
127    /// Renders the template for one match.
128    pub fn render(&self, caps: &regex::Captures<'_>) -> String {
129        let mut out = String::new();
130        for part in &self.0 {
131            match part {
132                Part::Text(t) => out.push_str(t),
133                Part::Placeholder(name) => {
134                    let replacement = if name == "match" {
135                        caps.get(0).map(|m| m.as_str())
136                    } else {
137                        caps.name(name).map(|m| m.as_str())
138                    };
139                    if let Some(replacement) = replacement {
140                        out.push_str(replacement);
141                    }
142                }
143            }
144        }
145        out
146    }
147
148    /// Renders the template from pre-collected capture pairs — named
149    /// groups by name, numbered groups as `"1"`, `"2"`, ... Used by
150    /// parser rules, whose captures do not come from a regex.
151    pub fn render_with(&self, match_text: &str, captures: &[(String, String)]) -> String {
152        let mut out = String::new();
153        for part in &self.0 {
154            match part {
155                Part::Text(t) => out.push_str(t),
156                Part::Placeholder(name) => {
157                    if name == "match" {
158                        out.push_str(match_text);
159                        continue;
160                    }
161                    if let Some((_, value)) =
162                        captures.iter().find(|(key, _)| key == name)
163                    {
164                        out.push_str(value);
165                    }
166                }
167            }
168        }
169        out
170    }
171}
172
173fn known_groups(regex: &regex::Regex) -> String {
174    let names: Vec<&str> = regex.capture_names().flatten().collect();
175    if names.is_empty() {
176        "(none — name a group with `(?<name>...)`)".to_string()
177    } else {
178        names.join(", ")
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn render(src: &str, pattern: &str, haystack: &str) -> String {
187        let template = Template::parse(src).unwrap();
188        let regex = regex::Regex::new(pattern).unwrap();
189        template.validate(&regex).unwrap();
190        template
191            .render(&regex.captures(haystack).expect("must match"))
192    }
193
194    #[test]
195    fn literal_passes_through() {
196        assert_eq!(render("no placeholders here", "x", "x"), "no placeholders here");
197    }
198
199    #[test]
200    fn match_placeholder() {
201        assert_eq!(render("found '{match}'", "o+", "foo"), "found 'oo'");
202    }
203
204    #[test]
205    fn named_group_placeholder() {
206        assert_eq!(
207            render("got {word}!", "(?<word>\\w+)", "hi"),
208            "got hi!"
209        );
210    }
211
212    #[test]
213    fn brace_escapes() {
214        assert_eq!(render("{{literal}} {match}", "x", "x"), "{literal} x");
215    }
216
217    #[test]
218    fn lone_closing_brace_is_literal() {
219        assert_eq!(render("a } b", "x", "x"), "a } b");
220    }
221
222    #[test]
223    fn unknown_placeholder_rejected() {
224        let template = Template::parse("{nope}").unwrap();
225        let regex = regex::Regex::new("x").unwrap();
226        assert!(template.validate(&regex).is_err());
227    }
228
229    #[test]
230    fn malformed_placeholders_rejected() {
231        for src in ["{", "{ bad}", "o{"] {
232            assert!(Template::parse(src).is_err(), "{src:?} must not parse");
233        }
234    }
235}