1use console::Style;
68use std::collections::HashMap;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TagTransform {
73 Apply,
76
77 Remove,
80
81 Keep,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum UnknownTagBehavior {
89 #[default]
94 Passthrough,
95
96 Strip,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum UnknownTagKind {
106 Open,
108 Close,
110 Unbalanced,
112 UnexpectedClose,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct UnknownTagError {
119 pub tag: String,
121 pub kind: UnknownTagKind,
123 pub start: usize,
125 pub end: usize,
127}
128
129impl std::fmt::Display for UnknownTagError {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 let kind = match self.kind {
132 UnknownTagKind::Open => "unknown opening",
133 UnknownTagKind::Close => "unknown closing",
134 UnknownTagKind::Unbalanced => "unbalanced",
135 UnknownTagKind::UnexpectedClose => "unexpected closing",
136 };
137 write!(
138 f,
139 "{} tag '{}' at position {}..{}",
140 kind, self.tag, self.start, self.end
141 )
142 }
143}
144
145impl std::error::Error for UnknownTagError {}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct UnknownTagErrors {
150 pub errors: Vec<UnknownTagError>,
152}
153
154impl UnknownTagErrors {
155 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn is_empty(&self) -> bool {
162 self.errors.is_empty()
163 }
164
165 pub fn len(&self) -> usize {
167 self.errors.len()
168 }
169
170 pub fn push(&mut self, error: UnknownTagError) {
172 self.errors.push(error);
173 }
174}
175
176impl std::fmt::Display for UnknownTagErrors {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 writeln!(f, "found {} unknown tag(s):", self.errors.len())?;
179 for error in &self.errors {
180 writeln!(f, " - {}", error)?;
181 }
182 Ok(())
183 }
184}
185
186impl std::error::Error for UnknownTagErrors {}
187
188impl IntoIterator for UnknownTagErrors {
189 type Item = UnknownTagError;
190 type IntoIter = std::vec::IntoIter<UnknownTagError>;
191
192 fn into_iter(self) -> Self::IntoIter {
193 self.errors.into_iter()
194 }
195}
196
197impl<'a> IntoIterator for &'a UnknownTagErrors {
198 type Item = &'a UnknownTagError;
199 type IntoIter = std::slice::Iter<'a, UnknownTagError>;
200
201 fn into_iter(self) -> Self::IntoIter {
202 self.errors.iter()
203 }
204}
205
206pub fn strip_tags(input: &str) -> String {
223 let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
224 .unknown_behavior(UnknownTagBehavior::Strip);
225 parser.parse(input)
226}
227
228#[derive(Debug, Clone)]
233pub struct BBParser {
234 styles: HashMap<String, Style>,
235 transform: TagTransform,
236 unknown_behavior: UnknownTagBehavior,
237}
238
239impl BBParser {
240 pub fn new(styles: HashMap<String, Style>, transform: TagTransform) -> Self {
250 Self {
251 styles,
252 transform,
253 unknown_behavior: UnknownTagBehavior::default(),
254 }
255 }
256
257 pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
272 self.unknown_behavior = behavior;
273 self
274 }
275
276 pub fn parse(&self, input: &str) -> String {
280 let (output, _) = self.parse_internal(input);
281 output
282 }
283
284 pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
302 self.parse_internal(input)
303 }
304
305 pub fn validate(&self, input: &str) -> Result<(), UnknownTagErrors> {
329 let (_, errors) = self.parse_internal(input);
330 if errors.is_empty() {
331 Ok(())
332 } else {
333 Err(errors)
334 }
335 }
336
337 fn parse_internal(&self, input: &str) -> (String, UnknownTagErrors) {
339 let tokens = Tokenizer::new(input).collect::<Vec<_>>();
340 let valid_opens = self.compute_valid_tags(&tokens);
341 let mut events = Vec::new();
342 let mut errors = UnknownTagErrors::new();
343 let mut stack: Vec<&str> = Vec::new();
344
345 let mut i = 0;
348 while i < tokens.len() {
349 match &tokens[i] {
350 Token::Text { content, .. } => {
351 events.push(ParseEvent::Literal(unescape(content)));
352 }
353 Token::OpenTag { name, start, end } => {
354 if valid_opens.contains(&i) {
355 stack.push(name);
356 self.emit_open_tag_event(&mut events, &mut errors, name, *start, *end);
357 } else {
358 let is_valid_name = Tokenizer::is_valid_tag_name(name);
360 if is_valid_name {
361 errors.push(UnknownTagError {
363 tag: name.to_string(),
364 kind: UnknownTagKind::Unbalanced, start: *start,
366 end: *end,
367 });
368 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
373 "[{}]",
374 name
375 ))));
376 } else {
377 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
378 "[{}]",
379 name
380 ))));
381 }
382 }
383 }
384 Token::CloseTag { name, start, end } => {
385 if stack.last().copied() == Some(*name) {
386 stack.pop();
387 self.emit_close_tag_event(&mut events, &mut errors, name, *start, *end);
388 } else if stack.contains(name) {
389 while let Some(open) = stack.pop() {
390 self.emit_close_tag_event(&mut events, &mut errors, open, 0, 0);
391 if open == *name {
392 break;
393 }
394 }
395 } else {
396 let is_valid_name = Tokenizer::is_valid_tag_name(name);
398 if is_valid_name {
399 errors.push(UnknownTagError {
400 tag: name.to_string(),
401 kind: UnknownTagKind::UnexpectedClose, start: *start,
403 end: *end,
404 });
405 }
406 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
407 "[/{}]",
408 name
409 ))));
410 }
411 }
412 Token::InvalidTag { content, .. } => {
413 events.push(ParseEvent::Literal(std::borrow::Cow::Borrowed(content)));
414 }
415 }
416 i += 1;
417 }
418
419 while let Some(tag) = stack.pop() {
420 self.emit_close_tag_event(&mut events, &mut errors, tag, 0, 0);
421 }
422
423 let output = self.render(events);
424 (output, errors)
425 }
426
427 fn emit_open_tag_event<'a>(
428 &self,
429 events: &mut Vec<ParseEvent<'a>>,
430 errors: &mut UnknownTagErrors,
431 tag: &'a str,
432 start: usize,
433 end: usize,
434 ) {
435 let is_known = self.styles.contains_key(tag);
436
437 if !is_known {
438 errors.push(UnknownTagError {
439 tag: tag.to_string(),
440 kind: UnknownTagKind::Open,
441 start,
442 end,
443 });
444 }
445
446 match self.transform {
447 TagTransform::Keep => {
448 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
449 "[{}]",
450 tag
451 ))));
452 }
453 TagTransform::Remove => {
454 }
456 TagTransform::Apply => {
457 if is_known {
458 events.push(ParseEvent::StyleStart(tag));
459 } else {
460 match self.unknown_behavior {
461 UnknownTagBehavior::Passthrough => {
462 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
463 "[{}?]",
464 tag
465 ))));
466 }
467 UnknownTagBehavior::Strip => {
468 }
470 }
471 }
472 }
473 }
474 }
475
476 fn emit_close_tag_event<'a>(
477 &self,
478 events: &mut Vec<ParseEvent<'a>>,
479 errors: &mut UnknownTagErrors,
480 tag: &'a str,
481 start: usize,
482 end: usize,
483 ) {
484 let is_known = self.styles.contains_key(tag);
485
486 if !is_known && end > 0 {
488 errors.push(UnknownTagError {
489 tag: tag.to_string(),
490 kind: UnknownTagKind::Close,
491 start,
492 end,
493 });
494 }
495
496 match self.transform {
497 TagTransform::Keep => {
498 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
499 "[/{}]",
500 tag
501 ))));
502 }
503 TagTransform::Remove => {
504 }
506 TagTransform::Apply => {
507 if is_known {
508 events.push(ParseEvent::StyleEnd(tag));
509 } else {
510 match self.unknown_behavior {
511 UnknownTagBehavior::Passthrough => {
512 events.push(ParseEvent::Literal(std::borrow::Cow::Owned(format!(
513 "[/{}?]",
514 tag
515 ))));
516 }
517 UnknownTagBehavior::Strip => {
518 }
520 }
521 }
522 }
523 }
524 }
525
526 fn render(&self, events: Vec<ParseEvent>) -> String {
528 let mut result = String::new();
529 let mut style_stack: Vec<&Style> = Vec::new();
530
531 for event in events {
532 match event {
533 ParseEvent::Literal(text) => {
534 self.append_styled(&mut result, &text, &style_stack);
535 }
536 ParseEvent::StyleStart(tag) => {
537 if let Some(style) = self.styles.get(tag) {
538 style_stack.push(style);
539 }
540 }
541 ParseEvent::StyleEnd(tag) => {
542 if self.styles.contains_key(tag) {
543 style_stack.pop();
544 }
545 }
546 }
547 }
548 result
549 }
550
551 fn compute_valid_tags(&self, tokens: &[Token]) -> std::collections::HashSet<usize> {
554 use std::collections::{HashMap, HashSet};
555 let mut valid_indices = HashSet::new();
556 let mut open_indices_by_tag: HashMap<&str, Vec<usize>> = HashMap::new();
557
558 for (i, token) in tokens.iter().enumerate() {
559 match token {
560 Token::OpenTag { name, .. } => {
561 open_indices_by_tag.entry(name).or_default().push(i);
562 }
563 Token::CloseTag { name, .. } => {
564 if let Some(indices) = open_indices_by_tag.get_mut(name) {
565 if let Some(open_idx) = indices.pop() {
566 valid_indices.insert(open_idx);
567 }
568 }
569 }
570 _ => {}
571 }
572 }
573
574 valid_indices
575 }
576
577 fn append_styled(&self, output: &mut String, text: &str, style_stack: &[&Style]) {
579 if text.is_empty() {
580 return;
581 }
582
583 if style_stack.is_empty() {
584 output.push_str(text);
585 } else {
586 let mut current = text.to_string();
587 for style in style_stack.iter().rev() {
591 if current.ends_with("\x1b[0m") {
592 current.truncate(current.len() - 4);
593 }
594 current = style.apply_to(current).to_string();
595 }
596 output.push_str(¤t);
597 }
598 }
599}
600
601enum ParseEvent<'a> {
602 Literal(std::borrow::Cow<'a, str>),
603 StyleStart(&'a str),
604 StyleEnd(&'a str),
605}
606
607#[derive(Debug, Clone, PartialEq, Eq)]
609enum Token<'a> {
610 Text {
612 content: &'a str,
613 start: usize,
614 end: usize,
615 },
616 OpenTag {
618 name: &'a str,
619 start: usize,
620 end: usize,
621 },
622 CloseTag {
624 name: &'a str,
625 start: usize,
626 end: usize,
627 },
628 InvalidTag {
630 content: &'a str,
631 start: usize,
632 end: usize,
633 },
634}
635
636fn find_unescaped_bracket(s: &str) -> Option<usize> {
645 let bytes = s.as_bytes();
646 let mut i = 0;
647 while i < bytes.len() {
648 if bytes[i] == b'\\' && i + 1 < bytes.len() {
649 let next = bytes[i + 1];
650 if next == b'[' || next == b']' {
651 i += 2;
652 continue;
653 }
654 }
655 if bytes[i] == b'[' {
656 return Some(i);
657 }
658 i += 1;
659 }
660 None
661}
662
663fn unescape(s: &str) -> std::borrow::Cow<'_, str> {
669 let bytes = s.as_bytes();
670 let has_escape = bytes
671 .windows(2)
672 .any(|w| w[0] == b'\\' && (w[1] == b'[' || w[1] == b']'));
673 if !has_escape {
674 return std::borrow::Cow::Borrowed(s);
675 }
676 let mut out = String::with_capacity(s.len());
677 let mut chars = s.chars().peekable();
678 while let Some(c) = chars.next() {
679 if c == '\\' {
680 if let Some(&next) = chars.peek() {
681 if next == '[' || next == ']' {
682 out.push(next);
683 chars.next();
684 continue;
685 }
686 }
687 }
688 out.push(c);
689 }
690 std::borrow::Cow::Owned(out)
691}
692
693struct Tokenizer<'a> {
695 input: &'a str,
696 pos: usize,
697}
698
699impl<'a> Tokenizer<'a> {
700 fn new(input: &'a str) -> Self {
701 Self { input, pos: 0 }
702 }
703
704 fn is_valid_tag_name(s: &str) -> bool {
706 if s.is_empty() {
707 return false;
708 }
709
710 let mut chars = s.chars();
711 let first = chars.next().unwrap();
712
713 if !first.is_ascii_lowercase() && first != '_' {
715 return false;
716 }
717
718 for c in chars {
720 if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '-' {
721 return false;
722 }
723 }
724
725 true
726 }
727}
728
729impl<'a> Iterator for Tokenizer<'a> {
730 type Item = Token<'a>;
731
732 fn next(&mut self) -> Option<Self::Item> {
733 if self.pos >= self.input.len() {
734 return None;
735 }
736
737 let remaining = &self.input[self.pos..];
738 let start_pos = self.pos;
739
740 if let Some(bracket_pos) = find_unescaped_bracket(remaining) {
743 if bracket_pos > 0 {
744 let text = &remaining[..bracket_pos];
746 self.pos += bracket_pos;
747 return Some(Token::Text {
748 content: text,
749 start: start_pos,
750 end: self.pos,
751 });
752 }
753
754 if let Some(close_bracket) = remaining.find(']') {
757 let tag_content = &remaining[1..close_bracket];
758 let full_tag = &remaining[..=close_bracket];
759 let end_pos = start_pos + close_bracket + 1;
760
761 if let Some(tag_name) = tag_content.strip_prefix('/') {
763 if Self::is_valid_tag_name(tag_name) {
764 self.pos = end_pos;
765 Some(Token::CloseTag {
766 name: tag_name,
767 start: start_pos,
768 end: end_pos,
769 })
770 } else {
771 self.pos = end_pos;
772 Some(Token::InvalidTag {
773 content: full_tag,
774 start: start_pos,
775 end: end_pos,
776 })
777 }
778 } else if Self::is_valid_tag_name(tag_content) {
779 self.pos = end_pos;
780 Some(Token::OpenTag {
781 name: tag_content,
782 start: start_pos,
783 end: end_pos,
784 })
785 } else {
786 self.pos = end_pos;
787 Some(Token::InvalidTag {
788 content: full_tag,
789 start: start_pos,
790 end: end_pos,
791 })
792 }
793 } else {
794 let end_pos = self.input.len();
796 self.pos = end_pos;
797 Some(Token::Text {
798 content: remaining,
799 start: start_pos,
800 end: end_pos,
801 })
802 }
803 } else {
804 let end_pos = self.input.len();
806 self.pos = end_pos;
807 Some(Token::Text {
808 content: remaining,
809 start: start_pos,
810 end: end_pos,
811 })
812 }
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819
820 fn test_styles() -> HashMap<String, Style> {
821 let mut styles = HashMap::new();
822 styles.insert("bold".to_string(), Style::new().bold());
823 styles.insert("red".to_string(), Style::new().red());
824 styles.insert("dim".to_string(), Style::new().dim());
825 styles.insert("title".to_string(), Style::new().cyan().bold());
826 styles.insert("error".to_string(), Style::new().red().bold());
827 styles.insert("my_style".to_string(), Style::new().green());
828 styles.insert("style-with-dash".to_string(), Style::new().yellow());
829 styles
830 }
831
832 mod strip_tags_tests {
835 use super::super::strip_tags;
836
837 #[test]
838 fn strips_known_style_tags() {
839 assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
840 }
841
842 #[test]
843 fn strips_unknown_tags() {
844 assert_eq!(strip_tags("[additions]+32[/additions]"), "+32");
845 }
846
847 #[test]
848 fn strips_multiple_tags() {
849 assert_eq!(
850 strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"),
851 "+32/-0/32"
852 );
853 }
854
855 #[test]
856 fn plain_text_unchanged() {
857 assert_eq!(strip_tags("no tags here"), "no tags here");
858 }
859
860 #[test]
861 fn empty_string() {
862 assert_eq!(strip_tags(""), "");
863 }
864
865 #[test]
866 fn nested_tags() {
867 assert_eq!(strip_tags("[a][b]text[/b][/a]"), "text");
868 }
869 }
870
871 mod keep_mode {
874 use super::*;
875
876 #[test]
877 fn plain_text_unchanged() {
878 let parser = BBParser::new(test_styles(), TagTransform::Keep);
879 assert_eq!(parser.parse("hello world"), "hello world");
880 }
881
882 #[test]
883 fn single_tag_preserved() {
884 let parser = BBParser::new(test_styles(), TagTransform::Keep);
885 assert_eq!(parser.parse("[bold]hello[/bold]"), "[bold]hello[/bold]");
886 }
887
888 #[test]
889 fn nested_tags_preserved() {
890 let parser = BBParser::new(test_styles(), TagTransform::Keep);
891 assert_eq!(
892 parser.parse("[bold][red]hello[/red][/bold]"),
893 "[bold][red]hello[/red][/bold]"
894 );
895 }
896
897 #[test]
898 fn adjacent_tags_preserved() {
899 let parser = BBParser::new(test_styles(), TagTransform::Keep);
900 assert_eq!(
901 parser.parse("[bold]a[/bold][red]b[/red]"),
902 "[bold]a[/bold][red]b[/red]"
903 );
904 }
905
906 #[test]
907 fn text_around_tags() {
908 let parser = BBParser::new(test_styles(), TagTransform::Keep);
909 assert_eq!(
910 parser.parse("before [bold]middle[/bold] after"),
911 "before [bold]middle[/bold] after"
912 );
913 }
914
915 #[test]
916 fn unknown_tags_preserved() {
917 let parser = BBParser::new(test_styles(), TagTransform::Keep);
918 assert_eq!(
919 parser.parse("[unknown]text[/unknown]"),
920 "[unknown]text[/unknown]"
921 );
922 }
923 }
924
925 mod remove_mode {
928 use super::*;
929
930 #[test]
931 fn plain_text_unchanged() {
932 let parser = BBParser::new(test_styles(), TagTransform::Remove);
933 assert_eq!(parser.parse("hello world"), "hello world");
934 }
935
936 #[test]
937 fn single_tag_stripped() {
938 let parser = BBParser::new(test_styles(), TagTransform::Remove);
939 assert_eq!(parser.parse("[bold]hello[/bold]"), "hello");
940 }
941
942 #[test]
943 fn nested_tags_stripped() {
944 let parser = BBParser::new(test_styles(), TagTransform::Remove);
945 assert_eq!(parser.parse("[bold][red]hello[/red][/bold]"), "hello");
946 }
947
948 #[test]
949 fn adjacent_tags_stripped() {
950 let parser = BBParser::new(test_styles(), TagTransform::Remove);
951 assert_eq!(parser.parse("[bold]a[/bold][red]b[/red]"), "ab");
952 }
953
954 #[test]
955 fn text_around_tags() {
956 let parser = BBParser::new(test_styles(), TagTransform::Remove);
957 assert_eq!(
958 parser.parse("before [bold]middle[/bold] after"),
959 "before middle after"
960 );
961 }
962
963 #[test]
964 fn unknown_tags_stripped() {
965 let parser = BBParser::new(test_styles(), TagTransform::Remove);
966 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
968 }
969 }
970
971 mod unknown_tag_behavior {
974 use super::*;
975
976 #[test]
977 fn passthrough_adds_question_mark_in_apply_mode() {
978 let parser = BBParser::new(test_styles(), TagTransform::Apply)
979 .unknown_behavior(UnknownTagBehavior::Passthrough);
980 assert_eq!(
981 parser.parse("[unknown]text[/unknown]"),
982 "[unknown?]text[/unknown?]"
983 );
984 }
985
986 #[test]
987 fn passthrough_is_default() {
988 let parser = BBParser::new(test_styles(), TagTransform::Apply);
989 assert_eq!(
990 parser.parse("[unknown]text[/unknown]"),
991 "[unknown?]text[/unknown?]"
992 );
993 }
994
995 #[test]
996 fn strip_removes_unknown_tags_in_apply_mode() {
997 let parser = BBParser::new(test_styles(), TagTransform::Apply)
998 .unknown_behavior(UnknownTagBehavior::Strip);
999 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1000 }
1001
1002 #[test]
1003 fn passthrough_nested_with_known() {
1004 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1005 .unknown_behavior(UnknownTagBehavior::Passthrough);
1006 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
1007 assert!(result.contains("[unknown?]"));
1008 assert!(result.contains("[/unknown?]"));
1009 assert!(result.contains("text"));
1010 }
1011
1012 #[test]
1013 fn strip_nested_with_known() {
1014 let mut styles = HashMap::new();
1015 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1016 let parser = BBParser::new(styles, TagTransform::Apply)
1017 .unknown_behavior(UnknownTagBehavior::Strip);
1018 let result = parser.parse("[bold][unknown]text[/unknown][/bold]");
1019 assert!(!result.contains("[unknown"));
1021 assert!(result.contains("text"));
1022 }
1023
1024 #[test]
1025 fn keep_mode_ignores_unknown_behavior() {
1026 let parser = BBParser::new(test_styles(), TagTransform::Keep)
1028 .unknown_behavior(UnknownTagBehavior::Strip);
1029 assert_eq!(
1030 parser.parse("[unknown]text[/unknown]"),
1031 "[unknown]text[/unknown]"
1032 );
1033 }
1034
1035 #[test]
1036 fn remove_mode_always_strips_tags() {
1037 let parser = BBParser::new(test_styles(), TagTransform::Remove)
1039 .unknown_behavior(UnknownTagBehavior::Passthrough);
1040 assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1041 }
1042 }
1043
1044 mod validation {
1047 use super::*;
1048
1049 #[test]
1050 fn validate_all_known_tags_passes() {
1051 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1052 assert!(parser.validate("[bold]text[/bold]").is_ok());
1053 }
1054
1055 #[test]
1056 fn validate_nested_known_tags_passes() {
1057 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1058 assert!(parser.validate("[bold][red]text[/red][/bold]").is_ok());
1059 }
1060
1061 #[test]
1062 fn validate_unknown_tag_fails() {
1063 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1064 let result = parser.validate("[unknown]text[/unknown]");
1065 assert!(result.is_err());
1066 }
1067
1068 #[test]
1069 fn validate_returns_correct_error_count() {
1070 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1071 let result = parser.validate("[unknown]text[/unknown]");
1072 let errors = result.unwrap_err();
1073 assert_eq!(errors.len(), 2); }
1075
1076 #[test]
1077 fn validate_error_contains_tag_name() {
1078 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1079 let result = parser.validate("[foobar]text[/foobar]");
1080 let errors = result.unwrap_err();
1081 assert!(errors.errors.iter().all(|e| e.tag == "foobar"));
1082 }
1083
1084 #[test]
1085 fn validate_error_distinguishes_open_and_close() {
1086 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1087 let result = parser.validate("[unknown]text[/unknown]");
1088 let errors = result.unwrap_err();
1089
1090 let open_count = errors
1091 .errors
1092 .iter()
1093 .filter(|e| e.kind == UnknownTagKind::Open)
1094 .count();
1095 let close_count = errors
1096 .errors
1097 .iter()
1098 .filter(|e| e.kind == UnknownTagKind::Close)
1099 .count();
1100
1101 assert_eq!(open_count, 1);
1102 assert_eq!(close_count, 1);
1103 }
1104
1105 #[test]
1106 fn validate_error_has_correct_positions() {
1107 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1108 let input = "[unknown]text[/unknown]";
1109 let result = parser.validate(input);
1110 let errors = result.unwrap_err();
1111
1112 let open_error = errors
1113 .errors
1114 .iter()
1115 .find(|e| e.kind == UnknownTagKind::Open)
1116 .unwrap();
1117 assert_eq!(open_error.start, 0);
1118 assert_eq!(open_error.end, 9); let close_error = errors
1121 .errors
1122 .iter()
1123 .find(|e| e.kind == UnknownTagKind::Close)
1124 .unwrap();
1125 assert_eq!(close_error.start, 13);
1126 assert_eq!(close_error.end, 23); }
1128
1129 #[test]
1130 fn validate_multiple_unknown_tags() {
1131 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1132 let result = parser.validate("[foo]a[/foo][bar]b[/bar]");
1133 let errors = result.unwrap_err();
1134 assert_eq!(errors.len(), 4); let tags: std::collections::HashSet<_> =
1137 errors.errors.iter().map(|e| e.tag.as_str()).collect();
1138 assert!(tags.contains("foo"));
1139 assert!(tags.contains("bar"));
1140 }
1141
1142 #[test]
1143 fn validate_mixed_known_and_unknown() {
1144 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1145 let result = parser.validate("[bold][unknown]text[/unknown][/bold]");
1146 let errors = result.unwrap_err();
1147 assert_eq!(errors.len(), 2); for error in &errors.errors {
1150 assert_eq!(error.tag, "unknown");
1151 }
1152 }
1153
1154 #[test]
1155 fn validate_plain_text_passes() {
1156 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1157 assert!(parser.validate("plain text without tags").is_ok());
1158 }
1159
1160 #[test]
1161 fn validate_empty_string_passes() {
1162 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1163 assert!(parser.validate("").is_ok());
1164 }
1165 }
1166
1167 mod parse_with_diagnostics {
1170 use super::*;
1171
1172 #[test]
1173 fn returns_output_and_errors() {
1174 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1175 .unknown_behavior(UnknownTagBehavior::Passthrough);
1176 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1177
1178 assert_eq!(output, "[unknown?]text[/unknown?]");
1179 assert_eq!(errors.len(), 2);
1180 }
1181
1182 #[test]
1183 fn output_uses_strip_behavior() {
1184 let parser = BBParser::new(test_styles(), TagTransform::Apply)
1185 .unknown_behavior(UnknownTagBehavior::Strip);
1186 let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
1187
1188 assert_eq!(output, "text");
1189 assert_eq!(errors.len(), 2);
1190 }
1191
1192 #[test]
1193 fn no_errors_for_known_tags() {
1194 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1195 let (_, errors) = parser.parse_with_diagnostics("[bold]text[/bold]");
1196 assert!(errors.is_empty());
1197 }
1198
1199 #[test]
1200 fn errors_iterable() {
1201 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1202 let (_, errors) = parser.parse_with_diagnostics("[a]x[/a][b]y[/b]");
1203
1204 let mut count = 0;
1205 for error in &errors {
1206 assert!(error.tag == "a" || error.tag == "b");
1207 count += 1;
1208 }
1209 assert_eq!(count, 4);
1210 }
1211 }
1212
1213 mod tag_names {
1216 use super::*;
1217
1218 #[test]
1219 fn valid_simple_names() {
1220 assert!(Tokenizer::is_valid_tag_name("bold"));
1221 assert!(Tokenizer::is_valid_tag_name("red"));
1222 assert!(Tokenizer::is_valid_tag_name("a"));
1223 }
1224
1225 #[test]
1226 fn valid_with_underscore() {
1227 assert!(Tokenizer::is_valid_tag_name("my_style"));
1228 assert!(Tokenizer::is_valid_tag_name("_private"));
1229 assert!(Tokenizer::is_valid_tag_name("a_b_c"));
1230 }
1231
1232 #[test]
1233 fn valid_with_hyphen() {
1234 assert!(Tokenizer::is_valid_tag_name("my-style"));
1235 assert!(Tokenizer::is_valid_tag_name("font-bold"));
1236 assert!(Tokenizer::is_valid_tag_name("a-b-c"));
1237 }
1238
1239 #[test]
1240 fn valid_with_numbers() {
1241 assert!(Tokenizer::is_valid_tag_name("h1"));
1242 assert!(Tokenizer::is_valid_tag_name("col2"));
1243 assert!(Tokenizer::is_valid_tag_name("style123"));
1244 }
1245
1246 #[test]
1247 fn invalid_starts_with_digit() {
1248 assert!(!Tokenizer::is_valid_tag_name("1style"));
1249 assert!(!Tokenizer::is_valid_tag_name("123"));
1250 }
1251
1252 #[test]
1253 fn invalid_starts_with_hyphen() {
1254 assert!(!Tokenizer::is_valid_tag_name("-style"));
1255 assert!(!Tokenizer::is_valid_tag_name("-1"));
1256 }
1257
1258 #[test]
1259 fn invalid_uppercase() {
1260 assert!(!Tokenizer::is_valid_tag_name("Bold"));
1261 assert!(!Tokenizer::is_valid_tag_name("BOLD"));
1262 assert!(!Tokenizer::is_valid_tag_name("myStyle"));
1263 }
1264
1265 #[test]
1266 fn invalid_special_chars() {
1267 assert!(!Tokenizer::is_valid_tag_name("my.style"));
1268 assert!(!Tokenizer::is_valid_tag_name("my@style"));
1269 assert!(!Tokenizer::is_valid_tag_name("my style"));
1270 }
1271
1272 #[test]
1273 fn invalid_empty() {
1274 assert!(!Tokenizer::is_valid_tag_name(""));
1275 }
1276 }
1277
1278 mod edge_cases {
1281 use super::*;
1282
1283 #[test]
1284 fn empty_input() {
1285 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1286 assert_eq!(parser.parse(""), "");
1287 }
1288
1289 #[test]
1290 fn unclosed_tag_passthrough() {
1291 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1292 assert_eq!(parser.parse("[bold]hello"), "[bold]hello");
1293 }
1294
1295 #[test]
1296 fn orphan_close_tag_passthrough() {
1297 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1298 assert_eq!(parser.parse("hello[/bold]"), "hello[/bold]");
1299 }
1300
1301 #[test]
1302 fn mismatched_tags() {
1303 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1304 assert_eq!(
1305 parser.parse("[bold]hello[/red][/bold]"),
1306 "[bold]hello[/red][/bold]"
1307 );
1308 }
1309
1310 #[test]
1311 fn overlapping_tags_auto_close() {
1312 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1313 let result = parser.parse("[bold][red]hello[/bold][/red]");
1314 assert!(result.contains("hello"));
1315 }
1316
1317 #[test]
1318 fn empty_tag_content() {
1319 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1320 assert_eq!(parser.parse("[bold][/bold]"), "");
1321 }
1322
1323 #[test]
1324 fn brackets_in_content() {
1325 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1326 assert_eq!(parser.parse("[bold]array[0][/bold]"), "array[0]");
1327 }
1328
1329 #[test]
1330 fn invalid_tag_syntax_passthrough() {
1331 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1332 assert_eq!(parser.parse("[123]text[/123]"), "[123]text[/123]");
1333 assert_eq!(parser.parse("[-bad]text[/-bad]"), "[-bad]text[/-bad]");
1334 assert_eq!(parser.parse("[Bad]text[/Bad]"), "[Bad]text[/Bad]");
1335 }
1336
1337 #[test]
1338 fn deeply_nested() {
1339 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1340 assert_eq!(
1341 parser.parse("[bold][red][dim]deep[/dim][/red][/bold]"),
1342 "deep"
1343 );
1344 }
1345
1346 #[test]
1347 fn many_adjacent_tags() {
1348 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1349 assert_eq!(
1350 parser.parse("[bold]a[/bold][red]b[/red][dim]c[/dim]"),
1351 "abc"
1352 );
1353 }
1354
1355 #[test]
1356 fn unclosed_bracket() {
1357 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1358 assert_eq!(parser.parse("hello [bold world"), "hello [bold world");
1359 }
1360
1361 #[test]
1362 fn multiline_content() {
1363 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1364 assert_eq!(
1365 parser.parse("[bold]line1\nline2\nline3[/bold]"),
1366 "line1\nline2\nline3"
1367 );
1368 }
1369
1370 #[test]
1371 fn style_with_underscore() {
1372 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1373 assert_eq!(parser.parse("[my_style]text[/my_style]"), "text");
1374 }
1375
1376 #[test]
1377 fn style_with_dash() {
1378 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1379 assert_eq!(
1380 parser.parse("[style-with-dash]text[/style-with-dash]"),
1381 "text"
1382 );
1383 }
1384 }
1385
1386 mod escapes {
1389 use super::*;
1390
1391 #[test]
1392 fn escaped_open_bracket_is_literal() {
1393 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1394 assert_eq!(parser.parse("\\[bold\\]"), "[bold]");
1395 }
1396
1397 #[test]
1398 fn escaped_brackets_inside_known_tag() {
1399 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1400 assert_eq!(
1401 parser.parse("[bold]hello \\[world\\][/bold]"),
1402 "hello [world]"
1403 );
1404 }
1405
1406 #[test]
1407 fn escapes_keep_mode_emits_literal_brackets() {
1408 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1409 assert_eq!(parser.parse("\\[bold\\]"), "[bold]");
1410 }
1411
1412 #[test]
1413 fn escapes_apply_mode_styles_around_literals() {
1414 let mut styles = HashMap::new();
1415 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1416 let parser = BBParser::new(styles, TagTransform::Apply);
1417 let result = parser.parse("[bold]\\[x\\][/bold]");
1418 assert!(result.contains("[x]"));
1420 assert!(!result.contains("[bold]"));
1421 }
1422
1423 #[test]
1424 fn lone_backslash_is_literal() {
1425 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1426 assert_eq!(parser.parse("path C:\\foo\\bar"), "path C:\\foo\\bar");
1427 }
1428
1429 #[test]
1430 fn unescape_borrows_when_no_bracket_escape_present() {
1431 assert!(matches!(
1435 unescape("plain text"),
1436 std::borrow::Cow::Borrowed(_)
1437 ));
1438 assert!(matches!(
1439 unescape("C:\\foo\\bar"),
1440 std::borrow::Cow::Borrowed(_)
1441 ));
1442 assert!(matches!(unescape("\\d+"), std::borrow::Cow::Borrowed(_)));
1443 assert!(matches!(
1444 unescape("trailing\\"),
1445 std::borrow::Cow::Borrowed(_)
1446 ));
1447 assert!(matches!(unescape("\\["), std::borrow::Cow::Owned(_)));
1449 assert!(matches!(unescape("\\]"), std::borrow::Cow::Owned(_)));
1450 }
1451
1452 #[test]
1453 fn trailing_backslash_is_literal() {
1454 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1455 assert_eq!(parser.parse("end\\"), "end\\");
1456 }
1457
1458 #[test]
1459 fn double_backslash_then_open_emits_backslash_then_literal_bracket() {
1460 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1463 assert_eq!(parser.parse("\\\\[bold]"), "\\[bold]");
1464 }
1465
1466 #[test]
1467 fn escaped_brackets_dont_create_unknown_tags() {
1468 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1469 let (output, errors) = parser.parse_with_diagnostics("\\[unknown\\]");
1470 assert_eq!(output, "[unknown]");
1471 assert!(errors.is_empty());
1472 }
1473
1474 #[test]
1475 fn escapes_pass_validation() {
1476 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1477 assert!(parser.validate("\\[anything\\]").is_ok());
1478 assert!(parser.validate("[bold]a\\[b\\]c[/bold]").is_ok());
1479 }
1480
1481 #[test]
1482 fn strip_tags_handles_escapes() {
1483 assert_eq!(strip_tags("\\[bold\\]"), "[bold]");
1484 assert_eq!(strip_tags("[bold]a\\[b\\]c[/bold]"), "a[b]c");
1485 }
1486
1487 #[test]
1488 fn escape_does_not_apply_inside_tag_name() {
1489 let parser = BBParser::new(test_styles(), TagTransform::Keep);
1493 assert_eq!(parser.parse("[bo\\ld]"), "[bo\\ld]");
1494 }
1495
1496 #[test]
1497 fn escapes_with_multibyte_text() {
1498 let parser = BBParser::new(test_styles(), TagTransform::Remove);
1499 assert_eq!(parser.parse("café \\[é\\] 🎉"), "café [é] 🎉");
1500 }
1501
1502 #[test]
1503 fn only_open_escaped_leaves_close_unmatched() {
1504 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1508 let (output, errors) = parser.parse_with_diagnostics("\\[bold]hi[/bold]");
1509 assert!(output.contains("[bold]hi"));
1510 assert!(output.contains("[/bold]"));
1511 assert!(!errors.is_empty());
1512 assert!(errors
1513 .errors
1514 .iter()
1515 .any(|e| e.kind == UnknownTagKind::UnexpectedClose));
1516 }
1517 }
1518
1519 mod tokenizer {
1522 use super::*;
1523
1524 #[test]
1525 fn tokenize_plain_text() {
1526 let tokens: Vec<_> = Tokenizer::new("hello world").collect();
1527 assert_eq!(
1528 tokens,
1529 vec![Token::Text {
1530 content: "hello world",
1531 start: 0,
1532 end: 11
1533 }]
1534 );
1535 }
1536
1537 #[test]
1538 fn tokenize_single_tag() {
1539 let tokens: Vec<_> = Tokenizer::new("[bold]hello[/bold]").collect();
1540 assert_eq!(
1541 tokens,
1542 vec![
1543 Token::OpenTag {
1544 name: "bold",
1545 start: 0,
1546 end: 6
1547 },
1548 Token::Text {
1549 content: "hello",
1550 start: 6,
1551 end: 11
1552 },
1553 Token::CloseTag {
1554 name: "bold",
1555 start: 11,
1556 end: 18
1557 },
1558 ]
1559 );
1560 }
1561
1562 #[test]
1563 fn tokenize_nested_tags() {
1564 let tokens: Vec<_> = Tokenizer::new("[a][b]x[/b][/a]").collect();
1565 assert_eq!(
1566 tokens,
1567 vec![
1568 Token::OpenTag {
1569 name: "a",
1570 start: 0,
1571 end: 3
1572 },
1573 Token::OpenTag {
1574 name: "b",
1575 start: 3,
1576 end: 6
1577 },
1578 Token::Text {
1579 content: "x",
1580 start: 6,
1581 end: 7
1582 },
1583 Token::CloseTag {
1584 name: "b",
1585 start: 7,
1586 end: 11
1587 },
1588 Token::CloseTag {
1589 name: "a",
1590 start: 11,
1591 end: 15
1592 },
1593 ]
1594 );
1595 }
1596
1597 #[test]
1598 fn tokenize_invalid_tag() {
1599 let tokens: Vec<_> = Tokenizer::new("[123]text[/123]").collect();
1600 assert_eq!(
1601 tokens,
1602 vec![
1603 Token::InvalidTag {
1604 content: "[123]",
1605 start: 0,
1606 end: 5
1607 },
1608 Token::Text {
1609 content: "text",
1610 start: 5,
1611 end: 9
1612 },
1613 Token::InvalidTag {
1614 content: "[/123]",
1615 start: 9,
1616 end: 15
1617 },
1618 ]
1619 );
1620 }
1621
1622 #[test]
1623 fn tokenize_mixed() {
1624 let tokens: Vec<_> = Tokenizer::new("a[b]c[/b]d").collect();
1625 assert_eq!(
1626 tokens,
1627 vec![
1628 Token::Text {
1629 content: "a",
1630 start: 0,
1631 end: 1
1632 },
1633 Token::OpenTag {
1634 name: "b",
1635 start: 1,
1636 end: 4
1637 },
1638 Token::Text {
1639 content: "c",
1640 start: 4,
1641 end: 5
1642 },
1643 Token::CloseTag {
1644 name: "b",
1645 start: 5,
1646 end: 9
1647 },
1648 Token::Text {
1649 content: "d",
1650 start: 9,
1651 end: 10
1652 },
1653 ]
1654 );
1655 }
1656 }
1657
1658 mod apply_mode {
1661 use super::*;
1662
1663 #[test]
1664 fn plain_text_unchanged() {
1665 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1666 assert_eq!(parser.parse("hello world"), "hello world");
1667 }
1668
1669 #[test]
1670 fn unknown_tag_passthrough_with_marker() {
1671 let parser = BBParser::new(test_styles(), TagTransform::Apply);
1672 let result = parser.parse("[unknown]text[/unknown]");
1673 assert!(result.contains("[unknown?]"));
1674 assert!(result.contains("[/unknown?]"));
1675 assert!(result.contains("text"));
1676 }
1677
1678 #[test]
1679 fn known_tag_applies_style() {
1680 let mut styles = HashMap::new();
1681 styles.insert("bold".to_string(), Style::new().bold().force_styling(true));
1682
1683 let parser = BBParser::new(styles, TagTransform::Apply);
1684 let result = parser.parse("[bold]hello[/bold]");
1685
1686 assert!(result.contains("\x1b[1m") || result.contains("hello"));
1687 }
1688 }
1689
1690 mod error_display {
1693 use super::*;
1694
1695 #[test]
1696 fn unknown_tag_error_display() {
1697 let error = UnknownTagError {
1698 tag: "foo".to_string(),
1699 kind: UnknownTagKind::Open,
1700 start: 0,
1701 end: 5,
1702 };
1703 let display = format!("{}", error);
1704 assert!(display.contains("foo"));
1705 assert!(display.contains("opening"));
1706 assert!(display.contains("0..5"));
1707 }
1708
1709 #[test]
1710 fn unknown_tag_errors_display() {
1711 let mut errors = UnknownTagErrors::new();
1712 errors.push(UnknownTagError {
1713 tag: "foo".to_string(),
1714 kind: UnknownTagKind::Open,
1715 start: 0,
1716 end: 5,
1717 });
1718 errors.push(UnknownTagError {
1719 tag: "foo".to_string(),
1720 kind: UnknownTagKind::Close,
1721 start: 9,
1722 end: 15,
1723 });
1724
1725 let display = format!("{}", errors);
1726 assert!(display.contains("2 unknown tag"));
1727 }
1728 }
1729}
1730
1731#[cfg(test)]
1732mod proptests {
1733 use super::*;
1734 use proptest::prelude::*;
1735
1736 fn valid_tag_name() -> impl Strategy<Value = String> {
1737 "[a-z_][a-z0-9_-]{0,10}"
1738 }
1739
1740 fn plain_text() -> impl Strategy<Value = String> {
1741 "[a-zA-Z0-9 .,!?:;'\"]{0,50}"
1742 .prop_filter("no brackets", |s| !s.contains('[') && !s.contains(']'))
1743 }
1744
1745 proptest! {
1746 #![proptest_config(ProptestConfig::with_cases(500))]
1747
1748 #[test]
1749 fn keep_mode_roundtrip(content in plain_text()) {
1750 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1751 prop_assert_eq!(parser.parse(&content), content);
1752 }
1753
1754 #[test]
1755 fn remove_mode_plain_text_unchanged(content in plain_text()) {
1756 let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
1757 prop_assert_eq!(parser.parse(&content), content);
1758 }
1759
1760 #[test]
1761 fn valid_tag_names_accepted(tag in valid_tag_name()) {
1762 prop_assert!(Tokenizer::is_valid_tag_name(&tag));
1763 }
1764
1765 #[test]
1766 fn remove_strips_known_tags(tag in valid_tag_name(), content in plain_text()) {
1767 let mut styles = HashMap::new();
1768 styles.insert(tag.clone(), Style::new());
1769
1770 let parser = BBParser::new(styles, TagTransform::Remove);
1771 let input = format!("[{}]{}[/{}]", tag, content, tag);
1772 let result = parser.parse(&input);
1773
1774 prop_assert_eq!(result, content);
1775 }
1776
1777 #[test]
1778 fn keep_preserves_structure(tag in valid_tag_name(), content in plain_text()) {
1779 let parser = BBParser::new(HashMap::new(), TagTransform::Keep);
1780 let input = format!("[{}]{}[/{}]", tag, content, tag);
1781 let result = parser.parse(&input);
1782
1783 prop_assert_eq!(result, input);
1784 }
1785
1786 #[test]
1787 fn nested_tags_balanced(
1788 outer in valid_tag_name(),
1789 inner in valid_tag_name(),
1790 content in plain_text()
1791 ) {
1792 let mut styles = HashMap::new();
1793 styles.insert(outer.clone(), Style::new());
1794 styles.insert(inner.clone(), Style::new());
1795
1796 let parser = BBParser::new(styles, TagTransform::Remove);
1797 let input = format!("[{}][{}]{}[/{}][/{}]", outer, inner, content, inner, outer);
1798 let result = parser.parse(&input);
1799
1800 prop_assert_eq!(result, content);
1801 }
1802
1803 #[test]
1804 fn validate_finds_unknown_tags(tag in valid_tag_name(), content in plain_text()) {
1805 let parser = BBParser::new(HashMap::new(), TagTransform::Apply);
1806 let input = format!("[{}]{}[/{}]", tag, content, tag);
1807 let result = parser.validate(&input);
1808
1809 prop_assert!(result.is_err());
1810 let errors = result.unwrap_err();
1811 prop_assert_eq!(errors.len(), 2); }
1813
1814 #[test]
1815 fn invalid_start_digit_rejected(n in 0..10u8, rest in "[a-z0-9_-]{0,5}") {
1816 let tag = format!("{}{}", n, rest);
1817 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1818 }
1819
1820 #[test]
1821 fn invalid_start_hyphen_rejected(rest in "[a-z0-9_-]{0,5}") {
1822 let tag = format!("-{}", rest);
1823 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1824 }
1825
1826 #[test]
1827 fn uppercase_rejected(tag in "[A-Z][a-zA-Z0-9_-]{0,5}") {
1828 prop_assert!(!Tokenizer::is_valid_tag_name(&tag));
1829 }
1830 }
1831}