Skip to main content

alint_core/
template.rs

1//! String substitution for path templates and message templates.
2//!
3//! Two variants, distinguished by delimiter style:
4//!
5//! - **Path templates** — single braces, fixed token set derived from a
6//!   matched file's relative path. Example: `"{dir}/{stem}.h"`.
7//! - **Message templates** — double braces, namespaced lookups for rule
8//!   messages and similar user-facing strings. Example:
9//!   `"{{ctx.primary}} has no matching header at {{ctx.partner}}"`.
10//!
11//! Both are intentionally small and self-contained: no regex dependency,
12//! no dynamic parser. Unknown tokens are preserved literally so a typo
13//! surfaces in output rather than silently blanking out.
14
15use std::path::Path;
16
17use crate::config::PathsSpec;
18
19/// Token values derived from a relative path. Consumed by
20/// [`render_path`] and by cross-file rules to resolve partner paths.
21#[derive(Debug, Clone)]
22pub struct PathTokens {
23    pub path: String,
24    pub dir: String,
25    pub basename: String,
26    pub stem: String,
27    pub ext: String,
28    pub parent_name: String,
29}
30
31impl PathTokens {
32    /// Derive tokens from a relative path. Missing components (e.g. a path
33    /// with no parent, or no extension) resolve to the empty string.
34    pub fn from_path(rel: &Path) -> Self {
35        Self {
36            path: rel.display().to_string(),
37            dir: rel
38                .parent()
39                .map(|p| p.display().to_string())
40                .unwrap_or_default(),
41            basename: rel
42                .file_name()
43                .and_then(|s| s.to_str())
44                .unwrap_or_default()
45                .to_string(),
46            stem: rel
47                .file_stem()
48                .and_then(|s| s.to_str())
49                .unwrap_or_default()
50                .to_string(),
51            ext: rel
52                .extension()
53                .and_then(|s| s.to_str())
54                .unwrap_or_default()
55                .to_string(),
56            parent_name: rel
57                .parent()
58                .and_then(|p| p.file_name())
59                .and_then(|s| s.to_str())
60                .unwrap_or_default()
61                .to_string(),
62        }
63    }
64}
65
66/// Substitute `{token}` placeholders in a path-shaped template. Unknown
67/// tokens are preserved literally (so `"{unknown}"` renders as `"{unknown}"`).
68///
69/// Multi-character tokens are replaced longest-first so future additions like
70/// `{stem_kebab}` do not accidentally match `{stem}` first.
71pub fn render_path(template: &str, t: &PathTokens) -> String {
72    let mut out = template.to_string();
73    // Order matters: longest keys first.
74    out = out.replace("{parent_name}", &t.parent_name);
75    out = out.replace("{basename}", &t.basename);
76    out = out.replace("{path}", &t.path);
77    out = out.replace("{stem}", &t.stem);
78    out = out.replace("{dir}", &t.dir);
79    out = out.replace("{ext}", &t.ext);
80    out
81}
82
83/// Substitute `{{namespace.key}}` placeholders in a message template. The
84/// caller-supplied `resolve` closure returns the substituted value, or
85/// `None` to leave the placeholder literal.
86///
87/// Whitespace inside the braces (`{{ ctx.primary }}`) is ignored so users
88/// can format their messages for readability.
89/// Apply path-template substitution to every string inside a YAML mapping,
90/// recursively into nested mappings and sequences. Non-string values pass
91/// through unchanged. Used by nested-rule specs (e.g. `for_each_dir`) so that
92/// the `{dir}` in a nested rule's `paths`, `pattern`, or `partner` field
93/// resolves to the iterated entry's path at rule-build time.
94pub fn render_mapping(m: serde_yaml_ng::Mapping, tokens: &PathTokens) -> serde_yaml_ng::Mapping {
95    let mut out = serde_yaml_ng::Mapping::with_capacity(m.len());
96    for (k, v) in m {
97        out.insert(k, render_value(v, tokens));
98    }
99    out
100}
101
102/// Recursive mate to [`render_mapping`] for arbitrary YAML values.
103pub fn render_value(v: serde_yaml_ng::Value, tokens: &PathTokens) -> serde_yaml_ng::Value {
104    use serde_yaml_ng::Value;
105    match v {
106        Value::String(s) => Value::String(render_path(&s, tokens)),
107        Value::Sequence(seq) => {
108            Value::Sequence(seq.into_iter().map(|e| render_value(e, tokens)).collect())
109        }
110        Value::Mapping(m) => Value::Mapping(render_mapping(m, tokens)),
111        other => other,
112    }
113}
114
115/// Apply path-template substitution to every pattern in a `PathsSpec`.
116pub fn render_paths_spec(spec: &PathsSpec, tokens: &PathTokens) -> PathsSpec {
117    match spec {
118        PathsSpec::Single(s) => PathsSpec::Single(render_path(s, tokens)),
119        PathsSpec::Many(v) => PathsSpec::Many(v.iter().map(|s| render_path(s, tokens)).collect()),
120        PathsSpec::IncludeExclude { include, exclude } => PathsSpec::IncludeExclude {
121            include: include.iter().map(|s| render_path(s, tokens)).collect(),
122            exclude: exclude.iter().map(|s| render_path(s, tokens)).collect(),
123        },
124    }
125}
126
127pub fn render_message<F>(template: &str, resolve: F) -> String
128where
129    F: Fn(&str, &str) -> Option<String>,
130{
131    let mut out = String::with_capacity(template.len());
132    let mut rest = template;
133    while let Some(start) = rest.find("{{") {
134        out.push_str(&rest[..start]);
135        let after = &rest[start + 2..];
136        let Some(end) = after.find("}}") else {
137            // Unterminated {{ — preserve rest literally.
138            out.push_str(&rest[start..]);
139            return out;
140        };
141        let inner = after[..end].trim();
142        let rendered = inner
143            .split_once('.')
144            .and_then(|(ns, key)| resolve(ns.trim(), key.trim()));
145        if let Some(val) = rendered {
146            out.push_str(&val);
147        } else {
148            out.push_str("{{");
149            out.push_str(&after[..end]);
150            out.push_str("}}");
151        }
152        rest = &after[end + 2..];
153    }
154    out.push_str(rest);
155    out
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use std::path::Path;
162
163    #[test]
164    fn path_tokens_basic_rs_file() {
165        let t = PathTokens::from_path(Path::new("crates/alint-core/src/lib.rs"));
166        assert_eq!(t.path, "crates/alint-core/src/lib.rs");
167        assert_eq!(t.dir, "crates/alint-core/src");
168        assert_eq!(t.basename, "lib.rs");
169        assert_eq!(t.stem, "lib");
170        assert_eq!(t.ext, "rs");
171        assert_eq!(t.parent_name, "src");
172    }
173
174    #[test]
175    fn path_tokens_root_file() {
176        let t = PathTokens::from_path(Path::new("README.md"));
177        assert_eq!(t.path, "README.md");
178        assert_eq!(t.dir, "");
179        assert_eq!(t.basename, "README.md");
180        assert_eq!(t.stem, "README");
181        assert_eq!(t.ext, "md");
182        assert_eq!(t.parent_name, "");
183    }
184
185    #[test]
186    fn render_path_c_to_h() {
187        let t = PathTokens::from_path(Path::new("src/mod/foo.c"));
188        assert_eq!(render_path("{dir}/{stem}.h", &t), "src/mod/foo.h");
189    }
190
191    #[test]
192    fn render_path_unknown_token_preserved() {
193        let t = PathTokens::from_path(Path::new("a.c"));
194        assert_eq!(render_path("{bogus}/{stem}.x", &t), "{bogus}/a.x");
195    }
196
197    #[test]
198    fn render_message_simple() {
199        let out = render_message("{{ctx.primary}} → {{ctx.partner}}", |ns, key| {
200            match (ns, key) {
201                ("ctx", "primary") => Some("a.c".into()),
202                ("ctx", "partner") => Some("a.h".into()),
203                _ => None,
204            }
205        });
206        assert_eq!(out, "a.c → a.h");
207    }
208
209    #[test]
210    fn render_message_ignores_inner_whitespace() {
211        let out = render_message("[{{ ctx . primary }}]", |ns, key| {
212            if ns == "ctx" && key == "primary" {
213                Some("x".into())
214            } else {
215                None
216            }
217        });
218        assert_eq!(out, "[x]");
219    }
220
221    #[test]
222    fn render_message_unknown_key_preserved() {
223        let out = render_message("{{ctx.unknown}}", |_, _| None);
224        assert_eq!(out, "{{ctx.unknown}}");
225    }
226
227    #[test]
228    fn render_message_unterminated_is_preserved() {
229        let out = render_message("before {{ctx.primary", |_, _| Some("X".into()));
230        assert_eq!(out, "before {{ctx.primary");
231    }
232
233    #[test]
234    fn render_message_no_placeholders() {
235        let out = render_message("plain text", |_, _| Some("never".into()));
236        assert_eq!(out, "plain text");
237    }
238}