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::Style;
68use std::collections::HashMap;
69
70/// How to transform matched tags in the output.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TagTransform {
73    /// Apply ANSI escape codes from the associated Style.
74    /// Used for terminal output with color support.
75    Apply,
76
77    /// Remove all tags, outputting only the content.
78    /// Used for plain text output without styling.
79    Remove,
80
81    /// Keep tags as-is in the output.
82    /// Used for debug mode to visualize tag structure.
83    Keep,
84}
85
86/// How to handle tags not found in the styles map.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum UnknownTagBehavior {
89    /// Keep unknown tags as literal text with a `?` marker.
90    /// `[foo]text[/foo]` → `[foo?]text[/foo?]`
91    ///
92    /// This makes unknown tags visible without breaking output.
93    #[default]
94    Passthrough,
95
96    /// Strip unknown tags entirely, keeping only inner content.
97    /// `[foo]text[/foo]` → `text`
98    ///
99    /// Use this for graceful degradation in production.
100    Strip,
101}
102
103/// The kind of unknown tag encountered.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum UnknownTagKind {
106    /// An opening tag: `[foo]`
107    Open,
108    /// A closing tag: `[/foo]`
109    Close,
110    /// An unbalanced opening tag: `[foo]...` (no matching close)
111    Unbalanced,
112    /// An unexpected closing tag: `...[/foo]` (no matching open)
113    UnexpectedClose,
114}
115
116/// An error representing an unknown tag in the input.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct UnknownTagError {
119    /// The tag name that was not found in styles.
120    pub tag: String,
121    /// The kind of tag (open or close).
122    pub kind: UnknownTagKind,
123    /// Byte offset of the opening `[` in the input.
124    pub start: usize,
125    /// Byte offset after the closing `]` in the input.
126    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/// A collection of unknown tag errors found during parsing.
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct UnknownTagErrors {
150    /// The list of unknown tag errors.
151    pub errors: Vec<UnknownTagError>,
152}
153
154impl UnknownTagErrors {
155    /// Creates an empty error collection.
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Returns true if no errors were found.
161    pub fn is_empty(&self) -> bool {
162        self.errors.is_empty()
163    }
164
165    /// Returns the number of errors.
166    pub fn len(&self) -> usize {
167        self.errors.len()
168    }
169
170    /// Adds an error to the collection.
171    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
206/// Strips all BBCode-style tags from input, returning only visible content.
207///
208/// This is a convenience function that creates a parser in [`TagTransform::Remove`] mode
209/// with [`UnknownTagBehavior::Strip`] to remove all tags (both known and unknown).
210///
211/// Useful for measuring the visible width of text that may contain inline markup.
212///
213/// # Example
214///
215/// ```rust
216/// use standout_bbparser::strip_tags;
217///
218/// assert_eq!(strip_tags("[bold]hello[/bold]"), "hello");
219/// assert_eq!(strip_tags("[additions]+32[/additions]/[deletions]-0[/deletions]/32"), "+32/-0/32");
220/// assert_eq!(strip_tags("no tags here"), "no tags here");
221/// ```
222pub 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/// A BBCode-style tag parser for terminal styling.
229///
230/// The parser processes `[tag]content[/tag]` patterns and transforms them
231/// according to the configured [`TagTransform`] mode.
232#[derive(Debug, Clone)]
233pub struct BBParser {
234    styles: HashMap<String, Style>,
235    transform: TagTransform,
236    unknown_behavior: UnknownTagBehavior,
237}
238
239impl BBParser {
240    /// Creates a new parser with the given styles and transform mode.
241    ///
242    /// # Arguments
243    ///
244    /// * `styles` - Map of tag names to console styles.
245    ///   Note: These styles are used directly; no alias resolution is performed.
246    /// * `transform` - How to handle matched tags
247    ///
248    /// Unknown tags default to [`UnknownTagBehavior::Passthrough`].
249    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    /// Sets the behavior for unknown tags.
258    ///
259    /// # Example
260    ///
261    /// ```rust
262    /// use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
263    /// use std::collections::HashMap;
264    ///
265    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove)
266    ///     .unknown_behavior(UnknownTagBehavior::Strip);
267    ///
268    /// // Unknown tags are stripped
269    /// assert_eq!(parser.parse("[foo]text[/foo]"), "text");
270    /// ```
271    pub fn unknown_behavior(mut self, behavior: UnknownTagBehavior) -> Self {
272        self.unknown_behavior = behavior;
273        self
274    }
275
276    /// Parses and transforms input.
277    ///
278    /// Unknown tags are handled according to the configured [`UnknownTagBehavior`].
279    pub fn parse(&self, input: &str) -> String {
280        let (output, _) = self.parse_internal(input);
281        output
282    }
283
284    /// Parses input and collects any unknown tag errors.
285    ///
286    /// Returns the transformed output AND any errors found.
287    /// The output uses the configured [`UnknownTagBehavior`] for transformation.
288    ///
289    /// # Example
290    ///
291    /// ```rust
292    /// use standout_bbparser::{BBParser, TagTransform};
293    /// use std::collections::HashMap;
294    ///
295    /// let parser = BBParser::new(HashMap::new(), TagTransform::Remove);
296    /// let (output, errors) = parser.parse_with_diagnostics("[unknown]text[/unknown]");
297    ///
298    /// assert!(!errors.is_empty());
299    /// assert_eq!(errors.len(), 2); // open and close tags
300    /// ```
301    pub fn parse_with_diagnostics(&self, input: &str) -> (String, UnknownTagErrors) {
302        self.parse_internal(input)
303    }
304
305    /// Validates input for unknown tags without producing transformed output.
306    ///
307    /// Returns `Ok(())` if all tags are known, `Err` with details otherwise.
308    ///
309    /// # Example
310    ///
311    /// ```rust
312    /// use standout_bbparser::{BBParser, TagTransform};
313    /// use std::collections::HashMap;
314    /// use console::Style;
315    ///
316    /// let mut styles = HashMap::new();
317    /// styles.insert("bold".to_string(), Style::new().bold());
318    ///
319    /// let parser = BBParser::new(styles, TagTransform::Apply);
320    ///
321    /// // Known tag passes validation
322    /// assert!(parser.validate("[bold]text[/bold]").is_ok());
323    ///
324    /// // Unknown tag fails validation
325    /// let result = parser.validate("[unknown]text[/unknown]");
326    /// assert!(result.is_err());
327    /// ```
328    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    /// Internal parsing that returns both output and errors.
338    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        // ...
346        // ...
347        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                        // Check if this looks like a valid tag name but was just unclosed/unbalanced
359                        let is_valid_name = Tokenizer::is_valid_tag_name(name);
360                        if is_valid_name {
361                            // Strictly error on unbalanced tags
362                            errors.push(UnknownTagError {
363                                tag: name.to_string(),
364                                kind: UnknownTagKind::Unbalanced, // NEW VARIANT
365                                start: *start,
366                                end: *end,
367                            });
368                            // Also treat as literal to not break output entirely?
369                            // Or just error? Issue says "Unbalanced tags must error".
370                            // We record error. Output depends on transform.
371                            // We'll output literal text for visual feedback?
372                            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                        // Unexpected close tag
397                        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, // NEW VARIANT
402                                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                // Nothing to emit for known or stripped unknown tags
455            }
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                            // Nothing to emit
469                        }
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        // Only record error if we have valid position info (not auto-closed)
487        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                // Nothing to emit
505            }
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                            // Nothing to emit
519                        }
520                    }
521                }
522            }
523        }
524    }
525
526    /// Renders events to a string.
527    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    /// Pre-computes which OpenTag tokens have a valid matching CloseTag.
552    /// This is O(N) instead of O(N^2).
553    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    /// Helper to append styled text.
578    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            // Apply styles from innermost (top of stack) to outermost (bottom).
588            // This ensures that inner styles override outer styles (ANSI rules: last code wins).
589            // Also optimizes by stripping nested resets.
590            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(&current);
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/// Token types produced by the tokenizer.
608#[derive(Debug, Clone, PartialEq, Eq)]
609enum Token<'a> {
610    /// Plain text content.
611    Text {
612        content: &'a str,
613        start: usize,
614        end: usize,
615    },
616    /// Opening tag: `[tagname]`
617    OpenTag {
618        name: &'a str,
619        start: usize,
620        end: usize,
621    },
622    /// Closing tag: `[/tagname]`
623    CloseTag {
624        name: &'a str,
625        start: usize,
626        end: usize,
627    },
628    /// Invalid tag syntax (passed through as text).
629    InvalidTag {
630        content: &'a str,
631        start: usize,
632        end: usize,
633    },
634}
635
636/// Finds the byte offset of the next `[` that is not preceded by a `\` escape.
637///
638/// Both `\[` and `\]` are treated as escape sequences and skipped. Other
639/// backslashes (e.g. `\n`, `\\`, trailing `\`) are not consumed and don't
640/// affect bracket detection.
641///
642/// Byte-level scanning is safe here: `\`, `[`, and `]` are ASCII and cannot
643/// appear as continuation bytes in a UTF-8 sequence.
644fn 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
663/// Replaces `\[` with `[` and `\]` with `]` in a text segment. Other
664/// backslashes pass through unchanged. Returns `Cow::Borrowed` when no
665/// actual `\[` or `\]` escape sequence is present, so backslash-containing
666/// but escape-free inputs (Windows paths, `\d+` regex examples, etc.) stay
667/// allocation-free.
668fn 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
693/// Tokenizer for BBCode-style tags.
694struct 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    /// Checks if a string is a valid tag name (CSS identifier rules).
705    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        // First char must be letter or underscore
714        if !first.is_ascii_lowercase() && first != '_' {
715            return false;
716        }
717
718        // Rest can be letter, digit, underscore, or hyphen
719        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        // Look for the next unescaped '['. `\[` and `\]` are skipped so they
741        // can be emitted as literal characters by the text path.
742        if let Some(bracket_pos) = find_unescaped_bracket(remaining) {
743            if bracket_pos > 0 {
744                // There's text before the bracket
745                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            // We're at a '['
755            // Try to parse a tag
756            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                // Check for closing tag
762                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                // No closing bracket - rest is text
795                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            // No more brackets - rest is text
805            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    // ==================== strip_tags Tests ====================
833
834    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    // ==================== TagTransform::Keep Tests ====================
872
873    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    // ==================== TagTransform::Remove Tests ====================
926
927    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            // Default is Passthrough, but Remove mode ignores unknown_behavior for output
967            assert_eq!(parser.parse("[unknown]text[/unknown]"), "text");
968        }
969    }
970
971    // ==================== Unknown Tag Behavior Tests ====================
972
973    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            // Should have bold styling but no unknown tag markers
1020            assert!(!result.contains("[unknown"));
1021            assert!(result.contains("text"));
1022        }
1023
1024        #[test]
1025        fn keep_mode_ignores_unknown_behavior() {
1026            // In Keep mode, all tags are preserved as-is regardless of unknown_behavior
1027            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            // In Remove mode, all tags are stripped regardless of unknown_behavior
1038            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    // ==================== Validation Tests ====================
1045
1046    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); // open and close
1074        }
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); // "[unknown]"
1119
1120            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); // "[/unknown]"
1127        }
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); // 2 opens + 2 closes
1135
1136            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); // only unknown tag errors
1148
1149            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    // ==================== Parse With Diagnostics Tests ====================
1168
1169    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    // ==================== Tag Name Validation Tests ====================
1214
1215    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    // ==================== Edge Cases ====================
1279
1280    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    // ==================== Escape Sequence Tests ====================
1387
1388    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            // Inner text should contain literal brackets, no `[bold]` re-emitted.
1419            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            // Backslash-containing inputs without `\[` or `\]` (Windows paths,
1432            // `\d+` regex examples) must not allocate — they should round-trip
1433            // through `Cow::Borrowed`.
1434            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            // Actual escape sequences must take the owned path.
1448            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            // `\\` is not an escape sequence, so the first `\` is literal;
1461            // the second `\` pairs with `[` to emit a literal `[`.
1462            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            // `\` is not a valid tag-name char, so the bracket scanner still
1490            // sees the opening `[` and the malformed content becomes an
1491            // InvalidTag passthrough rather than a styled tag.
1492            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            // Escaping only the open turns it into literal text; the close
1505            // becomes an unexpected close. Output contains both literally,
1506            // and validation surfaces the error.
1507            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    // ==================== Tokenizer Tests ====================
1520
1521    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    // ==================== Apply Mode Tests ====================
1659
1660    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    // ==================== Error Display Tests ====================
1691
1692    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); // open + close
1812        }
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}