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
192pub fn strip_tags(input: &str) -> String {
209 let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
210 .unknown_behavior(UnknownTagBehavior::Strip);
211 parser.parse(input)
212}
213
214#[derive(Debug, Clone)]
219pub struct BBParser {
220 styles: HashMap<String, Style>,
221 transform: TagTransform,
222 unknown_behavior: UnknownTagBehavior,
223}
224
225impl BBParser {
226 pub fn new(styles: HashMap<String, Style>, transform: TagTransform) -> Self {
236 Self {
237 styles,
238 transform,
239 unknown_behavior: UnknownTagBehavior::default(),
240 }
241 }
242
243 pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
258 self.unknown_behavior = behavior;
259 self
260 }
261
262 pub fn parse(&self, input: &str) -> String {
266 let (output, _) = self.parse_internal(input);
267 output
268 }
269
270 pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
288 self.parse_internal(input)
289 }
290
291 pub fn validate(&self, input: &str) -> Result<(), UnknownTagErrors> {
315 let (_, errors) = self.parse_internal(input);
316 if errors.is_empty() {
317 Ok(())
318 } else {
319 Err(errors)
320 }
321 }
322
323 fn parse_internal(&self, input: &str) -> (String, UnknownTagErrors) {
325 let tokens = Tokenizer::new(input).collect::<Vec<_>>();
326 let valid_opens = self.compute_valid_tags(&tokens);
327 let mut events = Vec::new();
328 let mut errors = UnknownTagErrors::new();
329 let mut stack: Vec<&str> = Vec::new();
330
331 let mut i = 0;
334 while i < tokens.len() {
335 match &tokens[i] {
336 Token::Text { content, .. } => {
337 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
338 }
339 Token::OpenTag { name, start, end } => {
340 if valid_opens.contains(&i) {
341 stack.push(name);
342 self.emit_open_tag_event(&mut events, &mut errors, name, *start, *end);
343 } else {
344 let is_valid_name = Tokenizer::is_valid_tag_name(name);
346 if is_valid_name {
347 errors.push(UnknownTagError {
349 tag: name.to_string(),
350 kind: UnknownTagKind::Unbalanced, start: *start,
352 end: *end,
353 });
354 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
359 "[{}]",
360 name
361 ))));
362 } else {
363 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
364 "[{}]",
365 name
366 ))));
367 }
368 }
369 }
370 Token::CloseTag { name, start, end } => {
371 if stack.last().copied() == Some(*name) {
372 stack.pop();
373 self.emit_close_tag_event(&mut events, &mut errors, name, *start, *end);
374 } else if stack.contains(name) {
375 while let Some(open) = stack.pop() {
376 self.emit_close_tag_event(&mut events, &mut errors, open, 0, 0);
377 if open == *name {
378 break;
379 }
380 }
381 } else {
382 let is_valid_name = Tokenizer::is_valid_tag_name(name);
384 if is_valid_name {
385 errors.push(UnknownTagError {
386 tag: name.to_string(),
387 kind: UnknownTagKind::UnexpectedClose, start: *start,
389 end: *end,
390 });
391 }
392 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
393 "[/{}]",
394 name
395 ))));
396 }
397 }
398 Token::InvalidTag { content, .. } => {
399 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
400 }
401 }
402 i += 1;
403 }
404
405 while let Some(tag) = stack.pop() {
406 self.emit_close_tag_event(&mut events, &mut errors, tag, 0, 0);
407 }
408
409 let output = self.render(events);
410 (output, errors)
411 }
412
413 fn emit_open_tag_event<'a>(
414 &self,
415 events: &mut Vec<ParseEvent<'a>>,
416 errors: &mut UnknownTagErrors,
417 tag: &'a str,
418 start: usize,
419 end: usize,
420 ) {
421 let is_known = self.styles.contains_key(tag);
422
423 if !is_known {
424 errors.push(UnknownTagError {
425 tag: tag.to_string(),
426 kind: UnknownTagKind::Open,
427 start,
428 end,
429 });
430 }
431
432 match self.transform {
433 TagTransform::Keep => {
434 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
435 "[{}]",
436 tag
437 ))));
438 }
439 TagTransform::Remove => {
440 }
442 TagTransform::Apply => {
443 if is_known {
444 events.push(ParseEvent::StyleStart(tag));
445 } else {
446 match self.unknown_behavior {
447 UnknownTagBehavior::Passthrough => {
448 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
449 "[{}?]",
450 tag
451 ))));
452 }
453 UnknownTagBehavior::Strip => {
454 }
456 }
457 }
458 }
459 }
460 }
461
462 fn emit_close_tag_event<'a>(
463 &self,
464 events: &mut Vec<ParseEvent<'a>>,
465 errors: &mut UnknownTagErrors,
466 tag: &'a str,
467 start: usize,
468 end: usize,
469 ) {
470 let is_known = self.styles.contains_key(tag);
471
472 if !is_known && end > 0 {
474 errors.push(UnknownTagError {
475 tag: tag.to_string(),
476 kind: UnknownTagKind::Close,
477 start,
478 end,
479 });
480 }
481
482 match self.transform {
483 TagTransform::Keep => {
484 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
485 "[/{}]",
486 tag
487 ))));
488 }
489 TagTransform::Remove => {
490 }
492 TagTransform::Apply => {
493 if is_known {
494 events.push(ParseEvent::StyleEnd(tag));
495 } else {
496 match self.unknown_behavior {
497 UnknownTagBehavior::Passthrough => {
498 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
499 "[/{}?]",
500 tag
501 ))));
502 }
503 UnknownTagBehavior::Strip => {
504 }
506 }
507 }
508 }
509 }
510 }
511
512 fn render(&self, events: Vec<ParseEvent>) -> String {
514 let mut result = String::new();
515 let mut style_stack: Vec<&Style> = Vec::new();
516
517 for event in events {
518 match event {
519 ParseEvent::Literal(text) => {
520 self.append_styled(&mut result, &text, &style_stack);
521 }
522 ParseEvent::StyleStart(tag) => {
523 if let Some(style) = self.styles.get(tag) {
524 style_stack.push(style);
525 }
526 }
527 ParseEvent::StyleEnd(tag) => {
528 if self.styles.contains_key(tag) {
529 style_stack.pop();
530 }
531 }
532 }
533 }
534 result
535 }
536
537 fn compute_valid_tags(&self, tokens: &[Token]) -> std::collections::HashSet<usize> {
540 use std::collections::{HashMap, HashSet};
541 let mut valid_indices = HashSet::new();
542 let mut open_indices_by_tag: HashMap<&str, Vec<usize>> = HashMap::new();
543
544 for (i, token) in tokens.iter().enumerate() {
545 match token {
546 Token::OpenTag { name, .. } => {
547 open_indices_by_tag.entry(name).or_default().push(i);
548 }
549 Token::CloseTag { name, .. } => {
550 if let Some(indices) = open_indices_by_tag.get_mut(name) {
551 if let Some(open_idx) = indices.pop() {
552 valid_indices.insert(open_idx);
553 }
554 }
555 }
556 _ => {}
557 }
558 }
559
560 valid_indices
561 }
562
563 fn append_styled(&self, output: &mut String, text: &str, style_stack: &[&Style]) {
565 if text.is_empty() {
566 return;
567 }
568
569 if style_stack.is_empty() {
570 output.push_str(text);
571 } else {
572 let mut current = text.to_string();
573 for style in style_stack.iter().rev() {
577 if current.ends_with("\x1b[0m") {
578 current.truncate(current.len() - 4);
579 }
580 current = style.apply_to(current).to_string();
581 }
582 output.push_str(¤t);
583 }
584 }
585}
586
587enum ParseEvent<'a> {
588 Literal(std::borrow::Cow<'a, str>),
589 StyleStart(&'a str),
590 StyleEnd(&'a str),
591}
592
593#[derive(Debug, Clone, PartialEq, Eq)]
595enum Token<'a> {
596 Text {
598 content: &'a str,
599 start: usize,
600 end: usize,
601 },
602 OpenTag {
604 name: &'a str,
605 start: usize,
606 end: usize,
607 },
608 CloseTag {
610 name: &'a str,
611 start: usize,
612 end: usize,
613 },
614 InvalidTag {
616 content: &'a str,
617 start: usize,
618 end: usize,
619 },
620}
621
622struct Tokenizer<'a> {
624 input: &'a str,
625 pos: usize,
626}
627
628impl<'a> Tokenizer<'a> {
629 fn new(input: &'a str) -> Self {
630 Self { input, pos: 0 }
631 }
632
633 fn is_valid_tag_name(s: &str) -> bool {
635 if s.is_empty() {
636 return false;
637 }
638
639 let mut chars = s.chars();
640 let first = chars.next().unwrap();
641
642 if !first.is_ascii_lowercase() && first != '_' {
644 return false;
645 }
646
647 for c in chars {
649 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
650 return false;
651 }
652 }
653
654 true
655 }
656}
657
658impl<'a> Iterator for Tokenizer<'a> {
659 type Item = Token<'a>;
660
661 fn next(&mut self) -> Option<Self::Item> {
662 if self.pos >= self.input.len() {
663 return None;
664 }
665
666 let remaining = &self.input[self.pos..];
667 let start_pos = self.pos;
668
669 if let Some(bracket_pos) = remaining.find('[') {
671 if bracket_pos > 0 {
672 let text = &remaining[..bracket_pos];
674 self.pos += bracket_pos;
675 return Some(Token::Text {
676 content: text,
677 start: start_pos,
678 end: self.pos,
679 });
680 }
681
682 if let Some(close_bracket) = remaining.find(']') {
685 let tag_content = &remaining[1..close_bracket];
686 let full_tag = &remaining[..=close_bracket];
687 let end_pos = start_pos + close_bracket + 1;
688
689 if let Some(tag_name) = tag_content.strip_prefix('/') {
691 if Self::is_valid_tag_name(tag_name) {
692 self.pos = end_pos;
693 Some(Token::CloseTag {
694 name: tag_name,
695 start: start_pos,
696 end: end_pos,
697 })
698 } else {
699 self.pos = end_pos;
700 Some(Token::InvalidTag {
701 content: full_tag,
702 start: start_pos,
703 end: end_pos,
704 })
705 }
706 } else if Self::is_valid_tag_name(tag_content) {
707 self.pos = end_pos;
708 Some(Token::OpenTag {
709 name: tag_content,
710 start: start_pos,
711 end: end_pos,
712 })
713 } else {
714 self.pos = end_pos;
715 Some(Token::InvalidTag {
716 content: full_tag,
717 start: start_pos,
718 end: end_pos,
719 })
720 }
721 } else {
722 let end_pos = self.input.len();
724 self.pos = end_pos;
725 Some(Token::Text {
726 content: remaining,
727 start: start_pos,
728 end: end_pos,
729 })
730 }
731 } else {
732 let end_pos = self.input.len();
734 self.pos = end_pos;
735 Some(Token::Text {
736 content: remaining,
737 start: start_pos,
738 end: end_pos,
739 })
740 }
741 }
742}
743
744#[cfg(test)]
745mod tests {
746 use super::*;
747
748 fn test_styles() -> HashMap<String, Style> {
749 let mut styles = HashMap::new();
750 styles.insert("bold".to_string(), Style::new().bold());
751 styles.insert("red".to_string(), Style::new().red());
752 styles.insert("dim".to_string(), Style::new().dim());
753 styles.insert("title".to_string(), Style::new().cyan().bold());
754 styles.insert("error".to_string(), Style::new().red().bold());
755 styles.insert("my_style".to_string(), Style::new().green());
756 styles.insert("style-with-dash".to_string(), Style::new().yellow());
757 styles
758 }
759
760 mod strip_tags_tests {
763 use super::super::strip_tags;
764
765 #[test]
766 fn strips_known_style_tags() {
767 assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
768 }
769
770 #[test]
771 fn strips_unknown_tags() {
772 assert_eq!(strip_tags("[additions]+32[/additions]"), "+32");
773 }
774
775 #[test]
776 fn strips_multiple_tags() {
777 assert_eq!(
778 strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"),
779 "+32/-0/32"
780 );
781 }
782
783 #[test]
784 fn plain_text_unchanged() {
785 assert_eq!(strip_tags("no tags here"), "no tags here");
786 }
787
788 #[test]
789 fn empty_string() {
790 assert_eq!(strip_tags(""), "");
791 }
792
793 #[test]
794 fn nested_tags() {
795 assert_eq!(strip_tags("[a][b]text[/b][/a]"), "text");
796 }
797 }
798
799 mod keep_mode {
802 use super::*;
803
804 #[test]
805 fn plain_text_unchanged() {
806 let parser = BBParser::new(test_styles(), TagTransform::Keep);
807 assert_eq!(parser.parse("hello world"), "hello world");
808 }
809
810 #[test]
811 fn single_tag_preserved() {
812 let parser = BBParser::new(test_styles(), TagTransform::Keep);
813 assert_eq!(parser.parse("[bold]hello[/bold]"), "[bold]hello[/bold]");
814 }
815
816 #[test]
817 fn nested_tags_preserved() {
818 let parser = BBParser::new(test_styles(), TagTransform::Keep);
819 assert_eq!(
820 parser.parse("[bold][red]hello[/red][/bold]"),
821 "[bold][red]hello[/red][/bold]"
822 );
823 }
824
825 #[test]
826 fn adjacent_tags_preserved() {
827 let parser = BBParser::new(test_styles(), TagTransform::Keep);
828 assert_eq!(
829 parser.parse("[bold]a[/bold][red]b[/red]"),
830 "[bold]a[/bold][red]b[/red]"
831 );
832 }
833
834 #[test]
835 fn text_around_tags() {
836 let parser = BBParser::new(test_styles(), TagTransform::Keep);
837 assert_eq!(
838 parser.parse("before [bold]middle[/bold] after"),
839 "before [bold]middle[/bold] after"
840 );
841 }
842
843 #[test]
844 fn unknown_tags_preserved() {
845 let parser = BBParser::new(test_styles(), TagTransform::Keep);
846 assert_eq!(
847 parser.parse("[unknown]text[/unknown]"),
848 "[unknown]text[/unknown]"
849 );
850 }
851 }
852
853 mod remove_mode {
856 use super::*;
857
858 #[test]
859 fn plain_text_unchanged() {
860 let parser = BBParser::new(test_styles(), TagTransform::Remove);
861 assert_eq!(parser.parse("hello world"), "hello world");
862 }
863
864 #[test]
865 fn single_tag_stripped() {
866 let parser = BBParser::new(test_styles(), TagTransform::Remove);
867 assert_eq!(parser.parse("[bold]hello[/bold]"), "hello");
868 }
869
870 #[test]
871 fn nested_tags_stripped() {
872 let parser = BBParser::new(test_styles(), TagTransform::Remove);
873 assert_eq!(parser.parse("[bold][red]hello[/red][/bold]"), "hello");
874 }
875
876 #[test]
877 fn adjacent_tags_stripped() {
878 let parser = BBParser::new(test_styles(), TagTransform::Remove);
879 assert_eq!(parser.parse("[bold]a[/bold][red]b[/red]"), "ab");
880 }
881
882 #[test]
883 fn text_around_tags() {
884 let parser = BBParser::new(test_styles(), TagTransform::Remove);
885 assert_eq!(
886 parser.parse("before [bold]middle[/bold] after"),
887 "before middle after"
888 );
889 }
890
891 #[test]
892 fn unknown_tags_stripped() {
893 let parser = BBParser::new(test_styles(), TagTransform::Remove);
894 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
896 }
897 }
898
899 mod unknown_tag_behavior {
902 use super::*;
903
904 #[test]
905 fn passthrough_adds_question_mark_in_apply_mode() {
906 let parser = BBParser::new(test_styles(), TagTransform::Apply)
907 .unknown_behavior(UnknownTagBehavior::Passthrough);
908 assert_eq!(
909 parser.parse("[unknown]text[/unknown]"),
910 "[unknown?]text[/unknown?]"
911 );
912 }
913
914 #[test]
915 fn passthrough_is_default() {
916 let parser = BBParser::new(test_styles(), TagTransform::Apply);
917 assert_eq!(
918 parser.parse("[unknown]text[/unknown]"),
919 "[unknown?]text[/unknown?]"
920 );
921 }
922
923 #[test]
924 fn strip_removes_unknown_tags_in_apply_mode() {
925 let parser = BBParser::new(test_styles(), TagTransform::Apply)
926 .unknown_behavior(UnknownTagBehavior::Strip);
927 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
928 }
929
930 #[test]
931 fn passthrough_nested_with_known() {
932 let parser = BBParser::new(test_styles(), TagTransform::Apply)
933 .unknown_behavior(UnknownTagBehavior::Passthrough);
934 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
935 assert!(result.contains("[unknown?]"));
936 assert!(result.contains("[/unknown?]"));
937 assert!(result.contains("text"));
938 }
939
940 #[test]
941 fn strip_nested_with_known() {
942 let mut styles = HashMap::new();
943 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
944 let parser = BBParser::new(styles, TagTransform::Apply)
945 .unknown_behavior(UnknownTagBehavior::Strip);
946 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
947 assert!(!result.contains("[unknown"));
949 assert!(result.contains("text"));
950 }
951
952 #[test]
953 fn keep_mode_ignores_unknown_behavior() {
954 let parser = BBParser::new(test_styles(), TagTransform::Keep)
956 .unknown_behavior(UnknownTagBehavior::Strip);
957 assert_eq!(
958 parser.parse("[unknown]text[/unknown]"),
959 "[unknown]text[/unknown]"
960 );
961 }
962
963 #[test]
964 fn remove_mode_always_strips_tags() {
965 let parser = BBParser::new(test_styles(), TagTransform::Remove)
967 .unknown_behavior(UnknownTagBehavior::Passthrough);
968 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
969 }
970 }
971
972 mod validation {
975 use super::*;
976
977 #[test]
978 fn validate_all_known_tags_passes() {
979 let parser = BBParser::new(test_styles(), TagTransform::Apply);
980 assert!(parser.validate("[bold]text[/bold]").is_ok());
981 }
982
983 #[test]
984 fn validate_nested_known_tags_passes() {
985 let parser = BBParser::new(test_styles(), TagTransform::Apply);
986 assert!(parser.validate("[bold][red]text[/red][/bold]").is_ok());
987 }
988
989 #[test]
990 fn validate_unknown_tag_fails() {
991 let parser = BBParser::new(test_styles(), TagTransform::Apply);
992 let result = parser.validate("[unknown]text[/unknown]");
993 assert!(result.is_err());
994 }
995
996 #[test]
997 fn validate_returns_correct_error_count() {
998 let parser = BBParser::new(test_styles(), TagTransform::Apply);
999 let result = parser.validate("[unknown]text[/unknown]");
1000 let errors = result.unwrap_err();
1001 assert_eq!(errors.len(), 2); }
1003
1004 #[test]
1005 fn validate_error_contains_tag_name() {
1006 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1007 let result = parser.validate("[foobar]text[/foobar]");
1008 let errors = result.unwrap_err();
1009 assert!(errors.errors.iter().all(|e| e.tag == "foobar"));
1010 }
1011
1012 #[test]
1013 fn validate_error_distinguishes_open_and_close() {
1014 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1015 let result = parser.validate("[unknown]text[/unknown]");
1016 let errors = result.unwrap_err();
1017
1018 let open_count = errors
1019 .errors
1020 .iter()
1021 .filter(|e| e.kind == UnknownTagKind::Open)
1022 .count();
1023 let close_count = errors
1024 .errors
1025 .iter()
1026 .filter(|e| e.kind == UnknownTagKind::Close)
1027 .count();
1028
1029 assert_eq!(open_count, 1);
1030 assert_eq!(close_count, 1);
1031 }
1032
1033 #[test]
1034 fn validate_error_has_correct_positions() {
1035 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1036 let input = "[unknown]text[/unknown]";
1037 let result = parser.validate(input);
1038 let errors = result.unwrap_err();
1039
1040 let open_error = errors
1041 .errors
1042 .iter()
1043 .find(|e| e.kind == UnknownTagKind::Open)
1044 .unwrap();
1045 assert_eq!(open_error.start, 0);
1046 assert_eq!(open_error.end, 9); let close_error = errors
1049 .errors
1050 .iter()
1051 .find(|e| e.kind == UnknownTagKind::Close)
1052 .unwrap();
1053 assert_eq!(close_error.start, 13);
1054 assert_eq!(close_error.end, 23); }
1056
1057 #[test]
1058 fn validate_multiple_unknown_tags() {
1059 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1060 let result = parser.validate("[foo]a[/foo][bar]b[/bar]");
1061 let errors = result.unwrap_err();
1062 assert_eq!(errors.len(), 4); let tags: std::collections::HashSet<_> =
1065 errors.errors.iter().map(|e| e.tag.as_str()).collect();
1066 assert!(tags.contains("foo"));
1067 assert!(tags.contains("bar"));
1068 }
1069
1070 #[test]
1071 fn validate_mixed_known_and_unknown() {
1072 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1073 let result = parser.validate("[bold][unknown]text[/unknown][/bold]");
1074 let errors = result.unwrap_err();
1075 assert_eq!(errors.len(), 2); for error in &errors.errors {
1078 assert_eq!(error.tag, "unknown");
1079 }
1080 }
1081
1082 #[test]
1083 fn validate_plain_text_passes() {
1084 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1085 assert!(parser.validate("plain text without tags").is_ok());
1086 }
1087
1088 #[test]
1089 fn validate_empty_string_passes() {
1090 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1091 assert!(parser.validate("").is_ok());
1092 }
1093 }
1094
1095 mod parse_with_diagnostics {
1098 use super::*;
1099
1100 #[test]
1101 fn returns_output_and_errors() {
1102 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1103 .unknown_behavior(UnknownTagBehavior::Passthrough);
1104 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1105
1106 assert_eq!(output, "[unknown?]text[/unknown?]");
1107 assert_eq!(errors.len(), 2);
1108 }
1109
1110 #[test]
1111 fn output_uses_strip_behavior() {
1112 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1113 .unknown_behavior(UnknownTagBehavior::Strip);
1114 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1115
1116 assert_eq!(output, "text");
1117 assert_eq!(errors.len(), 2);
1118 }
1119
1120 #[test]
1121 fn no_errors_for_known_tags() {
1122 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1123 let (_, errors) = parser.parse_with_diagnostics("[bold]text[/bold]");
1124 assert!(errors.is_empty());
1125 }
1126
1127 #[test]
1128 fn errors_iterable() {
1129 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1130 let (_, errors) = parser.parse_with_diagnostics("[a]x[/a][b]y[/b]");
1131
1132 let mut count = 0;
1133 for error in &errors {
1134 assert!(error.tag == "a" || error.tag == "b");
1135 count += 1;
1136 }
1137 assert_eq!(count, 4);
1138 }
1139 }
1140
1141 mod tag_names {
1144 use super::*;
1145
1146 #[test]
1147 fn valid_simple_names() {
1148 assert!(Tokenizer::is_valid_tag_name("bold"));
1149 assert!(Tokenizer::is_valid_tag_name("red"));
1150 assert!(Tokenizer::is_valid_tag_name("a"));
1151 }
1152
1153 #[test]
1154 fn valid_with_underscore() {
1155 assert!(Tokenizer::is_valid_tag_name("my_style"));
1156 assert!(Tokenizer::is_valid_tag_name("_private"));
1157 assert!(Tokenizer::is_valid_tag_name("a_b_c"));
1158 }
1159
1160 #[test]
1161 fn valid_with_hyphen() {
1162 assert!(Tokenizer::is_valid_tag_name("my-style"));
1163 assert!(Tokenizer::is_valid_tag_name("font-bold"));
1164 assert!(Tokenizer::is_valid_tag_name("a-b-c"));
1165 }
1166
1167 #[test]
1168 fn valid_with_numbers() {
1169 assert!(Tokenizer::is_valid_tag_name("h1"));
1170 assert!(Tokenizer::is_valid_tag_name("col2"));
1171 assert!(Tokenizer::is_valid_tag_name("style123"));
1172 }
1173
1174 #[test]
1175 fn invalid_starts_with_digit() {
1176 assert!(!Tokenizer::is_valid_tag_name("1style"));
1177 assert!(!Tokenizer::is_valid_tag_name("123"));
1178 }
1179
1180 #[test]
1181 fn invalid_starts_with_hyphen() {
1182 assert!(!Tokenizer::is_valid_tag_name("-style"));
1183 assert!(!Tokenizer::is_valid_tag_name("-1"));
1184 }
1185
1186 #[test]
1187 fn invalid_uppercase() {
1188 assert!(!Tokenizer::is_valid_tag_name("Bold"));
1189 assert!(!Tokenizer::is_valid_tag_name("BOLD"));
1190 assert!(!Tokenizer::is_valid_tag_name("myStyle"));
1191 }
1192
1193 #[test]
1194 fn invalid_special_chars() {
1195 assert!(!Tokenizer::is_valid_tag_name("my.style"));
1196 assert!(!Tokenizer::is_valid_tag_name("my@style"));
1197 assert!(!Tokenizer::is_valid_tag_name("my style"));
1198 }
1199
1200 #[test]
1201 fn invalid_empty() {
1202 assert!(!Tokenizer::is_valid_tag_name(""));
1203 }
1204 }
1205
1206 mod edge_cases {
1209 use super::*;
1210
1211 #[test]
1212 fn empty_input() {
1213 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1214 assert_eq!(parser.parse(""), "");
1215 }
1216
1217 #[test]
1218 fn unclosed_tag_passthrough() {
1219 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1220 assert_eq!(parser.parse("[bold]hello"), "[bold]hello");
1221 }
1222
1223 #[test]
1224 fn orphan_close_tag_passthrough() {
1225 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1226 assert_eq!(parser.parse("hello[/bold]"), "hello[/bold]");
1227 }
1228
1229 #[test]
1230 fn mismatched_tags() {
1231 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1232 assert_eq!(
1233 parser.parse("[bold]hello[/red][/bold]"),
1234 "[bold]hello[/red][/bold]"
1235 );
1236 }
1237
1238 #[test]
1239 fn overlapping_tags_auto_close() {
1240 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1241 let result = parser.parse("[bold][red]hello[/bold][/red]");
1242 assert!(result.contains("hello"));
1243 }
1244
1245 #[test]
1246 fn empty_tag_content() {
1247 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1248 assert_eq!(parser.parse("[bold][/bold]"), "");
1249 }
1250
1251 #[test]
1252 fn brackets_in_content() {
1253 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1254 assert_eq!(parser.parse("[bold]array[0][/bold]"), "array[0]");
1255 }
1256
1257 #[test]
1258 fn invalid_tag_syntax_passthrough() {
1259 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1260 assert_eq!(parser.parse("[123]text[/123]"), "[123]text[/123]");
1261 assert_eq!(parser.parse("[-bad]text[/-bad]"), "[-bad]text[/-bad]");
1262 assert_eq!(parser.parse("[Bad]text[/Bad]"), "[Bad]text[/Bad]");
1263 }
1264
1265 #[test]
1266 fn deeply_nested() {
1267 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1268 assert_eq!(
1269 parser.parse("[bold][red][dim]deep[/dim][/red][/bold]"),
1270 "deep"
1271 );
1272 }
1273
1274 #[test]
1275 fn many_adjacent_tags() {
1276 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1277 assert_eq!(
1278 parser.parse("[bold]a[/bold][red]b[/red][dim]c[/dim]"),
1279 "abc"
1280 );
1281 }
1282
1283 #[test]
1284 fn unclosed_bracket() {
1285 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1286 assert_eq!(parser.parse("hello [bold world"), "hello [bold world");
1287 }
1288
1289 #[test]
1290 fn multiline_content() {
1291 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1292 assert_eq!(
1293 parser.parse("[bold]line1\nline2\nline3[/bold]"),
1294 "line1\nline2\nline3"
1295 );
1296 }
1297
1298 #[test]
1299 fn style_with_underscore() {
1300 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1301 assert_eq!(parser.parse("[my_style]text[/my_style]"), "text");
1302 }
1303
1304 #[test]
1305 fn style_with_dash() {
1306 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1307 assert_eq!(
1308 parser.parse("[style-with-dash]text[/style-with-dash]"),
1309 "text"
1310 );
1311 }
1312 }
1313
1314 mod tokenizer {
1317 use super::*;
1318
1319 #[test]
1320 fn tokenize_plain_text() {
1321 let tokens: Vec<_> = Tokenizer::new("hello world").collect();
1322 assert_eq!(
1323 tokens,
1324 vec![Token::Text {
1325 content: "hello world",
1326 start: 0,
1327 end: 11
1328 }]
1329 );
1330 }
1331
1332 #[test]
1333 fn tokenize_single_tag() {
1334 let tokens: Vec<_> = Tokenizer::new("[bold]hello[/bold]").collect();
1335 assert_eq!(
1336 tokens,
1337 vec![
1338 Token::OpenTag {
1339 name: "bold",
1340 start: 0,
1341 end: 6
1342 },
1343 Token::Text {
1344 content: "hello",
1345 start: 6,
1346 end: 11
1347 },
1348 Token::CloseTag {
1349 name: "bold",
1350 start: 11,
1351 end: 18
1352 },
1353 ]
1354 );
1355 }
1356
1357 #[test]
1358 fn tokenize_nested_tags() {
1359 let tokens: Vec<_> = Tokenizer::new("[a][b]x[/b][/a]").collect();
1360 assert_eq!(
1361 tokens,
1362 vec![
1363 Token::OpenTag {
1364 name: "a",
1365 start: 0,
1366 end: 3
1367 },
1368 Token::OpenTag {
1369 name: "b",
1370 start: 3,
1371 end: 6
1372 },
1373 Token::Text {
1374 content: "x",
1375 start: 6,
1376 end: 7
1377 },
1378 Token::CloseTag {
1379 name: "b",
1380 start: 7,
1381 end: 11
1382 },
1383 Token::CloseTag {
1384 name: "a",
1385 start: 11,
1386 end: 15
1387 },
1388 ]
1389 );
1390 }
1391
1392 #[test]
1393 fn tokenize_invalid_tag() {
1394 let tokens: Vec<_> = Tokenizer::new("[123]text[/123]").collect();
1395 assert_eq!(
1396 tokens,
1397 vec![
1398 Token::InvalidTag {
1399 content: "[123]",
1400 start: 0,
1401 end: 5
1402 },
1403 Token::Text {
1404 content: "text",
1405 start: 5,
1406 end: 9
1407 },
1408 Token::InvalidTag {
1409 content: "[/123]",
1410 start: 9,
1411 end: 15
1412 },
1413 ]
1414 );
1415 }
1416
1417 #[test]
1418 fn tokenize_mixed() {
1419 let tokens: Vec<_> = Tokenizer::new("a[b]c[/b]d").collect();
1420 assert_eq!(
1421 tokens,
1422 vec![
1423 Token::Text {
1424 content: "a",
1425 start: 0,
1426 end: 1
1427 },
1428 Token::OpenTag {
1429 name: "b",
1430 start: 1,
1431 end: 4
1432 },
1433 Token::Text {
1434 content: "c",
1435 start: 4,
1436 end: 5
1437 },
1438 Token::CloseTag {
1439 name: "b",
1440 start: 5,
1441 end: 9
1442 },
1443 Token::Text {
1444 content: "d",
1445 start: 9,
1446 end: 10
1447 },
1448 ]
1449 );
1450 }
1451 }
1452
1453 mod apply_mode {
1456 use super::*;
1457
1458 #[test]
1459 fn plain_text_unchanged() {
1460 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1461 assert_eq!(parser.parse("hello world"), "hello world");
1462 }
1463
1464 #[test]
1465 fn unknown_tag_passthrough_with_marker() {
1466 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1467 let result = parser.parse("[unknown]text[/unknown]");
1468 assert!(result.contains("[unknown?]"));
1469 assert!(result.contains("[/unknown?]"));
1470 assert!(result.contains("text"));
1471 }
1472
1473 #[test]
1474 fn known_tag_applies_style() {
1475 let mut styles = HashMap::new();
1476 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1477
1478 let parser = BBParser::new(styles, TagTransform::Apply);
1479 let result = parser.parse("[bold]hello[/bold]");
1480
1481 assert!(result.contains("\x1b[1m") || result.contains("hello"));
1482 }
1483 }
1484
1485 mod error_display {
1488 use super::*;
1489
1490 #[test]
1491 fn unknown_tag_error_display() {
1492 let error = UnknownTagError {
1493 tag: "foo".to_string(),
1494 kind: UnknownTagKind::Open,
1495 start: 0,
1496 end: 5,
1497 };
1498 let display = format!("{}", error);
1499 assert!(display.contains("foo"));
1500 assert!(display.contains("opening"));
1501 assert!(display.contains("0..5"));
1502 }
1503
1504 #[test]
1505 fn unknown_tag_errors_display() {
1506 let mut errors = UnknownTagErrors::new();
1507 errors.push(UnknownTagError {
1508 tag: "foo".to_string(),
1509 kind: UnknownTagKind::Open,
1510 start: 0,
1511 end: 5,
1512 });
1513 errors.push(UnknownTagError {
1514 tag: "foo".to_string(),
1515 kind: UnknownTagKind::Close,
1516 start: 9,
1517 end: 15,
1518 });
1519
1520 let display = format!("{}", errors);
1521 assert!(display.contains("2 unknown tag"));
1522 }
1523 }
1524}
1525
1526#[cfg(test)]
1527mod proptests {
1528 use super::*;
1529 use proptest::prelude::*;
1530
1531 fn valid_tag_name() -> impl Strategy<Value = String> {
1532 "[a-z_][a-z0-9_-]{0,10}"
1533 }
1534
1535 fn plain_text() -> impl Strategy<Value = String> {
1536 "[a-zA-Z0-9 .,!?:;'\"]{0,50}"
1537 .prop_filter("no brackets", |s| !s.contains('[') && !s.contains(']'))
1538 }
1539
1540 proptest! {
1541 #![proptest_config(ProptestConfig::with_cases(500))]
1542
1543 #[test]
1544 fn keep_mode_roundtrip(content in plain_text()) {
1545 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1546 prop_assert_eq!(parser.parse(&content), content);
1547 }
1548
1549 #[test]
1550 fn remove_mode_plain_text_unchanged(content in plain_text()) {
1551 let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
1552 prop_assert_eq!(parser.parse(&content), content);
1553 }
1554
1555 #[test]
1556 fn valid_tag_names_accepted(tag in valid_tag_name()) {
1557 prop_assert!(Tokenizer::is_valid_tag_name(&tag));
1558 }
1559
1560 #[test]
1561 fn remove_strips_known_tags(tag in valid_tag_name(), content in plain_text()) {
1562 let mut styles = HashMap::new();
1563 styles.insert(tag.clone(), Style::new());
1564
1565 let parser = BBParser::new(styles, TagTransform::Remove);
1566 let input = format!("[{}]{}[/{}]", tag, content, tag);
1567 let result = parser.parse(&input);
1568
1569 prop_assert_eq!(result, content);
1570 }
1571
1572 #[test]
1573 fn keep_preserves_structure(tag in valid_tag_name(), content in plain_text()) {
1574 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1575 let input = format!("[{}]{}[/{}]", tag, content, tag);
1576 let result = parser.parse(&input);
1577
1578 prop_assert_eq!(result, input);
1579 }
1580
1581 #[test]
1582 fn nested_tags_balanced(
1583 outer in valid_tag_name(),
1584 inner in valid_tag_name(),
1585 content in plain_text()
1586 ) {
1587 let mut styles = HashMap::new();
1588 styles.insert(outer.clone(), Style::new());
1589 styles.insert(inner.clone(), Style::new());
1590
1591 let parser = BBParser::new(styles, TagTransform::Remove);
1592 let input = format!("[{}][{}]{}[/{}][/{}]", outer, inner, content, inner, outer);
1593 let result = parser.parse(&input);
1594
1595 prop_assert_eq!(result, content);
1596 }
1597
1598 #[test]
1599 fn validate_finds_unknown_tags(tag in valid_tag_name(), content in plain_text()) {
1600 let parser = BBParser::new(HashMap::new(), TagTransform::Apply);
1601 let input = format!("[{}]{}[/{}]", tag, content, tag);
1602 let result = parser.validate(&input);
1603
1604 prop_assert!(result.is_err());
1605 let errors = result.unwrap_err();
1606 prop_assert_eq!(errors.len(), 2); }
1608
1609 #[test]
1610 fn invalid_start_digit_rejected(n in 0..10u8, rest in "[a-z0-9_-]{0,5}") {
1611 let tag = format!("{}{}", n, rest);
1612 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1613 }
1614
1615 #[test]
1616 fn invalid_start_hyphen_rejected(rest in "[a-z0-9_-]{0,5}") {
1617 let tag = format!("-{}", rest);
1618 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1619 }
1620
1621 #[test]
1622 fn uppercase_rejected(tag in "[A-Z][a-zA-Z0-9_-]{0,5}") {
1623 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1624 }
1625 }
1626}