Skip to main content

yaml_edit/
lex.rs

1//! Lexer for YAML files.
2
3/// Whitespace and formatting validation errors
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct WhitespaceError {
6    /// The error message
7    pub message: String,
8    /// The byte range where the error occurred
9    pub range: std::ops::Range<usize>,
10    /// Error category
11    pub category: WhitespaceErrorCategory,
12}
13
14/// Categories of whitespace errors
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum WhitespaceErrorCategory {
17    /// Tab character used for indentation (forbidden in YAML)
18    TabIndentation,
19    /// Line too long according to configured limit
20    LineTooLong,
21    /// Mixed line ending styles
22    MixedLineEndings,
23    /// Invalid scalar indentation
24    InvalidIndentation,
25}
26
27/// YAML Concrete Syntax Tree (CST) node types.
28///
29/// This enum defines all possible node types in the YAML syntax tree, representing both
30/// lexical tokens (from the lexer) and semantic nodes (created by the parser).
31///
32/// # Tree Hierarchy
33///
34/// The YAML syntax tree follows this general structure:
35///
36/// ```text
37/// ROOT
38/// ├── DOCUMENT*
39/// │   ├── DIRECTIVE* (optional, e.g., %YAML 1.2)
40/// │   ├── DOC_START? (optional ---)
41/// │   ├── MAPPING | SEQUENCE | SCALAR | TAGGED_NODE
42/// │   └── DOC_END? (optional ...)
43/// └── WHITESPACE | NEWLINE | COMMENT (between documents)
44///
45/// MAPPING
46/// ├── MAPPING_ENTRY*
47/// │   ├── KEY
48/// │   │   └── SCALAR | SEQUENCE | MAPPING (YAML 1.2 allows complex keys)
49/// │   ├── COLON
50/// │   ├── WHITESPACE?
51/// │   └── VALUE
52/// │       └── SCALAR | SEQUENCE | MAPPING | TAGGED_NODE
53/// ├── NEWLINE
54/// ├── INDENT
55/// └── COMMENT?
56///
57/// SEQUENCE  
58/// ├── SEQUENCE_ENTRY*
59/// │   ├── DASH
60/// │   ├── WHITESPACE?
61/// │   └── SCALAR | SEQUENCE | MAPPING | TAGGED_NODE
62/// ├── NEWLINE
63/// ├── INDENT
64/// └── COMMENT?
65///
66/// SCALAR
67/// └── STRING | INT | FLOAT | BOOL | NULL
68///
69/// TAGGED_NODE
70/// ├── TAG (e.g., !!str, !custom)
71/// ├── WHITESPACE?
72/// └── SCALAR | MAPPING | SEQUENCE
73/// ```
74///
75/// # Node Categories
76///
77/// ## Structural Nodes (created by parser)
78/// - **ROOT**: Top-level container for the entire document
79/// - **DOCUMENT**: A single YAML document (separated by --- or ...)
80/// - **MAPPING**: Key-value pairs `{key: value}` or block style
81/// - **SEQUENCE**: Lists `[item1, item2]` or block style with `-`
82/// - **SCALAR**: Leaf values (strings, numbers, booleans, null)
83/// - **TAGGED_NODE**: Values with explicit type tags `!!str "hello"`
84///
85/// ## Container Nodes (created by parser)
86/// - **MAPPING_ENTRY**: A single key-value pair within a mapping
87/// - **SEQUENCE_ENTRY**: A single item within a sequence
88/// - **KEY**: The key part of a key-value pair (can contain complex types)
89/// - **VALUE**: The value part of a key-value pair
90///
91/// ## Lexical Tokens (from lexer)
92/// - **Punctuation**: COLON, DASH, COMMA, etc.
93/// - **Brackets**: LEFT_BRACKET, RIGHT_BRACKET, LEFT_BRACE, RIGHT_BRACE
94/// - **Literals**: STRING, INT, FLOAT, BOOL, NULL
95/// - **YAML-specific**: TAG, ANCHOR, REFERENCE, MERGE_KEY
96/// - **Document markers**: DOC_START (---), DOC_END (...)
97/// - **Formatting**: WHITESPACE, NEWLINE, INDENT, COMMENT
98///
99/// ## Special Cases
100///
101/// ### Complex Keys (YAML 1.2.2)
102/// Keys can be sequences or mappings, not just scalars:
103/// ```yaml
104/// [1, 2]: value        # Sequence key
105/// {a: b}: value        # Mapping key
106/// ```
107///
108/// ### Tagged Values
109/// Values can have explicit type information:
110/// ```yaml
111/// number: !!int "123"  # Force string "123" to be treated as integer
112/// binary: !!binary |   # Base64 encoded binary data
113///   R0lGODlhDAAMAIQ...
114/// ```
115///
116/// ### Block Scalars
117/// Multi-line strings with special parsing rules:
118/// ```yaml
119/// literal: |           # PIPE indicates literal scalar
120///   Line 1
121///   Line 2
122/// folded: >            # GREATER indicates folded scalar  
123///   Long text that
124///   gets folded
125/// ```
126///
127/// The tree preserves all original formatting, comments, and whitespace,
128/// enabling lossless round-trip parsing and precise source location tracking.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
130#[repr(u16)]
131#[allow(non_camel_case_types, clippy::upper_case_acronyms)]
132pub enum SyntaxKind {
133    // Structural
134    /// Root node of the syntax tree
135    ROOT = 0,
136    /// A YAML document
137    DOCUMENT,
138    /// A YAML sequence (list)
139    SEQUENCE,
140    /// A YAML mapping (key-value pairs)
141    MAPPING,
142    /// A YAML scalar value
143    SCALAR,
144    /// A YAML alias reference (e.g., *anchor_name)
145    ALIAS,
146    /// A YAML tagged scalar (tag + value)
147    TAGGED_NODE,
148    /// Parse error marker
149    ERROR,
150
151    // Tokens
152    /// Dash character '-'
153    DASH,
154    /// Plus character '+'
155    PLUS,
156    /// Colon character ':'
157    COLON,
158    /// Question mark '?'
159    QUESTION,
160    /// Left bracket '['
161    LEFT_BRACKET,
162    /// Right bracket ']'
163    RIGHT_BRACKET,
164    /// Left brace '{'
165    LEFT_BRACE,
166    /// Right brace '}'
167    RIGHT_BRACE,
168    /// Comma ','
169    COMMA,
170    /// Pipe '|'
171    PIPE,
172    /// Greater than '>'
173    GREATER,
174    /// Ampersand '&'
175    AMPERSAND,
176    /// Asterisk '*'
177    ASTERISK,
178    /// Exclamation '!'
179    EXCLAMATION,
180    /// Percent '%'
181    PERCENT,
182    /// At symbol '@'
183    AT,
184    /// Backtick '`'
185    BACKTICK,
186    /// Double quote '"'
187    QUOTE,
188    /// Single quote "'"
189    SINGLE_QUOTE,
190
191    // Document markers
192    /// Document start marker '---'
193    DOC_START,
194    /// Document end marker '...'
195    DOC_END,
196
197    // Parser-generated semantic nodes
198    /// A mapping key (created by parser from context)
199    KEY,
200    /// A value in key-value pair (created by parser from context)
201    VALUE,
202    /// A complete mapping entry (key-value pair with associated tokens)
203    MAPPING_ENTRY,
204    /// A sequence entry (item with associated tokens)
205    SEQUENCE_ENTRY,
206
207    // Content tokens (from lexer)
208    /// String literal (quoted or unquoted identifier)
209    STRING,
210    /// Unterminated string (missing closing quote)
211    UNTERMINATED_STRING,
212    /// Integer literal
213    INT,
214    /// Float literal
215    FLOAT,
216    /// Boolean literal (true/false)
217    BOOL,
218    /// Null literal
219    NULL,
220    /// YAML tag like '!tag'
221    TAG,
222    /// YAML anchor like '&anchor'
223    ANCHOR,
224    /// YAML reference like '*reference'
225    REFERENCE,
226    /// YAML merge key '<<'
227    MERGE_KEY,
228    /// YAML directive like '%YAML 1.2'
229    DIRECTIVE,
230
231    // Whitespace and formatting
232    /// Spaces and tabs
233    WHITESPACE,
234    /// Newline characters
235    NEWLINE,
236    /// Leading whitespace that determines structure
237    INDENT,
238    /// Comments starting with '#'
239    COMMENT,
240
241    // Special
242    /// UTF-8 Byte Order Mark (BOM) - U+FEFF at start of file
243    BOM,
244    /// End of file marker
245    EOF,
246}
247
248impl From<SyntaxKind> for rowan::SyntaxKind {
249    fn from(kind: SyntaxKind) -> Self {
250        Self(kind as u16)
251    }
252}
253
254/// Decide whether the whitespace run starting at `ws_idx` is internal to a
255/// plain scalar or a real terminator. Internal whitespace is followed by
256/// more plain-scalar content on the same line; a terminator is followed by
257/// end-of-line, end-of-input, a `#` comment marker, a `:` that ends the
258/// scalar, or a structural indicator. Quotes and hyphens remain scalar
259/// content here; flow delimiters terminate only in flow context.
260///
261/// Caller must guarantee the character at `ws_idx` is a space or tab.
262fn plain_scalar_continues_past_whitespace(input: &str, ws_idx: usize, flow_depth: u32) -> bool {
263    let rest = &input[ws_idx..];
264    // Find the first non-whitespace char in the run.
265    let (offset, next) = match rest.char_indices().find(|(_, c)| *c != ' ' && *c != '\t') {
266        Some(v) => v,
267        None => return false,
268    };
269    match next {
270        '\n' | '\r' | '#' => false,
271        ',' | '[' | ']' | '{' | '}' if flow_depth > 0 => false,
272        ':' => !is_colon_a_mapping_indicator(input, ws_idx + offset, flow_depth),
273        // Once a scalar has begun these are all content on the next word:
274        // the signs, the quotes, the flow indicators, and the node
275        // properties and block headers, which are indicators only at the
276        // start of a node. So `+ ?: v` is keyed `+ ?`, as `+ -: v` was.
277        '-' | '+' | '?' | '|' | '>' | '&' | '*' | '!' | '%' | '\'' | '"' | ',' | '[' | ']'
278        | '{' | '}' => true,
279        c if is_yaml_special(c) => false,
280        _ => true,
281    }
282}
283
284/// Read an anchor or alias name (`ns-anchor-name`).
285///
286/// Per YAML 1.2 an anchor name is a run of non-space characters excluding
287/// the flow indicators, so `-`, `*`, `:` and the rest are ordinary name
288/// characters: saphyr reads `&xT*U---` as the single anchor `xT*U---`.
289/// Reading these with the general scalar reader stopped at the first
290/// YAML-special character and stranded the remainder.
291fn read_anchor_name_from<'a>(
292    chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
293    input: &'a str,
294    start_idx: usize,
295) -> &'a str {
296    let mut end_idx = start_idx;
297    while let Some((idx, ch)) = chars.peek().copied() {
298        if ch.is_whitespace() || matches!(ch, ',' | '[' | ']' | '{' | '}') {
299            break;
300        }
301        end_idx = idx + ch.len_utf8();
302        chars.next();
303    }
304    &input[start_idx..end_idx]
305}
306
307/// Is a `:` at `input[colon_idx]` a mapping indicator (rather than
308/// plain-scalar content)?
309///
310/// Per YAML 1.2 the answer is context-dependent. `:` is an indicator
311/// when:
312///   - followed by whitespace or EOF, in any context; or
313///   - inside a flow collection, followed by a flow terminator
314///     (`,`, `]`, `}`).
315///
316/// It is *not* an indicator when embedded in a plain scalar (`http://`,
317/// `::vector`, timestamp bodies, etc.).
318///
319/// `colon_idx` must point at the `:` byte itself; only the char that
320/// follows is inspected. Callers already in flow context should pass a
321/// non-zero `flow_depth`.
322fn is_colon_a_mapping_indicator(input: &str, colon_idx: usize, flow_depth: u32) -> bool {
323    // Per YAML 1.2 section 7.4 a `:` directly after a JSON-like node -- a
324    // closing `}` or `]`, or a quoted scalar -- separates that key from its
325    // value in flow context, with no space required: `[ {a: b}:c ]` holds
326    // the mapping `{{a: b}: c}`, as the YAML test suite's 9MMW expects.
327    if flow_depth > 0
328        && input[..colon_idx]
329            .chars()
330            .next_back()
331            .is_some_and(|ch| matches!(ch, '}' | ']' | '"' | '\''))
332    {
333        return true;
334    }
335    let after = input[colon_idx + 1..].chars().next();
336    match after {
337        None => true,
338        Some(ch) if ch.is_whitespace() => true,
339        Some(ch) if flow_depth > 0 && matches!(ch, ',' | ']' | '}') => true,
340        _ => false,
341    }
342}
343
344/// Is a `#` at `input[hash_idx]` the start of a comment (rather than
345/// scalar content)?
346///
347/// Per YAML 1.2 section 6.6, `#` starts a comment only when preceded
348/// by whitespace (or is at the start of the input / a fresh line).
349/// Inside a plain scalar with no preceding whitespace, `#` is scalar
350/// content (URLs, `#frag`, etc.).
351fn is_hash_a_comment_start(input: &str, hash_idx: usize) -> bool {
352    hash_idx == 0
353        || input[..hash_idx]
354            .chars()
355            .next_back()
356            .is_some_and(|c| c.is_whitespace())
357}
358
359/// Read a plain-scalar body, treating `:` as scalar content when it's not
360/// followed by whitespace (matching YAML plain-scalar semantics), and `-`
361/// as scalar content unconditionally.
362///
363/// Used from the `.` and `-` scalar-prefix branches, where the char
364/// that dispatched us was itself a scalar prefix rather than a
365/// structural indicator.
366/// Scan a double-quoted string body, consuming through the closing quote.
367///
368/// Returns the end offset and whether a closing quote was found; a backslash
369/// escapes the next character, so `"a\""` runs to the second `"`.
370fn scan_double_quoted(
371    chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
372    start_idx: usize,
373) -> (usize, bool) {
374    let mut end_idx = start_idx + 1;
375    let mut escaped = false;
376
377    while let Some((idx, ch)) = chars.peek() {
378        let (current_idx, current_ch) = (*idx, *ch);
379        end_idx = current_idx + current_ch.len_utf8();
380        chars.next();
381
382        if escaped {
383            escaped = false;
384        } else if current_ch == '\\' {
385            escaped = true;
386        } else if current_ch == '"' {
387            return (end_idx, true);
388        }
389    }
390    (end_idx, false)
391}
392
393/// Scan a single-quoted string body, consuming through the closing quote.
394///
395/// Returns the end offset and whether a closing quote was found. A doubled
396/// `''` is an escaped quote and does not end the string.
397fn scan_single_quoted(
398    chars: &mut std::iter::Peekable<std::str::CharIndices<'_>>,
399    start_idx: usize,
400) -> (usize, bool) {
401    let mut end_idx = start_idx + 1;
402
403    while let Some((idx, ch)) = chars.peek() {
404        let (current_idx, current_ch) = (*idx, *ch);
405        end_idx = current_idx + current_ch.len_utf8();
406        chars.next();
407
408        if current_ch == '\'' {
409            if let Some((next_idx, '\'')) = chars.peek() {
410                // Doubled quote: an escaped `'`, not the end.
411                end_idx = *next_idx + 1;
412                chars.next();
413            } else {
414                return (end_idx, true);
415            }
416        }
417    }
418    (end_idx, false)
419}
420
421/// Push a quoted-string token, marking it UNTERMINATED_STRING when the
422/// closing quote was never reached.
423fn push_quoted_string<'a>(
424    tokens: &mut Vec<(SyntaxKind, &'a str)>,
425    input: &'a str,
426    token_start: usize,
427    end_idx: usize,
428    found_closing: bool,
429) {
430    let kind = if found_closing {
431        SyntaxKind::STRING
432    } else {
433        SyntaxKind::UNTERMINATED_STRING
434    };
435    tokens.push((kind, &input[token_start..end_idx]));
436}
437
438/// Read a plain scalar that starts at `token_start`, whose body begins at
439/// `body_start`, classify it, and push it as one token.
440///
441/// The leading character has already been consumed by the caller's match arm
442/// (`-`, `+`, `.`, ...) and stays part of the scalar text.
443fn push_plain_scalar_from<'a>(
444    tokens: &mut Vec<(SyntaxKind, &'a str)>,
445    chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
446    input: &'a str,
447    token_start: usize,
448    body_start: usize,
449    flow_depth: u32,
450    absorb_spaces: bool,
451) {
452    let body = read_plain_scalar_body_from(chars, input, body_start, flow_depth, absorb_spaces);
453    let text = &input[token_start..body_start + body.len()];
454    tokens.push((classify_scalar(text), text));
455}
456
457/// `absorb_spaces` folds an internal whitespace run into the scalar when
458/// more scalar content follows, as a plain scalar spanning several words
459/// requires. A block scalar's chomping indicator (`|-`, `>-`) is a token
460/// in its own right, so that caller passes false.
461fn read_plain_scalar_body_from<'a>(
462    chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
463    input: &'a str,
464    start_idx: usize,
465    flow_depth: u32,
466    absorb_spaces: bool,
467) -> &'a str {
468    let mut end_idx = start_idx;
469    while let Some((idx, ch)) = chars.peek().copied() {
470        // Intra-line whitespace belongs to the scalar when more scalar
471        // content follows on the same line, exactly as in the catch-all
472        // arm. Without this a space ended the body, so `-{ [a]: v }`
473        // lexed `[` as a flow collection rather than scalar content.
474        if absorb_spaces && (ch == ' ' || ch == '\t') {
475            if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
476                break;
477            }
478            while let Some((wi, wc)) = chars.peek().copied() {
479                if wc != ' ' && wc != '\t' {
480                    break;
481                }
482                end_idx = wi + wc.len_utf8();
483                chars.next();
484            }
485            continue;
486        }
487        if ch.is_whitespace() {
488            break;
489        }
490        if ch == ':' && is_colon_a_mapping_indicator(input, idx, flow_depth) {
491            break;
492        }
493        if ch == '#' && is_hash_a_comment_start(input, idx) {
494            break;
495        }
496        // `-` and `+` are indicators only where a node starts (a sequence
497        // entry, or a block-scalar chomping suffix); inside a body they are
498        // content, so `++` is the single scalar `++`.
499        // `?`, `|`, `>`, `&`, `*`, `!` and `%` are indicators only at the
500        // start of a node; this is a scalar body, so they are content here,
501        // as the catch-all arm already has it. `+?` is one scalar.
502        if is_yaml_special_except(ch, "-+:#'\"?|>&*!%")
503            && !(flow_depth == 0 && matches!(ch, ',' | '[' | ']' | '{' | '}'))
504        {
505            break;
506        }
507        end_idx = idx + ch.len_utf8();
508        chars.next();
509    }
510    &input[start_idx..end_idx]
511}
512
513/// Tokenize YAML input with whitespace validation
514pub fn lex(input: &str) -> Vec<(SyntaxKind, &str)> {
515    let (tokens, _) = lex_with_validation(input);
516    tokens
517}
518
519/// Configuration for whitespace and formatting validation
520pub struct ValidationConfig {
521    /// Maximum line length (None = no limit)
522    pub max_line_length: Option<usize>,
523    /// Whether to enforce consistent line endings
524    pub enforce_consistent_line_endings: bool,
525}
526
527impl Default for ValidationConfig {
528    fn default() -> Self {
529        Self {
530            max_line_length: Some(120), // Default to 120 characters
531            enforce_consistent_line_endings: true,
532        }
533    }
534}
535
536/// Tokenize YAML input with whitespace and formatting validation
537pub fn lex_with_validation(input: &str) -> (Vec<(SyntaxKind, &str)>, Vec<WhitespaceError>) {
538    lex_with_validation_config(input, &ValidationConfig::default())
539}
540
541/// Record a "line too long" error if the line ending at `end` exceeds the
542/// configured maximum.
543fn check_line_length(
544    errors: &mut Vec<WhitespaceError>,
545    config: &ValidationConfig,
546    line_start: usize,
547    end: usize,
548) {
549    let Some(max_len) = config.max_line_length else {
550        return;
551    };
552    let line_length = end - line_start;
553    if line_length > max_len {
554        errors.push(WhitespaceError {
555            message: format!("Line too long ({line_length} > {max_len} characters)"),
556            range: line_start..end,
557            category: WhitespaceErrorCategory::LineTooLong,
558        });
559    }
560}
561
562/// Record the line ending style on first sight, and flag any later one that
563/// disagrees with it.
564fn note_line_ending<'a>(
565    errors: &mut Vec<WhitespaceError>,
566    config: &ValidationConfig,
567    detected: &mut Option<&'a str>,
568    line_ending: &'a str,
569    range: std::ops::Range<usize>,
570) {
571    if !config.enforce_consistent_line_endings {
572        return;
573    }
574    match *detected {
575        Some(seen) if seen != line_ending => errors.push(WhitespaceError {
576            message: "Inconsistent line endings detected".to_string(),
577            range,
578            category: WhitespaceErrorCategory::MixedLineEndings,
579        }),
580        Some(_) => {}
581        None => *detected = Some(line_ending),
582    }
583}
584
585/// Tokenize YAML input with custom validation configuration
586pub fn lex_with_validation_config<'a>(
587    input: &'a str,
588    config: &ValidationConfig,
589) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
590    use SyntaxKind::*;
591
592    let mut tokens = Vec::with_capacity(input.len() / 8); // Pre-allocate based on estimate
593    let mut chars = input.char_indices().peekable();
594    let mut whitespace_errors = Vec::new();
595    let bytes = input.as_bytes();
596
597    // Track line information for validation
598    let mut current_line_start = 0;
599    let mut detected_line_ending: Option<&str> = None;
600
601    // Track flow collection depth for context-aware tokenization
602    let mut flow_depth: u32 = 0;
603
604    // Indentation of the line carrying a block-scalar header, while its body
605    // is still running. Everything indented past that column is literal
606    // text, so a `{` or `[` there must not open a flow collection: the
607    // depth would never come back down and every later `,` in the file
608    // would lex as a delimiter.
609    let mut block_scalar_header_indent: Option<usize> = None;
610    // Column of the block scalar's body, set by its first content line. The
611    // body is every later line reaching that column; the header's own indent
612    // is only a lower bound, so a line between the two ends the body rather
613    // than continuing it.
614    let mut block_scalar_body_indent: Option<usize> = None;
615
616    // Handle UTF-8 BOM (U+FEFF) at the start of the file
617    // Per YAML spec, BOM is allowed and should be processed transparently
618    if let Some((0, '\u{FEFF}')) = chars.peek() {
619        chars.next(); // Consume the BOM
620        tokens.push((BOM, "\u{FEFF}"));
621        current_line_start = '\u{FEFF}'.len_utf8();
622    }
623
624    while let Some((start_idx, ch)) = chars.next() {
625        let token_start = start_idx;
626
627        match ch {
628            // Context-aware hyphen handling
629            '-' => {
630                if flow_depth == 0
631                    && start_idx == current_line_start
632                    && input[start_idx..].starts_with("---")
633                    && input[start_idx + 3..]
634                        .chars()
635                        .next()
636                        .map_or(true, ends_document_marker)
637                {
638                    chars.next();
639                    chars.next();
640                    tokens.push((DOC_START, &input[token_start..start_idx + 3]));
641                } else {
642                    // Check if this hyphen should be treated as a sequence marker
643                    // It's a sequence marker if:
644                    // 1. It's at the beginning of a line (after optional indentation)
645                    // 2. OR it follows a value context (after ? or : plus whitespace)
646                    // AND it's followed by whitespace or end of input
647
648                    // Check if preceded only by whitespace from start of line
649                    let line_start_pos = current_line_start;
650                    let before_dash = &input[line_start_pos..token_start];
651                    let only_whitespace_before = before_dash.chars().all(|c| c == ' ' || c == '\t');
652
653                    // Check if the previous non-whitespace token was ? or :
654                    // indicating a value context where sequences are allowed.
655                    //
656                    // A DASH counts too: an entry's own node may be a nested
657                    // sequence, so `- - x` is `[[x]]`, as saphyr and PyYAML
658                    // both read it and as value.rs already writes it.
659                    let after_value_indicator = tokens
660                        .iter()
661                        .rev()
662                        .find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT))
663                        .is_some_and(|(kind, _)| matches!(kind, QUESTION | COLON | DASH));
664
665                    // Check if followed by whitespace or end of input
666                    let followed_by_whitespace_or_end = chars
667                        .peek()
668                        .map_or(true, |(_, next_ch)| next_ch.is_whitespace());
669
670                    let is_sequence_marker = (only_whitespace_before || after_value_indicator)
671                        && followed_by_whitespace_or_end;
672
673                    if is_sequence_marker {
674                        tokens.push((DASH, &input[token_start..start_idx + 1]));
675                    } else {
676                        // This hyphen is part of a scalar value. Use the
677                        // plain-scalar body reader so embedded `:` (not
678                        // followed by whitespace) stays inside the scalar.
679                        //
680                        // A `-` closing a block-scalar header (`|-`, `>2-`)
681                        // is a chomping indicator and stands alone, so it
682                        // must not swallow the space that follows it. Same
683                        // test as the `+` arm below.
684                        let is_chomping_indicator = input[current_line_start..start_idx]
685                            .bytes()
686                            .rev()
687                            .find(|b| !b.is_ascii_digit())
688                            .is_some_and(|b| b == b'|' || b == b'>');
689                        push_plain_scalar_from(
690                            &mut tokens,
691                            &mut chars,
692                            input,
693                            token_start,
694                            start_idx + 1,
695                            flow_depth,
696                            !is_chomping_indicator,
697                        );
698                    }
699                }
700            }
701            '+' => {
702                // `+` is a chomping indicator when it appears inside a
703                // block-scalar header (`|+`, `|2+`, `>+`, `>2+`). Detect
704                // that by looking at the current line: the last non-digit
705                // byte before this `+` must be `|` or `>`. Otherwise, if
706                // the next char begins a plain-scalar body (digit, `.`,
707                // letter, ...) this `+` is a sign prefix and belongs in
708                // the same scalar token -- so `+.INF` lexes as one FLOAT
709                // rather than PLUS + FLOAT. A bare `+` followed by
710                // whitespace stays PLUS.
711                let line_start = current_line_start;
712                let is_chomping_indicator = input[line_start..start_idx]
713                    .bytes()
714                    .rev()
715                    .find(|b| !b.is_ascii_digit())
716                    .is_some_and(|b| b == b'|' || b == b'>');
717                // Outside a flow collection the flow indicators are ordinary
718                // scalar content, a `:` not followed by whitespace is never a
719                // mapping indicator, and a quote no longer opens a quoted
720                // scalar once this one has begun. All of them continue the
721                // body: `a+[b]`, `a {b+:c}` and `+'a'` are each one scalar,
722                // as the `-` spellings already were. These are the same
723                // exceptions the body reader itself makes.
724                let next_starts_scalar = chars.peek().is_some_and(|(idx, c)| {
725                    // A space does not end the scalar when more scalar
726                    // content follows on the same line: `+ x: v` is keyed
727                    // `+ x`, exactly as `a x: v` is keyed `a x`. The body
728                    // reader makes the same test before absorbing a run.
729                    if c.is_whitespace() {
730                        return *c != '\n'
731                            && *c != '\r'
732                            && plain_scalar_continues_past_whitespace(input, *idx, flow_depth);
733                    }
734                    !c.is_whitespace()
735                        && (!is_yaml_special(*c)
736                            // Everything the body reader keeps as content
737                            // starts a body: quotes, signs, and the node
738                            // properties and block headers, which are
739                            // indicators only at the start of a node. So
740                            // `+?`, `+|` and `+&` are each one scalar, as
741                            // `+x` already was.
742                            || matches!(
743                                c,
744                                '\'' | '"' | '+' | '-' | '?' | '|' | '>' | '&' | '*' | '!' | '%'
745                            )
746                            // A `#` glued to the sign is content, not a
747                            // comment: `+#: v` is keyed `+#`.
748                            || (*c == '#' && !is_hash_a_comment_start(input, *idx))
749                            || (flow_depth == 0 && matches!(c, '[' | ']' | '{' | '}' | ','))
750                            || (*c == ':'
751                                && !is_colon_a_mapping_indicator(input, *idx, flow_depth)))
752                });
753                if !is_chomping_indicator && next_starts_scalar {
754                    push_plain_scalar_from(
755                        &mut tokens,
756                        &mut chars,
757                        input,
758                        token_start,
759                        start_idx + 1,
760                        flow_depth,
761                        true,
762                    );
763                } else if !is_chomping_indicator && {
764                    // A key may be separated from its colon by spaces
765                    // (`+ : v`), so look past them as well.
766                    let rest = &input[start_idx + 1..];
767                    let gap = rest.len() - rest.trim_start_matches([' ', '\t']).len();
768                    rest[gap..].starts_with(':')
769                        && is_colon_a_mapping_indicator(input, start_idx + 1 + gap, flow_depth)
770                } {
771                    // `+: v` is a mapping keyed by the plain scalar `+`, as
772                    // `-: v` already was. A bare PLUS left the key invisible
773                    // and stranded the rest of the entry.
774                    let text = &input[token_start..start_idx + 1];
775                    tokens.push((classify_scalar(text), text));
776                } else if is_chomping_indicator {
777                    tokens.push((PLUS, &input[token_start..start_idx + 1]));
778                } else {
779                    // Nothing follows on this line to extend the body, but a
780                    // `+` outside a block-scalar header is still content, not
781                    // an indicator: `+\nx\n` is the scalar `+ x`. A bare PLUS
782                    // token no parse rule claims left the `+` invisible and
783                    // stranded whatever followed it.
784                    let text = &input[token_start..start_idx + 1];
785                    tokens.push((classify_scalar(text), text));
786                }
787            }
788            ':' => {
789                // Colon at token boundary. It's a mapping indicator when
790                // its lookahead matches is_colon_a_mapping_indicator, OR
791                // when we're in flow context and the immediately
792                // preceding meaningful token was a scalar (i.e., a key
793                // has been laid down and this `:` separates it from the
794                // value):
795                //     { "foo"\n  :bar }    -- COLON after "foo"
796                // vs.
797                //     [ ::vector ]         -- `:` starts a plain scalar
798                let preceded_by_scalar = flow_depth > 0
799                    && tokens
800                        .iter()
801                        .rev()
802                        .find(|(k, _)| !matches!(k, WHITESPACE | INDENT | NEWLINE | COMMENT))
803                        .is_some_and(|(k, _)| matches!(k, STRING | INT | FLOAT | BOOL | NULL));
804                if preceded_by_scalar || is_colon_a_mapping_indicator(input, start_idx, flow_depth)
805                {
806                    tokens.push((COLON, &input[token_start..start_idx + 1]));
807                } else {
808                    // This colon starts (or continues) a plain scalar such
809                    // as a URL, `::vector`, or a timestamp. The shared body
810                    // reader knows the rules: a `:` not followed by space is
811                    // content, flow indicators are content in block context,
812                    // and a `#` glued to content is not a comment. Do not let
813                    // it absorb an internal space run, though -- `: v` after
814                    // this colon is a value, not more key.
815                    let rest = read_plain_scalar_body_from(
816                        &mut chars,
817                        input,
818                        start_idx + 1,
819                        flow_depth,
820                        false,
821                    );
822                    let text = &input[token_start..start_idx + 1 + rest.len()];
823                    tokens.push((classify_scalar(text), text));
824                }
825            }
826            // An explicit-key indicator, like the node properties below, only
827            // at the start of a node: `a ?b` is the scalar `a ?b`, not a key
828            // indicator inside it.
829            //
830            // Per YAML 1.2 `c-complex-mapping-key` the `?` must also be
831            // followed by a space or a line break to be an indicator. Without
832            // that test `a: ?!!r{2}x` opened an explicit key and then read
833            // `!!r` as a tag, which stranded the rest of the line; PyYAML
834            // reads the value as the plain scalar `?!!r{2}x`.
835            '?' if node_property_can_start(&tokens)
836                && chars.peek().map_or(true, |(_, next)| next.is_whitespace()) =>
837            {
838                tokens.push((QUESTION, &input[token_start..start_idx + 1]))
839            }
840            // A flow indicator inside a block scalar's body is literal
841            // text, so it neither opens nor closes a collection. Read the
842            // rest of the line as the scalar content it is.
843            '[' | ']' | '{' | '}' | ','
844                if at_block_scalar_body_indent(
845                    block_scalar_body_indent,
846                    input,
847                    current_line_start,
848                    start_idx,
849                ) || in_block_scalar_body(
850                    block_scalar_header_indent,
851                    input,
852                    current_line_start,
853                    start_idx,
854                ) =>
855            {
856                let rest =
857                    read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
858                let text = &input[token_start..start_idx + 1 + rest.len()];
859                tokens.push((classify_scalar(text), text));
860            }
861            '[' => {
862                flow_depth += 1;
863                tokens.push((LEFT_BRACKET, &input[token_start..start_idx + 1]));
864            }
865            ']' => {
866                flow_depth = flow_depth.saturating_sub(1);
867                tokens.push((RIGHT_BRACKET, &input[token_start..start_idx + 1]));
868            }
869            '{' => {
870                flow_depth += 1;
871                tokens.push((LEFT_BRACE, &input[token_start..start_idx + 1]));
872            }
873            '}' => {
874                flow_depth = flow_depth.saturating_sub(1);
875                tokens.push((RIGHT_BRACE, &input[token_start..start_idx + 1]));
876            }
877            // A `,` separates entries only inside a flow collection. In block
878            // context it is ordinary plain-scalar content (`a: x\n  ,y`), so
879            // fall through to the catch-all arm, which already keeps flow
880            // indicators inside a block scalar.
881            ',' if flow_depth > 0 => tokens.push((COMMA, &input[token_start..start_idx + 1])),
882            // A block-scalar header, like the node properties below, only at
883            // the start of a node: `a|b` is the scalar `a|b`, not a header
884            // inside it. `>` reaches this arm only at a node start already,
885            // because the catch-all treats it as scalar content.
886            //
887            // And only in block context: YAML 1.2 section 8.1 gives block
888            // scalars a block-context production only, so inside a flow
889            // collection a `|` is plain content and `[|, x]` has two
890            // entries, as saphyr reads it.
891            '|' if flow_depth == 0 && node_property_can_start(&tokens) => {
892                block_scalar_header_indent = Some(line_indent(input, current_line_start));
893                block_scalar_body_indent = None;
894                tokens.push((PIPE, &input[token_start..start_idx + 1]))
895            }
896            '>' if flow_depth == 0 && node_property_can_start(&tokens) => {
897                block_scalar_header_indent = Some(line_indent(input, current_line_start));
898                block_scalar_body_indent = None;
899                tokens.push((GREATER, &input[token_start..start_idx + 1]))
900            }
901            // `<<` is a merge key only when the key is exactly `<<`, i.e. the
902            // next character ends the token. Anything else starting with `<`
903            // (`<<foo`, a bare `<`) is a plain scalar and falls through to the
904            // catch-all arm below, which knows the real plain-scalar rules.
905            '<' if is_merge_key_at(input, start_idx) => {
906                chars.next(); // consume second <
907                tokens.push((MERGE_KEY, &input[token_start..start_idx + 2]));
908            }
909            // Like tags, only at the start of a node: `a: x &anc` is the
910            // scalar `x &anc`, not an anchored value.
911            '&' if node_property_can_start(&tokens) => {
912                // Check if this is an anchor definition
913                let name = read_anchor_name_from(&mut chars, input, start_idx + 1);
914                if !name.is_empty() {
915                    tokens.push((ANCHOR, &input[token_start..start_idx + 1 + name.len()]));
916                } else {
917                    tokens.push((AMPERSAND, &input[token_start..start_idx + 1]));
918                }
919            }
920            '*' if node_property_can_start(&tokens) => {
921                // Check if this is an alias reference
922                let name = read_anchor_name_from(&mut chars, input, start_idx + 1);
923                if !name.is_empty() {
924                    tokens.push((REFERENCE, &input[token_start..start_idx + 1 + name.len()]));
925                } else {
926                    tokens.push((ASTERISK, &input[token_start..start_idx + 1]));
927                }
928            }
929            '"' => {
930                let (end_idx, found_closing) = scan_double_quoted(&mut chars, start_idx);
931                push_quoted_string(&mut tokens, input, token_start, end_idx, found_closing);
932            }
933            '\'' => {
934                let (end_idx, found_closing) = scan_single_quoted(&mut chars, start_idx);
935                push_quoted_string(&mut tokens, input, token_start, end_idx, found_closing);
936            }
937
938            // Document end
939            '.' => {
940                // Check for three dots (document end marker)
941                // `...` is the document-end marker only when it is the whole
942                // line, in block context: the same three conditions the `---`
943                // arm applies. `...w` is the plain scalar `...w`, as both
944                // saphyr and PyYAML read it, and `---x` already was.
945                let is_doc_end = flow_depth == 0
946                    && start_idx == current_line_start
947                    && input[start_idx..].starts_with("...")
948                    && input[start_idx + 3..]
949                        .chars()
950                        .next()
951                        .map_or(true, ends_document_marker);
952                if is_doc_end && chars.peek() == Some(&(start_idx + 1, '.')) {
953                    chars.next(); // consume second .
954                    if chars.peek() == Some(&(start_idx + 2, '.')) {
955                        chars.next(); // consume third .
956                        tokens.push((DOC_END, &input[token_start..start_idx + 3]));
957                    } else {
958                        // Two dots -- continue as plain scalar body (allows
959                        // embedded `-` and `:` per YAML plain-scalar rules).
960                        push_plain_scalar_from(
961                            &mut tokens,
962                            &mut chars,
963                            input,
964                            token_start,
965                            start_idx + 2,
966                            flow_depth,
967                            true,
968                        );
969                    }
970                } else {
971                    // Single dot -- part of plain scalar body.
972                    push_plain_scalar_from(
973                        &mut tokens,
974                        &mut chars,
975                        input,
976                        token_start,
977                        start_idx + 1,
978                        flow_depth,
979                        true,
980                    );
981                }
982            }
983
984            // Comments.
985            //
986            // Per YAML 1.2 section 6.6, `#` starts a comment only when
987            // preceded by whitespace. At outer-token boundary we have
988            // one of two situations:
989            //   - `#` at the start of the document / a fresh line, or
990            //     after a whitespace/newline: legitimate comment.
991            //   - `#` glued to the tail of a non-whitespace token
992            //     (e.g., `]#foo`): the input violates 4.6.6. The
993            //     validator's check_comment_token_whitespace flags
994            //     this at validation time; the lexer still emits a
995            //     COMMENT token so downstream error reporting has
996            //     something to point at.
997            //
998            // A `#` glued to the tail of *plain-scalar* content is different:
999            // `:#: v` is a mapping keyed `:#`, as saphyr reads it. Continue
1000            // the scalar rather than starting a comment, which the catch-all
1001            // arm already does for `a#: v`.
1002            // A `#` inside a block scalar's body is literal text: the body
1003            // is taken verbatim, so `- >\n  # detected\n` holds the content
1004            // `# detected` rather than a comment (test suite 4QFQ). A line
1005            // that does not reach the body's column has left it, where a
1006            // comment is a comment again (T26H).
1007            '#' if at_block_scalar_body_indent(
1008                block_scalar_body_indent,
1009                input,
1010                current_line_start,
1011                start_idx,
1012            ) =>
1013            {
1014                let rest =
1015                    read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
1016                let text = &input[token_start..start_idx + 1 + rest.len()];
1017                tokens.push((classify_scalar(text), text));
1018            }
1019            // A `#` not preceded by whitespace is scalar content wherever it
1020            // sits, including a continuation line whose previous token is the
1021            // INDENT (`safe: a!"#$%\n     !"#$%` -- test suite FBC9).
1022            //
1023            // A quoted scalar ends at its closing quote, so a `#` glued to
1024            // one really is the 6.6 violation the validator reports
1025            // (`key: "value"# c`).
1026            '#' if !is_hash_a_comment_start(input, start_idx)
1027                && {
1028                    // The character before decides, but it has to belong to
1029                    // scalar content: glued to a flow delimiter (`c,#x`) the
1030                    // `#` is still the 6.6 violation CVW2 expects.
1031                    let prev = input[..start_idx].chars().next_back();
1032                    prev.is_some_and(|c| !is_yaml_special(c) || matches!(c, '!' | '"' | '\''))
1033                }
1034                && !tokens.last().is_some_and(|(k, text)| {
1035                    matches!(k, STRING | UNTERMINATED_STRING)
1036                        && text.starts_with(['"', '\''])
1037                        // Only when the quote really ends at this `#`.
1038                        && input[..start_idx].ends_with(['"', '\''])
1039                }) =>
1040            {
1041                let rest =
1042                    read_plain_scalar_body_from(&mut chars, input, start_idx + 1, flow_depth, true);
1043                let text = &input[token_start..start_idx + 1 + rest.len()];
1044                tokens.push((classify_scalar(text), text));
1045            }
1046            '#' => {
1047                let mut end_idx = start_idx + 1;
1048                while let Some((idx, ch)) = chars.peek() {
1049                    if *ch == '\n' || *ch == '\r' {
1050                        break;
1051                    }
1052                    end_idx = *idx + ch.len_utf8();
1053                    chars.next();
1054                }
1055                tokens.push((COMMENT, &input[token_start..end_idx]));
1056            }
1057
1058            // Tags
1059            //
1060            // Only at the start of a node: mid-scalar a `!` is ordinary
1061            // content (`a: x !!b` is the scalar `x !!b`), which the
1062            // catch-all arm below reads with plain-scalar rules.
1063            '!' if node_property_can_start(&tokens) => {
1064                // Handle tag indicators - both ! and !!
1065                let mut end_idx = start_idx + 1;
1066
1067                // A verbatim tag `!<uri>` runs to its closing `>` and may hold
1068                // any URI character, commas included, so take it whole.
1069                if let Some((_, '<')) = chars.peek() {
1070                    chars.next(); // consume '<'
1071                    end_idx = start_idx + 2;
1072                    // An unterminated `!<x` stops at the line break, which
1073                    // the main loop then handles; the text still round-trips
1074                    // and the parser reports the malformed tag.
1075                    while let Some((idx, ch)) = chars.peek() {
1076                        if *ch == '\n' {
1077                            break;
1078                        }
1079                        end_idx = *idx + ch.len_utf8();
1080                        let closing = *ch == '>';
1081                        chars.next();
1082                        if closing {
1083                            break;
1084                        }
1085                    }
1086                    tokens.push((TAG, &input[token_start..end_idx]));
1087                    continue;
1088                }
1089
1090                // Check for double exclamation (global tag)
1091                if let Some((_, '!')) = chars.peek() {
1092                    chars.next(); // consume the second !
1093                    end_idx = start_idx + 2;
1094                }
1095
1096                // Read the tag name after the ! or !!
1097                while let Some((idx, ch)) = chars.peek() {
1098                    if !is_tag_char(*ch) {
1099                        break;
1100                    }
1101                    end_idx = *idx + ch.len_utf8();
1102                    chars.next();
1103                }
1104
1105                tokens.push((TAG, &input[token_start..end_idx]));
1106            }
1107
1108            '%' => {
1109                // A directive is only a `%` that opens a line. Anywhere else,
1110                // and anywhere inside a flow collection, it is ordinary plain
1111                // scalar content (`a: b%c`).
1112                if flow_depth > 0 || start_idx != current_line_start {
1113                    // Treat as part of a plain scalar. The shared body reader
1114                    // knows the rules this needs: flow indicators are content
1115                    // in block context, and an internal whitespace run stays
1116                    // in the scalar when more content follows on the line, so
1117                    // `r % {` is the single scalar `r % {`.
1118                    let rest = read_plain_scalar_body_from(
1119                        &mut chars,
1120                        input,
1121                        start_idx + 1,
1122                        flow_depth,
1123                        true,
1124                    );
1125                    let text = &input[token_start..start_idx + 1 + rest.len()];
1126                    tokens.push((classify_scalar(text), text));
1127                } else {
1128                    // At the start of a line in block context, % starts a directive
1129                    let mut end_idx = start_idx + 1;
1130                    while let Some((idx, ch)) = chars.peek() {
1131                        if *ch == '\n' || *ch == '\r' {
1132                            break;
1133                        }
1134                        end_idx = *idx + ch.len_utf8();
1135                        chars.next();
1136                    }
1137                    tokens.push((DIRECTIVE, &input[token_start..end_idx]));
1138                }
1139            }
1140
1141            // Newlines
1142            '\n' => {
1143                check_line_length(
1144                    &mut whitespace_errors,
1145                    config,
1146                    current_line_start,
1147                    start_idx,
1148                );
1149                note_line_ending(
1150                    &mut whitespace_errors,
1151                    config,
1152                    &mut detected_line_ending,
1153                    "\n",
1154                    token_start..start_idx + 1,
1155                );
1156
1157                tokens.push((NEWLINE, &input[token_start..start_idx + 1]));
1158                current_line_start = start_idx + 1;
1159                track_block_scalar_body(
1160                    input,
1161                    current_line_start,
1162                    &mut block_scalar_header_indent,
1163                    &mut block_scalar_body_indent,
1164                );
1165            }
1166            '\r' => {
1167                check_line_length(
1168                    &mut whitespace_errors,
1169                    config,
1170                    current_line_start,
1171                    start_idx,
1172                );
1173
1174                let (line_ending, end_pos) = if let Some((_, '\n')) = chars.peek() {
1175                    chars.next();
1176                    ("\r\n", start_idx + 2)
1177                } else {
1178                    ("\r", start_idx + 1)
1179                };
1180
1181                note_line_ending(
1182                    &mut whitespace_errors,
1183                    config,
1184                    &mut detected_line_ending,
1185                    line_ending,
1186                    token_start..end_pos,
1187                );
1188
1189                tokens.push((NEWLINE, &input[token_start..end_pos]));
1190                current_line_start = end_pos;
1191                track_block_scalar_body(
1192                    input,
1193                    current_line_start,
1194                    &mut block_scalar_header_indent,
1195                    &mut block_scalar_body_indent,
1196                );
1197            }
1198
1199            // Whitespace (spaces and tabs)
1200            ' ' | '\t' => {
1201                let mut end_idx = start_idx + 1;
1202                let mut has_tabs = ch == '\t';
1203
1204                while let Some((idx, ch)) = chars.peek() {
1205                    if *ch != ' ' && *ch != '\t' {
1206                        break;
1207                    }
1208                    if *ch == '\t' {
1209                        has_tabs = true;
1210                    }
1211                    end_idx = *idx + 1;
1212                    chars.next();
1213                }
1214
1215                // Determine if this is structural indentation
1216                // Check for any line break: \n, \r\n (already consumed \n), or \r alone
1217                let is_indentation = token_start == 0
1218                    || (token_start > 0
1219                        && (bytes[token_start - 1] == b'\n' || bytes[token_start - 1] == b'\r'));
1220
1221                if is_indentation {
1222                    // Check for tab characters in indentation (forbidden in YAML)
1223                    if has_tabs {
1224                        whitespace_errors.push(WhitespaceError {
1225                            message: "Tab character used for indentation (forbidden in YAML)"
1226                                .to_string(),
1227                            range: token_start..end_idx,
1228                            category: WhitespaceErrorCategory::TabIndentation,
1229                        });
1230                    }
1231                    tokens.push((INDENT, &input[token_start..end_idx]));
1232                } else {
1233                    tokens.push((WHITESPACE, &input[token_start..end_idx]));
1234                }
1235            }
1236
1237            // Everything else is scalar content
1238            _ => {
1239                let mut end_idx = start_idx + ch.len_utf8();
1240
1241                // Read the rest of the scalar normally, including embedded hyphens
1242                while let Some((idx, next_ch)) = chars.peek().copied() {
1243                    // Line breaks always terminate the current scalar token.
1244                    if next_ch == '\n' || next_ch == '\r' {
1245                        break;
1246                    }
1247
1248                    // Intra-line whitespace (space/tab) is part of a plain
1249                    // scalar when followed by more scalar content on the same
1250                    // line. Per YAML 1.2, plain scalars may contain single or
1251                    // multiple internal spaces, but stop at line break, at
1252                    // ` #` (comment start), or at end of line.
1253                    if next_ch == ' ' || next_ch == '\t' {
1254                        if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
1255                            break;
1256                        }
1257                        // Absorb the whitespace run into the scalar
1258                        while let Some((wi, wc)) = chars.peek().copied() {
1259                            if wc != ' ' && wc != '\t' {
1260                                break;
1261                            }
1262                            end_idx = wi + wc.len_utf8();
1263                            chars.next();
1264                        }
1265                        continue;
1266                    }
1267
1268                    // `:` inside a plain scalar breaks the scalar iff
1269                    // it's a mapping indicator (see helper for rules).
1270                    if next_ch == ':' {
1271                        if is_colon_a_mapping_indicator(input, idx, flow_depth) {
1272                            break;
1273                        }
1274                        end_idx = idx + next_ch.len_utf8();
1275                        chars.next();
1276                        continue;
1277                    }
1278
1279                    // Check other special characters (excluding hyphen and colon).
1280                    // `&`, `*`, `!`, `?` are node-property / block-key indicators
1281                    // that are only meaningful at the start of a node. Once we
1282                    // are inside a plain-scalar body they are ordinary content
1283                    // per YAML 1.2 §7.1 (a `&anchor` must be followed by
1284                    // whitespace to actually be an anchor).
1285                    // `%` is an indicator only at the start of a line, where
1286                    // the main loop takes it as a directive; inside a scalar it
1287                    // is ordinary content (`a: b%c`).
1288                    // `|` and `>` open a block scalar only at a node start, so
1289                    // mid-scalar they are content too (`a|b`, `a>b`). A `+` is
1290                    // an indicator only as the chomping suffix of such a
1291                    // header, where `|2+` must keep it as its own token;
1292                    // anywhere else it is content (`a+b`).
1293                    // A `+` closing a block-scalar header (`|+`, `>2+`) is a
1294                    // chomping indicator. The `|` has to *open* the value for
1295                    // that, though: in `/|+:` the pipe is scalar content, so
1296                    // the `+` is content too and the key is `/|+`. Require
1297                    // nothing but the value's start before the `|`.
1298                    let plus_is_chomping = next_ch == '+' && {
1299                        // Step back over any explicit indentation digits to
1300                        // the `|` or `>` this `+` would close.
1301                        let head = input[current_line_start..idx]
1302                            .trim_end_matches(|c: char| c.is_ascii_digit());
1303                        match head.strip_suffix(['|', '>']) {
1304                            // The header must open the value: only the value's
1305                            // start may precede it. In `/|+:` the pipe is
1306                            // scalar content, so the `+` is content too and the
1307                            // key is `/|+`.
1308                            Some(before) => {
1309                                let before = before.trim_end();
1310                                before.is_empty() || before.ends_with(':') || before.ends_with('-')
1311                            }
1312                            None => false,
1313                        }
1314                    };
1315                    if plus_is_chomping {
1316                        break;
1317                    }
1318                    if is_yaml_special_except(next_ch, "-:#&*!?'\"|>%+") {
1319                        // In block context, flow indicators do NOT break scalars
1320                        if flow_depth == 0 && matches!(next_ch, '[' | ']' | '{' | '}' | ',') {
1321                            // do nothing, let it be part of the scalar
1322                        } else {
1323                            break;
1324                        }
1325                    }
1326
1327                    // `#` inside a plain scalar breaks the scalar iff
1328                    // it's a comment start (see helper for rules).
1329                    if next_ch == '#' {
1330                        if is_hash_a_comment_start(input, idx) {
1331                            break;
1332                        }
1333                        end_idx = idx + next_ch.len_utf8();
1334                        chars.next();
1335                        continue;
1336                    }
1337
1338                    // Special case: check if hyphen is a sequence marker
1339                    if next_ch == '-' {
1340                        // A hyphen is only a sequence marker if it's at line start
1341                        // and this scalar is already complete (we're at a word boundary)
1342                        let before_hyphen = &input[current_line_start..idx];
1343
1344                        // If there's only whitespace before the hyphen, it might be a sequence marker
1345                        // Break here to let the main loop handle it
1346                        if before_hyphen.chars().all(|c| c == ' ' || c == '\t') && idx == end_idx {
1347                            break;
1348                        }
1349                    }
1350
1351                    end_idx = idx + next_ch.len_utf8();
1352                    chars.next();
1353                }
1354
1355                let text = &input[token_start..end_idx];
1356                tokens.push((classify_scalar(text), text));
1357            }
1358        }
1359    }
1360
1361    // Check the final line length if there's no trailing newline
1362    if let Some(max_len) = config.max_line_length {
1363        let final_line_length = input.len() - current_line_start;
1364        if final_line_length > max_len && final_line_length > 0 {
1365            whitespace_errors.push(WhitespaceError {
1366                message: format!("Line too long ({final_line_length} > {max_len} characters)"),
1367                range: current_line_start..input.len(),
1368                category: WhitespaceErrorCategory::LineTooLong,
1369            });
1370        }
1371    }
1372
1373    (tokens, whitespace_errors)
1374}
1375
1376/// Map a plain-scalar's semantic classification onto the concrete
1377/// [`SyntaxKind`] the tokenizer emits. The classification itself lives
1378/// in [`ScalarValue::classify_plain`](crate::ScalarValue::classify_plain);
1379/// this function only translates enum variants.
1380fn classify_scalar(text: &str) -> SyntaxKind {
1381    use crate::CoreScalarType;
1382    match crate::scalar::ScalarValue::classify_plain(text) {
1383        CoreScalarType::Null => SyntaxKind::NULL,
1384        CoreScalarType::Boolean => SyntaxKind::BOOL,
1385        CoreScalarType::Integer => SyntaxKind::INT,
1386        CoreScalarType::Float => SyntaxKind::FLOAT,
1387        CoreScalarType::String => SyntaxKind::STRING,
1388    }
1389}
1390
1391/// Common set of YAML special characters
1392const YAML_SPECIAL_CHARS: &str = ":+-?[]{},'|>&*!%\"#";
1393
1394/// Check whether the `<` at `idx` starts a merge key (`<<`).
1395///
1396/// `<<` is a merge key only when the whole key is `<<`, per the YAML 1.1 merge
1397/// type. A `<<` followed by more scalar content (`<<foo`) is a plain scalar,
1398/// which is what PyYAML and other implementations accept.
1399fn is_merge_key_at(input: &str, idx: usize) -> bool {
1400    let rest = &input[idx..];
1401    if !rest.starts_with("<<") {
1402        return false;
1403    }
1404    match rest[2..].chars().next() {
1405        None => true,
1406        Some(ch) => ch.is_whitespace() || matches!(ch, ':' | ',' | ']' | '}' | '#'),
1407    }
1408}
1409
1410/// Check if a character has special meaning in YAML
1411/// Whether `ch` may appear in a tag shorthand's suffix.
1412///
1413/// YAML 1.2 `ns-tag-char` is any URI character except the flow indicators
1414/// `,`, `[`, `]`, `{`, `}` and the `!` that would start another tag. That
1415/// admits plenty the lexer treats as special elsewhere -- `:` in `!!ss:eq`,
1416/// `-` in `!!ss---` -- so a tag cannot be scanned with the general
1417/// [`YAML_SPECIAL_CHARS`] set.
1418/// Whether a node property (`!tag`, `&anchor`, `*alias`) may start here.
1419///
1420/// Per YAML 1.2 §7.1 these apply to the start of a node. Once a plain
1421/// scalar has begun on the line, a `!` or `&` is ordinary content, so
1422/// `a: x !!b` is the scalar `x !!b` rather than a tagged node. A preceding
1423/// space is not enough to start a new node: what matters is whether the
1424/// last token was scalar content.
1425/// Whether the character ends a `---` or `...` marker.
1426///
1427/// YAML 1.2 `s-white` is space and tab only, and a marker may also end at a
1428/// line break. Rust's `char::is_whitespace` is Unicode-wide and counts
1429/// U+00A0 and friends, which are ordinary scalar content here: `"...\u{a0}-"`
1430/// is one plain scalar, as saphyr and PyYAML both read it.
1431fn ends_document_marker(ch: char) -> bool {
1432    matches!(ch, ' ' | '\t' | '\n' | '\r')
1433}
1434
1435fn node_property_can_start(tokens: &[(SyntaxKind, &str)]) -> bool {
1436    for (kind, _) in tokens.iter().rev() {
1437        match kind {
1438            // Skip the layout between the property and what precedes it.
1439            SyntaxKind::WHITESPACE | SyntaxKind::INDENT => continue,
1440            // A node starts after these.
1441            SyntaxKind::NEWLINE
1442            | SyntaxKind::COLON
1443            | SyntaxKind::DASH
1444            | SyntaxKind::QUESTION
1445            | SyntaxKind::COMMA
1446            | SyntaxKind::LEFT_BRACKET
1447            | SyntaxKind::LEFT_BRACE
1448            | SyntaxKind::DOC_START
1449            | SyntaxKind::TAG
1450            | SyntaxKind::ANCHOR
1451            // A bare `&` or `*` is punctuation rather than content, so a
1452            // property may still follow it.
1453            | SyntaxKind::AMPERSAND
1454            | SyntaxKind::ASTERISK => return true,
1455            // Anything else is content the property would sit inside.
1456            _ => return false,
1457        }
1458    }
1459    // Nothing before it: the very start of the input is a node start.
1460    true
1461}
1462
1463/// Whether the line starting at `line_start` carries no content.
1464fn line_is_blank(input: &str, line_start: usize) -> bool {
1465    input[line_start..]
1466        .split(['\n', '\r'])
1467        .next()
1468        .unwrap_or_default()
1469        .trim()
1470        .is_empty()
1471}
1472
1473/// Leading whitespace of the line starting at `line_start`.
1474fn line_indent(input: &str, line_start: usize) -> usize {
1475    let line = &input[line_start..];
1476    line.len() - line.trim_start_matches([' ', '\t']).len()
1477}
1478
1479/// Whether the line starting at `line_start` carries content at or left of
1480/// `indent`, which is what ends a block scalar's body.
1481///
1482/// A blank line says nothing about the body's indentation, so it stays in.
1483fn line_starts_content_at_or_before(input: &str, line_start: usize, indent: usize) -> bool {
1484    let line = input[line_start..]
1485        .split(['\n', '\r'])
1486        .next()
1487        .unwrap_or_default();
1488    if line.trim().is_empty() {
1489        return false;
1490    }
1491    line.len() - line.trim_start_matches([' ', '\t']).len() <= indent
1492}
1493
1494/// Update the lexer's block-scalar tracking at the start of a new line.
1495///
1496/// A block scalar's body ends at the first following line that is not
1497/// indented past its header and not blank. This runs for every line ending,
1498/// CRLF and lone CR included: left to the `\n` arm alone, a CRLF file kept
1499/// the tracking set and read a later `[` as literal body text rather than a
1500/// flow collection.
1501fn track_block_scalar_body(
1502    input: &str,
1503    line_start: usize,
1504    header_indent: &mut Option<usize>,
1505    body_indent: &mut Option<usize>,
1506) {
1507    let Some(header) = *header_indent else {
1508        return;
1509    };
1510    // The body's own column, once a content line has set it, is what later
1511    // lines must reach; before that the header's indent is the only bound we
1512    // have.
1513    let bound = body_indent.unwrap_or(header);
1514    if line_starts_content_at_or_before(input, line_start, bound) {
1515        *header_indent = None;
1516        *body_indent = None;
1517    } else if body_indent.is_none() && !line_is_blank(input, line_start) {
1518        *body_indent = Some(line_indent(input, line_start));
1519    }
1520}
1521
1522/// Whether `idx` is inside the body of a block scalar whose header sat on a
1523/// line indented `header_indent`.
1524///
1525/// A block scalar's body is every following line indented past its header,
1526/// and all of it is literal text: `a: |\n  {\nb,c: 1\n` holds the scalar
1527/// `{`, and the `,` on the next line belongs to the key `b,c`. The caller
1528/// clears `header_indent` once a line ends the body, so this only has to
1529/// rule out the header's own line, which `idx > line_start` does not cover
1530/// on its own.
1531fn in_block_scalar_body(
1532    header_indent: Option<usize>,
1533    input: &str,
1534    line_start: usize,
1535    idx: usize,
1536) -> bool {
1537    let Some(header_indent) = header_indent else {
1538        return false;
1539    };
1540    idx > line_start && line_indent(input, line_start) > header_indent
1541}
1542
1543/// As [`in_block_scalar_body`], for a body whose own column is known.
1544///
1545/// A body line sits *at* that column rather than past it, so `>` followed by
1546/// `  # detected` at the body's indent holds the literal text `# detected`
1547/// (test suite 4QFQ), while a line left of it has ended the body and a `#`
1548/// there is a comment again (T26H).
1549fn at_block_scalar_body_indent(
1550    body_indent: Option<usize>,
1551    input: &str,
1552    line_start: usize,
1553    idx: usize,
1554) -> bool {
1555    let Some(body_indent) = body_indent else {
1556        return false;
1557    };
1558    idx > line_start && line_indent(input, line_start) >= body_indent
1559}
1560
1561fn is_tag_char(ch: char) -> bool {
1562    if ch.is_whitespace() {
1563        return false;
1564    }
1565    !matches!(ch, ',' | '[' | ']' | '{' | '}' | '!' | '#')
1566}
1567
1568fn is_yaml_special(ch: char) -> bool {
1569    YAML_SPECIAL_CHARS.contains(ch)
1570}
1571
1572/// Check if character is YAML special, with optional exclusions
1573fn is_yaml_special_except(ch: char, exclude: &str) -> bool {
1574    YAML_SPECIAL_CHARS.contains(ch) && !exclude.contains(ch)
1575}
1576
1577/// Check whether `name` is a well-formed anchor/alias name for this lexer.
1578///
1579/// The lexer reads an anchor (`&name`) or alias (`*name`) to the first
1580/// whitespace or flow indicator (see [`read_anchor_name_from`]), so a name
1581/// holding one of those would be silently truncated on round-trip. This
1582/// check is deliberately stricter than the lexer: it also rejects the
1583/// [`YAML_SPECIAL_CHARS`], which are legal in a name but easy to misread.
1584pub(crate) fn is_valid_anchor_name(name: &str) -> bool {
1585    !name.is_empty()
1586        && !name
1587            .chars()
1588            .any(|c| c.is_whitespace() || is_yaml_special(c))
1589}
1590
1591#[cfg(test)]
1592mod tests {
1593    use super::*;
1594
1595    #[test]
1596    fn test_is_valid_anchor_name() {
1597        assert!(is_valid_anchor_name("shared"));
1598        assert!(is_valid_anchor_name("shared_1"));
1599        assert!(is_valid_anchor_name("anchor.dotted"));
1600        assert!(is_valid_anchor_name("naïve"));
1601
1602        assert!(!is_valid_anchor_name(""));
1603        assert!(!is_valid_anchor_name("has space"));
1604        assert!(!is_valid_anchor_name("has\ttab"));
1605        assert!(!is_valid_anchor_name("has\nnewline"));
1606        assert!(!is_valid_anchor_name("*star"));
1607        assert!(!is_valid_anchor_name("&amp"));
1608        assert!(!is_valid_anchor_name("comma,name"));
1609        assert!(!is_valid_anchor_name("bracket]name"));
1610        assert!(!is_valid_anchor_name("colon:name"));
1611    }
1612
1613    #[test]
1614    fn test_embedded_hyphens_stay_in_one_plain_scalar() {
1615        let input = format!("k: {}\n", "a-".repeat(200));
1616        let kinds: Vec<_> = lex(&input).into_iter().map(|(k, _)| k).collect();
1617        assert!(kinds.contains(&SyntaxKind::STRING) || kinds.contains(&SyntaxKind::SCALAR));
1618        let dash_count = kinds.iter().filter(|k| **k == SyntaxKind::DASH).count();
1619        assert_eq!(dash_count, 0);
1620        let seq = lex("- a\n- b\n");
1621        let seq_dashes = seq.iter().filter(|(k, _)| *k == SyntaxKind::DASH).count();
1622        assert_eq!(seq_dashes, 2);
1623    }
1624
1625    /// Names accepted by [`is_valid_anchor_name`] must round-trip through the
1626    /// lexer as a single `REFERENCE` token, so `Alias::new` and the lexer stay
1627    /// in agreement.
1628    #[test]
1629    fn test_valid_anchor_names_round_trip_through_lexer() {
1630        for name in ["shared", "shared_1", "anchor.dotted", "naïve"] {
1631            let input = format!("*{name}");
1632            let tokens = lex(&input);
1633            assert_eq!(
1634                tokens,
1635                vec![(SyntaxKind::REFERENCE, input.as_str())],
1636                "name {name:?} did not round-trip as a single REFERENCE token"
1637            );
1638        }
1639    }
1640
1641    #[test]
1642    fn test_simple_mapping() {
1643        let input = "key: value";
1644        let tokens = lex(input);
1645
1646        assert_eq!(tokens.len(), 4);
1647        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1648        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1649        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1650        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
1651    }
1652
1653    #[test]
1654    fn test_scalar_types() {
1655        // Test integer
1656        let tokens = lex("age: 42");
1657        assert_eq!(tokens[0], (SyntaxKind::STRING, "age"));
1658        assert_eq!(tokens[3], (SyntaxKind::INT, "42"));
1659
1660        // Test float
1661        let tokens = lex("pi: 3.14");
1662        assert_eq!(tokens[0], (SyntaxKind::STRING, "pi"));
1663        assert_eq!(tokens[3], (SyntaxKind::FLOAT, "3.14"));
1664
1665        // Test boolean true
1666        let tokens = lex("enabled: true");
1667        assert_eq!(tokens[0], (SyntaxKind::STRING, "enabled"));
1668        assert_eq!(tokens[3], (SyntaxKind::BOOL, "true"));
1669
1670        // Test boolean false
1671        let tokens = lex("disabled: false");
1672        assert_eq!(tokens[3], (SyntaxKind::BOOL, "false"));
1673
1674        // Test null
1675        let tokens = lex("value: null");
1676        assert_eq!(tokens[3], (SyntaxKind::NULL, "null"));
1677
1678        // Test tilde as null
1679        let tokens = lex("value: ~");
1680        assert_eq!(tokens[3], (SyntaxKind::NULL, "~"));
1681    }
1682
1683    #[test]
1684    fn test_sequences() {
1685        let input = "- item1\n- item2";
1686        let tokens = lex(input);
1687
1688        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1689        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1690        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
1691        assert_eq!(tokens[3], (SyntaxKind::NEWLINE, "\n"));
1692        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
1693        assert_eq!(tokens[5], (SyntaxKind::WHITESPACE, " "));
1694        assert_eq!(tokens[6], (SyntaxKind::STRING, "item2"));
1695    }
1696
1697    #[test]
1698    fn test_hyphen_in_scalars() {
1699        // Test hyphens in scalar values should not be treated as sequence markers
1700        let input = "Name: example-project";
1701        let tokens = lex(input);
1702
1703        println!("Hyphen test tokens:");
1704        for (i, (kind, text)) in tokens.iter().enumerate() {
1705            println!("  {}: {:?} = {:?}", i, kind, text);
1706        }
1707
1708        // Should get: STRING("Name"), COLON(":"), WHITESPACE(" "), STRING("example-project")
1709        assert_eq!(tokens.len(), 4);
1710        assert_eq!(tokens[0], (SyntaxKind::STRING, "Name"));
1711        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1712        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1713        assert_eq!(tokens[3], (SyntaxKind::STRING, "example-project"));
1714    }
1715
1716    #[test]
1717    fn test_hyphen_sequence_vs_scalar() {
1718        // Test that sequence markers are still recognized correctly
1719        let sequence_input = "- example-item";
1720        let tokens = lex(sequence_input);
1721
1722        println!("Sequence hyphen tokens:");
1723        for (i, (kind, text)) in tokens.iter().enumerate() {
1724            println!("  {}: {:?} = {:?}", i, kind, text);
1725        }
1726
1727        // Should get: DASH("-"), WHITESPACE(" "), STRING("example-item")
1728        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1729        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1730        assert_eq!(tokens[2], (SyntaxKind::STRING, "example-item"));
1731
1732        // Test scalar with hyphens in different contexts
1733        let scalar_input = "package-name: my-awesome-package";
1734        let tokens = lex(scalar_input);
1735
1736        println!("Package hyphen tokens:");
1737        for (i, (kind, text)) in tokens.iter().enumerate() {
1738            println!("  {}: {:?} = {:?}", i, kind, text);
1739        }
1740
1741        // Should get: STRING("package-name"), COLON(":"), WHITESPACE(" "), STRING("my-awesome-package")
1742        assert_eq!(tokens.len(), 4);
1743        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
1744        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package"));
1745    }
1746
1747    #[test]
1748    fn test_flow_style() {
1749        // Flow sequence
1750        let tokens = lex("[1, 2, 3]");
1751        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
1752        assert_eq!(tokens[1], (SyntaxKind::INT, "1"));
1753        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
1754        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1755        assert_eq!(tokens[4], (SyntaxKind::INT, "2"));
1756        assert_eq!(tokens[5], (SyntaxKind::COMMA, ","));
1757        assert_eq!(tokens[6], (SyntaxKind::WHITESPACE, " "));
1758        assert_eq!(tokens[7], (SyntaxKind::INT, "3"));
1759        assert_eq!(tokens[8], (SyntaxKind::RIGHT_BRACKET, "]"));
1760
1761        // Flow mapping
1762        let tokens = lex("{a: 1, b: 2}");
1763        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
1764        assert_eq!(tokens[1], (SyntaxKind::STRING, "a"));
1765        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
1766        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1767        assert_eq!(tokens[4], (SyntaxKind::INT, "1"));
1768    }
1769
1770    #[test]
1771    fn test_comments() {
1772        let input = "key: value # this is a comment\n# full line comment";
1773        let tokens = lex(input);
1774
1775        // Find comment tokens
1776        let comments: Vec<_> = tokens
1777            .iter()
1778            .filter(|(kind, _)| *kind == SyntaxKind::COMMENT)
1779            .collect();
1780
1781        assert_eq!(comments.len(), 2);
1782        assert_eq!(comments[0].1, "# this is a comment");
1783        assert_eq!(comments[1].1, "# full line comment");
1784    }
1785
1786    #[test]
1787    fn test_multiline_scalar() {
1788        let input = "key: value\n  continued";
1789        let tokens = lex(input);
1790
1791        // Check for indent token
1792        let indents: Vec<_> = tokens
1793            .iter()
1794            .filter(|(kind, _)| *kind == SyntaxKind::INDENT)
1795            .collect();
1796        assert_eq!(indents.len(), 1);
1797        assert_eq!(indents[0].1, "  ");
1798    }
1799
1800    #[test]
1801    fn test_quoted_strings() {
1802        let input = r#"single: 'quoted'
1803double: "quoted""#;
1804        let tokens = lex(input);
1805
1806        // Find quoted string tokens - after fix, quotes are included in STRING tokens
1807        let quoted_strings: Vec<_> = tokens
1808            .iter()
1809            .filter(|(kind, text)| {
1810                *kind == SyntaxKind::STRING && (text.starts_with('\'') || text.starts_with('"'))
1811            })
1812            .collect();
1813        assert_eq!(quoted_strings.len(), 2); // single and double quoted strings
1814
1815        // Verify content (order depends on which appears first in the source)
1816        let quoted_texts: Vec<&str> = {
1817            let mut v: Vec<&str> = quoted_strings.iter().map(|(_, t)| *t).collect();
1818            v.sort();
1819            v
1820        };
1821        assert_eq!(quoted_texts, ["\"quoted\"", "'quoted'"]);
1822    }
1823
1824    #[test]
1825    fn test_document_markers() {
1826        let input = "---\nkey: value\n...";
1827        let tokens = lex(input);
1828
1829        println!("Document tokens:");
1830        for (i, (kind, text)) in tokens.iter().enumerate() {
1831            println!("  {}: {:?} = {:?}", i, kind, text);
1832        }
1833
1834        // Check for document start and end markers
1835        let doc_start_count = tokens
1836            .iter()
1837            .filter(|(kind, _)| *kind == SyntaxKind::DOC_START)
1838            .count();
1839        let doc_end_count = tokens
1840            .iter()
1841            .filter(|(kind, _)| *kind == SyntaxKind::DOC_END)
1842            .count();
1843        assert_eq!(doc_start_count, 1);
1844        assert_eq!(doc_end_count, 1);
1845    }
1846
1847    #[test]
1848    fn test_empty_input() {
1849        let input = "";
1850        let tokens = lex(input);
1851        println!("Empty input tokens: {:?}", tokens);
1852        assert_eq!(tokens.len(), 0);
1853    }
1854
1855    #[test]
1856    fn test_anchors_and_aliases() {
1857        // Test anchor definition
1858        let input = "key: &anchor_name value";
1859        let tokens = lex(input);
1860        println!("Anchor tokens: {:?}", tokens);
1861
1862        let anchors: Vec<_> = tokens
1863            .iter()
1864            .filter(|(kind, _)| *kind == SyntaxKind::ANCHOR)
1865            .collect();
1866        assert_eq!(anchors.len(), 1);
1867        assert_eq!(anchors[0].1, "&anchor_name");
1868
1869        // Test alias reference
1870        let input = "key: *reference_name";
1871        let tokens = lex(input);
1872        println!("Reference tokens: {:?}", tokens);
1873
1874        let references: Vec<_> = tokens
1875            .iter()
1876            .filter(|(kind, _)| *kind == SyntaxKind::REFERENCE)
1877            .collect();
1878        assert_eq!(references.len(), 1);
1879        assert_eq!(references[0].1, "*reference_name");
1880
1881        // Test bare ampersand and asterisk (should not be treated as anchors/references)
1882        let input = "key: & *";
1883        let tokens = lex(input);
1884
1885        let ampersands: Vec<_> = tokens
1886            .iter()
1887            .filter(|(kind, _)| *kind == SyntaxKind::AMPERSAND)
1888            .collect();
1889        assert_eq!(ampersands.len(), 1);
1890
1891        let asterisks: Vec<_> = tokens
1892            .iter()
1893            .filter(|(kind, _)| *kind == SyntaxKind::ASTERISK)
1894            .collect();
1895        assert_eq!(asterisks.len(), 1);
1896    }
1897
1898    #[test]
1899    fn test_indicators_mid_plain_scalar_are_scalar_content() {
1900        // Per YAML 1.2 §7.1 the node-property indicators `&`, `*`, `!`
1901        // only apply when a scalar starts with them (followed by an
1902        // identifier). Mid-scalar they are ordinary content and must
1903        // not create phantom ANCHOR / REFERENCE / TAG tokens.
1904        // Including space-separated: a preceding space does not start a new
1905        // node, so `k: a !b` is still one plain scalar.
1906        for input in [
1907            "k: 3.1&1", "k: a&b", "k: a*b", "k: a!b", "k: a?b", "k: a !b", "k: a !!b", "k: a &b",
1908            "k: a *b", "- a !b",
1909        ] {
1910            let tokens = lex(input);
1911            let bad: Vec<_> = tokens
1912                .iter()
1913                .filter(|(kind, _)| {
1914                    matches!(
1915                        kind,
1916                        SyntaxKind::ANCHOR
1917                            | SyntaxKind::REFERENCE
1918                            | SyntaxKind::TAG
1919                            | SyntaxKind::QUESTION
1920                    )
1921                })
1922                .collect();
1923            assert!(
1924                bad.is_empty(),
1925                "{:?} produced structural tokens mid-scalar: {:?}",
1926                input,
1927                bad
1928            );
1929        }
1930        // And a real anchor still tokenizes as ANCHOR.
1931        let tokens = lex("a: &real 1");
1932        assert!(
1933            tokens.iter().any(|(k, _)| *k == SyntaxKind::ANCHOR),
1934            "real anchor should still be recognized: {:?}",
1935            tokens
1936        );
1937    }
1938
1939    #[test]
1940    fn test_merge_key_token() {
1941        // Test merge key '<<'
1942        let input = "<<: *defaults";
1943        let tokens = lex(input);
1944
1945        let merge_keys: Vec<_> = tokens
1946            .iter()
1947            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1948            .collect();
1949        assert_eq!(merge_keys.len(), 1);
1950        assert_eq!(merge_keys[0].1, "<<");
1951
1952        // Test single '<' is not a merge key
1953        let input2 = "key: < value";
1954        let tokens2 = lex(input2);
1955
1956        let merge_keys2: Vec<_> = tokens2
1957            .iter()
1958            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1959            .collect();
1960        assert_eq!(merge_keys2.len(), 0, "Single < should not be a merge key");
1961    }
1962
1963    #[test]
1964    fn test_angle_prefix_is_plain_scalar_not_merge_key() {
1965        let tokens = lex("<<foo: 1");
1966        let merge_keys: Vec<_> = tokens
1967            .iter()
1968            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1969            .collect();
1970        assert_eq!(
1971            merge_keys.len(),
1972            0,
1973            "<<foo should be a plain key, got {:?}",
1974            tokens
1975        );
1976        assert!(
1977            tokens
1978                .iter()
1979                .any(|(kind, text)| *kind != SyntaxKind::MERGE_KEY && *text == "<<foo"),
1980            "expected <<foo scalar, got {:?}",
1981            tokens
1982        );
1983
1984        let tokens = lex("{<<foo: 1, more: x}");
1985        assert_eq!(
1986            tokens
1987                .iter()
1988                .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1989                .count(),
1990            0,
1991            "flow <<foo should not be MERGE_KEY: {:?}",
1992            tokens
1993        );
1994
1995        let tokens = lex("<<: *defaults");
1996        assert_eq!(
1997            tokens
1998                .iter()
1999                .filter(|(kind, text)| *kind == SyntaxKind::MERGE_KEY && *text == "<<")
2000                .count(),
2001            1
2002        );
2003
2004        use crate::YamlFile;
2005        use std::str::FromStr;
2006        let file = YamlFile::from_str("<<foo: 1\nbar: 2\n").unwrap();
2007        let mapping = file.document().unwrap().as_mapping().unwrap();
2008        assert_eq!(mapping.keys().count(), 2);
2009        assert_eq!(
2010            mapping
2011                .get("<<foo")
2012                .unwrap()
2013                .as_scalar()
2014                .unwrap()
2015                .as_string(),
2016            "1"
2017        );
2018    }
2019
2020    #[test]
2021    fn test_angle_prefixed_scalar_uses_plain_scalar_rules() {
2022        // A key starting with `<` follows the same plain-scalar rules as any
2023        // other key: `-`, `!`, `&` and a non-indicator `:` are ordinary
2024        // content, not token breaks.
2025        use crate::YamlFile;
2026        use std::str::FromStr;
2027
2028        for key in [
2029            "<foo-bar",
2030            "<<foo-bar",
2031            "<foo!x",
2032            "<<foo!x",
2033            "<foo&x",
2034            "<<foo&x",
2035            "<foo:x",
2036            "<<foo:x",
2037        ] {
2038            let src = format!("{}: 1\n", key);
2039            let tokens = lex(&src);
2040            assert_eq!(
2041                tokens
2042                    .iter()
2043                    .filter(|(kind, _)| *kind != SyntaxKind::WHITESPACE)
2044                    .map(|(_, text)| *text)
2045                    .collect::<Vec<_>>(),
2046                vec![key, ":", "1", "\n"],
2047                "unexpected tokens for {:?}",
2048                src
2049            );
2050
2051            let file = YamlFile::from_str(&src).unwrap();
2052            assert_eq!(file.to_string(), src);
2053            let mapping = file.document().unwrap().as_mapping().unwrap();
2054            assert_eq!(
2055                mapping.get(key).unwrap().as_scalar().unwrap().as_string(),
2056                "1"
2057            );
2058        }
2059    }
2060
2061    #[test]
2062    fn test_plus_token() {
2063        // Test plus as standalone token
2064        let input = "key: |+ value";
2065        let tokens = lex(input);
2066
2067        let plus_tokens: Vec<_> = tokens
2068            .iter()
2069            .filter(|(kind, _)| *kind == SyntaxKind::PLUS)
2070            .collect();
2071        assert_eq!(plus_tokens.len(), 1);
2072        assert_eq!(plus_tokens[0].1, "+");
2073    }
2074
2075    #[test]
2076    fn test_block_scalar_indicators() {
2077        // Test literal with chomping indicators
2078        let input1 = "key: |+ content";
2079        let tokens1 = lex(input1);
2080
2081        assert!(tokens1
2082            .iter()
2083            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2084        assert!(tokens1
2085            .iter()
2086            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2087
2088        // Test folded with chomping indicators
2089        let input2 = "key: >- content";
2090        let tokens2 = lex(input2);
2091
2092        assert!(tokens2
2093            .iter()
2094            .any(|(kind, text)| *kind == SyntaxKind::GREATER && *text == ">"));
2095        assert!(tokens2
2096            .iter()
2097            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
2098
2099        // Test with explicit indentation
2100        let input3 = "key: |2+ content";
2101        let tokens3 = lex(input3);
2102
2103        assert!(tokens3
2104            .iter()
2105            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2106        assert!(tokens3
2107            .iter()
2108            .any(|(kind, text)| *kind == SyntaxKind::INT && *text == "2"));
2109        assert!(tokens3
2110            .iter()
2111            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2112    }
2113
2114    #[test]
2115    fn test_special_characters_in_block_content() {
2116        let input = "line with - and + and : characters";
2117        let tokens = lex(input);
2118
2119        // The `-` and `+` are content, so the whole key is one plain scalar:
2120        // saphyr reads this as `line with - and + and` mapped to
2121        // `characters`.
2122        assert!(tokens
2123            .iter()
2124            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "line with - and + and"));
2125        assert!(!tokens.iter().any(|(kind, _)| *kind == SyntaxKind::PLUS));
2126
2127        // The mapping colon still stands alone.
2128        assert!(tokens
2129            .iter()
2130            .any(|(kind, text)| *kind == SyntaxKind::COLON && *text == ":"));
2131
2132        // Round-trip: concatenating tokens reproduces input.
2133        let joined: String = tokens.iter().map(|(_, t)| *t).collect();
2134        assert_eq!(joined, input);
2135    }
2136
2137    #[test]
2138    fn test_token_recognition() {
2139        let input = "key: |2+ \n  content with - and : and > chars\n  more content";
2140        let tokens = lex(input);
2141
2142        // Print tokens for debugging
2143        println!("Tokens:");
2144        for (i, (kind, text)) in tokens.iter().enumerate() {
2145            println!("  {}: {:?} = {:?}", i, kind, text);
2146        }
2147
2148        // Verify all expected token kinds are present (input: "key: |2+ \n  content with - and : and > chars\n  more content")
2149        let count = |k: SyntaxKind| tokens.iter().filter(|(kind, _)| *kind == k).count();
2150        // Two colons: one for the mapping ("key:"), one in the value content ("and :")
2151        assert_eq!(count(SyntaxKind::COLON), 2);
2152        assert_eq!(count(SyntaxKind::PIPE), 1); // "|"
2153        assert_eq!(count(SyntaxKind::INT), 1); // "2"
2154        assert_eq!(count(SyntaxKind::PLUS), 1); // "+"
2155                                                // The `>` here sits inside the block scalar's content, not at a
2156                                                // node start, so it is scalar text rather than a folded header.
2157        assert_eq!(count(SyntaxKind::GREATER), 0);
2158        assert_eq!(count(SyntaxKind::NEWLINE), 2); // after "|2+" line and after first content line
2159        assert_eq!(count(SyntaxKind::INDENT), 2); // "  " before each content line
2160                                                  // Multiple STRING tokens: "key", content words, and the hyphen
2161        assert!(count(SyntaxKind::STRING) >= 1, "expected STRING tokens");
2162
2163        assert_eq!(
2164            tokens
2165                .iter()
2166                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "content with - and")
2167                .count(),
2168            1
2169        );
2170    }
2171
2172    #[test]
2173    fn test_dash_handling() {
2174        // Test 1: Document start marker
2175        let input = "---\nkey: value";
2176        let tokens = lex(input);
2177        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2178
2179        // Test 2: Document with just three dashes
2180        let input = "---";
2181        let tokens = lex(input);
2182        assert_eq!(tokens.len(), 1);
2183        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2184
2185        // Test 3: Two dashes (not a document marker)
2186        let input = "--";
2187        let tokens = lex(input);
2188        assert_eq!(tokens, vec![(SyntaxKind::STRING, "--")]);
2189
2190        // Test 4: Four dashes
2191        let input = "----";
2192        let tokens = lex(input);
2193        assert_eq!(tokens, vec![(SyntaxKind::STRING, "----")]);
2194    }
2195
2196    #[test]
2197    fn test_dash_in_different_scalar_contexts() {
2198        // Test kebab-case identifiers
2199        let input = "package-name: my-awesome-package-v2";
2200        let tokens = lex(input);
2201        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
2202        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2203        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2204        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package-v2"));
2205
2206        // Test UUID-like strings
2207        let input = "id: 123e4567-e89b-12d3-a456-426614174000";
2208        let tokens = lex(input);
2209        assert_eq!(tokens[0], (SyntaxKind::STRING, "id"));
2210        assert_eq!(
2211            tokens[3],
2212            (SyntaxKind::STRING, "123e4567-e89b-12d3-a456-426614174000")
2213        );
2214
2215        // Test command-line arguments. The two words are one plain
2216        // scalar, so they lex as a single token rather than being
2217        // rejoined by the parser.
2218        let input = "args: --verbose --log-level=debug";
2219        let tokens = lex(input);
2220        assert_eq!(
2221            tokens[3],
2222            (SyntaxKind::STRING, "--verbose --log-level=debug")
2223        );
2224
2225        // Test negative numbers
2226        let input = "temperature: -40";
2227        let tokens = lex(input);
2228        // Negative numbers are tokenized as INT tokens
2229        assert_eq!(
2230            tokens
2231                .iter()
2232                .filter(|(kind, text)| *kind == SyntaxKind::INT && *text == "-40")
2233                .count(),
2234            1
2235        );
2236
2237        // Test ranges
2238        let input = "range: 1-10";
2239        let tokens = lex(input);
2240        assert_eq!(
2241            tokens
2242                .iter()
2243                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1-10")
2244                .count(),
2245            1
2246        );
2247    }
2248
2249    #[test]
2250    fn test_sequence_markers_with_indentation() {
2251        // Test basic sequence
2252        let input = "- item1\n- item2";
2253        let tokens = lex(input);
2254        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2255        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
2256        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
2257
2258        // Test indented sequence
2259        let input = "  - item1\n  - item2";
2260        let tokens = lex(input);
2261        assert_eq!(tokens[0], (SyntaxKind::INDENT, "  "));
2262        assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
2263
2264        // Test nested sequences
2265        let input = "- item1\n  - nested1\n  - nested2\n- item2";
2266        let tokens = lex(input);
2267        let dash_tokens: Vec<_> = tokens
2268            .iter()
2269            .filter(|(kind, _)| *kind == SyntaxKind::DASH)
2270            .collect();
2271        assert_eq!(dash_tokens.len(), 4); // Four sequence markers
2272
2273        // Test sequence with hyphenated values
2274        let input = "- first-item\n- second-item";
2275        let tokens = lex(input);
2276        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2277        assert_eq!(tokens[2], (SyntaxKind::STRING, "first-item"));
2278        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
2279        assert_eq!(tokens[6], (SyntaxKind::STRING, "second-item"));
2280    }
2281
2282    #[test]
2283    fn test_dash_after_colon() {
2284        // Test hyphen immediately after colon
2285        // According to YAML spec, "key:-value" is a single plain scalar
2286        // because the colon is not followed by whitespace
2287        let input = "key:-value";
2288        let tokens = lex(input);
2289        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:-value"));
2290
2291        // Test with space - this creates a mapping
2292        let input = "key: -value";
2293        let tokens = lex(input);
2294        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2295        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2296        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2297        assert_eq!(tokens[3], (SyntaxKind::STRING, "-value"));
2298    }
2299
2300    #[test]
2301    fn test_yaml_spec_compliant_colon_handling() {
2302        // Test that colons are handled according to YAML spec:
2303        // - Colon followed by whitespace indicates mapping
2304        // - Colon not followed by whitespace is part of plain scalar
2305
2306        // URLs should be single scalars (no space after colon)
2307        let input = "http://example.com:8080/path";
2308        let tokens = lex(input);
2309        assert_eq!(tokens.len(), 1);
2310        assert_eq!(
2311            tokens[0],
2312            (SyntaxKind::STRING, "http://example.com:8080/path")
2313        );
2314
2315        // Timestamps should be single scalars
2316        let input = "2024:12:31:23:59:59";
2317        let tokens = lex(input);
2318        assert_eq!(tokens.len(), 1);
2319        assert_eq!(tokens[0], (SyntaxKind::STRING, "2024:12:31:23:59:59"));
2320
2321        // Key-value pairs need space after colon
2322        let input = "key: value";
2323        let tokens = lex(input);
2324        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2325        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2326        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2327        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
2328
2329        // Without space, it's a single scalar
2330        let input = "key:value";
2331        let tokens = lex(input);
2332        assert_eq!(tokens.len(), 1);
2333        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:value"));
2334
2335        // Multiple colons without spaces
2336        let input = "a:b:c:d";
2337        let tokens = lex(input);
2338        assert_eq!(tokens.len(), 1);
2339        assert_eq!(tokens[0], (SyntaxKind::STRING, "a:b:c:d"));
2340    }
2341
2342    #[test]
2343    fn test_block_scalar_with_chomping() {
2344        // Helper to count tokens by kind
2345        let count_kind = |toks: &[(SyntaxKind, &str)], k: SyntaxKind| {
2346            toks.iter().filter(|(kind, _)| *kind == k).count()
2347        };
2348
2349        // Test literal block scalar with strip chomping
2350        let input = "text: |-\n  content";
2351        let tokens = lex(input);
2352        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2353        assert_eq!(
2354            tokens
2355                .iter()
2356                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2357                .count(),
2358            1
2359        );
2360
2361        // Test literal block scalar with keep chomping
2362        let input = "text: |+\n  content";
2363        let tokens = lex(input);
2364        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2365        assert_eq!(count_kind(&tokens, SyntaxKind::PLUS), 1);
2366
2367        // Test folded block scalar with strip chomping
2368        let input = "text: >-\n  content";
2369        let tokens = lex(input);
2370        assert_eq!(count_kind(&tokens, SyntaxKind::GREATER), 1);
2371        assert_eq!(
2372            tokens
2373                .iter()
2374                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2375                .count(),
2376            1
2377        );
2378
2379        // Test with explicit indentation and chomping
2380        let input = "text: |2-\n  content";
2381        let tokens = lex(input);
2382        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2383        // The "2-" after pipe gets read as one token because hyphens in scalars are included
2384        let has_2_token = tokens.iter().any(|(kind, text)| {
2385            (*kind == SyntaxKind::STRING || *kind == SyntaxKind::INT) && text.contains("2")
2386        });
2387        assert!(has_2_token, "expected a token containing '2'");
2388    }
2389
2390    #[test]
2391    fn test_dash_edge_cases() {
2392        // Test trailing hyphen
2393        let input = "value-";
2394        let tokens = lex(input);
2395        assert_eq!(tokens[0], (SyntaxKind::STRING, "value-"));
2396
2397        // Test leading hyphen (not a sequence marker)
2398        let input = "-value";
2399        let tokens = lex(input);
2400        assert_eq!(tokens[0], (SyntaxKind::STRING, "-value"));
2401
2402        // Test multiple consecutive hyphens in scalar
2403        let input = "key: a---b";
2404        let tokens = lex(input);
2405        assert_eq!(
2406            tokens
2407                .iter()
2408                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "a---b")
2409                .count(),
2410            1
2411        );
2412
2413        // Test hyphen at end of line
2414        let input = "key: value-\nnext: item";
2415        let tokens = lex(input);
2416        assert!(tokens
2417            .iter()
2418            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "value-"));
2419
2420        // Test mix of dashes and underscores
2421        let input = "snake_case-with-dash_mix";
2422        let tokens = lex(input);
2423        assert_eq!(tokens[0], (SyntaxKind::STRING, "snake_case-with-dash_mix"));
2424    }
2425
2426    #[test]
2427    fn test_whitespace_validation_tab_indentation() {
2428        // Test tab character validation
2429        let input_with_tabs = "key: value\n\tindented_key: indented_value";
2430        let (tokens, errors) = lex_with_validation(input_with_tabs);
2431
2432        // Should have detected tab indentation error
2433        assert_eq!(errors.len(), 1);
2434        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2435        assert_eq!(
2436            errors[0].message,
2437            "Tab character used for indentation (forbidden in YAML)"
2438        );
2439
2440        // But should still tokenize correctly
2441        assert!(tokens
2442            .iter()
2443            .any(|(kind, text)| *kind == SyntaxKind::INDENT && text.contains('\t')));
2444    }
2445
2446    #[test]
2447    fn test_whitespace_validation_line_endings() {
2448        // Test mixed line ending detection
2449        let input_mixed = "line1\nline2\r\nline3\rline4";
2450        let config = ValidationConfig {
2451            enforce_consistent_line_endings: true,
2452            max_line_length: None,
2453        };
2454        let (tokens, errors) = lex_with_validation_config(input_mixed, &config);
2455
2456        // Should detect mixed line endings
2457        assert!(errors
2458            .iter()
2459            .any(|e| e.category == WhitespaceErrorCategory::MixedLineEndings));
2460
2461        // Should still tokenize all line endings
2462        let newlines: Vec<_> = tokens
2463            .iter()
2464            .filter(|(kind, _)| *kind == SyntaxKind::NEWLINE)
2465            .collect();
2466        assert_eq!(newlines.len(), 3); // Three line endings
2467        assert_eq!(newlines[0].1, "\n");
2468        assert_eq!(newlines[1].1, "\r\n");
2469        assert_eq!(newlines[2].1, "\r");
2470    }
2471
2472    #[test]
2473    fn test_whitespace_validation_line_length() {
2474        // Test line length validation
2475        let long_line = format!("key: {}", "a".repeat(150));
2476        let config = ValidationConfig {
2477            enforce_consistent_line_endings: false,
2478            max_line_length: Some(120),
2479        };
2480        let (_, errors) = lex_with_validation_config(&long_line, &config);
2481
2482        // Should detect line too long
2483        assert_eq!(errors.len(), 1);
2484        assert_eq!(errors[0].category, WhitespaceErrorCategory::LineTooLong);
2485        assert_eq!(errors[0].message, "Line too long (155 > 120 characters)");
2486    }
2487
2488    #[test]
2489    fn test_whitespace_validation_disabled() {
2490        // Test with validation disabled
2491        let input_with_issues = "key: value\n\tindented: with_tabs\n";
2492        let config = ValidationConfig {
2493            enforce_consistent_line_endings: false,
2494            max_line_length: None,
2495        };
2496        let (tokens, errors) = lex_with_validation_config(input_with_issues, &config);
2497
2498        // Should still detect tab indentation (always enforced in YAML)
2499        assert_eq!(errors.len(), 1);
2500        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2501
2502        // Should tokenize normally
2503        assert!(!tokens.is_empty());
2504    }
2505
2506    #[test]
2507    fn test_dash_in_flow_collections() {
2508        // Test dash in flow sequence
2509        let input = "[item-one, item-two]";
2510        let tokens = lex(input);
2511        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
2512        assert_eq!(tokens[1], (SyntaxKind::STRING, "item-one"));
2513        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
2514        assert_eq!(tokens[4], (SyntaxKind::STRING, "item-two"));
2515        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACKET, "]"));
2516
2517        // Test dash in flow mapping
2518        let input = "{kebab-key: kebab-value}";
2519        let tokens = lex(input);
2520        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
2521        assert_eq!(tokens[1], (SyntaxKind::STRING, "kebab-key"));
2522        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
2523        assert_eq!(tokens[4], (SyntaxKind::STRING, "kebab-value"));
2524        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACE, "}"));
2525    }
2526
2527    #[test]
2528    fn test_dash_with_quotes() {
2529        // Quoted strings should preserve everything inside as STRING tokens
2530        let input = r#"key: "- not a sequence marker""#;
2531        let tokens = lex(input);
2532        assert_eq!(
2533            tokens
2534                .iter()
2535                .filter(|(kind, text)| {
2536                    *kind == SyntaxKind::STRING && *text == "\"- not a sequence marker\""
2537                })
2538                .count(),
2539            1
2540        );
2541
2542        let input = r#"key: '- also not a sequence marker'"#;
2543        let tokens = lex(input);
2544        assert_eq!(
2545            tokens
2546                .iter()
2547                .filter(|(kind, text)| {
2548                    *kind == SyntaxKind::STRING && *text == "'- also not a sequence marker'"
2549                })
2550                .count(),
2551            1
2552        );
2553    }
2554
2555    #[test]
2556    fn test_dash_in_multiline_values() {
2557        // Test multiline with dashes: plain scalars absorb intra-line
2558        // whitespace, so the first line becomes one STRING token ending
2559        // in the trailing hyphen (hyphen-at-end-of-word is scalar content,
2560        // not a sequence marker mid-scalar).
2561        let input = "description: This is a multi-\n  line value with dashes";
2562        let tokens = lex(input);
2563        assert!(tokens
2564            .iter()
2565            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "This is a multi-"));
2566
2567        // Test continuation with sequence-like line
2568        let input = "text: value\n  - but this is not a sequence";
2569        let tokens = lex(input);
2570        // The dash after indentation should be treated as a sequence marker
2571        let indent_dash: Vec<_> = tokens
2572            .windows(2)
2573            .filter(|w| w[0].0 == SyntaxKind::INDENT && w[1].0 == SyntaxKind::DASH)
2574            .collect();
2575        assert_eq!(indent_dash.len(), 1);
2576    }
2577
2578    #[test]
2579    fn test_dash_special_yaml_values() {
2580        // Test that special YAML values with dashes work
2581        let input = "date: 2024-01-15";
2582        let tokens = lex(input);
2583        assert!(tokens
2584            .iter()
2585            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15"));
2586
2587        // Test ISO timestamp - gets tokenized as multiple parts due to hyphens
2588        let input = "timestamp: 2024-01-15T10:30:00-05:00";
2589        let tokens = lex(input);
2590        // The timestamp is split into multiple tokens but parses correctly
2591        assert!(tokens.iter().any(
2592            |(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15T10:30:00-05:00"
2593        ));
2594
2595        // Test version strings
2596        let input = "version: 1.0.0-beta.1";
2597        let tokens = lex(input);
2598        assert!(tokens
2599            .iter()
2600            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1.0.0-beta.1"));
2601    }
2602
2603    #[test]
2604    fn test_flow_indicators_in_block_scalar() {
2605        // Flow indicators should be allowed in block context scalars
2606        // This is valid YAML: the curly braces are part of the scalar value
2607        let input = "key: unix:///Users/${metadata.username}/path";
2608        let tokens = lex(input);
2609        assert_eq!(tokens.len(), 4);
2610        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2611        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2612        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2613        assert_eq!(
2614            tokens[3],
2615            (
2616                SyntaxKind::STRING,
2617                "unix:///Users/${metadata.username}/path"
2618            )
2619        );
2620    }
2621}