Skip to main content

aoc_runtime/
template.rs

1//! Path templates.
2//!
3//! A template such as `~/projects/aoc/{{year}}/day{{pad day}}/{{language}}` is
4//! parsed once into [`Segment`]s and then used in both directions: rendered
5//! into a project path, and compiled into a regex that recovers the parameters
6//! from the current working directory.
7
8pub mod matcher;
9
10use crate::{
11    language::Language,
12    puzzle::{Day, Year},
13};
14use std::{fmt, mem, path::PathBuf, str::FromStr};
15
16/// One piece of a parsed template.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Segment {
19    /// Text copied through verbatim.
20    Literal(String),
21    /// The `{{year}}` placeholder.
22    Year,
23    /// The `{{day}}` or `{{pad day}}` placeholder.
24    Day {
25        /// Whether the day is zero-padded to two digits.
26        padded: bool,
27    },
28    /// The `{{language}}` placeholder.
29    Language,
30}
31
32/// The values substituted into a template.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Params {
35    /// The event year.
36    pub year: Year,
37    /// The puzzle day.
38    pub day: Day,
39    /// The solution language.
40    pub language: Language,
41}
42
43/// A parsed path template.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Template {
46    segments: Vec<Segment>,
47}
48
49impl Template {
50    /// Parses a template, validating its placeholders.
51    ///
52    /// Whitespace inside the braces is insignificant, so `{{pad day}}`,
53    /// `{{ pad day }}` and `{{  pad   day }}` are the same placeholder.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`TemplateError`] if a placeholder is unclosed or unrecognised,
58    /// or if the template is missing the year or day placeholder - both are
59    /// always substituted, so a template without them can never render.
60    ///
61    /// ```
62    /// use aoc_runtime::template::{Segment, Template};
63    ///
64    /// let template = Template::parse("/aoc/{{year}}/day{{ pad day }}")?;
65    /// assert_eq!(template.segments(), [
66    ///     Segment::Literal("/aoc/".into()),
67    ///     Segment::Year,
68    ///     Segment::Literal("/day".into()),
69    ///     Segment::Day { padded: true },
70    /// ]);
71    /// # Ok::<(), aoc_runtime::template::TemplateError>(())
72    /// ```
73    pub fn parse(source: &str) -> Result<Self, TemplateError> {
74        let mut segments = Vec::new();
75        let mut literal = String::new();
76        let mut rest = source;
77        let mut offset = 0usize;
78
79        while let Some((before, after_open)) = rest.split_once("{{") {
80            literal.push_str(before);
81            let open_at = offset + before.len();
82
83            let Some((name, after_close)) = after_open.split_once("}}") else {
84                return Err(TemplateError::Unclosed { offset: open_at });
85            };
86
87            let segment = parse_placeholder(name, open_at)?;
88            if !literal.is_empty() {
89                segments.push(Segment::Literal(mem::take(&mut literal)));
90            }
91            segments.push(segment);
92
93            offset = open_at + name.len() + 4;
94            rest = after_close;
95        }
96
97        literal.push_str(rest);
98        if !literal.is_empty() {
99            segments.push(Segment::Literal(literal));
100        }
101
102        let template = Self { segments };
103        for (which, present) in [
104            ("year", template.contains(&Segment::Year)),
105            ("day", template.day_padding().is_some()),
106        ] {
107            if !present {
108                return Err(TemplateError::MissingPlaceholder { which });
109            }
110        }
111
112        Ok(template)
113    }
114
115    /// The parsed segments, in template order.
116    #[must_use]
117    pub fn segments(&self) -> &[Segment] {
118        &self.segments
119    }
120
121    /// Renders the template into a concrete project path.
122    ///
123    /// Infallible: [`Params`] carries validated values, and parsing has already
124    /// rejected templates that cannot be rendered.
125    #[must_use]
126    pub fn render(&self, params: Params) -> PathBuf {
127        let mut rendered = String::with_capacity(64);
128
129        for segment in &self.segments {
130            match segment {
131                Segment::Literal(text) => rendered.push_str(text),
132                Segment::Year => rendered.push_str(&params.year.get().to_string()),
133                Segment::Day { padded } => {
134                    if *padded && params.day.get() < 10 {
135                        rendered.push('0');
136                    }
137                    rendered.push_str(&params.day.get().to_string());
138                }
139                Segment::Language => rendered.push_str(params.language.name()),
140            }
141        }
142
143        PathBuf::from(rendered)
144    }
145
146    /// Compiles the matcher that recovers parameters from a directory path.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`TemplateError::Regex`] if the generated pattern is rejected,
151    /// which in practice only happens for pathologically large templates.
152    pub fn matcher(&self) -> Result<matcher::CwdMatcher, TemplateError> {
153        matcher::CwdMatcher::build(&self.segments)
154    }
155
156    /// Whether the template mentions the language placeholder.
157    #[must_use]
158    pub fn has_language(&self) -> bool {
159        self.contains(&Segment::Language)
160    }
161
162    fn contains(&self, wanted: &Segment) -> bool {
163        self.segments.iter().any(|segment| segment == wanted)
164    }
165
166    fn day_padding(&self) -> Option<bool> {
167        self.segments.iter().find_map(|segment| match segment {
168            Segment::Day { padded } => Some(*padded),
169            _ => None,
170        })
171    }
172}
173
174impl FromStr for Template {
175    type Err = TemplateError;
176
177    fn from_str(source: &str) -> Result<Self, Self::Err> {
178        Self::parse(source)
179    }
180}
181
182impl fmt::Display for Template {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        for segment in &self.segments {
185            match segment {
186                Segment::Literal(text) => f.write_str(text)?,
187                Segment::Year => f.write_str("{{year}}")?,
188                Segment::Day { padded: false } => f.write_str("{{day}}")?,
189                Segment::Day { padded: true } => f.write_str("{{pad day}}")?,
190                Segment::Language => f.write_str("{{language}}")?,
191            }
192        }
193        Ok(())
194    }
195}
196
197fn parse_placeholder(raw: &str, offset: usize) -> Result<Segment, TemplateError> {
198    let name = raw.split_whitespace().collect::<Vec<_>>().join(" ");
199
200    match name.as_str() {
201        "year" => Ok(Segment::Year),
202        "day" => Ok(Segment::Day { padded: false }),
203        "pad day" => Ok(Segment::Day { padded: true }),
204        "language" => Ok(Segment::Language),
205        _ => Err(TemplateError::UnknownPlaceholder { name, offset }),
206    }
207}
208
209/// Errors produced while parsing or compiling a template.
210#[derive(Debug, thiserror::Error)]
211pub enum TemplateError {
212    /// A `{{` was never closed.
213    #[error("unclosed `{{{{` at position {offset}")]
214    Unclosed {
215        /// Byte offset of the opening braces.
216        offset: usize,
217    },
218    /// A placeholder name is not recognised.
219    #[error(
220        "unknown placeholder `{{{{{name}}}}}` at position {offset} \
221         (expected `year`, `day`, `pad day` or `language`)"
222    )]
223    UnknownPlaceholder {
224        /// The normalised placeholder name.
225        name: String,
226        /// Byte offset of the opening braces.
227        offset: usize,
228    },
229    /// A placeholder that must always be substituted is absent.
230    #[error("template is missing the `{{{{{which}}}}}` placeholder")]
231    MissingPlaceholder {
232        /// The name of the missing placeholder.
233        which: &'static str,
234    },
235    /// The generated matching pattern was rejected.
236    #[error("template produced an invalid pattern")]
237    Regex(#[from] regex::Error),
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn template(source: &str) -> Template {
245        Template::parse(source).expect("template should parse")
246    }
247
248    fn params(year: u16, day: u8, language: Language) -> Params {
249        Params {
250            year: Year::new(year).expect("valid year"),
251            day: Day::new(day).expect("valid day"),
252            language,
253        }
254    }
255
256    #[test]
257    fn parses_literals_and_placeholders_in_order() {
258        assert_eq!(
259            template("/aoc/{{year}}/day{{pad day}}/{{language}}").segments(),
260            [
261                Segment::Literal("/aoc/".to_owned()),
262                Segment::Year,
263                Segment::Literal("/day".to_owned()),
264                Segment::Day { padded: true },
265                Segment::Literal("/".to_owned()),
266                Segment::Language,
267            ]
268        );
269    }
270
271    #[test]
272    fn whitespace_inside_braces_is_insignificant() {
273        let spaced = template("/aoc/{{ year }}/day{{  pad   day }}/{{\tlanguage\t}}");
274        assert_eq!(
275            spaced,
276            template("/aoc/{{year}}/day{{pad day}}/{{language}}")
277        );
278    }
279
280    #[test]
281    fn distinguishes_padded_from_unpadded_days() {
282        assert_eq!(
283            template("{{year}}/{{day}}").segments().last(),
284            Some(&Segment::Day { padded: false })
285        );
286        assert_eq!(
287            template("{{year}}/{{pad day}}").segments().last(),
288            Some(&Segment::Day { padded: true })
289        );
290    }
291
292    #[test]
293    fn rejects_unknown_placeholders() {
294        let error = Template::parse("/aoc/{{year}}/{{month}}/{{day}}")
295            .expect_err("month is not a placeholder");
296
297        assert!(
298            matches!(&error, TemplateError::UnknownPlaceholder { name, .. } if name == "month"),
299            "got {error:?}"
300        );
301        assert!(error.to_string().contains("month"));
302    }
303
304    #[test]
305    fn rejects_unclosed_placeholders() {
306        let error = Template::parse("/aoc/{{year}}/day{{pad day").expect_err("brace is unclosed");
307        assert!(
308            matches!(error, TemplateError::Unclosed { .. }),
309            "got {error:?}"
310        );
311    }
312
313    #[test]
314    fn rejects_templates_that_can_never_render() {
315        for (source, which) in [("/aoc/{{day}}", "year"), ("/aoc/{{year}}", "day")] {
316            let error = Template::parse(source).expect_err("placeholder is missing");
317            assert!(
318                matches!(error, TemplateError::MissingPlaceholder { which: w } if w == which),
319                "got {error:?}"
320            );
321        }
322    }
323
324    #[test]
325    fn language_placeholder_is_optional() {
326        let template = template("/aoc/{{year}}/day{{pad day}}");
327
328        assert!(!template.has_language());
329        assert_eq!(
330            template.render(params(2024, 7, Language::Rust)),
331            PathBuf::from("/aoc/2024/day07")
332        );
333    }
334
335    #[test]
336    fn renders_padded_and_unpadded_days() {
337        let padded = template("/aoc/{{year}}/day{{pad day}}/{{language}}");
338        let plain = template("/aoc/{{year}}/day{{day}}/{{language}}");
339
340        assert_eq!(
341            padded.render(params(2024, 7, Language::Rust)),
342            PathBuf::from("/aoc/2024/day07/rust")
343        );
344        assert_eq!(
345            padded.render(params(2015, 25, Language::CSharp)),
346            PathBuf::from("/aoc/2015/day25/csharp")
347        );
348        assert_eq!(
349            plain.render(params(2024, 7, Language::Python)),
350            PathBuf::from("/aoc/2024/day7/python")
351        );
352        assert_eq!(
353            plain.render(params(2024, 25, Language::Java)),
354            PathBuf::from("/aoc/2024/day25/java")
355        );
356    }
357
358    #[test]
359    fn renders_every_occurrence_of_a_repeated_placeholder() {
360        let template = template("/aoc/{{year}}/day{{pad day}}/{{year}}-{{pad day}}");
361
362        assert_eq!(
363            template.render(params(2024, 3, Language::Rust)),
364            PathBuf::from("/aoc/2024/day03/2024-03")
365        );
366    }
367
368    #[test]
369    fn round_trips_through_display() {
370        let source = "/aoc/{{year}}/day{{pad day}}/{{language}}";
371        assert_eq!(template(source).to_string(), source);
372    }
373}