Skip to main content

tramlines_core/grammar/
shape.rs

1//! The shape of one unit (TL_22 to TL_26): exact `text`, a `template` with typed
2//! holes, or a `pattern` regex, one key per meaning wherever a rule
3//! declares a shape. Every shape reads the unit's text squashed, each run
4//! of whitespace one space.
5
6use regex::{Captures, Regex};
7
8use super::{LineTemplate, RegexCache, Text, TypeRegistry, template};
9use crate::{Problem, Spanned};
10
11/// A unit's shape.
12#[derive(Debug, Clone)]
13pub enum Shape {
14    /// `text`: what the text must equal, or the frontmatter key whose value
15    /// it must equal.
16    Text(Text),
17    /// `template`: the text written with typed holes.
18    Template(LineTemplate),
19    /// `pattern`: a regex the text must match somewhere.
20    Pattern(Regex),
21}
22
23impl Shape {
24    /// The shape's regex, whose named groups are the keys it can capture;
25    /// none for `text`.
26    pub fn regex(&self) -> Option<&Regex> {
27        match self {
28            Shape::Text(_) => None,
29            Shape::Template(t) => Some(t.regex()),
30            Shape::Pattern(p) => Some(p),
31        }
32    }
33
34    /// Whether `text`, squashed, has this shape. `value` resolves a
35    /// `{ from: <key> }` text to the frontmatter value, squashed.
36    pub fn matches(&self, text: &str, value: impl Fn(&str) -> Option<String>) -> bool {
37        match self {
38            Shape::Text(Text::Literal(want)) => squash(want) == text,
39            Shape::Text(Text::From(key)) => value(key).is_some_and(|v| squash(&v) == text),
40            Shape::Template(t) => t.is_match(text),
41            Shape::Pattern(p) => p.is_match(text),
42        }
43    }
44
45    /// The groups the shape captures from `text`.
46    pub fn captures<'t>(&self, text: &'t str) -> Option<Captures<'t>> {
47        self.regex()?.captures(text)
48    }
49
50    /// Whether the shape holds a group named `name`.
51    pub fn has_group(&self, name: &str) -> bool {
52        self.regex()
53            .is_some_and(|r| r.capture_names().flatten().any(|n| n == name))
54    }
55
56    /// Why `text` does not have this shape, as a finding continues a
57    /// sentence that opens with the text: `does not match …` or `fails at
58    /// column …`. `text` names the shape of a literal.
59    pub fn failure(&self, text: &str) -> String {
60        match self {
61            Shape::Text(Text::Literal(want)) => format!("must read `{}`", squash(want)),
62            Shape::Text(Text::From(key)) => format!("must equal the frontmatter `{key}`"),
63            Shape::Template(t) => format!("fails at {}", t.failure(text).describe(text)),
64            Shape::Pattern(p) => format!("does not match `{}`", p.as_str()),
65        }
66    }
67
68    /// How far into `text` the shape matched before it failed, for the
69    /// furthest-failure choice between shapes: a template and a text know,
70    /// a regex does not.
71    pub fn reach(&self, text: &str) -> usize {
72        match self {
73            Shape::Template(t) => t.failure(text).reach,
74            Shape::Text(Text::Literal(want)) => template::common_prefix(&squash(want), text),
75            _ => 0,
76        }
77    }
78
79    /// The shape as a finding or a label names it.
80    pub fn label(&self) -> String {
81        match self {
82            Shape::Text(Text::Literal(t)) => t.clone(),
83            Shape::Text(Text::From(k)) => format!("<{k}>"),
84            Shape::Template(t) => t.source.clone(),
85            Shape::Pattern(p) => format!("/{}/", p.as_str()),
86        }
87    }
88}
89
90// sokf:begin shape-of
91/// The shape a rule map declares, from its `text`, `template` or `pattern`,
92/// or `None` where it declares none. Two are refused: a rule has one shape.
93/// `text` may be `{ from: <key> }` where `from` is allowed.
94// sokf-implements: TL_22
95// sokf-implements: TL_118
96pub fn shape_of(
97    node: &Spanned,
98    types: &TypeRegistry,
99    regexes: &RegexCache,
100) -> Result<Option<Shape>, Problem> {
101    let declared: Vec<&str> = ["text", "template", "pattern"]
102        .into_iter()
103        .filter(|k| node.get(k).is_some())
104        .collect();
105    if declared.len() > 1 {
106        return Err(Problem::new(
107            "TL_22",
108            format!(
109                "`{}` and `{}` beside each other: a rule has one shape",
110                declared[0], declared[1]
111            ),
112        ));
113    }
114    if let Some(t) = node.get("text") {
115        return Ok(match t.as_str() {
116            Some(s) => Some(Shape::Text(Text::Literal(s.to_string()))),
117            None => t
118                .get("from")
119                .and_then(|f| f.as_str())
120                .map(|k| Shape::Text(Text::From(k.to_string()))),
121        });
122    }
123    if let Some(t) = node.get("template").and_then(|t| t.as_str()) {
124        return LineTemplate::parse(t, types).map(|t| Some(Shape::Template(t)));
125    }
126    if let Some(p) = node.get("pattern").and_then(|p| p.as_str()) {
127        return regexes
128            .get(p)
129            .map(|r| Some(Shape::Pattern(r)))
130            .map_err(|e| Problem::new("TL_118", e));
131    }
132    Ok(None)
133}
134// sokf:end shape-of
135
136/// A string as a `heading` short form, or as `text` a template may hold:
137/// a template when it has a hole, else exact text.
138pub fn text_or_template(s: &str, types: &TypeRegistry) -> Result<Shape, Problem> {
139    if has_hole(s) {
140        LineTemplate::parse(s, types).map(Shape::Template)
141    } else {
142        Ok(Shape::Text(Text::Literal(s.to_string())))
143    }
144}
145
146/// Whether `s` holds a `${`, not escaped as `$${`.
147pub fn has_hole(s: &str) -> bool {
148    let mut rest = s;
149    while let Some(i) = rest.find("${") {
150        if i == 0 || !rest[..i].ends_with('$') {
151            return true;
152        }
153        rest = &rest[i + 2..];
154    }
155    false
156}
157
158/// A text squashed, each run of whitespace one space and the ends trimmed,
159/// with the byte offset in the original text of each byte of the result
160/// and one past the end, so a position found in the squashed text maps
161/// back to the text as written.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct Squashed {
164    /// The squashed text.
165    pub text: String,
166    /// `map[i]` is the offset in the original text of byte `i`.
167    pub map: Vec<usize>,
168}
169
170// sokf-implements: TL_23
171// sokf-implements: TLMD_6
172impl Squashed {
173    /// `text` squashed.
174    pub fn new(text: &str) -> Squashed {
175        let mut out = String::with_capacity(text.len());
176        let mut map = Vec::with_capacity(text.len() + 1);
177        let mut pending: Option<usize> = None;
178        for (i, c) in text.char_indices() {
179            if c.is_whitespace() {
180                if !out.is_empty() && pending.is_none() {
181                    pending = Some(i);
182                }
183                continue;
184            }
185            if let Some(at) = pending.take() {
186                out.push(' ');
187                map.push(at);
188            }
189            let start = out.len();
190            out.push(c);
191            map.extend((0..out.len() - start).map(|k| i + k));
192        }
193        map.push(text.trim_end().len());
194        Squashed { text: out, map }
195    }
196
197    /// The offset in the original text of offset `i` of the squashed one.
198    pub fn original(&self, i: usize) -> usize {
199        self.map
200            .get(i)
201            .copied()
202            .unwrap_or(*self.map.last().unwrap_or(&0))
203    }
204}
205
206/// `text` with each run of whitespace one space and the ends trimmed.
207pub fn squash(text: &str) -> String {
208    text.split_whitespace().collect::<Vec<_>>().join(" ")
209}
210
211#[cfg(test)]
212mod tests {
213    use proptest::prelude::*;
214
215    use super::*;
216
217    #[test]
218    fn a_hole_is_a_dollar_brace_not_escaped() {
219        assert!(has_hole("a ${x}"));
220        assert!(has_hole("$${a} ${b}"));
221        assert!(!has_hole("$${a} and $5"));
222        assert!(!has_hole("plain"));
223    }
224
225    #[test]
226    fn a_literal_reaches_no_further_than_its_squashed_text() {
227        let shape = Shape::Text(Text::Literal("a  b".into()));
228        // The text holds the whole squashed literal, `a b`, and goes on.
229        assert_eq!(shape.reach("a bc"), 3);
230        assert_eq!(shape.reach("a b"), 3);
231        assert_eq!(shape.reach("a c"), 2);
232    }
233
234    #[test]
235    fn one_shape_per_rule() {
236        let node = crate::yaml::parse("text: a\npattern: b\n", 1).unwrap();
237        let e = shape_of(&node, &TypeRegistry::built_ins(), &RegexCache::new()).unwrap_err();
238        assert!(e.message.contains("one shape"), "{e:?}");
239        assert_eq!(e.criterion, "TL_22");
240    }
241
242    // sokf-tests: TL_23
243    // sokf-tests: TL_24
244    proptest! {
245        #![proptest_config(ProptestConfig::with_cases(256))]
246
247        /// The squashed text is `squash`, and each of its bytes maps back
248        /// to the same byte of the text as written, a space to a space.
249        #[test]
250        fn a_squashed_text_maps_back(text in "[ a-z\\t\\n]{0,24}") {
251            let s = Squashed::new(&text);
252            prop_assert_eq!(&s.text, &squash(&text));
253            for (i, b) in s.text.bytes().enumerate() {
254                let o = s.original(i);
255                if b == b' ' {
256                    prop_assert!(text.as_bytes()[o].is_ascii_whitespace());
257                } else {
258                    prop_assert_eq!(text.as_bytes()[o], b);
259                }
260            }
261        }
262    }
263}