1use console::{AnsiCodeIterator, Style};
68use std::collections::HashMap;
69use std::ops::Range;
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum TagTransform {
74 Apply,
77
78 Remove,
81
82 Keep,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum UnknownTagBehavior {
90 #[default]
95 Passthrough,
96
97 Strip,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum UnknownTagKind {
107 Open,
109 Close,
111 Unbalanced,
113 UnexpectedClose,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct UnknownTagError {
120 pub tag: String,
122 pub kind: UnknownTagKind,
124 pub start: usize,
126 pub end: usize,
128}
129
130impl std::fmt::Display for UnknownTagError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 let kind = match self.kind {
133 UnknownTagKind::Open => "unknown opening",
134 UnknownTagKind::Close => "unknown closing",
135 UnknownTagKind::Unbalanced => "unbalanced",
136 UnknownTagKind::UnexpectedClose => "unexpected closing",
137 };
138 write!(
139 f,
140 "{} tag '{}' at position {}..{}",
141 kind, self.tag, self.start, self.end
142 )
143 }
144}
145
146impl std::error::Error for UnknownTagError {}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
150pub struct UnknownTagErrors {
151 pub errors: Vec<UnknownTagError>,
153}
154
155impl UnknownTagErrors {
156 pub fn new() -> Self {
158 Self::default()
159 }
160
161 pub fn is_empty(&self) -> bool {
163 self.errors.is_empty()
164 }
165
166 pub fn len(&self) -> usize {
168 self.errors.len()
169 }
170
171 pub fn push(&mut self, error: UnknownTagError) {
173 self.errors.push(error);
174 }
175}
176
177impl std::fmt::Display for UnknownTagErrors {
178 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179 writeln!(f, "found {} unknown tag(s):", self.errors.len())?;
180 for error in &self.errors {
181 writeln!(f, " - {}", error)?;
182 }
183 Ok(())
184 }
185}
186
187impl std::error::Error for UnknownTagErrors {}
188
189impl IntoIterator for UnknownTagErrors {
190 type Item = UnknownTagError;
191 type IntoIter = std::vec::IntoIter<UnknownTagError>;
192
193 fn into_iter(self) -> Self::IntoIter {
194 self.errors.into_iter()
195 }
196}
197
198impl<'a> IntoIterator for &'a UnknownTagErrors {
199 type Item = &'a UnknownTagError;
200 type IntoIter = std::slice::Iter<'a, UnknownTagError>;
201
202 fn into_iter(self) -> Self::IntoIter {
203 self.errors.iter()
204 }
205}
206
207pub fn strip_tags(input: &str) -> String {
226 let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
227 .unknown_behavior(UnknownTagBehavior::Strip);
228 parser.parse(input)
229}
230
231#[derive(Debug, Clone)]
240pub struct StyledText<'a> {
241 events: Vec<StyledEvent<'a>>,
242}
243
244#[derive(Debug, Clone)]
245enum StyledEvent<'a> {
246 Text {
247 source: &'a str,
248 unescape_brackets: bool,
249 },
250 OpenTag(&'a str),
251 CloseTag(&'a str),
252}
253
254impl<'a> StyledText<'a> {
255 pub fn parse(input: &'a str) -> Self {
261 let tokens = Tokenizer::new(input).collect::<Vec<_>>();
262 let valid_opens = compute_valid_tags(&tokens);
263 let mut events = Vec::new();
264 let mut stack: Vec<&str> = Vec::new();
265
266 for (index, token) in tokens.iter().enumerate() {
267 match token {
268 Token::Text { content, .. } => events.push(StyledEvent::Text {
269 source: content,
270 unescape_brackets: true,
271 }),
272 Token::OpenTag { name, .. } if valid_opens.contains(&index) => {
273 stack.push(name);
274 events.push(StyledEvent::OpenTag(name));
275 }
276 Token::OpenTag { start, end, .. } => events.push(StyledEvent::Text {
277 source: &input[*start..*end],
278 unescape_brackets: false,
279 }),
280 Token::CloseTag { name, .. } if stack.last().copied() == Some(*name) => {
281 stack.pop();
282 events.push(StyledEvent::CloseTag(name));
283 }
284 Token::CloseTag { name, .. } if stack.contains(name) => {
285 while let Some(open) = stack.pop() {
286 events.push(StyledEvent::CloseTag(open));
287 if open == *name {
288 break;
289 }
290 }
291 }
292 Token::CloseTag { start, end, .. } => events.push(StyledEvent::Text {
293 source: &input[*start..*end],
294 unescape_brackets: false,
295 }),
296 Token::InvalidTag { content, .. } => events.push(StyledEvent::Text {
297 source: content,
298 unescape_brackets: false,
299 }),
300 }
301 }
302
303 while let Some(tag) = stack.pop() {
304 events.push(StyledEvent::CloseTag(tag));
305 }
306
307 Self { events }
308 }
309
310 pub fn visit_visible_chars(&self, mut visitor: impl FnMut(char)) {
315 for event in &self.events {
316 if let StyledEvent::Text {
317 source,
318 unescape_brackets,
319 } = event
320 {
321 visit_text_units(source, *unescape_brackets, |character, _| {
322 if let Some(character) = character {
323 visitor(character);
324 }
325 });
326 }
327 }
328 }
329
330 pub fn select(&self, ranges: &[Range<usize>], separator: &str) -> String {
336 let mut result = String::new();
337 let mut rendered_any = false;
338
339 for range in ranges.iter().filter(|range| range.start < range.end) {
340 if rendered_any {
341 result.push_str(separator);
342 }
343 result.push_str(&self.render_range(range.clone()));
344 rendered_any = true;
345 }
346
347 result
348 }
349
350 pub fn select_range(&self, range: Range<usize>) -> String {
354 self.render_range(range)
355 }
356
357 fn render_range(&self, range: Range<usize>) -> String {
358 let mut result = String::new();
359 let mut source_stack: Vec<&str> = Vec::new();
360 let mut output_stack: Vec<&str> = Vec::new();
361 let mut visible_index = 0;
362 let mut started = false;
363
364 for event in &self.events {
365 match event {
366 StyledEvent::OpenTag(tag) => {
367 source_stack.push(tag);
368 if started && visible_index < range.end {
369 push_open_tag(&mut result, tag);
370 output_stack.push(tag);
371 }
372 }
373 StyledEvent::CloseTag(tag) => {
374 source_stack.pop();
375 if started && output_stack.last().copied() == Some(*tag) {
376 push_close_tag(&mut result, tag);
377 output_stack.pop();
378 }
379 }
380 StyledEvent::Text {
381 source,
382 unescape_brackets,
383 } => {
384 visit_text_units(source, *unescape_brackets, |character, raw| {
385 if character.is_none() {
386 if (started && visible_index <= range.end)
387 || (!started
388 && visible_index == range.start
389 && range.start < range.end)
390 {
391 if !started {
392 for tag in &source_stack {
393 push_open_tag(&mut result, tag);
394 output_stack.push(tag);
395 }
396 started = true;
397 }
398 result.push_str(raw);
399 }
400 return;
401 }
402 if visible_index >= range.start && visible_index < range.end {
403 if !started {
404 for tag in &source_stack {
405 push_open_tag(&mut result, tag);
406 output_stack.push(tag);
407 }
408 started = true;
409 }
410 result.push_str(raw);
411 }
412 visible_index += 1;
413 });
414 }
415 }
416 }
417
418 for tag in output_stack.into_iter().rev() {
419 push_close_tag(&mut result, tag);
420 }
421 result
422 }
423}
424
425fn push_open_tag(output: &mut String, tag: &str) {
426 output.push('[');
427 output.push_str(tag);
428 output.push(']');
429}
430
431fn push_close_tag(output: &mut String, tag: &str) {
432 output.push_str("[/");
433 output.push_str(tag);
434 output.push(']');
435}
436
437fn visit_text_units<'a>(
438 source: &'a str,
439 unescape_brackets: bool,
440 mut visitor: impl FnMut(Option<char>, &'a str),
441) {
442 for (unit, is_ansi) in AnsiCodeIterator::new(source) {
443 if is_ansi {
444 visitor(None, unit);
445 } else {
446 visit_plain_text_units(unit, unescape_brackets, &mut visitor);
447 }
448 }
449}
450
451fn visit_plain_text_units<'a>(
452 source: &'a str,
453 unescape_brackets: bool,
454 visitor: &mut impl FnMut(Option<char>, &'a str),
455) {
456 let mut indices = source.char_indices().peekable();
457 while let Some((start, character)) = indices.next() {
458 if unescape_brackets && character == '\\' {
459 if let Some(&(next_start, next)) = indices.peek() {
460 if next == '[' || next == ']' {
461 indices.next();
462 let end = next_start + next.len_utf8();
463 visitor(Some(next), &source[start..end]);
464 continue;
465 }
466 }
467 }
468 let end = start + character.len_utf8();
469 visitor(Some(character), &source[start..end]);
470 }
471}
472
473#[derive(Debug, Clone)]
478pub struct BBParser {
479 styles: HashMap<String, Style>,
480 transform: TagTransform,
481 unknown_behavior: UnknownTagBehavior,
482}
483
484impl BBParser {
485 pub fn new(styles: HashMap<String, Style>, transform: TagTransform) -> Self {
495 Self {
496 styles,
497 transform,
498 unknown_behavior: UnknownTagBehavior::default(),
499 }
500 }
501
502 pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
517 self.unknown_behavior = behavior;
518 self
519 }
520
521 pub fn parse(&self, input: &str) -> String {
525 let (output, _) = self.parse_internal(input);
526 output
527 }
528
529 pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
547 self.parse_internal(input)
548 }
549
550 pub fn validate(&self, input: &str) -> Result<(), UnknownTagErrors> {
574 let (_, errors) = self.parse_internal(input);
575 if errors.is_empty() {
576 Ok(())
577 } else {
578 Err(errors)
579 }
580 }
581
582 fn parse_internal(&self, input: &str) -> (String, UnknownTagErrors) {
584 let tokens = Tokenizer::new(input).collect::<Vec<_>>();
585 let valid_opens = compute_valid_tags(&tokens);
586 let mut events = Vec::new();
587 let mut errors = UnknownTagErrors::new();
588 let mut stack: Vec<&str> = Vec::new();
589
590 let mut i = 0;
593 while i < tokens.len() {
594 match &tokens[i] {
595 Token::Text { content, .. } => {
596 events.push(ParseEvent::Literal(unescape(content)));
597 }
598 Token::OpenTag { name, start, end } => {
599 if valid_opens.contains(&i) {
600 stack.push(name);
601 self.emit_open_tag_event(&mut events, &mut errors, name, *start, *end);
602 } else {
603 let is_valid_name = Tokenizer::is_valid_tag_name(name);
605 if is_valid_name {
606 errors.push(UnknownTagError {
608 tag: name.to_string(),
609 kind: UnknownTagKind::Unbalanced, start: *start,
611 end: *end,
612 });
613 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
618 "[{}]",
619 name
620 ))));
621 } else {
622 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
623 "[{}]",
624 name
625 ))));
626 }
627 }
628 }
629 Token::CloseTag { name, start, end } => {
630 if stack.last().copied() == Some(*name) {
631 stack.pop();
632 self.emit_close_tag_event(&mut events, &mut errors, name, *start, *end);
633 } else if stack.contains(name) {
634 while let Some(open) = stack.pop() {
635 self.emit_close_tag_event(&mut events, &mut errors, open, 0, 0);
636 if open == *name {
637 break;
638 }
639 }
640 } else {
641 let is_valid_name = Tokenizer::is_valid_tag_name(name);
643 if is_valid_name {
644 errors.push(UnknownTagError {
645 tag: name.to_string(),
646 kind: UnknownTagKind::UnexpectedClose, start: *start,
648 end: *end,
649 });
650 }
651 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
652 "[/{}]",
653 name
654 ))));
655 }
656 }
657 Token::InvalidTag { content, .. } => {
658 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
659 }
660 }
661 i += 1;
662 }
663
664 while let Some(tag) = stack.pop() {
665 self.emit_close_tag_event(&mut events, &mut errors, tag, 0, 0);
666 }
667
668 let output = self.render(events);
669 (output, errors)
670 }
671
672 fn emit_open_tag_event<'a>(
673 &self,
674 events: &mut Vec<ParseEvent<'a>>,
675 errors: &mut UnknownTagErrors,
676 tag: &'a str,
677 start: usize,
678 end: usize,
679 ) {
680 let is_known = self.styles.contains_key(tag);
681
682 if !is_known {
683 errors.push(UnknownTagError {
684 tag: tag.to_string(),
685 kind: UnknownTagKind::Open,
686 start,
687 end,
688 });
689 }
690
691 match self.transform {
692 TagTransform::Keep => {
693 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
694 "[{}]",
695 tag
696 ))));
697 }
698 TagTransform::Remove => {
699 }
701 TagTransform::Apply => {
702 if is_known {
703 events.push(ParseEvent::StyleStart(tag));
704 } else {
705 match self.unknown_behavior {
706 UnknownTagBehavior::Passthrough => {
707 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
708 "[{}?]",
709 tag
710 ))));
711 }
712 UnknownTagBehavior::Strip => {
713 }
715 }
716 }
717 }
718 }
719 }
720
721 fn emit_close_tag_event<'a>(
722 &self,
723 events: &mut Vec<ParseEvent<'a>>,
724 errors: &mut UnknownTagErrors,
725 tag: &'a str,
726 start: usize,
727 end: usize,
728 ) {
729 let is_known = self.styles.contains_key(tag);
730
731 if !is_known && end > 0 {
733 errors.push(UnknownTagError {
734 tag: tag.to_string(),
735 kind: UnknownTagKind::Close,
736 start,
737 end,
738 });
739 }
740
741 match self.transform {
742 TagTransform::Keep => {
743 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
744 "[/{}]",
745 tag
746 ))));
747 }
748 TagTransform::Remove => {
749 }
751 TagTransform::Apply => {
752 if is_known {
753 events.push(ParseEvent::StyleEnd(tag));
754 } else {
755 match self.unknown_behavior {
756 UnknownTagBehavior::Passthrough => {
757 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
758 "[/{}?]",
759 tag
760 ))));
761 }
762 UnknownTagBehavior::Strip => {
763 }
765 }
766 }
767 }
768 }
769 }
770
771 fn render(&self, events: Vec<ParseEvent>) -> String {
773 let mut result = String::new();
774 let mut style_stack: Vec<&Style> = Vec::new();
775
776 for event in events {
777 match event {
778 ParseEvent::Literal(text) => {
779 self.append_styled(&mut result, &text, &style_stack);
780 }
781 ParseEvent::StyleStart(tag) => {
782 if let Some(style) = self.styles.get(tag) {
783 style_stack.push(style);
784 }
785 }
786 ParseEvent::StyleEnd(tag) => {
787 if self.styles.contains_key(tag) {
788 style_stack.pop();
789 }
790 }
791 }
792 }
793 result
794 }
795
796 fn append_styled(&self, output: &mut String, text: &str, style_stack: &[&Style]) {
798 if text.is_empty() {
799 return;
800 }
801
802 if style_stack.is_empty() {
803 output.push_str(text);
804 } else {
805 let mut current = text.to_string();
806 for style in style_stack.iter().rev() {
810 if current.ends_with("\x1b[0m") {
811 current.truncate(current.len() - 4);
812 }
813 current = style.apply_to(current).to_string();
814 }
815 output.push_str(¤t);
816 }
817 }
818}
819
820enum ParseEvent<'a> {
821 Literal(std::borrow::Cow<'a, str>),
822 StyleStart(&'a str),
823 StyleEnd(&'a str),
824}
825
826#[derive(Debug, Clone, PartialEq, Eq)]
828enum Token<'a> {
829 Text {
831 content: &'a str,
832 start: usize,
833 end: usize,
834 },
835 OpenTag {
837 name: &'a str,
838 start: usize,
839 end: usize,
840 },
841 CloseTag {
843 name: &'a str,
844 start: usize,
845 end: usize,
846 },
847 InvalidTag {
849 content: &'a str,
850 start: usize,
851 end: usize,
852 },
853}
854
855fn compute_valid_tags(tokens: &[Token<'_>]) -> std::collections::HashSet<usize> {
858 use std::collections::{HashMap, HashSet};
859 let mut valid_indices = HashSet::new();
860 let mut open_indices_by_tag: HashMap<&str, Vec<usize>> = HashMap::new();
861
862 for (index, token) in tokens.iter().enumerate() {
863 match token {
864 Token::OpenTag { name, .. } => {
865 open_indices_by_tag.entry(name).or_default().push(index);
866 }
867 Token::CloseTag { name, .. } => {
868 if let Some(indices) = open_indices_by_tag.get_mut(name) {
869 if let Some(open_index) = indices.pop() {
870 valid_indices.insert(open_index);
871 }
872 }
873 }
874 _ => {}
875 }
876 }
877
878 valid_indices
879}
880
881fn find_unescaped_bracket(s: &str) -> Option<usize> {
894 let mut source_offset = 0;
895 for (unit, is_ansi) in AnsiCodeIterator::new(s) {
896 if !is_ansi {
897 let bytes = unit.as_bytes();
898 let mut i = 0;
899 while i < bytes.len() {
900 if bytes[i] == b'\\' && i + 1 < bytes.len() {
901 let next = bytes[i + 1];
902 if next == b'[' || next == b']' {
903 i += 2;
904 continue;
905 }
906 }
907 if bytes[i] == b'[' {
908 return Some(source_offset + i);
909 }
910 i += 1;
911 }
912 }
913 source_offset += unit.len();
914 }
915 None
916}
917
918fn unescape(s: &str) -> std::borrow::Cow<'_, str> {
924 let bytes = s.as_bytes();
925 let has_escape = bytes
926 .windows(2)
927 .any(|w| w[0] == b'\\' && (w[1] == b'[' || w[1] == b']'));
928 if !has_escape {
929 return std::borrow::Cow::Borrowed(s);
930 }
931 let mut out = String::with_capacity(s.len());
932 let mut chars = s.chars().peekable();
933 while let Some(c) = chars.next() {
934 if c == '\\' {
935 if let Some(&next) = chars.peek() {
936 if next == '[' || next == ']' {
937 out.push(next);
938 chars.next();
939 continue;
940 }
941 }
942 }
943 out.push(c);
944 }
945 std::borrow::Cow::Owned(out)
946}
947
948struct Tokenizer<'a> {
950 input: &'a str,
951 pos: usize,
952}
953
954impl<'a> Tokenizer<'a> {
955 fn new(input: &'a str) -> Self {
956 Self { input, pos: 0 }
957 }
958
959 fn is_valid_tag_name(s: &str) -> bool {
961 if s.is_empty() {
962 return false;
963 }
964
965 let mut chars = s.chars();
966 let first = chars.next().unwrap();
967
968 if !first.is_ascii_lowercase() && first != '_' {
970 return false;
971 }
972
973 for c in chars {
975 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
976 return false;
977 }
978 }
979
980 true
981 }
982}
983
984impl<'a> Iterator for Tokenizer<'a> {
985 type Item = Token<'a>;
986
987 fn next(&mut self) -> Option<Self::Item> {
988 if self.pos >= self.input.len() {
989 return None;
990 }
991
992 let remaining = &self.input[self.pos..];
993 let start_pos = self.pos;
994
995 if let Some(bracket_pos) = find_unescaped_bracket(remaining) {
998 if bracket_pos > 0 {
999 let text = &remaining[..bracket_pos];
1001 self.pos += bracket_pos;
1002 return Some(Token::Text {
1003 content: text,
1004 start: start_pos,
1005 end: self.pos,
1006 });
1007 }
1008
1009 if let Some(close_bracket) = remaining.find(']') {
1012 let tag_content = &remaining[1..close_bracket];
1013 let full_tag = &remaining[..=close_bracket];
1014 let end_pos = start_pos + close_bracket + 1;
1015
1016 if let Some(tag_name) = tag_content.strip_prefix('/') {
1018 if Self::is_valid_tag_name(tag_name) {
1019 self.pos = end_pos;
1020 Some(Token::CloseTag {
1021 name: tag_name,
1022 start: start_pos,
1023 end: end_pos,
1024 })
1025 } else {
1026 self.pos = end_pos;
1027 Some(Token::InvalidTag {
1028 content: full_tag,
1029 start: start_pos,
1030 end: end_pos,
1031 })
1032 }
1033 } else if Self::is_valid_tag_name(tag_content) {
1034 self.pos = end_pos;
1035 Some(Token::OpenTag {
1036 name: tag_content,
1037 start: start_pos,
1038 end: end_pos,
1039 })
1040 } else {
1041 self.pos = end_pos;
1042 Some(Token::InvalidTag {
1043 content: full_tag,
1044 start: start_pos,
1045 end: end_pos,
1046 })
1047 }
1048 } else {
1049 let end_pos = self.input.len();
1051 self.pos = end_pos;
1052 Some(Token::Text {
1053 content: remaining,
1054 start: start_pos,
1055 end: end_pos,
1056 })
1057 }
1058 } else {
1059 let end_pos = self.input.len();
1061 self.pos = end_pos;
1062 Some(Token::Text {
1063 content: remaining,
1064 start: start_pos,
1065 end: end_pos,
1066 })
1067 }
1068 }
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074
1075 fn test_styles() -> HashMap<String, Style> {
1076 let mut styles = HashMap::new();
1077 styles.insert("bold".to_string(), Style::new().bold());
1078 styles.insert("red".to_string(), Style::new().red());
1079 styles.insert("dim".to_string(), Style::new().dim());
1080 styles.insert("title".to_string(), Style::new().cyan().bold());
1081 styles.insert("error".to_string(), Style::new().red().bold());
1082 styles.insert("my_style".to_string(), Style::new().green());
1083 styles.insert("style-with-dash".to_string(), Style::new().yellow());
1084 styles
1085 }
1086
1087 mod strip_tags_tests {
1090 use super::super::strip_tags;
1091
1092 #[test]
1093 fn strips_known_style_tags() {
1094 assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
1095 }
1096
1097 #[test]
1098 fn strips_unknown_tags() {
1099 assert_eq!(strip_tags("[additions]+32[/additions]"), "+32");
1100 }
1101
1102 #[test]
1103 fn strips_multiple_tags() {
1104 assert_eq!(
1105 strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"),
1106 "+32/-0/32"
1107 );
1108 }
1109
1110 #[test]
1111 fn plain_text_unchanged() {
1112 assert_eq!(strip_tags("no tags here"), "no tags here");
1113 }
1114
1115 #[test]
1116 fn empty_string() {
1117 assert_eq!(strip_tags(""), "");
1118 }
1119
1120 #[test]
1121 fn nested_tags() {
1122 assert_eq!(strip_tags("[a][b]text[/b][/a]"), "text");
1123 }
1124 }
1125
1126 mod styled_text_tests {
1127 use super::super::StyledText;
1128
1129 #[test]
1130 fn selected_range_rebuilds_nested_balanced_tags() {
1131 let text = StyledText::parse("[outer]ab[inner]cdef[/inner]gh[/outer]");
1132
1133 assert_eq!(
1134 text.select_range(0..5),
1135 "[outer]ab[inner]cde[/inner][/outer]"
1136 );
1137 assert_eq!(
1138 text.select_range(3..8),
1139 "[outer][inner]def[/inner]gh[/outer]"
1140 );
1141 }
1142
1143 #[test]
1144 fn separate_ranges_are_independently_balanced() {
1145 let text = StyledText::parse("[outer]ab[inner]cdef[/inner]gh[/outer]");
1146
1147 assert_eq!(
1148 text.select(&[0..2, 6..8], "…"),
1149 "[outer]ab[/outer]…[outer]gh[/outer]"
1150 );
1151 }
1152
1153 #[test]
1154 fn selected_escaped_brackets_remain_escaped_source() {
1155 let text = StyledText::parse(r"[outer]a\[inner\]z[/outer]");
1156 let mut visible = String::new();
1157 text.visit_visible_chars(|character| visible.push(character));
1158
1159 assert_eq!(visible, "a[inner]z");
1160 assert_eq!(
1161 text.select_range(0..visible.chars().count()),
1162 r"[outer]a\[inner\]z[/outer]"
1163 );
1164 }
1165
1166 #[test]
1167 fn ansi_sequences_are_zero_width_and_preserved_when_selected() {
1168 let text = StyledText::parse("\x1b[31m[outer]hello[/outer]\x1b[0m");
1169 let mut visible = String::new();
1170 text.visit_visible_chars(|character| visible.push(character));
1171
1172 assert_eq!(visible, "hello");
1173 assert_eq!(
1174 text.select_range(0..5),
1175 "\x1b[31m[outer]hello[/outer]\x1b[0m"
1176 );
1177 }
1178
1179 #[test]
1180 fn legacy_ansi_designation_sequences_are_zero_width_and_preserved() {
1181 let input = "\x1b(0[outer]hello[/outer]\x1b(B";
1182 let text = StyledText::parse(input);
1183 let mut visible = String::new();
1184 text.visit_visible_chars(|character| visible.push(character));
1185
1186 assert_eq!(visible, "hello");
1187 assert_eq!(text.select_range(0..5), input);
1188 }
1189
1190 #[test]
1191 fn c1_ansi_sequences_are_zero_width_and_preserved() {
1192 let input = "\u{9b}31m[outer]hello[/outer]\u{9b}0m";
1193 let text = StyledText::parse(input);
1194 let mut visible = String::new();
1195 text.visit_visible_chars(|character| visible.push(character));
1196
1197 assert_eq!(visible, "hello");
1198 assert_eq!(text.select_range(0..5), input);
1199 }
1200 }
1201
1202 mod keep_mode {
1205 use super::*;
1206
1207 #[test]
1208 fn plain_text_unchanged() {
1209 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1210 assert_eq!(parser.parse("hello world"), "hello world");
1211 }
1212
1213 #[test]
1214 fn single_tag_preserved() {
1215 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1216 assert_eq!(parser.parse("[bold]hello[/bold]"), "[bold]hello[/bold]");
1217 }
1218
1219 #[test]
1220 fn nested_tags_preserved() {
1221 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1222 assert_eq!(
1223 parser.parse("[bold][red]hello[/red][/bold]"),
1224 "[bold][red]hello[/red][/bold]"
1225 );
1226 }
1227
1228 #[test]
1229 fn adjacent_tags_preserved() {
1230 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1231 assert_eq!(
1232 parser.parse("[bold]a[/bold][red]b[/red]"),
1233 "[bold]a[/bold][red]b[/red]"
1234 );
1235 }
1236
1237 #[test]
1238 fn text_around_tags() {
1239 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1240 assert_eq!(
1241 parser.parse("before [bold]middle[/bold] after"),
1242 "before [bold]middle[/bold] after"
1243 );
1244 }
1245
1246 #[test]
1247 fn unknown_tags_preserved() {
1248 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1249 assert_eq!(
1250 parser.parse("[unknown]text[/unknown]"),
1251 "[unknown]text[/unknown]"
1252 );
1253 }
1254 }
1255
1256 mod remove_mode {
1259 use super::*;
1260
1261 #[test]
1262 fn plain_text_unchanged() {
1263 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1264 assert_eq!(parser.parse("hello world"), "hello world");
1265 }
1266
1267 #[test]
1268 fn single_tag_stripped() {
1269 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1270 assert_eq!(parser.parse("[bold]hello[/bold]"), "hello");
1271 }
1272
1273 #[test]
1274 fn nested_tags_stripped() {
1275 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1276 assert_eq!(parser.parse("[bold][red]hello[/red][/bold]"), "hello");
1277 }
1278
1279 #[test]
1280 fn adjacent_tags_stripped() {
1281 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1282 assert_eq!(parser.parse("[bold]a[/bold][red]b[/red]"), "ab");
1283 }
1284
1285 #[test]
1286 fn text_around_tags() {
1287 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1288 assert_eq!(
1289 parser.parse("before [bold]middle[/bold] after"),
1290 "before middle after"
1291 );
1292 }
1293
1294 #[test]
1295 fn unknown_tags_stripped() {
1296 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1297 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1299 }
1300 }
1301
1302 mod unknown_tag_behavior {
1305 use super::*;
1306
1307 #[test]
1308 fn passthrough_adds_question_mark_in_apply_mode() {
1309 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1310 .unknown_behavior(UnknownTagBehavior::Passthrough);
1311 assert_eq!(
1312 parser.parse("[unknown]text[/unknown]"),
1313 "[unknown?]text[/unknown?]"
1314 );
1315 }
1316
1317 #[test]
1318 fn passthrough_is_default() {
1319 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1320 assert_eq!(
1321 parser.parse("[unknown]text[/unknown]"),
1322 "[unknown?]text[/unknown?]"
1323 );
1324 }
1325
1326 #[test]
1327 fn strip_removes_unknown_tags_in_apply_mode() {
1328 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1329 .unknown_behavior(UnknownTagBehavior::Strip);
1330 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1331 }
1332
1333 #[test]
1334 fn passthrough_nested_with_known() {
1335 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1336 .unknown_behavior(UnknownTagBehavior::Passthrough);
1337 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
1338 assert!(result.contains("[unknown?]"));
1339 assert!(result.contains("[/unknown?]"));
1340 assert!(result.contains("text"));
1341 }
1342
1343 #[test]
1344 fn strip_nested_with_known() {
1345 let mut styles = HashMap::new();
1346 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1347 let parser = BBParser::new(styles, TagTransform::Apply)
1348 .unknown_behavior(UnknownTagBehavior::Strip);
1349 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
1350 assert!(!result.contains("[unknown"));
1352 assert!(result.contains("text"));
1353 }
1354
1355 #[test]
1356 fn keep_mode_ignores_unknown_behavior() {
1357 let parser = BBParser::new(test_styles(), TagTransform::Keep)
1359 .unknown_behavior(UnknownTagBehavior::Strip);
1360 assert_eq!(
1361 parser.parse("[unknown]text[/unknown]"),
1362 "[unknown]text[/unknown]"
1363 );
1364 }
1365
1366 #[test]
1367 fn remove_mode_always_strips_tags() {
1368 let parser = BBParser::new(test_styles(), TagTransform::Remove)
1370 .unknown_behavior(UnknownTagBehavior::Passthrough);
1371 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1372 }
1373 }
1374
1375 mod validation {
1378 use super::*;
1379
1380 #[test]
1381 fn validate_all_known_tags_passes() {
1382 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1383 assert!(parser.validate("[bold]text[/bold]").is_ok());
1384 }
1385
1386 #[test]
1387 fn validate_nested_known_tags_passes() {
1388 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1389 assert!(parser.validate("[bold][red]text[/red][/bold]").is_ok());
1390 }
1391
1392 #[test]
1393 fn validate_unknown_tag_fails() {
1394 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1395 let result = parser.validate("[unknown]text[/unknown]");
1396 assert!(result.is_err());
1397 }
1398
1399 #[test]
1400 fn validate_returns_correct_error_count() {
1401 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1402 let result = parser.validate("[unknown]text[/unknown]");
1403 let errors = result.unwrap_err();
1404 assert_eq!(errors.len(), 2); }
1406
1407 #[test]
1408 fn validate_error_contains_tag_name() {
1409 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1410 let result = parser.validate("[foobar]text[/foobar]");
1411 let errors = result.unwrap_err();
1412 assert!(errors.errors.iter().all(|e| e.tag == "foobar"));
1413 }
1414
1415 #[test]
1416 fn validate_error_distinguishes_open_and_close() {
1417 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1418 let result = parser.validate("[unknown]text[/unknown]");
1419 let errors = result.unwrap_err();
1420
1421 let open_count = errors
1422 .errors
1423 .iter()
1424 .filter(|e| e.kind == UnknownTagKind::Open)
1425 .count();
1426 let close_count = errors
1427 .errors
1428 .iter()
1429 .filter(|e| e.kind == UnknownTagKind::Close)
1430 .count();
1431
1432 assert_eq!(open_count, 1);
1433 assert_eq!(close_count, 1);
1434 }
1435
1436 #[test]
1437 fn validate_error_has_correct_positions() {
1438 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1439 let input = "[unknown]text[/unknown]";
1440 let result = parser.validate(input);
1441 let errors = result.unwrap_err();
1442
1443 let open_error = errors
1444 .errors
1445 .iter()
1446 .find(|e| e.kind == UnknownTagKind::Open)
1447 .unwrap();
1448 assert_eq!(open_error.start, 0);
1449 assert_eq!(open_error.end, 9); let close_error = errors
1452 .errors
1453 .iter()
1454 .find(|e| e.kind == UnknownTagKind::Close)
1455 .unwrap();
1456 assert_eq!(close_error.start, 13);
1457 assert_eq!(close_error.end, 23); }
1459
1460 #[test]
1461 fn validate_multiple_unknown_tags() {
1462 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1463 let result = parser.validate("[foo]a[/foo][bar]b[/bar]");
1464 let errors = result.unwrap_err();
1465 assert_eq!(errors.len(), 4); let tags: std::collections::HashSet<_> =
1468 errors.errors.iter().map(|e| e.tag.as_str()).collect();
1469 assert!(tags.contains("foo"));
1470 assert!(tags.contains("bar"));
1471 }
1472
1473 #[test]
1474 fn validate_mixed_known_and_unknown() {
1475 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1476 let result = parser.validate("[bold][unknown]text[/unknown][/bold]");
1477 let errors = result.unwrap_err();
1478 assert_eq!(errors.len(), 2); for error in &errors.errors {
1481 assert_eq!(error.tag, "unknown");
1482 }
1483 }
1484
1485 #[test]
1486 fn validate_plain_text_passes() {
1487 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1488 assert!(parser.validate("plain text without tags").is_ok());
1489 }
1490
1491 #[test]
1492 fn validate_empty_string_passes() {
1493 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1494 assert!(parser.validate("").is_ok());
1495 }
1496 }
1497
1498 mod parse_with_diagnostics {
1501 use super::*;
1502
1503 #[test]
1504 fn returns_output_and_errors() {
1505 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1506 .unknown_behavior(UnknownTagBehavior::Passthrough);
1507 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1508
1509 assert_eq!(output, "[unknown?]text[/unknown?]");
1510 assert_eq!(errors.len(), 2);
1511 }
1512
1513 #[test]
1514 fn output_uses_strip_behavior() {
1515 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1516 .unknown_behavior(UnknownTagBehavior::Strip);
1517 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1518
1519 assert_eq!(output, "text");
1520 assert_eq!(errors.len(), 2);
1521 }
1522
1523 #[test]
1524 fn no_errors_for_known_tags() {
1525 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1526 let (_, errors) = parser.parse_with_diagnostics("[bold]text[/bold]");
1527 assert!(errors.is_empty());
1528 }
1529
1530 #[test]
1531 fn errors_iterable() {
1532 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1533 let (_, errors) = parser.parse_with_diagnostics("[a]x[/a][b]y[/b]");
1534
1535 let mut count = 0;
1536 for error in &errors {
1537 assert!(error.tag == "a" || error.tag == "b");
1538 count += 1;
1539 }
1540 assert_eq!(count, 4);
1541 }
1542 }
1543
1544 mod tag_names {
1547 use super::*;
1548
1549 #[test]
1550 fn valid_simple_names() {
1551 assert!(Tokenizer::is_valid_tag_name("bold"));
1552 assert!(Tokenizer::is_valid_tag_name("red"));
1553 assert!(Tokenizer::is_valid_tag_name("a"));
1554 }
1555
1556 #[test]
1557 fn valid_with_underscore() {
1558 assert!(Tokenizer::is_valid_tag_name("my_style"));
1559 assert!(Tokenizer::is_valid_tag_name("_private"));
1560 assert!(Tokenizer::is_valid_tag_name("a_b_c"));
1561 }
1562
1563 #[test]
1564 fn valid_with_hyphen() {
1565 assert!(Tokenizer::is_valid_tag_name("my-style"));
1566 assert!(Tokenizer::is_valid_tag_name("font-bold"));
1567 assert!(Tokenizer::is_valid_tag_name("a-b-c"));
1568 }
1569
1570 #[test]
1571 fn valid_with_numbers() {
1572 assert!(Tokenizer::is_valid_tag_name("h1"));
1573 assert!(Tokenizer::is_valid_tag_name("col2"));
1574 assert!(Tokenizer::is_valid_tag_name("style123"));
1575 }
1576
1577 #[test]
1578 fn invalid_starts_with_digit() {
1579 assert!(!Tokenizer::is_valid_tag_name("1style"));
1580 assert!(!Tokenizer::is_valid_tag_name("123"));
1581 }
1582
1583 #[test]
1584 fn invalid_starts_with_hyphen() {
1585 assert!(!Tokenizer::is_valid_tag_name("-style"));
1586 assert!(!Tokenizer::is_valid_tag_name("-1"));
1587 }
1588
1589 #[test]
1590 fn invalid_uppercase() {
1591 assert!(!Tokenizer::is_valid_tag_name("Bold"));
1592 assert!(!Tokenizer::is_valid_tag_name("BOLD"));
1593 assert!(!Tokenizer::is_valid_tag_name("myStyle"));
1594 }
1595
1596 #[test]
1597 fn invalid_special_chars() {
1598 assert!(!Tokenizer::is_valid_tag_name("my.style"));
1599 assert!(!Tokenizer::is_valid_tag_name("my@style"));
1600 assert!(!Tokenizer::is_valid_tag_name("my style"));
1601 }
1602
1603 #[test]
1604 fn invalid_empty() {
1605 assert!(!Tokenizer::is_valid_tag_name(""));
1606 }
1607 }
1608
1609 mod edge_cases {
1612 use super::*;
1613
1614 #[test]
1615 fn empty_input() {
1616 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1617 assert_eq!(parser.parse(""), "");
1618 }
1619
1620 #[test]
1621 fn unclosed_tag_passthrough() {
1622 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1623 assert_eq!(parser.parse("[bold]hello"), "[bold]hello");
1624 }
1625
1626 #[test]
1627 fn orphan_close_tag_passthrough() {
1628 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1629 assert_eq!(parser.parse("hello[/bold]"), "hello[/bold]");
1630 }
1631
1632 #[test]
1633 fn mismatched_tags() {
1634 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1635 assert_eq!(
1636 parser.parse("[bold]hello[/red][/bold]"),
1637 "[bold]hello[/red][/bold]"
1638 );
1639 }
1640
1641 #[test]
1642 fn overlapping_tags_auto_close() {
1643 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1644 let result = parser.parse("[bold][red]hello[/bold][/red]");
1645 assert!(result.contains("hello"));
1646 }
1647
1648 #[test]
1649 fn empty_tag_content() {
1650 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1651 assert_eq!(parser.parse("[bold][/bold]"), "");
1652 }
1653
1654 #[test]
1655 fn brackets_in_content() {
1656 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1657 assert_eq!(parser.parse("[bold]array[0][/bold]"), "array[0]");
1658 }
1659
1660 #[test]
1661 fn invalid_tag_syntax_passthrough() {
1662 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1663 assert_eq!(parser.parse("[123]text[/123]"), "[123]text[/123]");
1664 assert_eq!(parser.parse("[-bad]text[/-bad]"), "[-bad]text[/-bad]");
1665 assert_eq!(parser.parse("[Bad]text[/Bad]"), "[Bad]text[/Bad]");
1666 }
1667
1668 #[test]
1669 fn deeply_nested() {
1670 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1671 assert_eq!(
1672 parser.parse("[bold][red][dim]deep[/dim][/red][/bold]"),
1673 "deep"
1674 );
1675 }
1676
1677 #[test]
1678 fn many_adjacent_tags() {
1679 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1680 assert_eq!(
1681 parser.parse("[bold]a[/bold][red]b[/red][dim]c[/dim]"),
1682 "abc"
1683 );
1684 }
1685
1686 #[test]
1687 fn unclosed_bracket() {
1688 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1689 assert_eq!(parser.parse("hello [bold world"), "hello [bold world");
1690 }
1691
1692 #[test]
1693 fn multiline_content() {
1694 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1695 assert_eq!(
1696 parser.parse("[bold]line1\nline2\nline3[/bold]"),
1697 "line1\nline2\nline3"
1698 );
1699 }
1700
1701 #[test]
1702 fn style_with_underscore() {
1703 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1704 assert_eq!(parser.parse("[my_style]text[/my_style]"), "text");
1705 }
1706
1707 #[test]
1708 fn style_with_dash() {
1709 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1710 assert_eq!(
1711 parser.parse("[style-with-dash]text[/style-with-dash]"),
1712 "text"
1713 );
1714 }
1715 }
1716
1717 mod escapes {
1720 use super::*;
1721
1722 #[test]
1723 fn escaped_open_bracket_is_literal() {
1724 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1725 assert_eq!(parser.parse("\\[bold\\]"), "[bold]");
1726 }
1727
1728 #[test]
1729 fn escaped_brackets_inside_known_tag() {
1730 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1731 assert_eq!(
1732 parser.parse("[bold]hello \\[world\\][/bold]"),
1733 "hello [world]"
1734 );
1735 }
1736
1737 #[test]
1738 fn escapes_keep_mode_emits_literal_brackets() {
1739 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1740 assert_eq!(parser.parse("\\[bold\\]"), "[bold]");
1741 }
1742
1743 #[test]
1744 fn escapes_apply_mode_styles_around_literals() {
1745 let mut styles = HashMap::new();
1746 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1747 let parser = BBParser::new(styles, TagTransform::Apply);
1748 let result = parser.parse("[bold]\\[x\\][/bold]");
1749 assert!(result.contains("[x]"));
1751 assert!(!result.contains("[bold]"));
1752 }
1753
1754 #[test]
1755 fn lone_backslash_is_literal() {
1756 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1757 assert_eq!(parser.parse("path C:\\foo\\bar"), "path C:\\foo\\bar");
1758 }
1759
1760 #[test]
1761 fn unescape_borrows_when_no_bracket_escape_present() {
1762 assert!(matches!(
1766 unescape("plain text"),
1767 std::borrow::Cow::Borrowed(_)
1768 ));
1769 assert!(matches!(
1770 unescape("C:\\foo\\bar"),
1771 std::borrow::Cow::Borrowed(_)
1772 ));
1773 assert!(matches!(unescape("\\d+"), std::borrow::Cow::Borrowed(_)));
1774 assert!(matches!(
1775 unescape("trailing\\"),
1776 std::borrow::Cow::Borrowed(_)
1777 ));
1778 assert!(matches!(unescape("\\["), std::borrow::Cow::Owned(_)));
1780 assert!(matches!(unescape("\\]"), std::borrow::Cow::Owned(_)));
1781 }
1782
1783 #[test]
1784 fn trailing_backslash_is_literal() {
1785 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1786 assert_eq!(parser.parse("end\\"), "end\\");
1787 }
1788
1789 #[test]
1790 fn double_backslash_then_open_emits_backslash_then_literal_bracket() {
1791 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1794 assert_eq!(parser.parse("\\\\[bold]"), "\\[bold]");
1795 }
1796
1797 #[test]
1798 fn escaped_brackets_dont_create_unknown_tags() {
1799 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1800 let (output, errors) = parser.parse_with_diagnostics("\\[unknown\\]");
1801 assert_eq!(output, "[unknown]");
1802 assert!(errors.is_empty());
1803 }
1804
1805 #[test]
1806 fn escapes_pass_validation() {
1807 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1808 assert!(parser.validate("\\[anything\\]").is_ok());
1809 assert!(parser.validate("[bold]a\\[b\\]c[/bold]").is_ok());
1810 }
1811
1812 #[test]
1813 fn strip_tags_handles_escapes() {
1814 assert_eq!(strip_tags("\\[bold\\]"), "[bold]");
1815 assert_eq!(strip_tags("[bold]a\\[b\\]c[/bold]"), "a[b]c");
1816 }
1817
1818 #[test]
1819 fn escape_does_not_apply_inside_tag_name() {
1820 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1824 assert_eq!(parser.parse("[bo\\ld]"), "[bo\\ld]");
1825 }
1826
1827 #[test]
1828 fn escapes_with_multibyte_text() {
1829 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1830 assert_eq!(parser.parse("café \\[é\\] 🎉"), "café [é] 🎉");
1831 }
1832
1833 #[test]
1834 fn only_open_escaped_leaves_close_unmatched() {
1835 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1839 let (output, errors) = parser.parse_with_diagnostics("\\[bold]hi[/bold]");
1840 assert!(output.contains("[bold]hi"));
1841 assert!(output.contains("[/bold]"));
1842 assert!(!errors.is_empty());
1843 assert!(errors
1844 .errors
1845 .iter()
1846 .any(|e| e.kind == UnknownTagKind::UnexpectedClose));
1847 }
1848 }
1849
1850 mod tokenizer {
1853 use super::*;
1854
1855 #[test]
1856 fn tokenize_plain_text() {
1857 let tokens: Vec<_> = Tokenizer::new("hello world").collect();
1858 assert_eq!(
1859 tokens,
1860 vec![Token::Text {
1861 content: "hello world",
1862 start: 0,
1863 end: 11
1864 }]
1865 );
1866 }
1867
1868 #[test]
1869 fn tokenize_single_tag() {
1870 let tokens: Vec<_> = Tokenizer::new("[bold]hello[/bold]").collect();
1871 assert_eq!(
1872 tokens,
1873 vec![
1874 Token::OpenTag {
1875 name: "bold",
1876 start: 0,
1877 end: 6
1878 },
1879 Token::Text {
1880 content: "hello",
1881 start: 6,
1882 end: 11
1883 },
1884 Token::CloseTag {
1885 name: "bold",
1886 start: 11,
1887 end: 18
1888 },
1889 ]
1890 );
1891 }
1892
1893 #[test]
1894 fn tokenize_nested_tags() {
1895 let tokens: Vec<_> = Tokenizer::new("[a][b]x[/b][/a]").collect();
1896 assert_eq!(
1897 tokens,
1898 vec![
1899 Token::OpenTag {
1900 name: "a",
1901 start: 0,
1902 end: 3
1903 },
1904 Token::OpenTag {
1905 name: "b",
1906 start: 3,
1907 end: 6
1908 },
1909 Token::Text {
1910 content: "x",
1911 start: 6,
1912 end: 7
1913 },
1914 Token::CloseTag {
1915 name: "b",
1916 start: 7,
1917 end: 11
1918 },
1919 Token::CloseTag {
1920 name: "a",
1921 start: 11,
1922 end: 15
1923 },
1924 ]
1925 );
1926 }
1927
1928 #[test]
1929 fn tokenize_invalid_tag() {
1930 let tokens: Vec<_> = Tokenizer::new("[123]text[/123]").collect();
1931 assert_eq!(
1932 tokens,
1933 vec![
1934 Token::InvalidTag {
1935 content: "[123]",
1936 start: 0,
1937 end: 5
1938 },
1939 Token::Text {
1940 content: "text",
1941 start: 5,
1942 end: 9
1943 },
1944 Token::InvalidTag {
1945 content: "[/123]",
1946 start: 9,
1947 end: 15
1948 },
1949 ]
1950 );
1951 }
1952
1953 #[test]
1954 fn tokenize_mixed() {
1955 let tokens: Vec<_> = Tokenizer::new("a[b]c[/b]d").collect();
1956 assert_eq!(
1957 tokens,
1958 vec![
1959 Token::Text {
1960 content: "a",
1961 start: 0,
1962 end: 1
1963 },
1964 Token::OpenTag {
1965 name: "b",
1966 start: 1,
1967 end: 4
1968 },
1969 Token::Text {
1970 content: "c",
1971 start: 4,
1972 end: 5
1973 },
1974 Token::CloseTag {
1975 name: "b",
1976 start: 5,
1977 end: 9
1978 },
1979 Token::Text {
1980 content: "d",
1981 start: 9,
1982 end: 10
1983 },
1984 ]
1985 );
1986 }
1987 }
1988
1989 mod apply_mode {
1992 use super::*;
1993
1994 #[test]
1995 fn plain_text_unchanged() {
1996 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1997 assert_eq!(parser.parse("hello world"), "hello world");
1998 }
1999
2000 #[test]
2001 fn unknown_tag_passthrough_with_marker() {
2002 let parser = BBParser::new(test_styles(), TagTransform::Apply);
2003 let result = parser.parse("[unknown]text[/unknown]");
2004 assert!(result.contains("[unknown?]"));
2005 assert!(result.contains("[/unknown?]"));
2006 assert!(result.contains("text"));
2007 }
2008
2009 #[test]
2010 fn known_tag_applies_style() {
2011 let mut styles = HashMap::new();
2012 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
2013
2014 let parser = BBParser::new(styles, TagTransform::Apply);
2015 let result = parser.parse("[bold]hello[/bold]");
2016
2017 assert!(result.contains("\x1b[1m") || result.contains("hello"));
2018 }
2019 }
2020
2021 mod error_display {
2024 use super::*;
2025
2026 #[test]
2027 fn unknown_tag_error_display() {
2028 let error = UnknownTagError {
2029 tag: "foo".to_string(),
2030 kind: UnknownTagKind::Open,
2031 start: 0,
2032 end: 5,
2033 };
2034 let display = format!("{}", error);
2035 assert!(display.contains("foo"));
2036 assert!(display.contains("opening"));
2037 assert!(display.contains("0..5"));
2038 }
2039
2040 #[test]
2041 fn unknown_tag_errors_display() {
2042 let mut errors = UnknownTagErrors::new();
2043 errors.push(UnknownTagError {
2044 tag: "foo".to_string(),
2045 kind: UnknownTagKind::Open,
2046 start: 0,
2047 end: 5,
2048 });
2049 errors.push(UnknownTagError {
2050 tag: "foo".to_string(),
2051 kind: UnknownTagKind::Close,
2052 start: 9,
2053 end: 15,
2054 });
2055
2056 let display = format!("{}", errors);
2057 assert!(display.contains("2 unknown tag"));
2058 }
2059 }
2060}
2061
2062#[cfg(test)]
2063mod proptests {
2064 use super::*;
2065 use console::strip_ansi_codes;
2066 use proptest::prelude::*;
2067
2068 fn valid_tag_name() -> impl Strategy<Value = String> {
2069 "[a-z_][a-z0-9_-]{0,10}"
2070 }
2071
2072 fn plain_text() -> impl Strategy<Value = String> {
2073 "[a-zA-Z0-9 .,!?:;'\"]{0,50}"
2074 .prop_filter("no brackets", |s| !s.contains('[') && !s.contains(']'))
2075 }
2076
2077 fn ansi_control() -> impl Strategy<Value = &'static str> {
2078 prop::sample::select(vec![
2079 "\x1b[31m",
2080 "\x1b[0m",
2081 "\x1b(0",
2082 "\x1b(B",
2083 "\x1b)0",
2084 "\x1b)B",
2085 "\u{9b}31m",
2086 "\u{9b}0m",
2087 ])
2088 }
2089
2090 proptest! {
2091 #![proptest_config(ProptestConfig::with_cases(500))]
2092
2093 #[test]
2094 fn keep_mode_roundtrip(content in plain_text()) {
2095 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
2096 prop_assert_eq!(parser.parse(&content), content);
2097 }
2098
2099 #[test]
2100 fn remove_mode_plain_text_unchanged(content in plain_text()) {
2101 let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
2102 prop_assert_eq!(parser.parse(&content), content);
2103 }
2104
2105 #[test]
2106 fn styled_text_matches_established_ansi_stripping_semantics(
2107 prefix in prop::collection::vec(ansi_control(), 0..4),
2108 content in plain_text(),
2109 suffix in prop::collection::vec(ansi_control(), 0..4),
2110 ) {
2111 let input = format!(
2112 "{}[outer]{}[/outer]{}",
2113 prefix.concat(),
2114 content,
2115 suffix.concat()
2116 );
2117 let styled = StyledText::parse(&input);
2118 let mut visible = String::new();
2119 styled.visit_visible_chars(|character| visible.push(character));
2120 let expected = strip_tags(&strip_ansi_codes(&input));
2121
2122 prop_assert_eq!(&visible, &expected);
2123 if !visible.is_empty() {
2124 prop_assert_eq!(styled.select_range(0..visible.chars().count()), input);
2125 }
2126 }
2127
2128 #[test]
2129 fn valid_tag_names_accepted(tag in valid_tag_name()) {
2130 prop_assert!(Tokenizer::is_valid_tag_name(&tag));
2131 }
2132
2133 #[test]
2134 fn remove_strips_known_tags(tag in valid_tag_name(), content in plain_text()) {
2135 let mut styles = HashMap::new();
2136 styles.insert(tag.clone(), Style::new());
2137
2138 let parser = BBParser::new(styles, TagTransform::Remove);
2139 let input = format!("[{}]{}[/{}]", tag, content, tag);
2140 let result = parser.parse(&input);
2141
2142 prop_assert_eq!(result, content);
2143 }
2144
2145 #[test]
2146 fn keep_preserves_structure(tag in valid_tag_name(), content in plain_text()) {
2147 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
2148 let input = format!("[{}]{}[/{}]", tag, content, tag);
2149 let result = parser.parse(&input);
2150
2151 prop_assert_eq!(result, input);
2152 }
2153
2154 #[test]
2155 fn nested_tags_balanced(
2156 outer in valid_tag_name(),
2157 inner in valid_tag_name(),
2158 content in plain_text()
2159 ) {
2160 let mut styles = HashMap::new();
2161 styles.insert(outer.clone(), Style::new());
2162 styles.insert(inner.clone(), Style::new());
2163
2164 let parser = BBParser::new(styles, TagTransform::Remove);
2165 let input = format!("[{}][{}]{}[/{}][/{}]", outer, inner, content, inner, outer);
2166 let result = parser.parse(&input);
2167
2168 prop_assert_eq!(result, content);
2169 }
2170
2171 #[test]
2172 fn validate_finds_unknown_tags(tag in valid_tag_name(), content in plain_text()) {
2173 let parser = BBParser::new(HashMap::new(), TagTransform::Apply);
2174 let input = format!("[{}]{}[/{}]", tag, content, tag);
2175 let result = parser.validate(&input);
2176
2177 prop_assert!(result.is_err());
2178 let errors = result.unwrap_err();
2179 prop_assert_eq!(errors.len(), 2); }
2181
2182 #[test]
2183 fn invalid_start_digit_rejected(n in 0..10u8, rest in "[a-z0-9_-]{0,5}") {
2184 let tag = format!("{}{}", n, rest);
2185 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
2186 }
2187
2188 #[test]
2189 fn invalid_start_hyphen_rejected(rest in "[a-z0-9_-]{0,5}") {
2190 let tag = format!("-{}", rest);
2191 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
2192 }
2193
2194 #[test]
2195 fn uppercase_rejected(tag in "[A-Z][a-zA-Z0-9_-]{0,5}") {
2196 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
2197 }
2198 }
2199}