Skip to main content

aoc_runtime/template/
matcher.rs

1//! Recovering template parameters from the current working directory.
2//!
3//! The pattern is generated from the parsed segments **in template order**:
4//! everything after each placeholder is wrapped in an optional group, so
5//! standing anywhere along the template's path recovers the parameters up to
6//! that point, whatever order the placeholders appear in.
7
8use super::{Segment, TemplateError};
9use crate::{
10    language::Language,
11    puzzle::{Day, Year},
12};
13use regex::Regex;
14use std::path::Path;
15
16/// Parameters recovered from a directory path. Every field is best-effort:
17/// only explicit command line arguments produce hard errors.
18#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub struct Detected {
20    /// The detected year, if the path contained a valid one.
21    pub year: Option<Year>,
22    /// The detected day, if the path contained a valid one.
23    pub day: Option<Day>,
24    /// The detected language, if the path contained one.
25    pub language: Option<Language>,
26}
27
28/// A compiled matcher for one template.
29#[derive(Debug, Clone)]
30pub struct CwdMatcher {
31    pattern: Regex,
32}
33
34impl CwdMatcher {
35    /// Compiles a matcher for the given segments.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`TemplateError::Regex`] if the generated pattern is rejected.
40    pub fn build(segments: &[Segment]) -> Result<Self, TemplateError> {
41        Ok(Self {
42            pattern: Regex::new(&build_pattern(segments))?,
43        })
44    }
45
46    /// The generated regular expression, exposed for diagnostics and tests.
47    #[must_use]
48    pub fn pattern(&self) -> &str {
49        self.pattern.as_str()
50    }
51
52    /// Recovers whatever parameters the path reveals.
53    #[must_use]
54    pub fn detect(&self, directory: &Path) -> Detected {
55        let path = directory.to_string_lossy();
56        let path = path.trim_end_matches(['/', '\\']);
57
58        let Some(captures) = self.pattern.captures(path) else {
59            return Detected::default();
60        };
61
62        let capture = |name| captures.name(name).map(|m| m.as_str());
63
64        Detected {
65            year: capture("year")
66                .and_then(|v| v.parse().ok())
67                .and_then(Year::new),
68            day: capture("day")
69                .and_then(|v| v.parse().ok())
70                .and_then(Day::new),
71            language: capture("language").and_then(|v| v.parse().ok()),
72        }
73    }
74}
75
76fn build_pattern(segments: &[Segment]) -> String {
77    let mut chunks: Vec<String> = Vec::new();
78    let mut pending = String::new();
79    let mut seen = Seen::default();
80
81    for segment in segments {
82        match segment {
83            Segment::Literal(text) => pending.push_str(&literal_pattern(text)),
84            Segment::Year | Segment::Day { .. } | Segment::Language => {
85                pending.push_str(&placeholder_pattern(segment, &mut seen));
86                chunks.push(std::mem::take(&mut pending));
87            }
88        }
89    }
90
91    let mut pattern = String::from("^");
92    let mut groups = 0usize;
93
94    for (index, chunk) in chunks.iter().enumerate() {
95        if index > 0 {
96            pattern.push_str("(?:");
97            groups += 1;
98        }
99        pattern.push_str(chunk);
100    }
101
102    if !pending.is_empty() {
103        pattern.push_str("(?:");
104        groups += 1;
105        pattern.push_str(&pending);
106    }
107
108    for _ in 0..groups {
109        pattern.push_str(")?");
110    }
111
112    pattern.push_str("(?:");
113    pattern.push_str(SEPARATOR);
114    pattern.push_str(".*)?$");
115    pattern
116}
117
118/// Matches either separator, whichever the template was written with: a
119/// template is configured by hand, but the working directory comes from the
120/// operating system, which on Windows always reports backslashes.
121const SEPARATOR: &str = r"[/\\]";
122
123fn literal_pattern(text: &str) -> String {
124    let mut pattern = String::with_capacity(text.len());
125    let mut rest = text;
126
127    while let Some(at) = rest.find(['/', '\\']) {
128        pattern.push_str(&regex::escape(&rest[..at]));
129        pattern.push_str(SEPARATOR);
130        rest = &rest[at + 1..];
131    }
132
133    pattern.push_str(&regex::escape(rest));
134    pattern
135}
136
137#[derive(Default)]
138struct Seen {
139    year: bool,
140    day: bool,
141    language: bool,
142}
143
144fn placeholder_pattern(segment: &Segment, seen: &mut Seen) -> String {
145    let (first, name, body) = match segment {
146        Segment::Year => (
147            !std::mem::replace(&mut seen.year, true),
148            "year",
149            r"\d{4}".to_owned(),
150        ),
151        Segment::Day { .. } => (
152            !std::mem::replace(&mut seen.day, true),
153            "day",
154            r"\d{1,2}".to_owned(),
155        ),
156        Segment::Language => (
157            !std::mem::replace(&mut seen.language, true),
158            "language",
159            language_alternation(),
160        ),
161        Segment::Literal(_) => return String::new(),
162    };
163
164    if first {
165        format!("(?<{name}>{body})")
166    } else {
167        format!("(?:{body})")
168    }
169}
170
171fn language_alternation() -> String {
172    let mut names: Vec<&str> = Language::ALL
173        .iter()
174        .map(|language| language.name())
175        .collect();
176    names.sort_unstable_by_key(|name| (std::cmp::Reverse(name.len()), *name));
177    names.join("|")
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::template::Template;
184
185    fn matcher(source: &str) -> CwdMatcher {
186        Template::parse(source)
187            .expect("template should parse")
188            .matcher()
189            .expect("pattern should compile")
190    }
191
192    fn detect(source: &str, cwd: &str) -> Detected {
193        matcher(source).detect(Path::new(cwd))
194    }
195
196    fn triple(detected: Detected) -> (Option<u16>, Option<u8>, Option<Language>) {
197        (
198            detected.year.map(Year::get),
199            detected.day.map(Day::get),
200            detected.language,
201        )
202    }
203
204    const CANONICAL: &str = "/root/{{year}}/day{{pad day}}/{{language}}";
205
206    #[test]
207    fn generates_an_anchored_pattern_with_nested_optional_groups() {
208        assert_eq!(
209            matcher(CANONICAL).pattern(),
210            r"^[/\\]root[/\\](?<year>\d{4})(?:[/\\]day(?<day>\d{1,2})(?:[/\\](?<language>csharp|python|java|rust))?)?(?:[/\\].*)?$"
211        );
212    }
213
214    #[test]
215    fn recovers_every_parameter_from_a_full_path() {
216        assert_eq!(
217            triple(detect(CANONICAL, "/root/2024/day07/rust")),
218            (Some(2024), Some(7), Some(Language::Rust))
219        );
220    }
221
222    #[test]
223    fn recovers_partial_parameters_from_a_prefix() {
224        assert_eq!(triple(detect(CANONICAL, "/root")), (None, None, None));
225        assert_eq!(
226            triple(detect(CANONICAL, "/root/2024")),
227            (Some(2024), None, None)
228        );
229        assert_eq!(
230            triple(detect(CANONICAL, "/root/2024/day07")),
231            (Some(2024), Some(7), None)
232        );
233    }
234
235    #[test]
236    fn recovers_parameters_from_a_deeper_directory() {
237        assert_eq!(
238            triple(detect(CANONICAL, "/root/2024/day07/rust/src/bin")),
239            (Some(2024), Some(7), Some(Language::Rust))
240        );
241    }
242
243    #[test]
244    fn two_digit_days_are_not_truncated() {
245        for (day, expected) in [
246            (1, 1),
247            (5, 5),
248            (9, 9),
249            (10, 10),
250            (15, 15),
251            (19, 19),
252            (25, 25),
253        ] {
254            let padded = format!("/root/2024/day{day:02}/rust");
255            let plain = format!("/root/2024/day{day}/rust");
256
257            assert_eq!(
258                triple(detect(CANONICAL, &padded)).1,
259                Some(expected),
260                "{padded}"
261            );
262            assert_eq!(
263                triple(detect("/root/{{year}}/day{{day}}/{{language}}", &plain)).1,
264                Some(expected),
265                "{plain}"
266            );
267        }
268    }
269
270    #[test]
271    fn out_of_range_values_are_dropped_individually() {
272        assert_eq!(
273            triple(detect(CANONICAL, "/root/2024/day26/rust")),
274            (Some(2024), None, Some(Language::Rust))
275        );
276        assert_eq!(
277            triple(detect(CANONICAL, "/root/2024/day00/rust")),
278            (Some(2024), None, Some(Language::Rust))
279        );
280        assert_eq!(
281            triple(detect(CANONICAL, "/root/1999/day07/rust")),
282            (None, Some(7), Some(Language::Rust))
283        );
284    }
285
286    #[test]
287    fn unrelated_directories_yield_nothing() {
288        assert_eq!(triple(detect(CANONICAL, "/tmp")), (None, None, None));
289        assert_eq!(
290            triple(detect(CANONICAL, "/elsewhere/2024/day07/rust")),
291            (None, None, None)
292        );
293    }
294
295    #[test]
296    fn a_broken_middle_segment_stops_detection_there() {
297        assert_eq!(
298            triple(detect(CANONICAL, "/root/2024/scratch/rust")),
299            (Some(2024), None, None)
300        );
301    }
302
303    #[test]
304    fn partial_matching_follows_template_order_not_a_fixed_order() {
305        let reordered = "/root/{{language}}/{{year}}/day{{day}}";
306
307        assert_eq!(
308            triple(detect(reordered, "/root/rust")),
309            (None, None, Some(Language::Rust))
310        );
311        assert_eq!(
312            triple(detect(reordered, "/root/rust/2024")),
313            (Some(2024), None, Some(Language::Rust))
314        );
315        assert_eq!(
316            triple(detect(reordered, "/root/rust/2024/day7")),
317            (Some(2024), Some(7), Some(Language::Rust))
318        );
319    }
320
321    #[test]
322    fn a_trailing_literal_is_optional() {
323        let with_suffix = "/root/{{year}}/day{{pad day}}/{{language}}/solution";
324
325        assert_eq!(
326            triple(detect(with_suffix, "/root/2024/day07/rust")),
327            (Some(2024), Some(7), Some(Language::Rust))
328        );
329        assert_eq!(
330            triple(detect(with_suffix, "/root/2024/day07/rust/solution")),
331            (Some(2024), Some(7), Some(Language::Rust))
332        );
333    }
334
335    #[test]
336    fn spaced_out_placeholders_still_detect() {
337        assert_eq!(
338            triple(detect(
339                "/root/{{ year }}/day{{ pad day }}/{{ language }}",
340                "/root/2024/day07/java"
341            )),
342            (Some(2024), Some(7), Some(Language::Java))
343        );
344    }
345
346    #[test]
347    fn literal_regex_metacharacters_are_escaped() {
348        let dotted = "/root/a.c/{{year}}/day{{pad day}}";
349
350        assert_eq!(triple(detect(dotted, "/root/a.c/2024/day07")).0, Some(2024));
351        assert_eq!(triple(detect(dotted, "/root/abc/2024/day07")).0, None);
352    }
353
354    #[test]
355    fn either_separator_matches_whichever_the_template_used() {
356        assert_eq!(
357            triple(detect(CANONICAL, r"\root\2024\day07\rust")),
358            (Some(2024), Some(7), Some(Language::Rust))
359        );
360        assert_eq!(
361            triple(detect(
362                r"C:\aoc\{{year}}\day{{pad day}}\{{language}}",
363                r"C:\aoc\2024\day07\rust"
364            )),
365            (Some(2024), Some(7), Some(Language::Rust))
366        );
367        assert_eq!(
368            triple(detect(
369                r"C:\aoc\{{year}}\day{{pad day}}\{{language}}",
370                "C:/aoc/2024/day07/rust"
371            )),
372            (Some(2024), Some(7), Some(Language::Rust))
373        );
374    }
375
376    #[test]
377    fn trailing_separators_are_ignored() {
378        assert_eq!(
379            triple(detect(CANONICAL, "/root/2024/day07/rust/")),
380            (Some(2024), Some(7), Some(Language::Rust))
381        );
382    }
383
384    #[test]
385    fn every_language_is_recognised() {
386        for language in Language::ALL {
387            let cwd = format!("/root/2024/day07/{}", language.name());
388            assert_eq!(triple(detect(CANONICAL, &cwd)).2, Some(*language), "{cwd}");
389        }
390    }
391
392    #[test]
393    fn matching_is_case_sensitive() {
394        assert_eq!(
395            triple(detect(CANONICAL, "/Root/2024/day07/rust")),
396            (None, None, None)
397        );
398        assert_eq!(triple(detect(CANONICAL, "/root/2024/day07/Rust")).2, None);
399    }
400
401    #[test]
402    fn repeated_placeholders_capture_once() {
403        let repeated = "/root/{{year}}/day{{pad day}}/{{year}}";
404
405        assert_eq!(
406            triple(detect(repeated, "/root/2024/day07/2024")),
407            (Some(2024), Some(7), None)
408        );
409    }
410}