1use console::Style;
54use std::collections::HashMap;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum TagTransform {
59 Apply,
62
63 Remove,
66
67 Keep,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum UnknownTagBehavior {
75 #[default]
80 Passthrough,
81
82 Strip,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum UnknownTagKind {
92 Open,
94 Close,
96 Unbalanced,
98 UnexpectedClose,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct UnknownTagError {
105 pub tag: String,
107 pub kind: UnknownTagKind,
109 pub start: usize,
111 pub end: usize,
113}
114
115impl std::fmt::Display for UnknownTagError {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 let kind = match self.kind {
118 UnknownTagKind::Open => "unknown opening",
119 UnknownTagKind::Close => "unknown closing",
120 UnknownTagKind::Unbalanced => "unbalanced",
121 UnknownTagKind::UnexpectedClose => "unexpected closing",
122 };
123 write!(
124 f,
125 "{} tag '{}' at position {}..{}",
126 kind, self.tag, self.start, self.end
127 )
128 }
129}
130
131impl std::error::Error for UnknownTagError {}
132
133#[derive(Debug, Clone, Default, PartialEq, Eq)]
135pub struct UnknownTagErrors {
136 pub errors: Vec<UnknownTagError>,
138}
139
140impl UnknownTagErrors {
141 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn is_empty(&self) -> bool {
148 self.errors.is_empty()
149 }
150
151 pub fn len(&self) -> usize {
153 self.errors.len()
154 }
155
156 pub fn push(&mut self, error: UnknownTagError) {
158 self.errors.push(error);
159 }
160}
161
162impl std::fmt::Display for UnknownTagErrors {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 writeln!(f, "found {} unknown tag(s):", self.errors.len())?;
165 for error in &self.errors {
166 writeln!(f, " - {}", error)?;
167 }
168 Ok(())
169 }
170}
171
172impl std::error::Error for UnknownTagErrors {}
173
174impl IntoIterator for UnknownTagErrors {
175 type Item = UnknownTagError;
176 type IntoIter = std::vec::IntoIter<UnknownTagError>;
177
178 fn into_iter(self) -> Self::IntoIter {
179 self.errors.into_iter()
180 }
181}
182
183impl<'a> IntoIterator for &'a UnknownTagErrors {
184 type Item = &'a UnknownTagError;
185 type IntoIter = std::slice::Iter<'a, UnknownTagError>;
186
187 fn into_iter(self) -> Self::IntoIter {
188 self.errors.iter()
189 }
190}
191
192#[derive(Debug, Clone)]
197pub struct BBParser {
198 styles: HashMap<String, Style>,
199 transform: TagTransform,
200 unknown_behavior: UnknownTagBehavior,
201}
202
203impl BBParser {
204 pub fn new(styles: HashMap<String, Style>, transform: TagTransform) -> Self {
214 Self {
215 styles,
216 transform,
217 unknown_behavior: UnknownTagBehavior::default(),
218 }
219 }
220
221 pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
236 self.unknown_behavior = behavior;
237 self
238 }
239
240 pub fn parse(&self, input: &str) -> String {
244 let (output, _) = self.parse_internal(input);
245 output
246 }
247
248 pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
266 self.parse_internal(input)
267 }
268
269 pub fn validate(&self, input: &str) -> Result<(), UnknownTagErrors> {
293 let (_, errors) = self.parse_internal(input);
294 if errors.is_empty() {
295 Ok(())
296 } else {
297 Err(errors)
298 }
299 }
300
301 fn parse_internal(&self, input: &str) -> (String, UnknownTagErrors) {
303 let tokens = Tokenizer::new(input).collect::<Vec<_>>();
304 let valid_opens = self.compute_valid_tags(&tokens);
305 let mut events = Vec::new();
306 let mut errors = UnknownTagErrors::new();
307 let mut stack: Vec<&str> = Vec::new();
308
309 let mut i = 0;
312 while i < tokens.len() {
313 match &tokens[i] {
314 Token::Text { content, .. } => {
315 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
316 }
317 Token::OpenTag { name, start, end } => {
318 if valid_opens.contains(&i) {
319 stack.push(name);
320 self.emit_open_tag_event(&mut events, &mut errors, name, *start, *end);
321 } else {
322 let is_valid_name = Tokenizer::is_valid_tag_name(name);
324 if is_valid_name {
325 errors.push(UnknownTagError {
327 tag: name.to_string(),
328 kind: UnknownTagKind::Unbalanced, start: *start,
330 end: *end,
331 });
332 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
337 "[{}]",
338 name
339 ))));
340 } else {
341 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
342 "[{}]",
343 name
344 ))));
345 }
346 }
347 }
348 Token::CloseTag { name, start, end } => {
349 if stack.last().copied() == Some(*name) {
350 stack.pop();
351 self.emit_close_tag_event(&mut events, &mut errors, name, *start, *end);
352 } else if stack.contains(name) {
353 while let Some(open) = stack.pop() {
354 self.emit_close_tag_event(&mut events, &mut errors, open, 0, 0);
355 if open == *name {
356 break;
357 }
358 }
359 } else {
360 let is_valid_name = Tokenizer::is_valid_tag_name(name);
362 if is_valid_name {
363 errors.push(UnknownTagError {
364 tag: name.to_string(),
365 kind: UnknownTagKind::UnexpectedClose, start: *start,
367 end: *end,
368 });
369 }
370 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
371 "[/{}]",
372 name
373 ))));
374 }
375 }
376 Token::InvalidTag { content, .. } => {
377 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
378 }
379 }
380 i += 1;
381 }
382
383 while let Some(tag) = stack.pop() {
384 self.emit_close_tag_event(&mut events, &mut errors, tag, 0, 0);
385 }
386
387 let output = self.render(events);
388 (output, errors)
389 }
390
391 fn emit_open_tag_event<'a>(
392 &self,
393 events: &mut Vec<ParseEvent<'a>>,
394 errors: &mut UnknownTagErrors,
395 tag: &'a str,
396 start: usize,
397 end: usize,
398 ) {
399 let is_known = self.styles.contains_key(tag);
400
401 if !is_known {
402 errors.push(UnknownTagError {
403 tag: tag.to_string(),
404 kind: UnknownTagKind::Open,
405 start,
406 end,
407 });
408 }
409
410 match self.transform {
411 TagTransform::Keep => {
412 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
413 "[{}]",
414 tag
415 ))));
416 }
417 TagTransform::Remove => {
418 }
420 TagTransform::Apply => {
421 if is_known {
422 events.push(ParseEvent::StyleStart(tag));
423 } else {
424 match self.unknown_behavior {
425 UnknownTagBehavior::Passthrough => {
426 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
427 "[{}?]",
428 tag
429 ))));
430 }
431 UnknownTagBehavior::Strip => {
432 }
434 }
435 }
436 }
437 }
438 }
439
440 fn emit_close_tag_event<'a>(
441 &self,
442 events: &mut Vec<ParseEvent<'a>>,
443 errors: &mut UnknownTagErrors,
444 tag: &'a str,
445 start: usize,
446 end: usize,
447 ) {
448 let is_known = self.styles.contains_key(tag);
449
450 if !is_known && end > 0 {
452 errors.push(UnknownTagError {
453 tag: tag.to_string(),
454 kind: UnknownTagKind::Close,
455 start,
456 end,
457 });
458 }
459
460 match self.transform {
461 TagTransform::Keep => {
462 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
463 "[/{}]",
464 tag
465 ))));
466 }
467 TagTransform::Remove => {
468 }
470 TagTransform::Apply => {
471 if is_known {
472 events.push(ParseEvent::StyleEnd(tag));
473 } else {
474 match self.unknown_behavior {
475 UnknownTagBehavior::Passthrough => {
476 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
477 "[/{}?]",
478 tag
479 ))));
480 }
481 UnknownTagBehavior::Strip => {
482 }
484 }
485 }
486 }
487 }
488 }
489
490 fn render(&self, events: Vec<ParseEvent>) -> String {
492 let mut result = String::new();
493 let mut style_stack: Vec<&Style> = Vec::new();
494
495 for event in events {
496 match event {
497 ParseEvent::Literal(text) => {
498 self.append_styled(&mut result, &text, &style_stack);
499 }
500 ParseEvent::StyleStart(tag) => {
501 if let Some(style) = self.styles.get(tag) {
502 style_stack.push(style);
503 }
504 }
505 ParseEvent::StyleEnd(tag) => {
506 if self.styles.contains_key(tag) {
507 style_stack.pop();
508 }
509 }
510 }
511 }
512 result
513 }
514
515 fn compute_valid_tags(&self, tokens: &[Token]) -> std::collections::HashSet<usize> {
518 use std::collections::{HashMap, HashSet};
519 let mut valid_indices = HashSet::new();
520 let mut open_indices_by_tag: HashMap<&str, Vec<usize>> = HashMap::new();
521
522 for (i, token) in tokens.iter().enumerate() {
523 match token {
524 Token::OpenTag { name, .. } => {
525 open_indices_by_tag.entry(name).or_default().push(i);
526 }
527 Token::CloseTag { name, .. } => {
528 if let Some(indices) = open_indices_by_tag.get_mut(name) {
529 if let Some(open_idx) = indices.pop() {
530 valid_indices.insert(open_idx);
531 }
532 }
533 }
534 _ => {}
535 }
536 }
537
538 valid_indices
539 }
540
541 fn append_styled(&self, output: &mut String, text: &str, style_stack: &[&Style]) {
543 if text.is_empty() {
544 return;
545 }
546
547 if style_stack.is_empty() {
548 output.push_str(text);
549 } else {
550 let mut current = text.to_string();
551 for style in style_stack.iter().rev() {
555 if current.ends_with("\x1b[0m") {
556 current.truncate(current.len() - 4);
557 }
558 current = style.apply_to(current).to_string();
559 }
560 output.push_str(¤t);
561 }
562 }
563}
564
565enum ParseEvent<'a> {
566 Literal(std::borrow::Cow<'a, str>),
567 StyleStart(&'a str),
568 StyleEnd(&'a str),
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
573enum Token<'a> {
574 Text {
576 content: &'a str,
577 start: usize,
578 end: usize,
579 },
580 OpenTag {
582 name: &'a str,
583 start: usize,
584 end: usize,
585 },
586 CloseTag {
588 name: &'a str,
589 start: usize,
590 end: usize,
591 },
592 InvalidTag {
594 content: &'a str,
595 start: usize,
596 end: usize,
597 },
598}
599
600struct Tokenizer<'a> {
602 input: &'a str,
603 pos: usize,
604}
605
606impl<'a> Tokenizer<'a> {
607 fn new(input: &'a str) -> Self {
608 Self { input, pos: 0 }
609 }
610
611 fn is_valid_tag_name(s: &str) -> bool {
613 if s.is_empty() {
614 return false;
615 }
616
617 let mut chars = s.chars();
618 let first = chars.next().unwrap();
619
620 if !first.is_ascii_lowercase() && first != '_' {
622 return false;
623 }
624
625 for c in chars {
627 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
628 return false;
629 }
630 }
631
632 true
633 }
634}
635
636impl<'a> Iterator for Tokenizer<'a> {
637 type Item = Token<'a>;
638
639 fn next(&mut self) -> Option<Self::Item> {
640 if self.pos >= self.input.len() {
641 return None;
642 }
643
644 let remaining = &self.input[self.pos..];
645 let start_pos = self.pos;
646
647 if let Some(bracket_pos) = remaining.find('[') {
649 if bracket_pos > 0 {
650 let text = &remaining[..bracket_pos];
652 self.pos += bracket_pos;
653 return Some(Token::Text {
654 content: text,
655 start: start_pos,
656 end: self.pos,
657 });
658 }
659
660 if let Some(close_bracket) = remaining.find(']') {
663 let tag_content = &remaining[1..close_bracket];
664 let full_tag = &remaining[..=close_bracket];
665 let end_pos = start_pos + close_bracket + 1;
666
667 if let Some(tag_name) = tag_content.strip_prefix('/') {
669 if Self::is_valid_tag_name(tag_name) {
670 self.pos = end_pos;
671 Some(Token::CloseTag {
672 name: tag_name,
673 start: start_pos,
674 end: end_pos,
675 })
676 } else {
677 self.pos = end_pos;
678 Some(Token::InvalidTag {
679 content: full_tag,
680 start: start_pos,
681 end: end_pos,
682 })
683 }
684 } else if Self::is_valid_tag_name(tag_content) {
685 self.pos = end_pos;
686 Some(Token::OpenTag {
687 name: tag_content,
688 start: start_pos,
689 end: end_pos,
690 })
691 } else {
692 self.pos = end_pos;
693 Some(Token::InvalidTag {
694 content: full_tag,
695 start: start_pos,
696 end: end_pos,
697 })
698 }
699 } else {
700 let end_pos = self.input.len();
702 self.pos = end_pos;
703 Some(Token::Text {
704 content: remaining,
705 start: start_pos,
706 end: end_pos,
707 })
708 }
709 } else {
710 let end_pos = self.input.len();
712 self.pos = end_pos;
713 Some(Token::Text {
714 content: remaining,
715 start: start_pos,
716 end: end_pos,
717 })
718 }
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725
726 fn test_styles() -> HashMap<String, Style> {
727 let mut styles = HashMap::new();
728 styles.insert("bold".to_string(), Style::new().bold());
729 styles.insert("red".to_string(), Style::new().red());
730 styles.insert("dim".to_string(), Style::new().dim());
731 styles.insert("title".to_string(), Style::new().cyan().bold());
732 styles.insert("error".to_string(), Style::new().red().bold());
733 styles.insert("my_style".to_string(), Style::new().green());
734 styles.insert("style-with-dash".to_string(), Style::new().yellow());
735 styles
736 }
737
738 mod keep_mode {
741 use super::*;
742
743 #[test]
744 fn plain_text_unchanged() {
745 let parser = BBParser::new(test_styles(), TagTransform::Keep);
746 assert_eq!(parser.parse("hello world"), "hello world");
747 }
748
749 #[test]
750 fn single_tag_preserved() {
751 let parser = BBParser::new(test_styles(), TagTransform::Keep);
752 assert_eq!(parser.parse("[bold]hello[/bold]"), "[bold]hello[/bold]");
753 }
754
755 #[test]
756 fn nested_tags_preserved() {
757 let parser = BBParser::new(test_styles(), TagTransform::Keep);
758 assert_eq!(
759 parser.parse("[bold][red]hello[/red][/bold]"),
760 "[bold][red]hello[/red][/bold]"
761 );
762 }
763
764 #[test]
765 fn adjacent_tags_preserved() {
766 let parser = BBParser::new(test_styles(), TagTransform::Keep);
767 assert_eq!(
768 parser.parse("[bold]a[/bold][red]b[/red]"),
769 "[bold]a[/bold][red]b[/red]"
770 );
771 }
772
773 #[test]
774 fn text_around_tags() {
775 let parser = BBParser::new(test_styles(), TagTransform::Keep);
776 assert_eq!(
777 parser.parse("before [bold]middle[/bold] after"),
778 "before [bold]middle[/bold] after"
779 );
780 }
781
782 #[test]
783 fn unknown_tags_preserved() {
784 let parser = BBParser::new(test_styles(), TagTransform::Keep);
785 assert_eq!(
786 parser.parse("[unknown]text[/unknown]"),
787 "[unknown]text[/unknown]"
788 );
789 }
790 }
791
792 mod remove_mode {
795 use super::*;
796
797 #[test]
798 fn plain_text_unchanged() {
799 let parser = BBParser::new(test_styles(), TagTransform::Remove);
800 assert_eq!(parser.parse("hello world"), "hello world");
801 }
802
803 #[test]
804 fn single_tag_stripped() {
805 let parser = BBParser::new(test_styles(), TagTransform::Remove);
806 assert_eq!(parser.parse("[bold]hello[/bold]"), "hello");
807 }
808
809 #[test]
810 fn nested_tags_stripped() {
811 let parser = BBParser::new(test_styles(), TagTransform::Remove);
812 assert_eq!(parser.parse("[bold][red]hello[/red][/bold]"), "hello");
813 }
814
815 #[test]
816 fn adjacent_tags_stripped() {
817 let parser = BBParser::new(test_styles(), TagTransform::Remove);
818 assert_eq!(parser.parse("[bold]a[/bold][red]b[/red]"), "ab");
819 }
820
821 #[test]
822 fn text_around_tags() {
823 let parser = BBParser::new(test_styles(), TagTransform::Remove);
824 assert_eq!(
825 parser.parse("before [bold]middle[/bold] after"),
826 "before middle after"
827 );
828 }
829
830 #[test]
831 fn unknown_tags_stripped() {
832 let parser = BBParser::new(test_styles(), TagTransform::Remove);
833 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
835 }
836 }
837
838 mod unknown_tag_behavior {
841 use super::*;
842
843 #[test]
844 fn passthrough_adds_question_mark_in_apply_mode() {
845 let parser = BBParser::new(test_styles(), TagTransform::Apply)
846 .unknown_behavior(UnknownTagBehavior::Passthrough);
847 assert_eq!(
848 parser.parse("[unknown]text[/unknown]"),
849 "[unknown?]text[/unknown?]"
850 );
851 }
852
853 #[test]
854 fn passthrough_is_default() {
855 let parser = BBParser::new(test_styles(), TagTransform::Apply);
856 assert_eq!(
857 parser.parse("[unknown]text[/unknown]"),
858 "[unknown?]text[/unknown?]"
859 );
860 }
861
862 #[test]
863 fn strip_removes_unknown_tags_in_apply_mode() {
864 let parser = BBParser::new(test_styles(), TagTransform::Apply)
865 .unknown_behavior(UnknownTagBehavior::Strip);
866 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
867 }
868
869 #[test]
870 fn passthrough_nested_with_known() {
871 let parser = BBParser::new(test_styles(), TagTransform::Apply)
872 .unknown_behavior(UnknownTagBehavior::Passthrough);
873 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
874 assert!(result.contains("[unknown?]"));
875 assert!(result.contains("[/unknown?]"));
876 assert!(result.contains("text"));
877 }
878
879 #[test]
880 fn strip_nested_with_known() {
881 let mut styles = HashMap::new();
882 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
883 let parser = BBParser::new(styles, TagTransform::Apply)
884 .unknown_behavior(UnknownTagBehavior::Strip);
885 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
886 assert!(!result.contains("[unknown"));
888 assert!(result.contains("text"));
889 }
890
891 #[test]
892 fn keep_mode_ignores_unknown_behavior() {
893 let parser = BBParser::new(test_styles(), TagTransform::Keep)
895 .unknown_behavior(UnknownTagBehavior::Strip);
896 assert_eq!(
897 parser.parse("[unknown]text[/unknown]"),
898 "[unknown]text[/unknown]"
899 );
900 }
901
902 #[test]
903 fn remove_mode_always_strips_tags() {
904 let parser = BBParser::new(test_styles(), TagTransform::Remove)
906 .unknown_behavior(UnknownTagBehavior::Passthrough);
907 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
908 }
909 }
910
911 mod validation {
914 use super::*;
915
916 #[test]
917 fn validate_all_known_tags_passes() {
918 let parser = BBParser::new(test_styles(), TagTransform::Apply);
919 assert!(parser.validate("[bold]text[/bold]").is_ok());
920 }
921
922 #[test]
923 fn validate_nested_known_tags_passes() {
924 let parser = BBParser::new(test_styles(), TagTransform::Apply);
925 assert!(parser.validate("[bold][red]text[/red][/bold]").is_ok());
926 }
927
928 #[test]
929 fn validate_unknown_tag_fails() {
930 let parser = BBParser::new(test_styles(), TagTransform::Apply);
931 let result = parser.validate("[unknown]text[/unknown]");
932 assert!(result.is_err());
933 }
934
935 #[test]
936 fn validate_returns_correct_error_count() {
937 let parser = BBParser::new(test_styles(), TagTransform::Apply);
938 let result = parser.validate("[unknown]text[/unknown]");
939 let errors = result.unwrap_err();
940 assert_eq!(errors.len(), 2); }
942
943 #[test]
944 fn validate_error_contains_tag_name() {
945 let parser = BBParser::new(test_styles(), TagTransform::Apply);
946 let result = parser.validate("[foobar]text[/foobar]");
947 let errors = result.unwrap_err();
948 assert!(errors.errors.iter().all(|e| e.tag == "foobar"));
949 }
950
951 #[test]
952 fn validate_error_distinguishes_open_and_close() {
953 let parser = BBParser::new(test_styles(), TagTransform::Apply);
954 let result = parser.validate("[unknown]text[/unknown]");
955 let errors = result.unwrap_err();
956
957 let open_count = errors
958 .errors
959 .iter()
960 .filter(|e| e.kind == UnknownTagKind::Open)
961 .count();
962 let close_count = errors
963 .errors
964 .iter()
965 .filter(|e| e.kind == UnknownTagKind::Close)
966 .count();
967
968 assert_eq!(open_count, 1);
969 assert_eq!(close_count, 1);
970 }
971
972 #[test]
973 fn validate_error_has_correct_positions() {
974 let parser = BBParser::new(test_styles(), TagTransform::Apply);
975 let input = "[unknown]text[/unknown]";
976 let result = parser.validate(input);
977 let errors = result.unwrap_err();
978
979 let open_error = errors
980 .errors
981 .iter()
982 .find(|e| e.kind == UnknownTagKind::Open)
983 .unwrap();
984 assert_eq!(open_error.start, 0);
985 assert_eq!(open_error.end, 9); let close_error = errors
988 .errors
989 .iter()
990 .find(|e| e.kind == UnknownTagKind::Close)
991 .unwrap();
992 assert_eq!(close_error.start, 13);
993 assert_eq!(close_error.end, 23); }
995
996 #[test]
997 fn validate_multiple_unknown_tags() {
998 let parser = BBParser::new(test_styles(), TagTransform::Apply);
999 let result = parser.validate("[foo]a[/foo][bar]b[/bar]");
1000 let errors = result.unwrap_err();
1001 assert_eq!(errors.len(), 4); let tags: std::collections::HashSet<_> =
1004 errors.errors.iter().map(|e| e.tag.as_str()).collect();
1005 assert!(tags.contains("foo"));
1006 assert!(tags.contains("bar"));
1007 }
1008
1009 #[test]
1010 fn validate_mixed_known_and_unknown() {
1011 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1012 let result = parser.validate("[bold][unknown]text[/unknown][/bold]");
1013 let errors = result.unwrap_err();
1014 assert_eq!(errors.len(), 2); for error in &errors.errors {
1017 assert_eq!(error.tag, "unknown");
1018 }
1019 }
1020
1021 #[test]
1022 fn validate_plain_text_passes() {
1023 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1024 assert!(parser.validate("plain text without tags").is_ok());
1025 }
1026
1027 #[test]
1028 fn validate_empty_string_passes() {
1029 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1030 assert!(parser.validate("").is_ok());
1031 }
1032 }
1033
1034 mod parse_with_diagnostics {
1037 use super::*;
1038
1039 #[test]
1040 fn returns_output_and_errors() {
1041 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1042 .unknown_behavior(UnknownTagBehavior::Passthrough);
1043 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1044
1045 assert_eq!(output, "[unknown?]text[/unknown?]");
1046 assert_eq!(errors.len(), 2);
1047 }
1048
1049 #[test]
1050 fn output_uses_strip_behavior() {
1051 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1052 .unknown_behavior(UnknownTagBehavior::Strip);
1053 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1054
1055 assert_eq!(output, "text");
1056 assert_eq!(errors.len(), 2);
1057 }
1058
1059 #[test]
1060 fn no_errors_for_known_tags() {
1061 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1062 let (_, errors) = parser.parse_with_diagnostics("[bold]text[/bold]");
1063 assert!(errors.is_empty());
1064 }
1065
1066 #[test]
1067 fn errors_iterable() {
1068 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1069 let (_, errors) = parser.parse_with_diagnostics("[a]x[/a][b]y[/b]");
1070
1071 let mut count = 0;
1072 for error in &errors {
1073 assert!(error.tag == "a" || error.tag == "b");
1074 count += 1;
1075 }
1076 assert_eq!(count, 4);
1077 }
1078 }
1079
1080 mod tag_names {
1083 use super::*;
1084
1085 #[test]
1086 fn valid_simple_names() {
1087 assert!(Tokenizer::is_valid_tag_name("bold"));
1088 assert!(Tokenizer::is_valid_tag_name("red"));
1089 assert!(Tokenizer::is_valid_tag_name("a"));
1090 }
1091
1092 #[test]
1093 fn valid_with_underscore() {
1094 assert!(Tokenizer::is_valid_tag_name("my_style"));
1095 assert!(Tokenizer::is_valid_tag_name("_private"));
1096 assert!(Tokenizer::is_valid_tag_name("a_b_c"));
1097 }
1098
1099 #[test]
1100 fn valid_with_hyphen() {
1101 assert!(Tokenizer::is_valid_tag_name("my-style"));
1102 assert!(Tokenizer::is_valid_tag_name("font-bold"));
1103 assert!(Tokenizer::is_valid_tag_name("a-b-c"));
1104 }
1105
1106 #[test]
1107 fn valid_with_numbers() {
1108 assert!(Tokenizer::is_valid_tag_name("h1"));
1109 assert!(Tokenizer::is_valid_tag_name("col2"));
1110 assert!(Tokenizer::is_valid_tag_name("style123"));
1111 }
1112
1113 #[test]
1114 fn invalid_starts_with_digit() {
1115 assert!(!Tokenizer::is_valid_tag_name("1style"));
1116 assert!(!Tokenizer::is_valid_tag_name("123"));
1117 }
1118
1119 #[test]
1120 fn invalid_starts_with_hyphen() {
1121 assert!(!Tokenizer::is_valid_tag_name("-style"));
1122 assert!(!Tokenizer::is_valid_tag_name("-1"));
1123 }
1124
1125 #[test]
1126 fn invalid_uppercase() {
1127 assert!(!Tokenizer::is_valid_tag_name("Bold"));
1128 assert!(!Tokenizer::is_valid_tag_name("BOLD"));
1129 assert!(!Tokenizer::is_valid_tag_name("myStyle"));
1130 }
1131
1132 #[test]
1133 fn invalid_special_chars() {
1134 assert!(!Tokenizer::is_valid_tag_name("my.style"));
1135 assert!(!Tokenizer::is_valid_tag_name("my@style"));
1136 assert!(!Tokenizer::is_valid_tag_name("my style"));
1137 }
1138
1139 #[test]
1140 fn invalid_empty() {
1141 assert!(!Tokenizer::is_valid_tag_name(""));
1142 }
1143 }
1144
1145 mod edge_cases {
1148 use super::*;
1149
1150 #[test]
1151 fn empty_input() {
1152 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1153 assert_eq!(parser.parse(""), "");
1154 }
1155
1156 #[test]
1157 fn unclosed_tag_passthrough() {
1158 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1159 assert_eq!(parser.parse("[bold]hello"), "[bold]hello");
1160 }
1161
1162 #[test]
1163 fn orphan_close_tag_passthrough() {
1164 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1165 assert_eq!(parser.parse("hello[/bold]"), "hello[/bold]");
1166 }
1167
1168 #[test]
1169 fn mismatched_tags() {
1170 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1171 assert_eq!(
1172 parser.parse("[bold]hello[/red][/bold]"),
1173 "[bold]hello[/red][/bold]"
1174 );
1175 }
1176
1177 #[test]
1178 fn overlapping_tags_auto_close() {
1179 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1180 let result = parser.parse("[bold][red]hello[/bold][/red]");
1181 assert!(result.contains("hello"));
1182 }
1183
1184 #[test]
1185 fn empty_tag_content() {
1186 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1187 assert_eq!(parser.parse("[bold][/bold]"), "");
1188 }
1189
1190 #[test]
1191 fn brackets_in_content() {
1192 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1193 assert_eq!(parser.parse("[bold]array[0][/bold]"), "array[0]");
1194 }
1195
1196 #[test]
1197 fn invalid_tag_syntax_passthrough() {
1198 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1199 assert_eq!(parser.parse("[123]text[/123]"), "[123]text[/123]");
1200 assert_eq!(parser.parse("[-bad]text[/-bad]"), "[-bad]text[/-bad]");
1201 assert_eq!(parser.parse("[Bad]text[/Bad]"), "[Bad]text[/Bad]");
1202 }
1203
1204 #[test]
1205 fn deeply_nested() {
1206 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1207 assert_eq!(
1208 parser.parse("[bold][red][dim]deep[/dim][/red][/bold]"),
1209 "deep"
1210 );
1211 }
1212
1213 #[test]
1214 fn many_adjacent_tags() {
1215 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1216 assert_eq!(
1217 parser.parse("[bold]a[/bold][red]b[/red][dim]c[/dim]"),
1218 "abc"
1219 );
1220 }
1221
1222 #[test]
1223 fn unclosed_bracket() {
1224 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1225 assert_eq!(parser.parse("hello [bold world"), "hello [bold world");
1226 }
1227
1228 #[test]
1229 fn multiline_content() {
1230 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1231 assert_eq!(
1232 parser.parse("[bold]line1\nline2\nline3[/bold]"),
1233 "line1\nline2\nline3"
1234 );
1235 }
1236
1237 #[test]
1238 fn style_with_underscore() {
1239 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1240 assert_eq!(parser.parse("[my_style]text[/my_style]"), "text");
1241 }
1242
1243 #[test]
1244 fn style_with_dash() {
1245 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1246 assert_eq!(
1247 parser.parse("[style-with-dash]text[/style-with-dash]"),
1248 "text"
1249 );
1250 }
1251 }
1252
1253 mod tokenizer {
1256 use super::*;
1257
1258 #[test]
1259 fn tokenize_plain_text() {
1260 let tokens: Vec<_> = Tokenizer::new("hello world").collect();
1261 assert_eq!(
1262 tokens,
1263 vec![Token::Text {
1264 content: "hello world",
1265 start: 0,
1266 end: 11
1267 }]
1268 );
1269 }
1270
1271 #[test]
1272 fn tokenize_single_tag() {
1273 let tokens: Vec<_> = Tokenizer::new("[bold]hello[/bold]").collect();
1274 assert_eq!(
1275 tokens,
1276 vec![
1277 Token::OpenTag {
1278 name: "bold",
1279 start: 0,
1280 end: 6
1281 },
1282 Token::Text {
1283 content: "hello",
1284 start: 6,
1285 end: 11
1286 },
1287 Token::CloseTag {
1288 name: "bold",
1289 start: 11,
1290 end: 18
1291 },
1292 ]
1293 );
1294 }
1295
1296 #[test]
1297 fn tokenize_nested_tags() {
1298 let tokens: Vec<_> = Tokenizer::new("[a][b]x[/b][/a]").collect();
1299 assert_eq!(
1300 tokens,
1301 vec![
1302 Token::OpenTag {
1303 name: "a",
1304 start: 0,
1305 end: 3
1306 },
1307 Token::OpenTag {
1308 name: "b",
1309 start: 3,
1310 end: 6
1311 },
1312 Token::Text {
1313 content: "x",
1314 start: 6,
1315 end: 7
1316 },
1317 Token::CloseTag {
1318 name: "b",
1319 start: 7,
1320 end: 11
1321 },
1322 Token::CloseTag {
1323 name: "a",
1324 start: 11,
1325 end: 15
1326 },
1327 ]
1328 );
1329 }
1330
1331 #[test]
1332 fn tokenize_invalid_tag() {
1333 let tokens: Vec<_> = Tokenizer::new("[123]text[/123]").collect();
1334 assert_eq!(
1335 tokens,
1336 vec![
1337 Token::InvalidTag {
1338 content: "[123]",
1339 start: 0,
1340 end: 5
1341 },
1342 Token::Text {
1343 content: "text",
1344 start: 5,
1345 end: 9
1346 },
1347 Token::InvalidTag {
1348 content: "[/123]",
1349 start: 9,
1350 end: 15
1351 },
1352 ]
1353 );
1354 }
1355
1356 #[test]
1357 fn tokenize_mixed() {
1358 let tokens: Vec<_> = Tokenizer::new("a[b]c[/b]d").collect();
1359 assert_eq!(
1360 tokens,
1361 vec![
1362 Token::Text {
1363 content: "a",
1364 start: 0,
1365 end: 1
1366 },
1367 Token::OpenTag {
1368 name: "b",
1369 start: 1,
1370 end: 4
1371 },
1372 Token::Text {
1373 content: "c",
1374 start: 4,
1375 end: 5
1376 },
1377 Token::CloseTag {
1378 name: "b",
1379 start: 5,
1380 end: 9
1381 },
1382 Token::Text {
1383 content: "d",
1384 start: 9,
1385 end: 10
1386 },
1387 ]
1388 );
1389 }
1390 }
1391
1392 mod apply_mode {
1395 use super::*;
1396
1397 #[test]
1398 fn plain_text_unchanged() {
1399 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1400 assert_eq!(parser.parse("hello world"), "hello world");
1401 }
1402
1403 #[test]
1404 fn unknown_tag_passthrough_with_marker() {
1405 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1406 let result = parser.parse("[unknown]text[/unknown]");
1407 assert!(result.contains("[unknown?]"));
1408 assert!(result.contains("[/unknown?]"));
1409 assert!(result.contains("text"));
1410 }
1411
1412 #[test]
1413 fn known_tag_applies_style() {
1414 let mut styles = HashMap::new();
1415 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1416
1417 let parser = BBParser::new(styles, TagTransform::Apply);
1418 let result = parser.parse("[bold]hello[/bold]");
1419
1420 assert!(result.contains("\x1b[1m") || result.contains("hello"));
1421 }
1422 }
1423
1424 mod error_display {
1427 use super::*;
1428
1429 #[test]
1430 fn unknown_tag_error_display() {
1431 let error = UnknownTagError {
1432 tag: "foo".to_string(),
1433 kind: UnknownTagKind::Open,
1434 start: 0,
1435 end: 5,
1436 };
1437 let display = format!("{}", error);
1438 assert!(display.contains("foo"));
1439 assert!(display.contains("opening"));
1440 assert!(display.contains("0..5"));
1441 }
1442
1443 #[test]
1444 fn unknown_tag_errors_display() {
1445 let mut errors = UnknownTagErrors::new();
1446 errors.push(UnknownTagError {
1447 tag: "foo".to_string(),
1448 kind: UnknownTagKind::Open,
1449 start: 0,
1450 end: 5,
1451 });
1452 errors.push(UnknownTagError {
1453 tag: "foo".to_string(),
1454 kind: UnknownTagKind::Close,
1455 start: 9,
1456 end: 15,
1457 });
1458
1459 let display = format!("{}", errors);
1460 assert!(display.contains("2 unknown tag"));
1461 }
1462 }
1463}
1464
1465#[cfg(test)]
1466mod proptests {
1467 use super::*;
1468 use proptest::prelude::*;
1469
1470 fn valid_tag_name() -> impl Strategy<Value = String> {
1471 "[a-z_][a-z0-9_-]{0,10}"
1472 }
1473
1474 fn plain_text() -> impl Strategy<Value = String> {
1475 "[a-zA-Z0-9 .,!?:;'\"]{0,50}"
1476 .prop_filter("no brackets", |s| !s.contains('[') && !s.contains(']'))
1477 }
1478
1479 proptest! {
1480 #![proptest_config(ProptestConfig::with_cases(500))]
1481
1482 #[test]
1483 fn keep_mode_roundtrip(content in plain_text()) {
1484 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1485 prop_assert_eq!(parser.parse(&content), content);
1486 }
1487
1488 #[test]
1489 fn remove_mode_plain_text_unchanged(content in plain_text()) {
1490 let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
1491 prop_assert_eq!(parser.parse(&content), content);
1492 }
1493
1494 #[test]
1495 fn valid_tag_names_accepted(tag in valid_tag_name()) {
1496 prop_assert!(Tokenizer::is_valid_tag_name(&tag));
1497 }
1498
1499 #[test]
1500 fn remove_strips_known_tags(tag in valid_tag_name(), content in plain_text()) {
1501 let mut styles = HashMap::new();
1502 styles.insert(tag.clone(), Style::new());
1503
1504 let parser = BBParser::new(styles, TagTransform::Remove);
1505 let input = format!("[{}]{}[/{}]", tag, content, tag);
1506 let result = parser.parse(&input);
1507
1508 prop_assert_eq!(result, content);
1509 }
1510
1511 #[test]
1512 fn keep_preserves_structure(tag in valid_tag_name(), content in plain_text()) {
1513 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1514 let input = format!("[{}]{}[/{}]", tag, content, tag);
1515 let result = parser.parse(&input);
1516
1517 prop_assert_eq!(result, input);
1518 }
1519
1520 #[test]
1521 fn nested_tags_balanced(
1522 outer in valid_tag_name(),
1523 inner in valid_tag_name(),
1524 content in plain_text()
1525 ) {
1526 let mut styles = HashMap::new();
1527 styles.insert(outer.clone(), Style::new());
1528 styles.insert(inner.clone(), Style::new());
1529
1530 let parser = BBParser::new(styles, TagTransform::Remove);
1531 let input = format!("[{}][{}]{}[/{}][/{}]", outer, inner, content, inner, outer);
1532 let result = parser.parse(&input);
1533
1534 prop_assert_eq!(result, content);
1535 }
1536
1537 #[test]
1538 fn validate_finds_unknown_tags(tag in valid_tag_name(), content in plain_text()) {
1539 let parser = BBParser::new(HashMap::new(), TagTransform::Apply);
1540 let input = format!("[{}]{}[/{}]", tag, content, tag);
1541 let result = parser.validate(&input);
1542
1543 prop_assert!(result.is_err());
1544 let errors = result.unwrap_err();
1545 prop_assert_eq!(errors.len(), 2); }
1547
1548 #[test]
1549 fn invalid_start_digit_rejected(n in 0..10u8, rest in "[a-z0-9_-]{0,5}") {
1550 let tag = format!("{}{}", n, rest);
1551 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1552 }
1553
1554 #[test]
1555 fn invalid_start_hyphen_rejected(rest in "[a-z0-9_-]{0,5}") {
1556 let tag = format!("-{}", rest);
1557 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1558 }
1559
1560 #[test]
1561 fn uppercase_rejected(tag in "[A-Z][a-zA-Z0-9_-]{0,5}") {
1562 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1563 }
1564 }
1565}