Skip to main content

tramlines_core/grammar/
template.rs

1//! Templates (TL_24 to TL_26): the shape of one unit written as the unit itself,
2//! with typed holes, `${name:type}`, where it varies. A template compiles
3//! once to an anchored regex with one named group per named hole, and a
4//! failure names the column where the text stops matching.
5
6use std::{cell::RefCell, collections::HashMap, sync::OnceLock};
7
8use regex::Regex;
9
10use super::types::{TypeDef, TypeRegistry, is_hole_name};
11use crate::{Problem, text};
12
13/// One piece of a template.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Segment {
16    /// Text the unit carries as written.
17    Literal(String),
18    /// A hole: its name where it is captured, and its type.
19    Hole {
20        /// The name, which is the name of its group.
21        name: Option<String>,
22        /// The type's name.
23        ty: String,
24        /// The type, as the registry resolved it.
25        def: TypeDef,
26    },
27}
28
29impl Segment {
30    /// The segment as a failure names what it expected.
31    fn describe(&self) -> String {
32        match self {
33            Segment::Literal(l) => format!("`{l}`"),
34            Segment::Hole { ty, def, .. } => {
35                format!("{} `{ty}` ({})", text::article(ty), def.describe())
36            }
37        }
38    }
39
40    fn regex(&self) -> String {
41        match self {
42            Segment::Literal(l) => regex::escape(l),
43            Segment::Hole {
44                name: Some(n), def, ..
45            } => format!("(?P<{n}>{})", def.regex()),
46            Segment::Hole {
47                name: None, def, ..
48            } => def.regex(),
49        }
50    }
51}
52
53/// Where a text stops matching a template, and what was expected there.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Failure {
56    /// The byte offset in the text where the fault is.
57    pub offset: usize,
58    /// What the template expected at the offset.
59    pub expected: String,
60    /// How far the text matches the template before it fails, which can
61    /// lie past the fault: of two shapes, the text meant the one it
62    /// follows furthest.
63    pub reach: usize,
64}
65
66impl Failure {
67    /// The failure as a finding says it: the column, one-based in
68    /// characters, what was expected and what was found.
69    pub fn describe(&self, text: &str) -> String {
70        let column = column(text, self.offset);
71        let rest = &text[self.offset.min(text.len())..];
72        let found = if rest.is_empty() {
73            "the end".to_string()
74        } else if rest.chars().count() > 24 {
75            format!("`{}…`", rest.chars().take(23).collect::<String>())
76        } else {
77            format!("`{rest}`")
78        };
79        format!("column {column}: expected {}, found {found}", self.expected)
80    }
81}
82
83/// A compiled template.
84#[derive(Debug, Clone)]
85pub struct Template {
86    /// The template as written.
87    pub source: String,
88    /// Its pieces, in order.
89    pub segments: Vec<Segment>,
90    /// The anchored regex it compiles to.
91    regex: Regex,
92    /// `prefixes[i]` matches the first `i` segments at the start of a text;
93    /// compiled the first time a text fails, since most never do.
94    prefixes: OnceLock<Vec<Regex>>,
95}
96
97thread_local! {
98    /// Compiled regexes by source, so a template the layers compile again
99    /// for each schema costs one compilation per run, as a pattern does
100    /// through `RegexCache`.
101    static COMPILED: RefCell<HashMap<String, Regex>> = RefCell::new(HashMap::new());
102}
103
104/// `re` compiled, once per run and thread.
105pub(super) fn compiled(re: &str) -> Result<Regex, regex::Error> {
106    if let Some(hit) = COMPILED.with(|c| c.borrow().get(re).cloned()) {
107        return Ok(hit);
108    }
109    let r = Regex::new(re)?;
110    COMPILED.with(|c| c.borrow_mut().insert(re.to_string(), r.clone()));
111    Ok(r)
112}
113
114impl PartialEq for Template {
115    fn eq(&self, other: &Self) -> bool {
116        self.source == other.source && self.segments == other.segments
117    }
118}
119
120impl Template {
121    /// Parse and compile `source` against `types`. Refuses an unclosed
122    /// hole, a bad or repeated name, an unknown type, and a template with
123    /// no hole, which is text.
124    // sokf-implements: TL_24
125    // sokf-implements: TL_25
126    // sokf-implements: TL_118
127    // sokf-implements: TL_119
128    // sokf-implements: TL_120
129    // sokf-implements: TL_121
130    pub fn parse(source: &str, types: &TypeRegistry) -> Result<Template, Problem> {
131        let mut segments = Vec::new();
132        let mut literal = String::new();
133        let mut rest = source;
134        let mut names: Vec<String> = Vec::new();
135        while let Some(c) = rest.chars().next() {
136            if let Some(after) = rest.strip_prefix("$${") {
137                literal.push_str("${");
138                rest = after;
139                continue;
140            }
141            let Some(after) = rest.strip_prefix("${") else {
142                literal.push(c);
143                rest = &rest[c.len_utf8()..];
144                continue;
145            };
146            let Some(end) = after.find('}') else {
147                return Err(Problem::new(
148                    "TL_121",
149                    format!("template `{source}` has an unclosed `${{`"),
150                ));
151            };
152            let inside = &after[..end];
153            rest = &after[end + 1..];
154            let (name, ty) = match inside.split_once(':') {
155                Some((n, t)) => (n, t),
156                None => (inside, "text"),
157            };
158            let name = if name.is_empty() {
159                None
160            } else if !is_hole_name(name) {
161                return Err(Problem::new(
162                    "TL_121",
163                    format!(
164                        "template `{source}`: hole name `{name}` must be letters, digits and `_`, not starting with a digit"
165                    ),
166                ));
167            } else if names.iter().any(|n| n == name) {
168                return Err(Problem::new(
169                    "TL_119",
170                    format!("template `{source}` names the hole `{name}` twice"),
171                ));
172            } else {
173                names.push(name.to_string());
174                Some(name.to_string())
175            };
176            let ty = if ty.is_empty() { "text" } else { ty };
177            let Some(def) = types.get(ty) else {
178                return Err(Problem::new(
179                    "TL_120",
180                    format!("template `{source}`: no type `{ty}`"),
181                ));
182            };
183            if !literal.is_empty() {
184                segments.push(Segment::Literal(std::mem::take(&mut literal)));
185            }
186            segments.push(Segment::Hole {
187                name,
188                ty: ty.to_string(),
189                def: def.clone(),
190            });
191        }
192        if !literal.is_empty() {
193            segments.push(Segment::Literal(literal));
194        }
195        if !segments.iter().any(|s| matches!(s, Segment::Hole { .. })) {
196            return Err(Problem::new(
197                "TL_25",
198                format!("template `{source}` has no hole; write it as `text`"),
199            ));
200        }
201        let body: String = segments.iter().map(Segment::regex).collect();
202        let regex = compiled(&format!("^(?:{body})$")).map_err(|e| {
203            Problem::new(
204                "TL_118",
205                format!(
206                    "template `{source}` does not compile: {}",
207                    super::regex::reason(&e)
208                ),
209            )
210        })?;
211        Ok(Template {
212            source: source.to_string(),
213            regex,
214            segments,
215            prefixes: OnceLock::new(),
216        })
217    }
218
219    /// `prefixes[i]` matches the first `i` segments at the start of a text.
220    /// Each is a prefix of the template's own regex, which compiled.
221    fn prefixes(&self) -> &[Regex] {
222        self.prefixes.get_or_init(|| {
223            let mut body = String::new();
224            let mut out = vec![compiled("^").expect("an anchor compiles")];
225            for s in &self.segments {
226                body.push_str(&s.regex());
227                out.push(
228                    compiled(&format!("^(?:{body})"))
229                        .expect("a prefix of a template that compiled compiles"),
230                );
231            }
232            out
233        })
234    }
235
236    /// The anchored regex, whose named groups are the named holes.
237    pub fn regex(&self) -> &Regex {
238        &self.regex
239    }
240
241    /// Whether `text` matches the template whole.
242    pub fn is_match(&self, text: &str) -> bool {
243        self.regex.is_match(text)
244    }
245
246    /// The names of the captured holes, in order.
247    pub fn names(&self) -> impl Iterator<Item = &str> {
248        self.segments.iter().filter_map(|s| match s {
249            Segment::Hole { name: Some(n), .. } => Some(n.as_str()),
250            _ => None,
251        })
252    }
253
254    /// Where `text` stops matching: the longest run of segments that
255    /// matches from its start, then the segment that does not.
256    // sokf-implements: TL_26
257    // sokf-implements: TL_74
258    pub fn failure(&self, text: &str) -> Failure {
259        let n = self.segments.len();
260        let prefixes = self.prefixes();
261        let k = (0..=n)
262            .rev()
263            .find(|&i| prefixes[i].is_match(text))
264            .unwrap_or(0);
265        let end_of = |i: usize| prefixes[i].find(text).map_or(0, |m| m.end());
266        let at = |offset: usize, expected: String, reach: usize| Failure {
267            offset,
268            expected,
269            reach,
270        };
271        if k == n {
272            let end = end_of(n);
273            // A literal end the hole before it reached inside the text, as
274            // a period in `1.2`, leaves the text short of the real end.
275            if let Some(h) = self.last_hole()
276                && let Some(f) = self.short_end(text, h, end_of(h))
277            {
278                return f;
279            }
280            return at(end, "the end".to_string(), end);
281        }
282        let end = end_of(k);
283        let prev = k.checked_sub(1).map(|p| &self.segments[p]);
284        match (prev, &self.segments[k]) {
285            // A hole that ran to the end of a text that ends the template
286            // with literals leaves only those literals missing: the text
287            // stops short, so the fault is at its end, after any part of
288            // them that it carries.
289            (Some(Segment::Hole { .. }), _)
290                if end == text.len()
291                    && self.last_hole() == Some(k - 1)
292                    && let Some(f) = self.short_end(text, k - 1, end_of(k - 1)) =>
293            {
294                f
295            }
296            // A hole that ran to the end, where the literal after it could
297            // not be part of its value, ended the line at its own end: the
298            // line lacks only the rest of the template, so the fault is there.
299            (Some(Segment::Hole { def, .. }), Segment::Literal(l))
300                if end == text.len() && !whole(def, &format!("{}{l}", &text[end_of(k - 1)..])) =>
301            {
302                at(text.len(), format!("`{l}`"), text.len())
303            }
304            // A hole that ran to the end ate what the next segment wanted, so
305            // the failure is where the hole began, naming both.
306            (Some(prev @ Segment::Hole { .. }), next) if end == text.len() => at(
307                end_of(k - 1),
308                format!("{} then {}", prev.describe(), next.describe()),
309                end,
310            ),
311            // A literal the text does not start to carry, after a hole: where
312            // the literal comes later, the text between is the value the
313            // writer meant for the hole, and the hole is what it fails.
314            (Some(hole @ Segment::Hole { def, .. }), Segment::Literal(l))
315                if common_prefix(l, &text[end..]) == 0
316                    && text[end..]
317                        .find(l.as_str())
318                        .is_some_and(|i| !whole(def, &text[end_of(k - 1)..end + i])) =>
319            {
320                at(end_of(k - 1), hole.describe(), end)
321            }
322            // A literal the text starts to carry fails where the two part,
323            // so of two templates that differ inside a literal the one the
324            // text follows further is the one it meant.
325            (_, Segment::Literal(l)) => {
326                let shared = common_prefix(l, &text[end..]);
327                at(end + shared, format!("`{}`", &l[shared..]), end + shared)
328            }
329            (_, hole) => at(end, hole.describe(), end),
330        }
331    }
332}
333
334impl Template {
335    /// The last hole, where only literals follow it to the end.
336    fn last_hole(&self) -> Option<usize> {
337        let h = self
338            .segments
339            .iter()
340            .rposition(|s| matches!(s, Segment::Hole { .. }))?;
341        (h + 1 < self.segments.len()).then_some(h)
342    }
343
344    /// A text that stops short of the literal end of the template: the
345    /// last hole, from `start`, takes the rest of the text, less any part
346    /// of that end the text carries, so the fault is the rest of the end.
347    fn short_end(&self, text: &str, h: usize, start: usize) -> Option<Failure> {
348        let Segment::Hole { def, .. } = &self.segments[h] else {
349            return None;
350        };
351        let tail: String = self.segments[h + 1..]
352            .iter()
353            .map(|s| match s {
354                Segment::Literal(l) => l.as_str(),
355                Segment::Hole { .. } => "",
356            })
357            .collect();
358        let carried = (1..tail.len())
359            .rev()
360            .find(|&j| tail.is_char_boundary(j) && text.ends_with(&tail[..j]))
361            .unwrap_or(0);
362        let value = text.get(start..text.len() - carried)?;
363        whole(def, value).then(|| Failure {
364            offset: text.len(),
365            expected: format!("`{}`", &tail[carried..]),
366            reach: text.len(),
367        })
368    }
369}
370
371/// Whether the type `def` matches `value` whole.
372fn whole(def: &TypeDef, value: &str) -> bool {
373    compiled(&format!("^(?:{})$", def.regex())).is_ok_and(|r| r.is_match(value))
374}
375
376/// The one-based column, in characters, of the byte `offset` of `text`.
377pub(super) fn column(text: &str, offset: usize) -> usize {
378    text[..offset.min(text.len())].chars().count() + 1
379}
380
381/// The length in bytes of the longest common prefix of `a` and `b`, on a
382/// char boundary.
383pub(super) fn common_prefix(a: &str, b: &str) -> usize {
384    a.char_indices()
385        .zip(b.chars())
386        .find(|((_, x), y)| x != y)
387        .map_or_else(|| a.len().min(b.len()), |((i, _), _)| i)
388}
389
390#[cfg(test)]
391mod tests {
392    use proptest::prelude::*;
393
394    use super::*;
395    use crate::{grammar::FormatSource, yaml};
396
397    fn types() -> TypeRegistry {
398        let node = yaml::parse(
399            "code: '[A-Z]{1,10}'\nkind: [Performance, Capacity]\nlabel: '[^\"<]+'\n",
400            1,
401        )
402        .unwrap();
403        let (own, problems) =
404            TypeRegistry::from_yaml(&node, &FormatSource::Repo, &TypeRegistry::default());
405        assert!(problems.is_empty());
406        TypeRegistry::built_ins().layered(&own)
407    }
408
409    fn t(src: &str) -> Template {
410        Template::parse(src, &types()).unwrap()
411    }
412
413    #[test]
414    fn parses_holes_escapes_and_literals() {
415        let tpl = t("${id:code}-${n:int} [x]: ${} $${a} costs $5 ${:kind}");
416        assert_eq!(tpl.names().collect::<Vec<_>>(), ["id", "n"]);
417        assert!(tpl.is_match("AB-12 [x]: any text ${a} costs $5 Capacity"));
418        assert!(!tpl.is_match("AB-12 [x]: any text ${a} costs $5 Speed"));
419        let caps = tpl
420            .regex()
421            .captures("AB-12 [x]: t ${a} costs $5 Performance")
422            .unwrap();
423        assert_eq!(&caps["id"], "AB");
424        assert_eq!(&caps["n"], "12");
425        // Regex characters in the literal are text.
426        assert!(t("a.b ${}").is_match("a.b c"));
427        assert!(!t("a.b ${}").is_match("axb c"));
428    }
429
430    // sokf-tests: TL_25
431    // sokf-tests: TL_119
432    // sokf-tests: TL_120
433    // sokf-tests: TL_121
434    #[test]
435    fn refuses_what_is_not_a_template() {
436        let ty = types();
437        for (bad, key, says) in [
438            ("no holes", "TL_25", "no hole"),
439            ("$${x}", "TL_25", "no hole"),
440            ("a ${x", "TL_121", "unclosed"),
441            ("${x:nope}", "TL_120", "no type `nope`"),
442            ("${x} ${x}", "TL_119", "twice"),
443            ("${1x}", "TL_121", "hole name"),
444            ("${a-b}", "TL_121", "hole name"),
445        ] {
446            let e = Template::parse(bad, &ty).unwrap_err();
447            assert!(e.message.contains(says), "{bad}: {e:?}");
448            assert_eq!(e.criterion, key, "{bad}");
449        }
450    }
451
452    // sokf-tests: TL_26
453    // sokf-tests: TL_74
454    #[test]
455    fn a_failure_names_the_column_and_what_was_expected() {
456        let tpl = t("${id:code} [event]: WHEN ${}, the ${} SHALL ${}.");
457        let text = "AB [state]: WHEN x, the y SHALL z.";
458        let f = tpl.failure(text);
459        assert_eq!(f.offset, 4);
460        assert_eq!(
461            f.describe(text),
462            "column 5: expected `event]: WHEN `, found `state]: WHEN x, the y S…`"
463        );
464        let text = "AB [event]: WHEN a user posts THEN the API SHALL go.";
465        let f = tpl.failure(text);
466        assert_eq!(f.offset, "AB [event]: WHEN ".len());
467        assert!(
468            f.expected.starts_with("a `text` (`.+`) then `, the `"),
469            "{f:?}"
470        );
471        // A text that lacks only the end of the template stops short.
472        let text = "AB [event]: WHEN x, the y SHALL z";
473        assert_eq!(
474            tpl.failure(text).describe(text),
475            "column 34: expected `.`, found the end"
476        );
477        let tpl = t("(${}.)");
478        assert_eq!(
479            tpl.failure("(x.").describe("(x."),
480            "column 4: expected `)`, found the end"
481        );
482        // A period inside the value does not end the template.
483        let tpl = t("${}.");
484        assert_eq!(
485            tpl.failure("Use version 1.2").describe("Use version 1.2"),
486            "column 16: expected `.`, found the end"
487        );
488        // A hole that can't take the rest of the template ends a short line.
489        let tpl = t("${:code} x${:int}");
490        assert_eq!(
491            tpl.failure("AB").describe("AB"),
492            "column 3: expected ` x`, found the end"
493        );
494        // A hole the text gives a value it does not take is the fault.
495        let tpl = t("${:word} ${:slug} ${:int}");
496        let text = "x a--b 4";
497        let f = tpl.failure(text);
498        assert_eq!(
499            f.describe(text),
500            "column 3: expected a `slug` (`[a-z0-9]+(?:-[a-z0-9]+)*`), found `a--b 4`"
501        );
502        assert_eq!(f.reach, 3);
503        let tpl = t("${:int}.${:int} ${:kind}");
504        let text = "2.1 Speed";
505        let f = tpl.failure(text);
506        assert_eq!(
507            f.describe(text),
508            "column 5: expected a `kind` (one of `Performance`, `Capacity`), found `Speed`"
509        );
510        let tpl = t("x ${:int}");
511        assert_eq!(
512            tpl.failure("x 12 more").describe("x 12 more"),
513            "column 5: expected the end, found ` more`"
514        );
515        assert_eq!(
516            tpl.failure("y").describe("y"),
517            "column 1: expected `x `, found `y`"
518        );
519        assert_eq!(
520            tpl.failure("x").describe("x"),
521            "column 2: expected ` `, found the end"
522        );
523    }
524
525    /// A template of literals and holes of simple types, a line rendered
526    /// from it, and the value each hole took.
527    fn arb_line() -> impl Strategy<Value = (String, String, Vec<(String, String)>)> {
528        prop::collection::vec(
529            prop_oneof![
530                "[a-z:;,. ]{1,4}".prop_map(|l| (None, l)),
531                "[A-Z]{1,5}".prop_map(|v| (Some("code"), v)),
532                "[0-9]{1,3}".prop_map(|v| (Some("int"), v)),
533            ],
534            1..6,
535        )
536        .prop_filter("a template has a hole", |parts| {
537            parts.iter().any(|(ty, _)| ty.is_some())
538        })
539        .prop_map(|parts| {
540            let (mut src, mut line, mut values) = (String::new(), String::new(), Vec::new());
541            let mut last_hole = false;
542            for (i, (ty, v)) in parts.into_iter().enumerate() {
543                match ty {
544                    Some(ty) => {
545                        // Two holes side by side split their text anyhow; a
546                        // literal between keeps each value its own.
547                        if last_hole {
548                            src.push('|');
549                            line.push('|');
550                        }
551                        src.push_str(&format!("${{h{i}:{ty}}}"));
552                        line.push_str(&v);
553                        values.push((format!("h{i}"), v));
554                        last_hole = true;
555                    }
556                    None => {
557                        src.push_str(&v);
558                        line.push_str(&v);
559                        last_hole = false;
560                    }
561                }
562            }
563            (src, line, values)
564        })
565    }
566
567    // sokf-tests: TL_24
568    // sokf-tests: TL_26
569    proptest! {
570        #![proptest_config(ProptestConfig::with_cases(256))]
571
572        /// A line rendered from a template, with values drawn from its hole
573        /// types, matches it and captures each value.
574        #[test]
575        fn a_rendered_line_matches_and_captures((src, line, values) in arb_line()) {
576            let tpl = t(&src);
577            let caps = tpl.regex().captures(&line);
578            prop_assert!(caps.is_some(), "{src} / {line}");
579            let caps = caps.unwrap();
580            for (name, value) in &values {
581                prop_assert_eq!(&caps[name.as_str()], value.as_str());
582            }
583        }
584
585        /// A changed literal fails at or before the column of the change,
586        /// and inside the line.
587        #[test]
588        fn a_changed_literal_fails_where_it_changed(
589            head in "[a-z]{1,6}",
590            tail in "[a-z]{1,6}",
591            value in "[A-Z]{1,5}",
592        ) {
593            let tpl = t(&format!("{head}: ${{v:code}} {tail}"));
594            let good = format!("{head}: {value} {tail}");
595            prop_assert!(tpl.is_match(&good));
596            // Change the first letter of the tail.
597            let bad_tail: String = std::iter::once(if tail.starts_with('z') { 'y' } else { 'z' })
598                .chain(tail.chars().skip(1))
599                .collect();
600            let bad = format!("{head}: {value} {bad_tail}");
601            let changed = format!("{head}: {value} ").len();
602            let f = tpl.failure(&bad);
603            prop_assert!(f.offset <= changed && f.offset <= bad.len(), "{f:?}");
604            prop_assert!(!tpl.is_match(&bad));
605        }
606    }
607}