Skip to main content

tramlines_core/grammar/
id_template.rs

1//! Id templates (TL_58): the `id` key's template, whose holes are
2//! the ones a new document is filled with, `${:seq}`, `${:slug}` and a
3//! lifecycle key's state, `${<key>}`.
4
5use regex::Regex;
6
7use super::{SLUG, template};
8use crate::{
9    Problem,
10    grammar::{LifecycleDimension, LineTemplate, Segment, States},
11    text::{self, is_slug},
12};
13
14/// The pieces of an id template.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Token {
17    /// Literal text.
18    Literal(String),
19    /// A `seq` hole: the number, `seq-width` digits.
20    Seq(usize),
21    /// A `slug` hole: one or more slug words.
22    Slug,
23    /// A hole named for a lifecycle key: the document's state of that key.
24    Key {
25        /// The lifecycle key.
26        name: String,
27        /// Its states, each a slug.
28        states: Vec<String>,
29    },
30}
31
32/// An id template a new document can be filled with.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct IdTemplate {
35    /// The template as written.
36    pub source: String,
37    /// The tokens, in order.
38    pub tokens: Vec<Token>,
39}
40
41impl IdTemplate {
42    /// The id template of `template`, its sequence `width` digits wide, a
43    /// hole named with no type standing for the state of the `lifecycle`
44    /// key of its name. Refuses a hole a new document cannot be filled with,
45    /// more than one `seq`, a zero width, a lifecycle key with a state that is
46    /// not a slug, and a template that admits a non-slug.
47    // sokf-implements: TL_58
48    // sokf-implements: TL_102
49    // sokf-implements: TL_114
50    // sokf-implements: TL_122
51    pub fn from_template(
52        template: &LineTemplate,
53        width: usize,
54        lifecycle: &[LifecycleDimension],
55    ) -> Result<Self, Problem> {
56        let source = &template.source;
57        let mut tokens = Vec::new();
58        let mut seqs = 0;
59        for segment in &template.segments {
60            match segment {
61                Segment::Literal(l) => tokens.push(Token::Literal(l.clone())),
62                Segment::Hole { ty, .. } if ty == "seq" => {
63                    seqs += 1;
64                    if seqs > 1 {
65                        return Err(Problem::new(
66                            "TL_114",
67                            format!("template `{source}` has more than one `seq` hole"),
68                        ));
69                    }
70                    if width == 0 {
71                        return Err(Problem::new(
72                            "TL_102",
73                            format!("template `{source}`: `seq-width` must be positive"),
74                        ));
75                    }
76                    tokens.push(Token::Seq(width));
77                }
78                Segment::Hole { ty, .. } if ty == "slug" => tokens.push(Token::Slug),
79                Segment::Hole {
80                    name: Some(name),
81                    ty,
82                    ..
83                } if ty == "text" => {
84                    let Some(dim) = lifecycle.iter().find(|d| &d.key == name) else {
85                        return Err(Problem::new(
86                            "TL_58",
87                            format!(
88                                "template `{source}`: the hole `${{{name}}}` names no lifecycle key"
89                            ),
90                        ));
91                    };
92                    if let Some(bad) = dim.states.iter().find(|s| !is_slug(s)) {
93                        return Err(Problem::new(
94                            "TL_122",
95                            format!(
96                                "template `{source}`: the state `{bad}` of `{name}` is not a slug, so no id can hold it"
97                            ),
98                        ));
99                    }
100                    tokens.push(Token::Key {
101                        name: name.clone(),
102                        states: dim.states.clone(),
103                    });
104                }
105                Segment::Hole { ty, .. } => {
106                    return Err(Problem::new(
107                        "TL_58",
108                        format!(
109                            "template `{source}`: an id holds only the holes a new document is filled with, `${{:seq}}`, `${{:slug}}` and `${{<lifecycle key>}}`, not {} `{ty}`",
110                            text::article(ty)
111                        ),
112                    ));
113                }
114            }
115        }
116        let sample: String = tokens
117            .iter()
118            .map(|t| match t {
119                Token::Literal(s) => s.as_str(),
120                _ => "x",
121            })
122            .collect();
123        if !is_slug(&sample) {
124            return Err(Problem::new(
125                "TL_122",
126                format!("template `{source}` admits a non-slug: `{sample}` is not a slug"),
127            ));
128        }
129        Ok(IdTemplate {
130            source: source.clone(),
131            tokens,
132        })
133    }
134
135    /// The id template of a fixed id, the `const` of `id`: one literal, a
136    /// slug.
137    // sokf-implements: TL_122
138    pub fn fixed(id: &str) -> Result<Self, Problem> {
139        if !is_slug(id) {
140            return Err(Problem::new(
141                "TL_122",
142                format!("the id `{id}` is not a slug"),
143            ));
144        }
145        Ok(IdTemplate {
146            source: id.to_string(),
147            tokens: vec![Token::Literal(id.to_string())],
148        })
149    }
150
151    /// Whether the template carries a `seq` hole, and its width.
152    pub fn seq_width(&self) -> Option<usize> {
153        self.tokens.iter().find_map(|t| match t {
154            Token::Seq(n) => Some(*n),
155            _ => None,
156        })
157    }
158
159    /// Whether the template carries a `slug` hole.
160    pub fn has_slug(&self) -> bool {
161        self.tokens.contains(&Token::Slug)
162    }
163
164    /// Whether the template carries a hole for a lifecycle key.
165    pub fn has_keys(&self) -> bool {
166        self.tokens.iter().any(|t| matches!(t, Token::Key { .. }))
167    }
168
169    /// An anchored regex matching the ids the template admits; the `seq`
170    /// hole is the group `seq`, and a lifecycle key's hole the group
171    /// `key_<name>`.
172    pub fn regex(&self) -> String {
173        let mut s = String::from("^");
174        for t in &self.tokens {
175            match t {
176                Token::Literal(l) => s.push_str(&regex::escape(l)),
177                Token::Seq(n) => s.push_str(&format!("(?P<seq>[0-9]{{{n}}})")),
178                Token::Slug => s.push_str(SLUG),
179                Token::Key { name, states } => {
180                    let alts: Vec<String> = states.iter().map(|s| regex::escape(s)).collect();
181                    s.push_str(&format!("(?P<key_{name}>{})", alts.join("|")));
182                }
183            }
184        }
185        s.push('$');
186        s
187    }
188
189    fn compiled(&self) -> Regex {
190        template::compiled(&self.regex()).expect("template regex is built from escaped parts")
191    }
192
193    /// Whether `id` matches the template.
194    pub fn matches(&self, id: &str) -> bool {
195        self.compiled().is_match(id)
196    }
197
198    /// The sequence number of `id`, when it matches and the template has a
199    /// sequence.
200    pub fn seq_of(&self, id: &str) -> Option<u64> {
201        let caps = self.compiled().captures(id)?;
202        caps.name("seq")?.as_str().parse().ok()
203    }
204
205    /// The lifecycle keys whose state `id` holds, each with that state, when
206    /// `id` matches the template.
207    pub fn keys_of(&self, id: &str) -> Vec<(String, String)> {
208        let Some(caps) = self.compiled().captures(id) else {
209            return vec![];
210        };
211        self.tokens
212            .iter()
213            .filter_map(|t| match t {
214                Token::Key { name, .. } => caps
215                    .name(&format!("key_{name}"))
216                    .map(|m| (name.clone(), m.as_str().to_string())),
217                _ => None,
218            })
219            .collect()
220    }
221
222    /// Render the id for `seq` and `slug`, each lifecycle key's hole taking
223    /// its first state. Refuses what [`IdTemplate::render_with`] refuses.
224    pub fn render(&self, seq: u64, slug: Option<&str>) -> Result<String, String> {
225        self.render_with(seq, slug, &States::default())
226    }
227
228    /// Render the id for `seq`, `slug` and `states`, a lifecycle key's hole
229    /// taking the state `states` gives it, else its first. Refuses a sequence
230    /// that needs more digits than the width, a missing slug, a slug that is
231    /// not slug words, and a state that is not one of the key's.
232    pub fn render_with(
233        &self,
234        seq: u64,
235        slug: Option<&str>,
236        states: &States,
237    ) -> Result<String, String> {
238        let mut s = String::new();
239        for t in &self.tokens {
240            match t {
241                Token::Literal(l) => s.push_str(l),
242                Token::Seq(n) => {
243                    let digits = format!("{seq:0width$}", width = n);
244                    if digits.len() > *n {
245                        return Err(format!("the next number {seq} needs more than {n} digits"));
246                    }
247                    s.push_str(&digits);
248                }
249                Token::Slug => match slug {
250                    Some(slug) if is_slug(slug) => s.push_str(slug),
251                    Some(slug) => return Err(format!("`{slug}` is not slug words")),
252                    None => return Err("the template has a `slug` hole; pass --slug".into()),
253                },
254                Token::Key { name, states: all } => {
255                    let state = states
256                        .0
257                        .get(name)
258                        .and_then(|s| s.first())
259                        .or_else(|| all.first())
260                        .cloned()
261                        .unwrap_or_default();
262                    if !all.contains(&state) {
263                        return Err(format!("`{state}` is not a state of `{name}`"));
264                    }
265                    s.push_str(&state);
266                }
267            }
268        }
269        Ok(s)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use proptest::prelude::*;
276
277    use super::*;
278    use crate::grammar::TypeRegistry;
279
280    fn parse(src: &str, width: usize) -> Result<IdTemplate, String> {
281        parse_in(src, width, &[])
282    }
283
284    fn parse_in(
285        src: &str,
286        width: usize,
287        lifecycle: &[LifecycleDimension],
288    ) -> Result<IdTemplate, String> {
289        let t = LineTemplate::parse(src, &TypeRegistry::built_ins()).map_err(|e| e.message)?;
290        IdTemplate::from_template(&t, width, lifecycle).map_err(|e| e.message)
291    }
292
293    fn kind(states: &[&str]) -> LifecycleDimension {
294        LifecycleDimension {
295            key: "kind".into(),
296            states: states.iter().map(|s| s.to_string()).collect(),
297            guidance: None,
298            line: 1,
299            extensions: Default::default(),
300        }
301    }
302
303    // sokf-tests: TL_58
304    #[test]
305    fn a_lifecycle_key_fills_its_hole() {
306        let kinds = [kind(&["feature", "bug", "chore"])];
307        let t = parse_in("issue-${:seq}-${kind}-${:slug}", 3, &kinds).unwrap();
308        assert!(t.has_keys());
309        assert!(t.matches("issue-004-bug-retry-logic"));
310        assert!(!t.matches("issue-004-task-retry-logic"));
311        assert_eq!(t.seq_of("issue-004-bug-retry-logic"), Some(4));
312        assert_eq!(
313            t.keys_of("issue-004-bug-retry-logic"),
314            vec![("kind".to_string(), "bug".to_string())]
315        );
316        let mut states = States::default();
317        states.0.insert("kind".into(), vec!["chore".into()]);
318        assert_eq!(
319            t.render_with(7, Some("tidy"), &states).unwrap(),
320            "issue-007-chore-tidy"
321        );
322        assert_eq!(t.render(7, Some("tidy")).unwrap(), "issue-007-feature-tidy");
323        states.0.insert("kind".into(), vec!["task".into()]);
324        assert!(t.render_with(7, Some("tidy"), &states).is_err());
325        // A hole of another name, or a key whose states are not slugs, is refused.
326        assert!(parse_in("issue-${:seq}-${state}-${:slug}", 3, &kinds).is_err());
327        assert!(parse_in("issue-${:seq}-${kind}", 3, &[kind(&["Bug"])]).is_err());
328    }
329
330    #[test]
331    fn parses_templates() {
332        let t = parse("issue-${:seq}-${:slug}", 3).unwrap();
333        assert_eq!(
334            t.tokens,
335            vec![
336                Token::Literal("issue-".into()),
337                Token::Seq(3),
338                Token::Literal("-".into()),
339                Token::Slug
340            ]
341        );
342        assert_eq!(t.seq_width(), Some(3));
343        assert!(t.has_slug());
344        assert_eq!(
345            t.regex(),
346            "^issue\\-(?P<seq>[0-9]{3})\\-[a-z0-9]+(?:-[a-z0-9]+)*$"
347        );
348        assert!(t.matches("issue-004-retry-logic"));
349        assert!(!t.matches("issue-4-retry"));
350        assert!(!t.matches("issue-004-Retry"));
351        assert_eq!(t.seq_of("issue-004-retry-logic"), Some(4));
352        assert_eq!(t.seq_of("nope"), None);
353        assert_eq!(
354            t.render(4, Some("retry-logic")).unwrap(),
355            "issue-004-retry-logic"
356        );
357        assert!(t.render(1000, Some("x")).is_err());
358        assert!(t.render(1, None).is_err());
359        assert!(t.render(1, Some("Bad Slug")).is_err());
360        let plain = parse("adr-${:seq}", 4).unwrap();
361        assert!(!plain.has_slug());
362        assert_eq!(plain.render(12, None).unwrap(), "adr-0012");
363        assert_eq!(parse("${:slug}", 3).unwrap().seq_width(), None);
364        // A named hole is still the hole of its type.
365        assert!(parse("x-${n:seq}", 2).unwrap().matches("x-07"));
366    }
367
368    #[test]
369    fn refuses_bad_templates() {
370        for (bad, width) in [
371            ("issue-${:seq}-${:seq}", 3),
372            ("issue-${:seq}", 0),
373            ("issue-${:int}", 3),
374            ("issue-${slug}", 3),
375            ("Issue-${:seq}", 3),
376            ("issue_${:slug}", 3),
377            ("issue--${:slug}", 3),
378            ("-${:slug}", 3),
379        ] {
380            assert!(parse(bad, width).is_err(), "{bad}");
381        }
382    }
383
384    fn arb_template() -> impl Strategy<Value = (String, usize)> {
385        (
386            "[a-z][a-z0-9]{0,4}",
387            1usize..5,
388            any::<bool>(),
389            any::<bool>(),
390        )
391            .prop_map(|(prefix, width, with_slug, with_kind)| {
392                let kind = if with_kind { "-${kind}" } else { "" };
393                let t = if with_slug {
394                    format!("{prefix}-${{:seq}}{kind}-${{:slug}}")
395                } else {
396                    format!("{prefix}-${{:seq}}{kind}")
397                };
398                (t, width)
399            })
400    }
401
402    // sokf-tests: TL_58
403    proptest! {
404        #![proptest_config(ProptestConfig::with_cases(256))]
405
406        #[test]
407        fn render_then_seq_of_round_trips((template, width) in arb_template(), seq in 0u64..10_000, slug in "[a-z0-9]+(-[a-z0-9]+){0,2}", state in prop::sample::select(vec!["feature", "bug", "chore"])) {
408            let t = parse_in(&template, width, &[kind(&["feature", "bug", "chore"])]).unwrap();
409            let mut states = States::default();
410            states.0.insert("kind".into(), vec![state.to_string()]);
411            let rendered = t.render_with(seq, Some(&slug), &states);
412            let fits = seq.to_string().len() <= width;
413            prop_assert_eq!(rendered.is_ok(), fits);
414            if let Ok(id) = rendered {
415                prop_assert!(is_slug(&id), "{id}");
416                prop_assert!(t.matches(&id));
417                prop_assert_eq!(t.seq_of(&id), Some(seq));
418                if t.has_keys() {
419                    prop_assert_eq!(t.keys_of(&id), vec![("kind".to_string(), state.to_string())]);
420                }
421            }
422        }
423    }
424}