Skip to main content

standout_bbparser/
lib.rs

1//! BBCode-style tag parser for terminal styling.
2//!
3//! This crate provides a parser for `[tag]content[/tag]` style markup,
4//! designed for terminal output styling. It handles nested tags correctly
5//! and supports multiple output modes.
6//!
7//! # Example
8//!
9//! ```rust
10//! use standout_bbparser::{BBParser, TagTransform};
11//! use console::Style;
12//! use std::collections::HashMap;
13//!
14//! let mut styles = HashMap::new();
15//! styles.insert("bold".to_string(), Style::new().bold());
16//! styles.insert("red".to_string(), Style::new().red());
17//!
18//! // Apply ANSI codes
19//! let parser = BBParser::new(styles.clone(), TagTransform::Apply);
20//! let output = parser.parse("[bold]hello[/bold]");
21//! // output contains ANSI escape codes for bold
22//!
23//! // Strip tags (plain text)
24//! let parser = BBParser::new(styles.clone(), TagTransform::Remove);
25//! let output = parser.parse("[bold]hello[/bold]");
26//! assert_eq!(output, "hello");
27//!
28//! // Keep tags visible (debug mode)
29//! let parser = BBParser::new(styles, TagTransform::Keep);
30//! let output = parser.parse("[bold]hello[/bold]");
31//! assert_eq!(output, "[bold]hello[/bold]");
32//! ```
33//!
34//! # Unknown Tag Handling
35//!
36//! Tags not found in the styles map can be handled in two ways:
37//!
38//! - [`UnknownTagBehavior::Passthrough`]: Keep tags with a `?` marker: `[foo]` → `[foo?]`
39//! - [`UnknownTagBehavior::Strip`]: Remove tags entirely, keep content: `[foo]text[/foo]` → `text`
40//!
41//! For validation, use [`BBParser::validate`] to check for unknown tags before parsing.
42//!
43//! # Tag Name Syntax
44//!
45//! Tag names follow CSS identifier rules:
46//! - Start with a letter (`a-z`) or underscore (`_`)
47//! - Followed by letters, digits (`0-9`), underscores, or hyphens (`-`)
48//! - Cannot start with a digit or hyphen followed by digit
49//! - Case-sensitive (lowercase recommended)
50//!
51//! Pattern: `[a-z_][a-z0-9_-]*`
52//!
53//! # Escaping
54//!
55//! To emit a literal `[` or `]` without it being treated as a tag delimiter,
56//! prefix it with a backslash:
57//!
58//! - `\[` → `[`
59//! - `\]` → `]`
60//!
61//! A backslash that is not followed by `[` or `]` is left alone, so file
62//! paths, regex examples, and other content containing `\` pass through
63//! unchanged. To emit a literal `\[` in the output, write `\\[` (the first
64//! `\` is kept as-is because `\\` is not a recognized escape, then `\[` is
65//! consumed as an escape and emits `[`).
66
67use console::{AnsiCodeIterator, Style};
68use std::collections::HashMap;
69use std::ops::Range;
70
71/// How to transform matched tags in the output.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum TagTransform {
74    /// Apply ANSI escape codes from the associated Style.
75    /// Used for terminal output with color support.
76    Apply,
77
78    /// Remove all tags, outputting only the content.
79    /// Used for plain text output without styling.
80    Remove,
81
82    /// Keep tags as-is in the output.
83    /// Used for debug mode to visualize tag structure.
84    Keep,
85}
86
87/// How to handle tags not found in the styles map.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum UnknownTagBehavior {
90    /// Keep unknown tags as literal text with a `?` marker.
91    /// `[foo]text[/foo]` → `[foo?]text[/foo?]`
92    ///
93    /// This makes unknown tags visible without breaking output.
94    #[default]
95    Passthrough,
96
97    /// Strip unknown tags entirely, keeping only inner content.
98    /// `[foo]text[/foo]` → `text`
99    ///
100    /// Use this for graceful degradation in production.
101    Strip,
102}
103
104/// The kind of unknown tag encountered.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum UnknownTagKind {
107    /// An opening tag: `[foo]`
108    Open,
109    /// A closing tag: `[/foo]`
110    Close,
111    /// An unbalanced opening tag: `[foo]...` (no matching close)
112    Unbalanced,
113    /// An unexpected closing tag: `...[/foo]` (no matching open)
114    UnexpectedClose,
115}
116
117/// An error representing an unknown tag in the input.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct UnknownTagError {
120    /// The tag name that was not found in styles.
121    pub tag: String,
122    /// The kind of tag (open or close).
123    pub kind: UnknownTagKind,
124    /// Byte offset of the opening `[` in the input.
125    pub start: usize,
126    /// Byte offset after the closing `]` in the input.
127    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/// A collection of unknown tag errors found during parsing.
149#[derive(Debug, Clone, Default, PartialEq, Eq)]
150pub struct UnknownTagErrors {
151    /// The list of unknown tag errors.
152    pub errors: Vec<UnknownTagError>,
153}
154
155impl UnknownTagErrors {
156    /// Creates an empty error collection.
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Returns true if no errors were found.
162    pub fn is_empty(&self) -> bool {
163        self.errors.is_empty()
164    }
165
166    /// Returns the number of errors.
167    pub fn len(&self) -> usize {
168        self.errors.len()
169    }
170
171    /// Adds an error to the collection.
172    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
207/// Strips all BBCode-style tags from input, returning only visible content.
208///
209/// This is a convenience function that creates a parser in [`TagTransform::Remove`] mode
210/// with [`UnknownTagBehavior::Strip`] to remove all tags (both known and unknown).
211///
212/// Use this only when producing plain-text output. Width measurement and layout
213/// should inspect [`StyledText`] so semantic styling remains available for later
214/// rendering.
215///
216/// # Example
217///
218/// ```rust
219/// use standout_bbparser::strip_tags;
220///
221/// assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
222/// assert_eq!(strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"), "+32/-0/32");
223/// assert_eq!(strip_tags("no tags here"), "no tags here");
224/// ```
225pub 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/// Parsed semantic style text that can be measured or selected without
232/// converting the whole value to plain text.
233///
234/// The parser keeps style tags out of the visible character stream. Selecting
235/// one or more character ranges preserves the styles that cover each range and
236/// closes every emitted tag, including when a range ends inside nested tags.
237/// ANSI controls recognized by [`console::strip_ansi_codes`] are likewise
238/// zero-width and are preserved when their position falls inside a selection.
239#[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    /// Parses semantic style tags while retaining the original tagged source.
256    ///
257    /// Balanced tags form zero-width style events. Invalid, unexpected, or
258    /// unbalanced tag syntax remains visible literal text, matching
259    /// [`BBParser`] behavior.
260    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    /// Visits each visible character in rendered order.
311    ///
312    /// Style tag bytes are never visited. Escaped brackets are visited as the
313    /// single literal bracket they render as.
314    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    /// Renders selected visible-character ranges with balanced semantic tags.
331    ///
332    /// Ranges use character offsets from [`Self::visit_visible_chars`]. Each
333    /// range is rendered as a self-contained balanced fragment. `separator` is
334    /// inserted outside styling between non-empty ranges.
335    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    /// Render one visible character range as a balanced styled fragment.
351    ///
352    /// The range uses character offsets from [`Self::visit_visible_chars`].
353    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/// A BBCode-style tag parser for terminal styling.
474///
475/// The parser processes `[tag]content[/tag]` patterns and transforms them
476/// according to the configured [`TagTransform`] mode.
477#[derive(Debug, Clone)]
478pub struct BBParser {
479    styles: HashMap<String, Style>,
480    transform: TagTransform,
481    unknown_behavior: UnknownTagBehavior,
482}
483
484impl BBParser {
485    /// Creates a new parser with the given styles and transform mode.
486    ///
487    /// # Arguments
488    ///
489    /// * `styles` - Map of tag names to console styles.
490    ///   Note: These styles are used directly; no alias resolution is performed.
491    /// * `transform` - How to handle matched tags
492    ///
493    /// Unknown tags default to [`UnknownTagBehavior::Passthrough`].
494    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    /// Sets the behavior for unknown tags.
503    ///
504    /// # Example
505    ///
506    /// ```rust
507    /// use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
508    /// use std::collections::HashMap;
509    ///
510    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
511    ///     .unknown_behavior(UnknownTagBehavior::Strip);
512    ///
513    /// // Unknown tags are stripped
514    /// assert_eq!(parser.parse("[foo]text[/foo]"), "text");
515    /// ```
516    pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
517        self.unknown_behavior = behavior;
518        self
519    }
520
521    /// Parses and transforms input.
522    ///
523    /// Unknown tags are handled according to the configured [`UnknownTagBehavior`].
524    pub fn parse(&self, input: &str) -> String {
525        let (output, _) = self.parse_internal(input);
526        output
527    }
528
529    /// Parses input and collects any unknown tag errors.
530    ///
531    /// Returns the transformed output AND any errors found.
532    /// The output uses the configured [`UnknownTagBehavior`] for transformation.
533    ///
534    /// # Example
535    ///
536    /// ```rust
537    /// use standout_bbparser::{BBParser, TagTransform};
538    /// use std::collections::HashMap;
539    ///
540    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
541    /// let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
542    ///
543    /// assert!(!errors.is_empty());
544    /// assert_eq!(errors.len(), 2); // open and close tags
545    /// ```
546    pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
547        self.parse_internal(input)
548    }
549
550    /// Validates input for unknown tags without producing transformed output.
551    ///
552    /// Returns `Ok(())` if all tags are known, `Err` with details otherwise.
553    ///
554    /// # Example
555    ///
556    /// ```rust
557    /// use standout_bbparser::{BBParser, TagTransform};
558    /// use std::collections::HashMap;
559    /// use console::Style;
560    ///
561    /// let mut styles = HashMap::new();
562    /// styles.insert("bold".to_string(), Style::new().bold());
563    ///
564    /// let parser = BBParser::new(styles, TagTransform::Apply);
565    ///
566    /// // Known tag passes validation
567    /// assert!(parser.validate("[bold]text[/bold]").is_ok());
568    ///
569    /// // Unknown tag fails validation
570    /// let result = parser.validate("[unknown]text[/unknown]");
571    /// assert!(result.is_err());
572    /// ```
573    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    /// Internal parsing that returns both output and errors.
583    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        // ...
591        // ...
592        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                        // Check if this looks like a valid tag name but was just unclosed/unbalanced
604                        let is_valid_name = Tokenizer::is_valid_tag_name(name);
605                        if is_valid_name {
606                            // Strictly error on unbalanced tags
607                            errors.push(UnknownTagError {
608                                tag: name.to_string(),
609                                kind: UnknownTagKind::Unbalanced, // NEW VARIANT
610                                start: *start,
611                                end: *end,
612                            });
613                            // Also treat as literal to not break output entirely?
614                            // Or just error? Issue says "Unbalanced tags must error".
615                            // We record error. Output depends on transform.
616                            // We'll output literal text for visual feedback?
617                            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                        // Unexpected close tag
642                        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, // NEW VARIANT
647                                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                // Nothing to emit for known or stripped unknown tags
700            }
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                            // Nothing to emit
714                        }
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        // Only record error if we have valid position info (not auto-closed)
732        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                // Nothing to emit
750            }
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                            // Nothing to emit
764                        }
765                    }
766                }
767            }
768        }
769    }
770
771    /// Renders events to a string.
772    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    /// Helper to append styled text.
797    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            // Apply styles from innermost (top of stack) to outermost (bottom).
807            // This ensures that inner styles override outer styles (ANSI rules: last code wins).
808            // Also optimizes by stripping nested resets.
809            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(&current);
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/// Token types produced by the tokenizer.
827#[derive(Debug, Clone, PartialEq, Eq)]
828enum Token<'a> {
829    /// Plain text content.
830    Text {
831        content: &'a str,
832        start: usize,
833        end: usize,
834    },
835    /// Opening tag: `[tagname]`
836    OpenTag {
837        name: &'a str,
838        start: usize,
839        end: usize,
840    },
841    /// Closing tag: `[/tagname]`
842    CloseTag {
843        name: &'a str,
844        start: usize,
845        end: usize,
846    },
847    /// Invalid tag syntax (passed through as text).
848    InvalidTag {
849        content: &'a str,
850        start: usize,
851        end: usize,
852    },
853}
854
855/// Pre-computes which OpenTag tokens have a matching CloseTag.
856/// This is O(N) instead of O(N^2).
857fn 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
881/// Finds the byte offset of the next `[` that is not preceded by a `\` escape.
882///
883/// Both `\[` and `\]` are treated as escape sequences and skipped. Other
884/// backslashes (e.g. `\n`, `\\`, trailing `\`) are not consumed and don't
885/// affect bracket detection. ANSI controls recognized by
886/// [`console::strip_ansi_codes`] are skipped as terminal syntax rather than
887/// parsed as semantic tags. This deliberately shares the parser used by the
888/// established visible-width interface instead of maintaining a CSI-only
889/// approximation here.
890///
891/// Byte-level scanning is safe here: `\`, `[`, and `]` are ASCII and cannot
892/// appear as continuation bytes in a UTF-8 sequence.
893fn 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
918/// Replaces `\[` with `[` and `\]` with `]` in a text segment. Other
919/// backslashes pass through unchanged. Returns `Cow::Borrowed` when no
920/// actual `\[` or `\]` escape sequence is present, so backslash-containing
921/// but escape-free inputs (Windows paths, `\d+` regex examples, etc.) stay
922/// allocation-free.
923fn 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
948/// Tokenizer for BBCode-style tags.
949struct 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    /// Checks if a string is a valid tag name (CSS identifier rules).
960    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        // First char must be letter or underscore
969        if !first.is_ascii_lowercase() && first != '_' {
970            return false;
971        }
972
973        // Rest can be letter, digit, underscore, or hyphen
974        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        // Look for the next unescaped '['. `\[` and `\]` are skipped so they
996        // can be emitted as literal characters by the text path.
997        if let Some(bracket_pos) = find_unescaped_bracket(remaining) {
998            if bracket_pos > 0 {
999                // There's text before the bracket
1000                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            // We're at a '['
1010            // Try to parse a tag
1011            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                // Check for closing tag
1017                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                // No closing bracket - rest is text
1050                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            // No more brackets - rest is text
1060            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    // ==================== strip_tags Tests ====================
1088
1089    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    // ==================== TagTransform::Keep Tests ====================
1203
1204    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    // ==================== TagTransform::Remove Tests ====================
1257
1258    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            // Default is Passthrough, but Remove mode ignores unknown_behavior for output
1298            assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
1299        }
1300    }
1301
1302    // ==================== Unknown Tag Behavior Tests ====================
1303
1304    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            // Should have bold styling but no unknown tag markers
1351            assert!(!result.contains("[unknown"));
1352            assert!(result.contains("text"));
1353        }
1354
1355        #[test]
1356        fn keep_mode_ignores_unknown_behavior() {
1357            // In Keep mode, all tags are preserved as-is regardless of unknown_behavior
1358            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            // In Remove mode, all tags are stripped regardless of unknown_behavior
1369            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    // ==================== Validation Tests ====================
1376
1377    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); // open and close
1405        }
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); // "[unknown]"
1450
1451            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); // "[/unknown]"
1458        }
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); // 2 opens + 2 closes
1466
1467            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); // only unknown tag errors
1479
1480            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    // ==================== Parse With Diagnostics Tests ====================
1499
1500    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    // ==================== Tag Name Validation Tests ====================
1545
1546    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    // ==================== Edge Cases ====================
1610
1611    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    // ==================== Escape Sequence Tests ====================
1718
1719    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            // Inner text should contain literal brackets, no `[bold]` re-emitted.
1750            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            // Backslash-containing inputs without `\[` or `\]` (Windows paths,
1763            // `\d+` regex examples) must not allocate — they should round-trip
1764            // through `Cow::Borrowed`.
1765            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            // Actual escape sequences must take the owned path.
1779            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            // `\\` is not an escape sequence, so the first `\` is literal;
1792            // the second `\` pairs with `[` to emit a literal `[`.
1793            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            // `\` is not a valid tag-name char, so the bracket scanner still
1821            // sees the opening `[` and the malformed content becomes an
1822            // InvalidTag passthrough rather than a styled tag.
1823            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            // Escaping only the open turns it into literal text; the close
1836            // becomes an unexpected close. Output contains both literally,
1837            // and validation surfaces the error.
1838            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    // ==================== Tokenizer Tests ====================
1851
1852    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    // ==================== Apply Mode Tests ====================
1990
1991    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    // ==================== Error Display Tests ====================
2022
2023    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); // open + close
2180        }
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}