tramlines_core/grammar/
shape.rs1use regex::{Captures, Regex};
7
8use super::{LineTemplate, RegexCache, Text, TypeRegistry, template};
9use crate::{Problem, Spanned};
10
11#[derive(Debug, Clone)]
13pub enum Shape {
14 Text(Text),
17 Template(LineTemplate),
19 Pattern(Regex),
21}
22
23impl Shape {
24 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 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 pub fn captures<'t>(&self, text: &'t str) -> Option<Captures<'t>> {
47 self.regex()?.captures(text)
48 }
49
50 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 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 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 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
90pub 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}
134pub 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
146pub 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#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct Squashed {
164 pub text: String,
166 pub map: Vec<usize>,
168}
169
170impl Squashed {
173 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 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
206pub 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 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 proptest! {
245 #![proptest_config(ProptestConfig::with_cases(256))]
246
247 #[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}