1use serde::Serialize;
21
22pub const BLOCK_NAMES: &[&str] = &[
27 "paragraph",
28 "list",
29 "table",
30 "code",
31 "blockquote",
32 "heading",
33 "thematicBreak",
34 "html",
35];
36
37#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ObservedBlock {
42 Paragraph,
43 List {
45 ordered: bool,
46 },
47 Table,
48 Code {
51 lang: String,
52 },
53 Blockquote,
54 Heading {
56 depth: u8,
57 },
58 ThematicBreak,
59 Html,
60}
61
62impl ObservedBlock {
63 pub fn name(&self) -> &'static str {
66 match self {
67 Self::Paragraph => "paragraph",
68 Self::List { .. } => "list",
69 Self::Table => "table",
70 Self::Code { .. } => "code",
71 Self::Blockquote => "blockquote",
72 Self::Heading { .. } => "heading",
73 Self::ThematicBreak => "thematicBreak",
74 Self::Html => "html",
75 }
76 }
77
78 pub fn display(&self) -> String {
81 match self {
82 Self::List { ordered: false } => "list(bullet)".to_string(),
83 Self::List { ordered: true } => "list(ordered)".to_string(),
84 Self::Heading { depth } => format!("heading({depth})"),
85 Self::Code { lang } if !lang.is_empty() => format!("code(lang={lang})"),
86 other => other.name().to_string(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
94pub struct Terminal {
95 pub name: String,
96 pub attr: Option<String>,
99}
100
101impl Terminal {
102 pub fn display(&self) -> String {
105 match &self.attr {
106 Some(a) => format!("{}({})", self.name, a),
107 None => self.name.clone(),
108 }
109 }
110
111 pub fn admits(&self, block: &ObservedBlock) -> bool {
113 if self.name != block.name() {
114 return false;
115 }
116 let Some(attr) = &self.attr else {
117 return true;
118 };
119 match block {
120 ObservedBlock::List { ordered } => {
121 (attr == "bullet" && !ordered) || (attr == "ordered" && *ordered)
122 }
123 ObservedBlock::Heading { depth } => attr.parse::<u8>() == Ok(*depth),
124 ObservedBlock::Code { lang } => attr
125 .strip_prefix("lang=")
126 .is_some_and(|expected| expected == lang),
127 _ => false,
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
138pub enum ContentExprError {
139 #[error(
140 "unknown block name '{0}' — the vocabulary is paragraph, list, table, code, blockquote, heading, thematicBreak, html"
141 )]
142 UnknownBlockName(String),
143 #[error("invalid attribute '{attr}' on '{name}'")]
144 InvalidAttribute { name: String, attr: String },
145 #[error(
146 "heading depth {0} is outside 3–6 — h1/h2 are the entity's own levels (title, section delimiters)"
147 )]
148 HeadingDepthReserved(u8),
149 #[error(
150 "nested groups are not allowed — the expression grammar is regular (no nesting, no recursion)"
151 )]
152 NestedGroup,
153 #[error(
154 "a group must be either an alternation of names or a sequence — mixing '|' and sequence inside one group is not allowed"
155 )]
156 MixedGroupOperators,
157 #[error("unbalanced parentheses")]
158 UnbalancedParens,
159 #[error("repetition operator '{0}' has nothing to apply to")]
160 DanglingRepetition(char),
161 #[error("empty expression")]
162 Empty,
163 #[error("empty group")]
164 EmptyGroup,
165 #[error("unexpected token '{0}'")]
166 UnexpectedToken(String),
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Repeat {
172 One,
173 OneOrMore,
174 ZeroOrMore,
175 ZeroOrOne,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
182enum Term {
183 Atom(Terminal, Repeat),
184 Alternation(Vec<Terminal>, Repeat),
186 Sequence(Vec<Terminal>, Repeat),
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct ContentExpr {
196 source: String,
197 terms: Vec<Term>,
198 nfa: Nfa,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
208pub struct MatchFailure {
209 pub failed_at: usize,
210 pub expected_next: Vec<String>,
211 pub found: Option<String>,
212}
213
214impl ContentExpr {
215 pub fn parse(source: &str) -> Result<Self, ContentExprError> {
219 let terms = parse_terms(source)?;
220 if terms.is_empty() {
221 return Err(ContentExprError::Empty);
222 }
223 let nfa = Nfa::compile(&terms);
224 Ok(Self {
225 source: source.to_string(),
226 terms,
227 nfa,
228 })
229 }
230
231 pub fn source(&self) -> &str {
233 &self.source
234 }
235
236 pub fn mentioned_names(&self) -> Vec<&str> {
241 let mut names: Vec<&str> = self
242 .all_terminals()
243 .map(|t| t.name.as_str())
244 .collect::<std::collections::BTreeSet<_>>()
245 .into_iter()
246 .collect();
247 names.sort_unstable();
248 names
249 }
250
251 fn all_terminals(&self) -> impl Iterator<Item = &Terminal> {
252 self.terms.iter().flat_map(|t| match t {
253 Term::Atom(a, _) => std::slice::from_ref(a).iter(),
254 Term::Alternation(v, _) | Term::Sequence(v, _) => v.iter(),
255 })
256 }
257
258 pub fn match_blocks(&self, blocks: &[ObservedBlock]) -> Result<(), MatchFailure> {
260 self.nfa.run(blocks)
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq)]
269enum Token {
270 Name(Terminal),
271 Open,
272 Close,
273 Pipe,
274 Rep(char),
275}
276
277fn tokenize(source: &str) -> Result<Vec<Token>, ContentExprError> {
278 let mut out = Vec::new();
279 let chars: Vec<char> = source.chars().collect();
280 let mut i = 0;
281 while i < chars.len() {
282 let c = chars[i];
283 match c {
284 ' ' | '\t' | '\n' => i += 1,
285 '(' => {
286 out.push(Token::Open);
291 i += 1;
292 }
293 ')' => {
294 out.push(Token::Close);
295 i += 1;
296 }
297 '|' => {
298 out.push(Token::Pipe);
299 i += 1;
300 }
301 '+' | '*' | '?' => {
302 out.push(Token::Rep(c));
303 i += 1;
304 }
305 c if c.is_ascii_alphabetic() => {
306 let start = i;
307 while i < chars.len() && (chars[i].is_ascii_alphanumeric()) {
308 i += 1;
309 }
310 let name: String = chars[start..i].iter().collect();
311 let mut attr = None;
312 if i < chars.len() && chars[i] == '(' {
313 let close = chars[i + 1..]
317 .iter()
318 .position(|&c| c == ')')
319 .map(|p| i + 1 + p);
320 if let Some(close_idx) = close {
321 let inner: String = chars[i + 1..close_idx].iter().collect();
322 if !inner.contains(' ') && !inner.contains('|') && !inner.is_empty() {
323 attr = Some(inner);
324 i = close_idx + 1;
325 }
326 }
327 }
328 out.push(Token::Name(validate_terminal(name, attr)?));
329 }
330 other => return Err(ContentExprError::UnexpectedToken(other.to_string())),
331 }
332 }
333 Ok(out)
334}
335
336fn validate_terminal(name: String, attr: Option<String>) -> Result<Terminal, ContentExprError> {
337 if !BLOCK_NAMES.contains(&name.as_str()) {
338 return Err(ContentExprError::UnknownBlockName(name));
339 }
340 if let Some(a) = &attr {
341 let valid = match name.as_str() {
342 "list" => a == "bullet" || a == "ordered",
343 "heading" => match a.parse::<u8>() {
344 Ok(d @ 3..=6) => {
345 let _ = d;
346 true
347 }
348 Ok(d @ (1 | 2)) => return Err(ContentExprError::HeadingDepthReserved(d)),
349 _ => false,
350 },
351 "code" => a.starts_with("lang=") && a.len() > "lang=".len(),
352 _ => false,
353 };
354 if !valid {
355 return Err(ContentExprError::InvalidAttribute {
356 name,
357 attr: a.clone(),
358 });
359 }
360 }
361 Ok(Terminal { name, attr })
362}
363
364fn parse_terms(source: &str) -> Result<Vec<Term>, ContentExprError> {
365 let tokens = tokenize(source)?;
366 let mut terms = Vec::new();
367 let mut i = 0;
368 while i < tokens.len() {
369 match &tokens[i] {
370 Token::Name(t) => {
371 let rep = take_rep(&tokens, i + 1);
372 let consumed = 1 + usize::from(rep != Repeat::One);
373 terms.push(Term::Atom(t.clone(), rep));
374 i += consumed;
375 }
376 Token::Open => {
377 let mut members: Vec<Terminal> = Vec::new();
380 let mut saw_pipe = false;
381 let mut saw_adjacent_names = false;
382 let mut j = i + 1;
383 let mut prev_was_name = false;
384 loop {
385 match tokens.get(j) {
386 None => return Err(ContentExprError::UnbalancedParens),
387 Some(Token::Close) => break,
388 Some(Token::Open) => return Err(ContentExprError::NestedGroup),
389 Some(Token::Pipe) => {
390 saw_pipe = true;
391 prev_was_name = false;
392 j += 1;
393 }
394 Some(Token::Name(t)) => {
395 if prev_was_name {
396 saw_adjacent_names = true;
397 }
398 members.push(t.clone());
399 prev_was_name = true;
400 j += 1;
401 }
402 Some(Token::Rep(c)) => {
403 return Err(ContentExprError::DanglingRepetition(*c));
405 }
406 }
407 }
408 if members.is_empty() {
409 return Err(ContentExprError::EmptyGroup);
410 }
411 if saw_pipe && saw_adjacent_names {
412 return Err(ContentExprError::MixedGroupOperators);
413 }
414 let rep = take_rep(&tokens, j + 1);
415 let consumed = j + 1 - i + usize::from(rep != Repeat::One);
416 if saw_pipe {
417 terms.push(Term::Alternation(members, rep));
418 } else {
419 terms.push(Term::Sequence(members, rep));
420 }
421 i += consumed;
422 }
423 Token::Close => return Err(ContentExprError::UnbalancedParens),
424 Token::Pipe => {
425 return Err(ContentExprError::UnexpectedToken("|".to_string()));
426 }
427 Token::Rep(c) => return Err(ContentExprError::DanglingRepetition(*c)),
428 }
429 }
430 Ok(terms)
431}
432
433fn take_rep(tokens: &[Token], at: usize) -> Repeat {
434 match tokens.get(at) {
435 Some(Token::Rep('+')) => Repeat::OneOrMore,
436 Some(Token::Rep('*')) => Repeat::ZeroOrMore,
437 Some(Token::Rep('?')) => Repeat::ZeroOrOne,
438 _ => Repeat::One,
439 }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq)]
450struct Nfa {
451 transitions: Vec<Vec<(Terminal, usize)>>,
453 epsilons: Vec<Vec<usize>>,
455 accept: usize,
456}
457
458impl Nfa {
459 fn compile(terms: &[Term]) -> Self {
460 let mut nfa = Nfa {
461 transitions: vec![Vec::new()],
462 epsilons: vec![Vec::new()],
463 accept: 0,
464 };
465 let mut current = 0;
466 for term in terms {
467 current = nfa.append_term(current, term);
468 }
469 nfa.accept = current;
470 nfa
471 }
472
473 fn new_state(&mut self) -> usize {
474 self.transitions.push(Vec::new());
475 self.epsilons.push(Vec::new());
476 self.transitions.len() - 1
477 }
478
479 fn append_term(&mut self, from: usize, term: &Term) -> usize {
481 type UnitBuilder = Box<dyn Fn(&mut Nfa, usize) -> usize>;
482 let (unit_entry_build, rep): (UnitBuilder, Repeat) = match term {
483 Term::Atom(t, rep) => {
484 let t = t.clone();
485 (
486 Box::new(move |nfa: &mut Nfa, from: usize| {
487 let next = nfa.new_state();
488 nfa.transitions[from].push((t.clone(), next));
489 next
490 }),
491 *rep,
492 )
493 }
494 Term::Alternation(alts, rep) => {
495 let alts = alts.clone();
496 (
497 Box::new(move |nfa: &mut Nfa, from: usize| {
498 let next = nfa.new_state();
499 for t in &alts {
500 nfa.transitions[from].push((t.clone(), next));
501 }
502 next
503 }),
504 *rep,
505 )
506 }
507 Term::Sequence(seq, rep) => {
508 let seq = seq.clone();
509 (
510 Box::new(move |nfa: &mut Nfa, from: usize| {
511 let mut cur = from;
512 for t in &seq {
513 let next = nfa.new_state();
514 nfa.transitions[cur].push((t.clone(), next));
515 cur = next;
516 }
517 cur
518 }),
519 *rep,
520 )
521 }
522 };
523
524 match rep {
525 Repeat::One => unit_entry_build(self, from),
526 Repeat::ZeroOrOne => {
527 let exit = unit_entry_build(self, from);
528 self.epsilons[from].push(exit);
529 exit
530 }
531 Repeat::OneOrMore => {
532 let exit = unit_entry_build(self, from);
533 let exit2 = unit_entry_build(self, exit);
535 self.epsilons[exit2].push(exit);
536 self.epsilons[exit].push(exit2);
537 exit
540 }
541 Repeat::ZeroOrMore => {
542 let exit = unit_entry_build(self, from);
543 self.epsilons[exit].push(from);
544 self.epsilons[from].push(exit);
545 exit
546 }
547 }
548 }
549
550 fn closure(&self, states: &mut std::collections::BTreeSet<usize>) {
551 let mut stack: Vec<usize> = states.iter().copied().collect();
552 while let Some(s) = stack.pop() {
553 for &e in &self.epsilons[s] {
554 if states.insert(e) {
555 stack.push(e);
556 }
557 }
558 }
559 }
560
561 fn run(&self, blocks: &[ObservedBlock]) -> Result<(), MatchFailure> {
562 let mut current: std::collections::BTreeSet<usize> = std::iter::once(0).collect();
563 self.closure(&mut current);
564 for (i, block) in blocks.iter().enumerate() {
565 let mut next: std::collections::BTreeSet<usize> = Default::default();
566 for &s in ¤t {
567 for (terminal, to) in &self.transitions[s] {
568 if terminal.admits(block) {
569 next.insert(*to);
570 }
571 }
572 }
573 if next.is_empty() {
574 return Err(MatchFailure {
575 failed_at: i,
576 expected_next: self.expected_from(¤t),
577 found: Some(block.display()),
578 });
579 }
580 self.closure(&mut next);
581 current = next;
582 }
583 if current.contains(&self.accept) {
584 Ok(())
585 } else {
586 Err(MatchFailure {
587 failed_at: blocks.len(),
588 expected_next: self.expected_from(¤t),
589 found: None,
590 })
591 }
592 }
593
594 fn expected_from(&self, states: &std::collections::BTreeSet<usize>) -> Vec<String> {
597 let mut out: std::collections::BTreeSet<String> = Default::default();
598 for &s in states {
599 for (terminal, _) in &self.transitions[s] {
600 out.insert(terminal.display());
601 }
602 }
603 out.into_iter().collect()
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 fn bullet() -> ObservedBlock {
612 ObservedBlock::List { ordered: false }
613 }
614 fn h(depth: u8) -> ObservedBlock {
615 ObservedBlock::Heading { depth }
616 }
617 fn para() -> ObservedBlock {
618 ObservedBlock::Paragraph
619 }
620
621 #[test]
622 fn single_name_matches_exactly_one_block() {
623 let e = ContentExpr::parse("list(bullet)").unwrap();
624 assert!(e.match_blocks(&[bullet()]).is_ok());
625 assert!(e.match_blocks(&[]).is_err());
626 assert!(e.match_blocks(&[bullet(), bullet()]).is_err());
627 assert!(
628 e.match_blocks(&[ObservedBlock::List { ordered: true }])
629 .is_err()
630 );
631 }
632
633 #[test]
634 fn plan_example_repeated_group() {
635 let e = ContentExpr::parse("(heading(3) list(bullet))+").unwrap();
637 assert!(e.match_blocks(&[h(3), bullet()]).is_ok());
638 assert!(e.match_blocks(&[h(3), bullet(), h(3), bullet()]).is_ok());
639 assert!(e.match_blocks(&[h(3)]).is_err(), "group half-done");
640 assert!(e.match_blocks(&[]).is_err(), "+ needs at least one");
641 assert!(e.match_blocks(&[bullet(), h(3)]).is_err(), "order matters");
642 assert!(e.match_blocks(&[h(4), bullet()]).is_err(), "depth pinned");
643 }
644
645 #[test]
646 fn alternation_and_optional() {
647 let e = ContentExpr::parse("(paragraph | list) table?").unwrap();
648 assert!(e.match_blocks(&[para()]).is_ok());
649 assert!(e.match_blocks(&[bullet()]).is_ok());
650 assert!(e.match_blocks(&[para(), ObservedBlock::Table]).is_ok());
651 assert!(e.match_blocks(&[ObservedBlock::Table]).is_err());
652 }
653
654 #[test]
655 fn star_admits_empty() {
656 let e = ContentExpr::parse("paragraph*").unwrap();
657 assert!(e.match_blocks(&[]).is_ok());
658 assert!(e.match_blocks(&[para(), para(), para()]).is_ok());
659 assert!(e.match_blocks(&[bullet()]).is_err());
660 }
661
662 #[test]
663 fn sequence_of_names() {
664 let e = ContentExpr::parse("paragraph list(bullet) paragraph?").unwrap();
665 assert!(e.match_blocks(&[para(), bullet()]).is_ok());
666 assert!(e.match_blocks(&[para(), bullet(), para()]).is_ok());
667 assert!(e.match_blocks(&[bullet(), para()]).is_err());
668 }
669
670 #[test]
671 fn failure_reports_position_and_expected_next() {
672 let e = ContentExpr::parse("heading(3) list(bullet)+").unwrap();
673 let err = e.match_blocks(&[h(3), para()]).unwrap_err();
674 assert_eq!(err.failed_at, 1);
675 assert_eq!(err.expected_next, vec!["list(bullet)".to_string()]);
676 assert_eq!(err.found.as_deref(), Some("paragraph"));
677
678 let err = e.match_blocks(&[h(3)]).unwrap_err();
679 assert_eq!(err.failed_at, 1);
680 assert_eq!(err.found, None, "body ended too early");
681 assert_eq!(err.expected_next, vec!["list(bullet)".to_string()]);
682 }
683
684 #[test]
685 fn code_lang_attribute() {
686 let e = ContentExpr::parse("code(lang=rust)").unwrap();
687 assert!(
688 e.match_blocks(&[ObservedBlock::Code {
689 lang: "rust".into()
690 }])
691 .is_ok()
692 );
693 assert!(
694 e.match_blocks(&[ObservedBlock::Code {
695 lang: "python".into()
696 }])
697 .is_err()
698 );
699 let bare = ContentExpr::parse("code").unwrap();
700 assert!(
701 bare.match_blocks(&[ObservedBlock::Code { lang: "".into() }])
702 .is_ok()
703 );
704 }
705
706 #[test]
707 fn validation_refusals() {
708 assert!(matches!(
709 ContentExpr::parse("bulletList"),
710 Err(ContentExprError::UnknownBlockName(n)) if n == "bulletList"
711 ));
712 assert!(matches!(
713 ContentExpr::parse("heading(2)"),
714 Err(ContentExprError::HeadingDepthReserved(2))
715 ));
716 assert!(matches!(
717 ContentExpr::parse("heading(1) list"),
718 Err(ContentExprError::HeadingDepthReserved(1))
719 ));
720 assert!(matches!(
721 ContentExpr::parse("list(numbered)"),
722 Err(ContentExprError::InvalidAttribute { .. })
723 ));
724 assert!(matches!(
725 ContentExpr::parse("paragraph(x)"),
726 Err(ContentExprError::InvalidAttribute { .. })
727 ));
728 assert!(matches!(
729 ContentExpr::parse("((list))"),
730 Err(ContentExprError::NestedGroup)
731 ));
732 assert!(matches!(
733 ContentExpr::parse("(paragraph | list table)"),
734 Err(ContentExprError::MixedGroupOperators)
735 ));
736 assert!(matches!(
737 ContentExpr::parse("(list"),
738 Err(ContentExprError::UnbalancedParens)
739 ));
740 assert!(matches!(
741 ContentExpr::parse("+list"),
742 Err(ContentExprError::DanglingRepetition('+'))
743 ));
744 assert!(matches!(
745 ContentExpr::parse(""),
746 Err(ContentExprError::Empty)
747 ));
748 assert!(matches!(
749 ContentExpr::parse("()"),
750 Err(ContentExprError::EmptyGroup)
751 ));
752 }
753
754 #[test]
755 fn mentioned_names_deduplicate() {
756 let e = ContentExpr::parse("(heading(3) list(bullet))+ list(bullet)?").unwrap();
757 assert_eq!(e.mentioned_names(), vec!["heading", "list"]);
758 }
759}