1use 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#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Segment {
16 Literal(String),
18 Hole {
20 name: Option<String>,
22 ty: String,
24 def: TypeDef,
26 },
27}
28
29impl Segment {
30 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Failure {
56 pub offset: usize,
58 pub expected: String,
60 pub reach: usize,
64}
65
66impl Failure {
67 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#[derive(Debug, Clone)]
85pub struct Template {
86 pub source: String,
88 pub segments: Vec<Segment>,
90 regex: Regex,
92 prefixes: OnceLock<Vec<Regex>>,
95}
96
97thread_local! {
98 static COMPILED: RefCell<HashMap<String, Regex>> = RefCell::new(HashMap::new());
102}
103
104pub(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 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 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 pub fn regex(&self) -> &Regex {
238 &self.regex
239 }
240
241 pub fn is_match(&self, text: &str) -> bool {
243 self.regex.is_match(text)
244 }
245
246 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 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 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 (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 (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 (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 (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 (_, 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 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 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
371fn whole(def: &TypeDef, value: &str) -> bool {
373 compiled(&format!("^(?:{})$", def.regex())).is_ok_and(|r| r.is_match(value))
374}
375
376pub(super) fn column(text: &str, offset: usize) -> usize {
378 text[..offset.min(text.len())].chars().count() + 1
379}
380
381pub(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 assert!(t("a.b ${}").is_match("a.b c"));
427 assert!(!t("a.b ${}").is_match("axb c"));
428 }
429
430 #[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 #[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 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 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 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 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 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 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 proptest! {
570 #![proptest_config(ProptestConfig::with_cases(256))]
571
572 #[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 #[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 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}