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 (colon + whitespace/EOF), a flow indicator, or any other YAML
259/// special character.
260///
261/// Whitespace before a flow indicator is treated as a terminator even in
262/// block context: while the main loop treats `[](){},` as scalar content
263/// when they appear directly (no leading space), a space introduces a
264/// clear boundary that should be preserved as its own WHITESPACE token.
265///
266/// Caller must guarantee the character at `ws_idx` is a space or tab.
267fn plain_scalar_continues_past_whitespace(input: &str, ws_idx: usize, _flow_depth: u32) -> bool {
268    let rest = &input[ws_idx..];
269    // Find the first non-whitespace char in the run.
270    let (offset, next) = match rest.char_indices().find(|(_, c)| *c != ' ' && *c != '\t') {
271        Some(v) => v,
272        None => return false,
273    };
274    match next {
275        '\n' | '\r' | '#' => false,
276        ',' | '[' | ']' | '{' | '}' => false,
277        ':' => {
278            let colon_pos = ws_idx + offset;
279            let after_colon = &input[colon_pos + 1..];
280            after_colon
281                .chars()
282                .next()
283                .is_some_and(|nc| !nc.is_whitespace())
284        }
285        c if is_yaml_special(c) => false,
286        _ => true,
287    }
288}
289
290/// Helper to read a scalar value starting from current position
291fn read_scalar_from<'a>(
292    chars: &mut std::iter::Peekable<std::str::CharIndices<'a>>,
293    input: &'a str,
294    start_idx: usize,
295    exclude_chars: &str,
296) -> &'a str {
297    let mut end_idx = start_idx;
298    while let Some((idx, ch)) = chars.peek() {
299        if ch.is_whitespace() || is_yaml_special_except(*ch, exclude_chars) {
300            break;
301        }
302        end_idx = *idx + ch.len_utf8();
303        chars.next();
304    }
305    &input[start_idx..end_idx]
306}
307
308/// Tokenize YAML input with whitespace validation
309pub fn lex(input: &str) -> Vec<(SyntaxKind, &str)> {
310    let (tokens, _) = lex_with_validation(input);
311    tokens
312}
313
314/// Configuration for whitespace and formatting validation
315pub struct ValidationConfig {
316    /// Maximum line length (None = no limit)
317    pub max_line_length: Option<usize>,
318    /// Whether to enforce consistent line endings
319    pub enforce_consistent_line_endings: bool,
320}
321
322impl Default for ValidationConfig {
323    fn default() -> Self {
324        Self {
325            max_line_length: Some(120), // Default to 120 characters
326            enforce_consistent_line_endings: true,
327        }
328    }
329}
330
331/// Tokenize YAML input with whitespace and formatting validation
332pub fn lex_with_validation(input: &str) -> (Vec<(SyntaxKind, &str)>, Vec<WhitespaceError>) {
333    lex_with_validation_config(input, &ValidationConfig::default())
334}
335
336/// Tokenize YAML input with custom validation configuration
337pub fn lex_with_validation_config<'a>(
338    input: &'a str,
339    config: &ValidationConfig,
340) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
341    use SyntaxKind::*;
342
343    let mut tokens = Vec::with_capacity(input.len() / 8); // Pre-allocate based on estimate
344    let mut chars = input.char_indices().peekable();
345    let mut whitespace_errors = Vec::new();
346    let bytes = input.as_bytes();
347
348    // Track line information for validation
349    let mut current_line_start = 0;
350    let mut detected_line_ending: Option<&str> = None;
351
352    // Track flow collection depth for context-aware tokenization
353    let mut flow_depth: u32 = 0;
354
355    // Handle UTF-8 BOM (U+FEFF) at the start of the file
356    // Per YAML spec, BOM is allowed and should be processed transparently
357    if let Some((0, '\u{FEFF}')) = chars.peek() {
358        chars.next(); // Consume the BOM
359        tokens.push((BOM, "\u{FEFF}"));
360    }
361
362    while let Some((start_idx, ch)) = chars.next() {
363        let token_start = start_idx;
364
365        match ch {
366            // Context-aware hyphen handling
367            '-' => {
368                if let Some((_, '-')) = chars.peek() {
369                    chars.next(); // consume second -
370                    if let Some((_, '-')) = chars.peek() {
371                        chars.next(); // consume third -
372                        tokens.push((DOC_START, &input[token_start..start_idx + 3]));
373                    } else {
374                        // Just two dashes, treat as sequence marker followed by dash
375                        tokens.push((DASH, &input[token_start..start_idx + 1]));
376                        tokens.push((DASH, &input[start_idx + 1..start_idx + 2]));
377                    }
378                } else {
379                    // Check if this hyphen should be treated as a sequence marker
380                    // It's a sequence marker if:
381                    // 1. It's at the beginning of a line (after optional indentation)
382                    // 2. OR it follows a value context (after ? or : plus whitespace)
383                    // AND it's followed by whitespace or end of input
384
385                    // Check if preceded only by whitespace from start of line
386                    // Look for either \n or \r as line breaks
387                    let line_start_pos = input[..token_start]
388                        .rfind(['\n', '\r'])
389                        .map(|pos| pos + 1)
390                        .unwrap_or(0);
391                    let before_dash = &input[line_start_pos..token_start];
392                    let only_whitespace_before = before_dash.chars().all(|c| c == ' ' || c == '\t');
393
394                    // Check if the previous non-whitespace token was ? or :
395                    // indicating a value context where sequences are allowed
396                    let after_value_indicator = tokens
397                        .iter()
398                        .rev()
399                        .find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT))
400                        .is_some_and(|(kind, _)| matches!(kind, QUESTION | COLON));
401
402                    // Check if followed by whitespace or end of input
403                    let followed_by_whitespace_or_end = chars
404                        .peek()
405                        .map_or(true, |(_, next_ch)| next_ch.is_whitespace());
406
407                    let is_sequence_marker = (only_whitespace_before || after_value_indicator)
408                        && followed_by_whitespace_or_end;
409
410                    if is_sequence_marker {
411                        tokens.push((DASH, &input[token_start..start_idx + 1]));
412                    } else {
413                        // This hyphen is part of a scalar value
414                        let text = read_scalar_from(&mut chars, input, start_idx + 1, "-");
415                        let full_text = &input[token_start..token_start + 1 + text.len()];
416                        let token_kind = classify_scalar(full_text);
417                        tokens.push((token_kind, full_text));
418                    }
419                }
420            }
421            '+' => tokens.push((PLUS, &input[token_start..start_idx + 1])),
422            ':' => {
423                // In flow collections, colon is always a structural character
424                // In block context, colon only indicates mapping if followed by whitespace
425                if flow_depth > 0 {
426                    // Inside flow collection: always tokenize as COLON
427                    tokens.push((COLON, &input[token_start..start_idx + 1]));
428                } else if let Some((_, next_ch)) = chars.peek() {
429                    if next_ch.is_whitespace() {
430                        // This is a mapping indicator in block context
431                        tokens.push((COLON, &input[token_start..start_idx + 1]));
432                    } else {
433                        // This colon is part of a plain scalar (e.g., URLs, timestamps)
434                        // Continue reading the scalar
435                        let mut end_idx = start_idx + 1;
436                        while let Some((idx, next_ch)) = chars.peek() {
437                            if next_ch.is_whitespace() {
438                                break;
439                            }
440                            // Check for special chars, but exclude colon since we're already in a scalar with colon
441                            if is_yaml_special_except(*next_ch, ":") {
442                                break;
443                            }
444                            end_idx = *idx + next_ch.len_utf8();
445                            chars.next();
446                        }
447                        let text = &input[token_start..end_idx];
448                        tokens.push((classify_scalar(text), text));
449                    }
450                } else {
451                    // Colon at end of input
452                    tokens.push((COLON, &input[token_start..start_idx + 1]));
453                }
454            }
455            '?' => tokens.push((QUESTION, &input[token_start..start_idx + 1])),
456            '[' => {
457                flow_depth += 1;
458                tokens.push((LEFT_BRACKET, &input[token_start..start_idx + 1]));
459            }
460            ']' => {
461                flow_depth = flow_depth.saturating_sub(1);
462                tokens.push((RIGHT_BRACKET, &input[token_start..start_idx + 1]));
463            }
464            '{' => {
465                flow_depth += 1;
466                tokens.push((LEFT_BRACE, &input[token_start..start_idx + 1]));
467            }
468            '}' => {
469                flow_depth = flow_depth.saturating_sub(1);
470                tokens.push((RIGHT_BRACE, &input[token_start..start_idx + 1]));
471            }
472            ',' => tokens.push((COMMA, &input[token_start..start_idx + 1])),
473            '|' => tokens.push((PIPE, &input[token_start..start_idx + 1])),
474            '>' => tokens.push((GREATER, &input[token_start..start_idx + 1])),
475            '<' => {
476                // Check if this is a merge key '<<'
477                if let Some((_, '<')) = chars.peek() {
478                    chars.next(); // consume second <
479                    tokens.push((MERGE_KEY, &input[token_start..start_idx + 2]));
480                } else {
481                    // Single '<' is not a special YAML character, treat as scalar
482                    let mut end_idx = start_idx + 1;
483                    while let Some((idx, ch)) = chars.peek() {
484                        if ch.is_whitespace() || is_yaml_special(*ch) {
485                            break;
486                        }
487                        end_idx = *idx + ch.len_utf8();
488                        chars.next();
489                    }
490                    let text = &input[token_start..end_idx];
491                    let token_kind = classify_scalar(text);
492                    tokens.push((token_kind, text));
493                }
494            }
495            '&' => {
496                // Check if this is an anchor definition
497                let name = read_scalar_from(&mut chars, input, start_idx + 1, "");
498                if !name.is_empty() {
499                    tokens.push((ANCHOR, &input[token_start..start_idx + 1 + name.len()]));
500                } else {
501                    tokens.push((AMPERSAND, &input[token_start..start_idx + 1]));
502                }
503            }
504            '*' => {
505                // Check if this is an alias reference
506                let name = read_scalar_from(&mut chars, input, start_idx + 1, "");
507                if !name.is_empty() {
508                    tokens.push((REFERENCE, &input[token_start..start_idx + 1 + name.len()]));
509                } else {
510                    tokens.push((ASTERISK, &input[token_start..start_idx + 1]));
511                }
512            }
513            '"' => {
514                // Read entire double-quoted string
515                let mut end_idx = start_idx + 1;
516                let mut escaped = false;
517                let mut found_closing = false;
518
519                while let Some((idx, ch)) = chars.peek() {
520                    let current_idx = *idx;
521                    let current_ch = *ch;
522
523                    if escaped {
524                        escaped = false;
525                        end_idx = current_idx + current_ch.len_utf8();
526                        chars.next();
527                        continue;
528                    }
529
530                    if current_ch == '\\' {
531                        escaped = true;
532                        end_idx = current_idx + current_ch.len_utf8();
533                        chars.next();
534                    } else if current_ch == '"' {
535                        end_idx = current_idx + current_ch.len_utf8();
536                        chars.next();
537                        found_closing = true;
538                        break;
539                    } else {
540                        end_idx = current_idx + current_ch.len_utf8();
541                        chars.next();
542                    }
543                }
544
545                if found_closing {
546                    tokens.push((STRING, &input[token_start..end_idx]));
547                } else {
548                    // Unterminated string - add UNTERMINATED_STRING token
549                    tokens.push((UNTERMINATED_STRING, &input[token_start..end_idx]));
550                }
551            }
552            '\'' => {
553                // Read entire single-quoted string
554                let mut end_idx = start_idx + 1;
555                let mut found_closing = false;
556
557                while let Some((idx, ch)) = chars.peek() {
558                    let current_idx = *idx;
559                    let current_ch = *ch;
560
561                    if current_ch == '\'' {
562                        // Check for escaped quote ('')
563                        end_idx = current_idx + current_ch.len_utf8();
564                        chars.next();
565                        if let Some((next_idx, '\'')) = chars.peek() {
566                            // Double quote - consume both and continue
567                            end_idx = *next_idx + 1;
568                            chars.next();
569                        } else {
570                            // Single quote - end of string
571                            found_closing = true;
572                            break;
573                        }
574                    } else {
575                        end_idx = current_idx + current_ch.len_utf8();
576                        chars.next();
577                    }
578                }
579
580                if found_closing {
581                    tokens.push((STRING, &input[token_start..end_idx]));
582                } else {
583                    // Unterminated string - add UNTERMINATED_STRING token
584                    tokens.push((UNTERMINATED_STRING, &input[token_start..end_idx]));
585                }
586            }
587
588            // Document end
589            '.' => {
590                // Check for three dots (document end marker)
591                if chars.peek() == Some(&(start_idx + 1, '.')) {
592                    chars.next(); // consume second .
593                    if chars.peek() == Some(&(start_idx + 2, '.')) {
594                        chars.next(); // consume third .
595                        tokens.push((DOC_END, &input[token_start..start_idx + 3]));
596                    } else {
597                        // Two dots - continue as scalar
598                        let rest = read_scalar_from(&mut chars, input, start_idx + 2, "");
599                        let text = &input[token_start..start_idx + 2 + rest.len()];
600                        let token_kind = classify_scalar(text);
601                        tokens.push((token_kind, text));
602                    }
603                } else {
604                    // Single dot - part of scalar
605                    let rest = read_scalar_from(&mut chars, input, start_idx + 1, "");
606                    let text = &input[token_start..start_idx + 1 + rest.len()];
607                    let token_kind = classify_scalar(text);
608                    tokens.push((token_kind, text));
609                }
610            }
611
612            // Comments
613            '#' => {
614                let mut end_idx = start_idx + 1;
615                while let Some((idx, ch)) = chars.peek() {
616                    if *ch == '\n' || *ch == '\r' {
617                        break;
618                    }
619                    end_idx = *idx + ch.len_utf8();
620                    chars.next();
621                }
622                tokens.push((COMMENT, &input[token_start..end_idx]));
623            }
624
625            // Tags
626            '!' => {
627                // Handle tag indicators - both ! and !!
628                let mut end_idx = start_idx + 1;
629
630                // Check for double exclamation (global tag)
631                if let Some((_, '!')) = chars.peek() {
632                    chars.next(); // consume the second !
633                    end_idx = start_idx + 2;
634                }
635
636                // Read the tag name after the ! or !!
637                while let Some((idx, ch)) = chars.peek() {
638                    if ch.is_whitespace() || is_yaml_special(*ch) {
639                        break;
640                    }
641                    end_idx = *idx + ch.len_utf8();
642                    chars.next();
643                }
644
645                tokens.push((TAG, &input[token_start..end_idx]));
646            }
647
648            '%' => {
649                // In flow collections, % is part of plain scalars, not a directive
650                if flow_depth > 0 {
651                    // Treat as part of a plain scalar in flow context
652                    let mut end_idx = start_idx + 1;
653                    while let Some((idx, next_ch)) = chars.peek() {
654                        if next_ch.is_whitespace() {
655                            break;
656                        }
657                        if is_yaml_special_except(*next_ch, "%") {
658                            break;
659                        }
660                        end_idx = *idx + next_ch.len_utf8();
661                        chars.next();
662                    }
663                    let text = &input[token_start..end_idx];
664                    tokens.push((classify_scalar(text), text));
665                } else {
666                    // In block context, % starts a directive
667                    let mut end_idx = start_idx + 1;
668                    while let Some((idx, ch)) = chars.peek() {
669                        if *ch == '\n' || *ch == '\r' {
670                            break;
671                        }
672                        end_idx = *idx + ch.len_utf8();
673                        chars.next();
674                    }
675                    tokens.push((DIRECTIVE, &input[token_start..end_idx]));
676                }
677            }
678
679            // Newlines
680            '\n' => {
681                // Check line length before processing newline
682                if let Some(max_len) = config.max_line_length {
683                    let line_length = start_idx - current_line_start;
684                    if line_length > max_len {
685                        whitespace_errors.push(WhitespaceError {
686                            message: format!(
687                                "Line too long ({} > {} characters)",
688                                line_length, max_len
689                            ),
690                            range: current_line_start..start_idx,
691                            category: WhitespaceErrorCategory::LineTooLong,
692                        });
693                    }
694                }
695
696                // Validate line ending consistency
697                let line_ending = "\n";
698                if config.enforce_consistent_line_endings {
699                    if let Some(detected) = detected_line_ending {
700                        if detected != line_ending {
701                            whitespace_errors.push(WhitespaceError {
702                                message: "Inconsistent line endings detected".to_string(),
703                                range: token_start..start_idx + 1,
704                                category: WhitespaceErrorCategory::MixedLineEndings,
705                            });
706                        }
707                    } else {
708                        detected_line_ending = Some(line_ending);
709                    }
710                }
711
712                tokens.push((NEWLINE, &input[token_start..start_idx + 1]));
713                current_line_start = start_idx + 1;
714            }
715            '\r' => {
716                // Check line length before processing newline
717                if let Some(max_len) = config.max_line_length {
718                    let line_length = start_idx - current_line_start;
719                    if line_length > max_len {
720                        whitespace_errors.push(WhitespaceError {
721                            message: format!(
722                                "Line too long ({} > {} characters)",
723                                line_length, max_len
724                            ),
725                            range: current_line_start..start_idx,
726                            category: WhitespaceErrorCategory::LineTooLong,
727                        });
728                    }
729                }
730
731                let (line_ending, end_pos) = if let Some((_, '\n')) = chars.peek() {
732                    chars.next();
733                    ("\r\n", start_idx + 2)
734                } else {
735                    ("\r", start_idx + 1)
736                };
737
738                // Validate line ending consistency
739                if config.enforce_consistent_line_endings {
740                    if let Some(detected) = detected_line_ending {
741                        if detected != line_ending {
742                            whitespace_errors.push(WhitespaceError {
743                                message: "Inconsistent line endings detected".to_string(),
744                                range: token_start..end_pos,
745                                category: WhitespaceErrorCategory::MixedLineEndings,
746                            });
747                        }
748                    } else {
749                        detected_line_ending = Some(line_ending);
750                    }
751                }
752
753                tokens.push((NEWLINE, &input[token_start..end_pos]));
754                current_line_start = end_pos;
755            }
756
757            // Whitespace (spaces and tabs)
758            ' ' | '\t' => {
759                let mut end_idx = start_idx + 1;
760                let mut has_tabs = ch == '\t';
761
762                while let Some((idx, ch)) = chars.peek() {
763                    if *ch != ' ' && *ch != '\t' {
764                        break;
765                    }
766                    if *ch == '\t' {
767                        has_tabs = true;
768                    }
769                    end_idx = *idx + 1;
770                    chars.next();
771                }
772
773                // Determine if this is structural indentation
774                // Check for any line break: \n, \r\n (already consumed \n), or \r alone
775                let is_indentation = token_start == 0
776                    || (token_start > 0
777                        && (bytes[token_start - 1] == b'\n' || bytes[token_start - 1] == b'\r'));
778
779                if is_indentation {
780                    // Check for tab characters in indentation (forbidden in YAML)
781                    if has_tabs {
782                        whitespace_errors.push(WhitespaceError {
783                            message: "Tab character used for indentation (forbidden in YAML)"
784                                .to_string(),
785                            range: token_start..end_idx,
786                            category: WhitespaceErrorCategory::TabIndentation,
787                        });
788                    }
789                    tokens.push((INDENT, &input[token_start..end_idx]));
790                } else {
791                    tokens.push((WHITESPACE, &input[token_start..end_idx]));
792                }
793            }
794
795            // Everything else is scalar content
796            _ => {
797                let mut end_idx = start_idx + ch.len_utf8();
798
799                // Read the rest of the scalar normally, including embedded hyphens
800                while let Some((idx, next_ch)) = chars.peek().copied() {
801                    // Line breaks always terminate the current scalar token.
802                    if next_ch == '\n' || next_ch == '\r' {
803                        break;
804                    }
805
806                    // Intra-line whitespace (space/tab) is part of a plain
807                    // scalar when followed by more scalar content on the same
808                    // line. Per YAML 1.2, plain scalars may contain single or
809                    // multiple internal spaces, but stop at line break, at
810                    // ` #` (comment start), or at end of line.
811                    if next_ch == ' ' || next_ch == '\t' {
812                        if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
813                            break;
814                        }
815                        // Absorb the whitespace run into the scalar
816                        while let Some((wi, wc)) = chars.peek().copied() {
817                            if wc != ' ' && wc != '\t' {
818                                break;
819                            }
820                            end_idx = wi + wc.len_utf8();
821                            chars.next();
822                        }
823                        continue;
824                    }
825
826                    // Check for YAML special characters
827                    // Special handling for colon: only special if followed by whitespace or EOF
828                    if next_ch == ':' {
829                        // Peek ahead one more to check if colon is followed by whitespace
830                        let next_idx = idx + next_ch.len_utf8();
831                        if next_idx >= input.len() {
832                            // Colon at EOF - stop here (treat as mapping indicator)
833                            break;
834                        } else if let Some(after) = input[next_idx..].chars().next() {
835                            if after.is_whitespace() {
836                                // Colon followed by whitespace - stop here
837                                break;
838                            }
839                        }
840                        // Colon not followed by whitespace - continue as part of scalar
841                        end_idx = idx + next_ch.len_utf8();
842                        chars.next();
843                        continue;
844                    }
845
846                    // Check other special characters (excluding hyphen and colon)
847                    if is_yaml_special_except(next_ch, "-:") {
848                        // In block context, flow indicators do NOT break scalars
849                        if flow_depth == 0 && matches!(next_ch, '[' | ']' | '{' | '}' | ',') {
850                            // do nothing, let it be part of the scalar
851                        } else {
852                            break;
853                        }
854                    }
855
856                    // Special case: check if hyphen is a sequence marker
857                    if next_ch == '-' {
858                        // A hyphen is only a sequence marker if it's at line start
859                        // and this scalar is already complete (we're at a word boundary)
860                        let line_start = input[..idx].rfind('\n').map(|p| p + 1).unwrap_or(0);
861                        let before_hyphen = &input[line_start..idx];
862
863                        // If there's only whitespace before the hyphen, it might be a sequence marker
864                        // Break here to let the main loop handle it
865                        if before_hyphen.chars().all(|c| c == ' ' || c == '\t') && idx == end_idx {
866                            break;
867                        }
868                    }
869
870                    end_idx = idx + next_ch.len_utf8();
871                    chars.next();
872                }
873
874                let text = &input[token_start..end_idx];
875                tokens.push((classify_scalar(text), text));
876            }
877        }
878    }
879
880    // Check the final line length if there's no trailing newline
881    if let Some(max_len) = config.max_line_length {
882        let final_line_length = input.len() - current_line_start;
883        if final_line_length > max_len && final_line_length > 0 {
884            whitespace_errors.push(WhitespaceError {
885                message: format!(
886                    "Line too long ({} > {} characters)",
887                    final_line_length, max_len
888                ),
889                range: current_line_start..input.len(),
890                category: WhitespaceErrorCategory::LineTooLong,
891            });
892        }
893    }
894
895    (tokens, whitespace_errors)
896}
897
898/// Classify a scalar token based on its content
899fn classify_scalar(text: &str) -> SyntaxKind {
900    use SyntaxKind::*;
901
902    // Boolean literals
903    match text {
904        "true" | "false" | "True" | "False" | "TRUE" | "FALSE" => return BOOL,
905        "null" | "Null" | "NULL" | "~" => return NULL,
906        _ => {}
907    }
908
909    // Try to parse as integer (handles 0x, 0o, 0b, octal, decimal)
910    if crate::scalar::ScalarValue::parse_integer(text).is_some() {
911        return INT;
912    }
913
914    // YAML special float values (infinity and NaN)
915    // Note: Must check these before general f64 parsing because Rust's parse::<f64>()
916    // accepts "infinity" and "inf" which should only be treated as floats in YAML
917    // when written as ".inf", not as bare "infinity" or "inf"
918    match text {
919        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" | "-.inf" | "-.Inf" | "-.INF"
920        | ".nan" | ".NaN" | ".NAN" => return FLOAT,
921        // Rust's parse::<f64>() accepts "infinity" and "inf", but in YAML these
922        // should be treated as strings unless written as ".inf"
923        "infinity" | "inf" | "Infinity" | "Inf" | "INFINITY" | "INF" | "-infinity" | "-inf"
924        | "-Infinity" | "-Inf" | "-INFINITY" | "-INF" | "+infinity" | "+inf" | "+Infinity"
925        | "+Inf" | "+INFINITY" | "+INF" | "nan" | "NaN" | "NAN" => return STRING,
926        _ => {}
927    }
928
929    // Try to parse as float
930    if text.parse::<f64>().is_ok() {
931        return FLOAT;
932    }
933
934    // Everything else is a string
935    STRING
936}
937
938/// Common set of YAML special characters
939const YAML_SPECIAL_CHARS: &str = ":+-?[]{},'|>&*!%\"#";
940
941/// Check if a character has special meaning in YAML
942fn is_yaml_special(ch: char) -> bool {
943    YAML_SPECIAL_CHARS.contains(ch)
944}
945
946/// Check if character is YAML special, with optional exclusions
947fn is_yaml_special_except(ch: char, exclude: &str) -> bool {
948    YAML_SPECIAL_CHARS.contains(ch) && !exclude.contains(ch)
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn test_simple_mapping() {
957        let input = "key: value";
958        let tokens = lex(input);
959
960        assert_eq!(tokens.len(), 4);
961        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
962        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
963        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
964        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
965    }
966
967    #[test]
968    fn test_scalar_types() {
969        // Test integer
970        let tokens = lex("age: 42");
971        assert_eq!(tokens[0], (SyntaxKind::STRING, "age"));
972        assert_eq!(tokens[3], (SyntaxKind::INT, "42"));
973
974        // Test float
975        let tokens = lex("pi: 3.14");
976        assert_eq!(tokens[0], (SyntaxKind::STRING, "pi"));
977        assert_eq!(tokens[3], (SyntaxKind::FLOAT, "3.14"));
978
979        // Test boolean true
980        let tokens = lex("enabled: true");
981        assert_eq!(tokens[0], (SyntaxKind::STRING, "enabled"));
982        assert_eq!(tokens[3], (SyntaxKind::BOOL, "true"));
983
984        // Test boolean false
985        let tokens = lex("disabled: false");
986        assert_eq!(tokens[3], (SyntaxKind::BOOL, "false"));
987
988        // Test null
989        let tokens = lex("value: null");
990        assert_eq!(tokens[3], (SyntaxKind::NULL, "null"));
991
992        // Test tilde as null
993        let tokens = lex("value: ~");
994        assert_eq!(tokens[3], (SyntaxKind::NULL, "~"));
995    }
996
997    #[test]
998    fn test_sequences() {
999        let input = "- item1\n- item2";
1000        let tokens = lex(input);
1001
1002        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1003        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1004        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
1005        assert_eq!(tokens[3], (SyntaxKind::NEWLINE, "\n"));
1006        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
1007        assert_eq!(tokens[5], (SyntaxKind::WHITESPACE, " "));
1008        assert_eq!(tokens[6], (SyntaxKind::STRING, "item2"));
1009    }
1010
1011    #[test]
1012    fn test_hyphen_in_scalars() {
1013        // Test hyphens in scalar values should not be treated as sequence markers
1014        let input = "Name: example-project";
1015        let tokens = lex(input);
1016
1017        println!("Hyphen test tokens:");
1018        for (i, (kind, text)) in tokens.iter().enumerate() {
1019            println!("  {}: {:?} = {:?}", i, kind, text);
1020        }
1021
1022        // Should get: STRING("Name"), COLON(":"), WHITESPACE(" "), STRING("example-project")
1023        assert_eq!(tokens.len(), 4);
1024        assert_eq!(tokens[0], (SyntaxKind::STRING, "Name"));
1025        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1026        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1027        assert_eq!(tokens[3], (SyntaxKind::STRING, "example-project"));
1028    }
1029
1030    #[test]
1031    fn test_hyphen_sequence_vs_scalar() {
1032        // Test that sequence markers are still recognized correctly
1033        let sequence_input = "- example-item";
1034        let tokens = lex(sequence_input);
1035
1036        println!("Sequence hyphen tokens:");
1037        for (i, (kind, text)) in tokens.iter().enumerate() {
1038            println!("  {}: {:?} = {:?}", i, kind, text);
1039        }
1040
1041        // Should get: DASH("-"), WHITESPACE(" "), STRING("example-item")
1042        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1043        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1044        assert_eq!(tokens[2], (SyntaxKind::STRING, "example-item"));
1045
1046        // Test scalar with hyphens in different contexts
1047        let scalar_input = "package-name: my-awesome-package";
1048        let tokens = lex(scalar_input);
1049
1050        println!("Package hyphen tokens:");
1051        for (i, (kind, text)) in tokens.iter().enumerate() {
1052            println!("  {}: {:?} = {:?}", i, kind, text);
1053        }
1054
1055        // Should get: STRING("package-name"), COLON(":"), WHITESPACE(" "), STRING("my-awesome-package")
1056        assert_eq!(tokens.len(), 4);
1057        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
1058        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package"));
1059    }
1060
1061    #[test]
1062    fn test_flow_style() {
1063        // Flow sequence
1064        let tokens = lex("[1, 2, 3]");
1065        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
1066        assert_eq!(tokens[1], (SyntaxKind::INT, "1"));
1067        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
1068        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1069        assert_eq!(tokens[4], (SyntaxKind::INT, "2"));
1070        assert_eq!(tokens[5], (SyntaxKind::COMMA, ","));
1071        assert_eq!(tokens[6], (SyntaxKind::WHITESPACE, " "));
1072        assert_eq!(tokens[7], (SyntaxKind::INT, "3"));
1073        assert_eq!(tokens[8], (SyntaxKind::RIGHT_BRACKET, "]"));
1074
1075        // Flow mapping
1076        let tokens = lex("{a: 1, b: 2}");
1077        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
1078        assert_eq!(tokens[1], (SyntaxKind::STRING, "a"));
1079        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
1080        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1081        assert_eq!(tokens[4], (SyntaxKind::INT, "1"));
1082    }
1083
1084    #[test]
1085    fn test_comments() {
1086        let input = "key: value # this is a comment\n# full line comment";
1087        let tokens = lex(input);
1088
1089        // Find comment tokens
1090        let comments: Vec<_> = tokens
1091            .iter()
1092            .filter(|(kind, _)| *kind == SyntaxKind::COMMENT)
1093            .collect();
1094
1095        assert_eq!(comments.len(), 2);
1096        assert_eq!(comments[0].1, "# this is a comment");
1097        assert_eq!(comments[1].1, "# full line comment");
1098    }
1099
1100    #[test]
1101    fn test_multiline_scalar() {
1102        let input = "key: value\n  continued";
1103        let tokens = lex(input);
1104
1105        // Check for indent token
1106        let indents: Vec<_> = tokens
1107            .iter()
1108            .filter(|(kind, _)| *kind == SyntaxKind::INDENT)
1109            .collect();
1110        assert_eq!(indents.len(), 1);
1111        assert_eq!(indents[0].1, "  ");
1112    }
1113
1114    #[test]
1115    fn test_quoted_strings() {
1116        let input = r#"single: 'quoted'
1117double: "quoted""#;
1118        let tokens = lex(input);
1119
1120        // Find quoted string tokens - after fix, quotes are included in STRING tokens
1121        let quoted_strings: Vec<_> = tokens
1122            .iter()
1123            .filter(|(kind, text)| {
1124                *kind == SyntaxKind::STRING && (text.starts_with('\'') || text.starts_with('"'))
1125            })
1126            .collect();
1127        assert_eq!(quoted_strings.len(), 2); // single and double quoted strings
1128
1129        // Verify content (order depends on which appears first in the source)
1130        let quoted_texts: Vec<&str> = {
1131            let mut v: Vec<&str> = quoted_strings.iter().map(|(_, t)| *t).collect();
1132            v.sort();
1133            v
1134        };
1135        assert_eq!(quoted_texts, ["\"quoted\"", "'quoted'"]);
1136    }
1137
1138    #[test]
1139    fn test_document_markers() {
1140        let input = "---\nkey: value\n...";
1141        let tokens = lex(input);
1142
1143        println!("Document tokens:");
1144        for (i, (kind, text)) in tokens.iter().enumerate() {
1145            println!("  {}: {:?} = {:?}", i, kind, text);
1146        }
1147
1148        // Check for document start and end markers
1149        let doc_start_count = tokens
1150            .iter()
1151            .filter(|(kind, _)| *kind == SyntaxKind::DOC_START)
1152            .count();
1153        let doc_end_count = tokens
1154            .iter()
1155            .filter(|(kind, _)| *kind == SyntaxKind::DOC_END)
1156            .count();
1157        assert_eq!(doc_start_count, 1);
1158        assert_eq!(doc_end_count, 1);
1159    }
1160
1161    #[test]
1162    fn test_empty_input() {
1163        let input = "";
1164        let tokens = lex(input);
1165        println!("Empty input tokens: {:?}", tokens);
1166        assert_eq!(tokens.len(), 0);
1167    }
1168
1169    #[test]
1170    fn test_anchors_and_aliases() {
1171        // Test anchor definition
1172        let input = "key: &anchor_name value";
1173        let tokens = lex(input);
1174        println!("Anchor tokens: {:?}", tokens);
1175
1176        let anchors: Vec<_> = tokens
1177            .iter()
1178            .filter(|(kind, _)| *kind == SyntaxKind::ANCHOR)
1179            .collect();
1180        assert_eq!(anchors.len(), 1);
1181        assert_eq!(anchors[0].1, "&anchor_name");
1182
1183        // Test alias reference
1184        let input = "key: *reference_name";
1185        let tokens = lex(input);
1186        println!("Reference tokens: {:?}", tokens);
1187
1188        let references: Vec<_> = tokens
1189            .iter()
1190            .filter(|(kind, _)| *kind == SyntaxKind::REFERENCE)
1191            .collect();
1192        assert_eq!(references.len(), 1);
1193        assert_eq!(references[0].1, "*reference_name");
1194
1195        // Test bare ampersand and asterisk (should not be treated as anchors/references)
1196        let input = "key: & *";
1197        let tokens = lex(input);
1198
1199        let ampersands: Vec<_> = tokens
1200            .iter()
1201            .filter(|(kind, _)| *kind == SyntaxKind::AMPERSAND)
1202            .collect();
1203        assert_eq!(ampersands.len(), 1);
1204
1205        let asterisks: Vec<_> = tokens
1206            .iter()
1207            .filter(|(kind, _)| *kind == SyntaxKind::ASTERISK)
1208            .collect();
1209        assert_eq!(asterisks.len(), 1);
1210    }
1211
1212    #[test]
1213    fn test_merge_key_token() {
1214        // Test merge key '<<'
1215        let input = "<<: *defaults";
1216        let tokens = lex(input);
1217
1218        let merge_keys: Vec<_> = tokens
1219            .iter()
1220            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1221            .collect();
1222        assert_eq!(merge_keys.len(), 1);
1223        assert_eq!(merge_keys[0].1, "<<");
1224
1225        // Test single '<' is not a merge key
1226        let input2 = "key: < value";
1227        let tokens2 = lex(input2);
1228
1229        let merge_keys2: Vec<_> = tokens2
1230            .iter()
1231            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
1232            .collect();
1233        assert_eq!(merge_keys2.len(), 0, "Single < should not be a merge key");
1234    }
1235
1236    #[test]
1237    fn test_plus_token() {
1238        // Test plus as standalone token
1239        let input = "key: |+ value";
1240        let tokens = lex(input);
1241
1242        let plus_tokens: Vec<_> = tokens
1243            .iter()
1244            .filter(|(kind, _)| *kind == SyntaxKind::PLUS)
1245            .collect();
1246        assert_eq!(plus_tokens.len(), 1);
1247        assert_eq!(plus_tokens[0].1, "+");
1248    }
1249
1250    #[test]
1251    fn test_block_scalar_indicators() {
1252        // Test literal with chomping indicators
1253        let input1 = "key: |+ content";
1254        let tokens1 = lex(input1);
1255
1256        assert!(tokens1
1257            .iter()
1258            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
1259        assert!(tokens1
1260            .iter()
1261            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
1262
1263        // Test folded with chomping indicators
1264        let input2 = "key: >- content";
1265        let tokens2 = lex(input2);
1266
1267        assert!(tokens2
1268            .iter()
1269            .any(|(kind, text)| *kind == SyntaxKind::GREATER && *text == ">"));
1270        assert!(tokens2
1271            .iter()
1272            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
1273
1274        // Test with explicit indentation
1275        let input3 = "key: |2+ content";
1276        let tokens3 = lex(input3);
1277
1278        assert!(tokens3
1279            .iter()
1280            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
1281        assert!(tokens3
1282            .iter()
1283            .any(|(kind, text)| *kind == SyntaxKind::INT && *text == "2"));
1284        assert!(tokens3
1285            .iter()
1286            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
1287    }
1288
1289    #[test]
1290    fn test_special_characters_in_block_content() {
1291        let input = "line with - and + and : characters";
1292        let tokens = lex(input);
1293
1294        // Plain scalars absorb intra-line whitespace until they hit a real
1295        // terminator, so `line with` is one STRING and the standalone `-`
1296        // between spaces is another (not a sequence marker mid-line).
1297        assert!(tokens
1298            .iter()
1299            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "line with"));
1300        assert!(tokens
1301            .iter()
1302            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
1303
1304        // Plus and colon remain separately tokenized.
1305        assert!(tokens
1306            .iter()
1307            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
1308        assert!(tokens
1309            .iter()
1310            .any(|(kind, text)| *kind == SyntaxKind::COLON && *text == ":"));
1311
1312        // Round-trip: concatenating tokens reproduces input.
1313        let joined: String = tokens.iter().map(|(_, t)| *t).collect();
1314        assert_eq!(joined, input);
1315    }
1316
1317    #[test]
1318    fn test_token_recognition() {
1319        let input = "key: |2+ \n  content with - and : and > chars\n  more content";
1320        let tokens = lex(input);
1321
1322        // Print tokens for debugging
1323        println!("Comprehensive tokens:");
1324        for (i, (kind, text)) in tokens.iter().enumerate() {
1325            println!("  {}: {:?} = {:?}", i, kind, text);
1326        }
1327
1328        // Verify all expected token kinds are present (input: "key: |2+ \n  content with - and : and > chars\n  more content")
1329        let count = |k: SyntaxKind| tokens.iter().filter(|(kind, _)| *kind == k).count();
1330        // Two colons: one for the mapping ("key:"), one in the value content ("and :")
1331        assert_eq!(count(SyntaxKind::COLON), 2);
1332        assert_eq!(count(SyntaxKind::PIPE), 1); // "|"
1333        assert_eq!(count(SyntaxKind::INT), 1); // "2"
1334        assert_eq!(count(SyntaxKind::PLUS), 1); // "+"
1335        assert_eq!(count(SyntaxKind::GREATER), 1); // ">"
1336        assert_eq!(count(SyntaxKind::NEWLINE), 2); // after "|2+" line and after first content line
1337        assert_eq!(count(SyntaxKind::INDENT), 2); // "  " before each content line
1338                                                  // Multiple STRING tokens: "key", content words, and the hyphen
1339        assert!(count(SyntaxKind::STRING) >= 1, "expected STRING tokens");
1340
1341        // With context-aware hyphen parsing, the hyphen in content is now part of a STRING
1342        assert_eq!(
1343            tokens
1344                .iter()
1345                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
1346                .count(),
1347            1
1348        );
1349    }
1350
1351    #[test]
1352    fn test_dash_handling() {
1353        // Test 1: Document start marker
1354        let input = "---\nkey: value";
1355        let tokens = lex(input);
1356        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
1357
1358        // Test 2: Document with just three dashes
1359        let input = "---";
1360        let tokens = lex(input);
1361        assert_eq!(tokens.len(), 1);
1362        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
1363
1364        // Test 3: Two dashes (not a document marker)
1365        let input = "--";
1366        let tokens = lex(input);
1367        assert_eq!(tokens.len(), 2);
1368        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1369        assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
1370
1371        // Test 4: Four dashes
1372        let input = "----";
1373        let tokens = lex(input);
1374        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
1375        assert_eq!(tokens[1], (SyntaxKind::STRING, "-"));
1376    }
1377
1378    #[test]
1379    fn test_dash_in_different_scalar_contexts() {
1380        // Test kebab-case identifiers
1381        let input = "package-name: my-awesome-package-v2";
1382        let tokens = lex(input);
1383        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
1384        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1385        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1386        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package-v2"));
1387
1388        // Test UUID-like strings
1389        let input = "id: 123e4567-e89b-12d3-a456-426614174000";
1390        let tokens = lex(input);
1391        assert_eq!(tokens[0], (SyntaxKind::STRING, "id"));
1392        assert_eq!(
1393            tokens[3],
1394            (SyntaxKind::STRING, "123e4567-e89b-12d3-a456-426614174000")
1395        );
1396
1397        // Test command-line arguments
1398        let input = "args: --verbose --log-level=debug";
1399        let tokens = lex(input);
1400        // Double dashes are tokenized as two DASH tokens
1401        assert_eq!(
1402            tokens
1403                .windows(3)
1404                .filter(|w| {
1405                    w[0] == (SyntaxKind::DASH, "-")
1406                        && w[1] == (SyntaxKind::DASH, "-")
1407                        && w[2] == (SyntaxKind::STRING, "verbose")
1408                })
1409                .count(),
1410            1
1411        );
1412
1413        // Test negative numbers
1414        let input = "temperature: -40";
1415        let tokens = lex(input);
1416        // Negative numbers are tokenized as INT tokens
1417        assert_eq!(
1418            tokens
1419                .iter()
1420                .filter(|(kind, text)| *kind == SyntaxKind::INT && *text == "-40")
1421                .count(),
1422            1
1423        );
1424
1425        // Test ranges
1426        let input = "range: 1-10";
1427        let tokens = lex(input);
1428        assert_eq!(
1429            tokens
1430                .iter()
1431                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1-10")
1432                .count(),
1433            1
1434        );
1435    }
1436
1437    #[test]
1438    fn test_sequence_markers_with_indentation() {
1439        // Test basic sequence
1440        let input = "- item1\n- item2";
1441        let tokens = lex(input);
1442        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1443        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1444        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
1445
1446        // Test indented sequence
1447        let input = "  - item1\n  - item2";
1448        let tokens = lex(input);
1449        assert_eq!(tokens[0], (SyntaxKind::INDENT, "  "));
1450        assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
1451
1452        // Test nested sequences
1453        let input = "- item1\n  - nested1\n  - nested2\n- item2";
1454        let tokens = lex(input);
1455        let dash_tokens: Vec<_> = tokens
1456            .iter()
1457            .filter(|(kind, _)| *kind == SyntaxKind::DASH)
1458            .collect();
1459        assert_eq!(dash_tokens.len(), 4); // Four sequence markers
1460
1461        // Test sequence with hyphenated values
1462        let input = "- first-item\n- second-item";
1463        let tokens = lex(input);
1464        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1465        assert_eq!(tokens[2], (SyntaxKind::STRING, "first-item"));
1466        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
1467        assert_eq!(tokens[6], (SyntaxKind::STRING, "second-item"));
1468    }
1469
1470    #[test]
1471    fn test_dash_after_colon() {
1472        // Test hyphen immediately after colon
1473        // According to YAML spec, "key:-value" is a single plain scalar
1474        // because the colon is not followed by whitespace
1475        let input = "key:-value";
1476        let tokens = lex(input);
1477        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:-value"));
1478
1479        // Test with space - this creates a mapping
1480        let input = "key: -value";
1481        let tokens = lex(input);
1482        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1483        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1484        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1485        assert_eq!(tokens[3], (SyntaxKind::STRING, "-value"));
1486    }
1487
1488    #[test]
1489    fn test_yaml_spec_compliant_colon_handling() {
1490        // Test that colons are handled according to YAML spec:
1491        // - Colon followed by whitespace indicates mapping
1492        // - Colon not followed by whitespace is part of plain scalar
1493
1494        // URLs should be single scalars (no space after colon)
1495        let input = "http://example.com:8080/path";
1496        let tokens = lex(input);
1497        assert_eq!(tokens.len(), 1);
1498        assert_eq!(
1499            tokens[0],
1500            (SyntaxKind::STRING, "http://example.com:8080/path")
1501        );
1502
1503        // Timestamps should be single scalars
1504        let input = "2024:12:31:23:59:59";
1505        let tokens = lex(input);
1506        assert_eq!(tokens.len(), 1);
1507        assert_eq!(tokens[0], (SyntaxKind::STRING, "2024:12:31:23:59:59"));
1508
1509        // Key-value pairs need space after colon
1510        let input = "key: value";
1511        let tokens = lex(input);
1512        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1513        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1514        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1515        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
1516
1517        // Without space, it's a single scalar
1518        let input = "key:value";
1519        let tokens = lex(input);
1520        assert_eq!(tokens.len(), 1);
1521        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:value"));
1522
1523        // Multiple colons without spaces
1524        let input = "a:b:c:d";
1525        let tokens = lex(input);
1526        assert_eq!(tokens.len(), 1);
1527        assert_eq!(tokens[0], (SyntaxKind::STRING, "a:b:c:d"));
1528    }
1529
1530    #[test]
1531    fn test_block_scalar_with_chomping() {
1532        // Helper to count tokens by kind
1533        let count_kind = |toks: &[(SyntaxKind, &str)], k: SyntaxKind| {
1534            toks.iter().filter(|(kind, _)| *kind == k).count()
1535        };
1536
1537        // Test literal block scalar with strip chomping
1538        let input = "text: |-\n  content";
1539        let tokens = lex(input);
1540        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
1541        assert_eq!(
1542            tokens
1543                .iter()
1544                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
1545                .count(),
1546            1
1547        );
1548
1549        // Test literal block scalar with keep chomping
1550        let input = "text: |+\n  content";
1551        let tokens = lex(input);
1552        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
1553        assert_eq!(count_kind(&tokens, SyntaxKind::PLUS), 1);
1554
1555        // Test folded block scalar with strip chomping
1556        let input = "text: >-\n  content";
1557        let tokens = lex(input);
1558        assert_eq!(count_kind(&tokens, SyntaxKind::GREATER), 1);
1559        assert_eq!(
1560            tokens
1561                .iter()
1562                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
1563                .count(),
1564            1
1565        );
1566
1567        // Test with explicit indentation and chomping
1568        let input = "text: |2-\n  content";
1569        let tokens = lex(input);
1570        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
1571        // The "2-" after pipe gets read as one token because hyphens in scalars are included
1572        let has_2_token = tokens.iter().any(|(kind, text)| {
1573            (*kind == SyntaxKind::STRING || *kind == SyntaxKind::INT) && text.contains("2")
1574        });
1575        assert!(has_2_token, "expected a token containing '2'");
1576    }
1577
1578    #[test]
1579    fn test_dash_edge_cases() {
1580        // Test trailing hyphen
1581        let input = "value-";
1582        let tokens = lex(input);
1583        assert_eq!(tokens[0], (SyntaxKind::STRING, "value-"));
1584
1585        // Test leading hyphen (not a sequence marker)
1586        let input = "-value";
1587        let tokens = lex(input);
1588        assert_eq!(tokens[0], (SyntaxKind::STRING, "-value"));
1589
1590        // Test multiple consecutive hyphens in scalar
1591        let input = "key: a---b";
1592        let tokens = lex(input);
1593        assert_eq!(
1594            tokens
1595                .iter()
1596                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "a---b")
1597                .count(),
1598            1
1599        );
1600
1601        // Test hyphen at end of line
1602        let input = "key: value-\nnext: item";
1603        let tokens = lex(input);
1604        assert!(tokens
1605            .iter()
1606            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "value-"));
1607
1608        // Test mix of dashes and underscores
1609        let input = "snake_case-with-dash_mix";
1610        let tokens = lex(input);
1611        assert_eq!(tokens[0], (SyntaxKind::STRING, "snake_case-with-dash_mix"));
1612    }
1613
1614    #[test]
1615    fn test_whitespace_validation_tab_indentation() {
1616        // Test tab character validation
1617        let input_with_tabs = "key: value\n\tindented_key: indented_value";
1618        let (tokens, errors) = lex_with_validation(input_with_tabs);
1619
1620        // Should have detected tab indentation error
1621        assert_eq!(errors.len(), 1);
1622        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
1623        assert_eq!(
1624            errors[0].message,
1625            "Tab character used for indentation (forbidden in YAML)"
1626        );
1627
1628        // But should still tokenize correctly
1629        assert!(tokens
1630            .iter()
1631            .any(|(kind, text)| *kind == SyntaxKind::INDENT && text.contains('\t')));
1632    }
1633
1634    #[test]
1635    fn test_whitespace_validation_line_endings() {
1636        // Test mixed line ending detection
1637        let input_mixed = "line1\nline2\r\nline3\rline4";
1638        let config = ValidationConfig {
1639            enforce_consistent_line_endings: true,
1640            max_line_length: None,
1641        };
1642        let (tokens, errors) = lex_with_validation_config(input_mixed, &config);
1643
1644        // Should detect mixed line endings
1645        assert!(errors
1646            .iter()
1647            .any(|e| e.category == WhitespaceErrorCategory::MixedLineEndings));
1648
1649        // Should still tokenize all line endings
1650        let newlines: Vec<_> = tokens
1651            .iter()
1652            .filter(|(kind, _)| *kind == SyntaxKind::NEWLINE)
1653            .collect();
1654        assert_eq!(newlines.len(), 3); // Three line endings
1655        assert_eq!(newlines[0].1, "\n");
1656        assert_eq!(newlines[1].1, "\r\n");
1657        assert_eq!(newlines[2].1, "\r");
1658    }
1659
1660    #[test]
1661    fn test_whitespace_validation_line_length() {
1662        // Test line length validation
1663        let long_line = format!("key: {}", "a".repeat(150));
1664        let config = ValidationConfig {
1665            enforce_consistent_line_endings: false,
1666            max_line_length: Some(120),
1667        };
1668        let (_, errors) = lex_with_validation_config(&long_line, &config);
1669
1670        // Should detect line too long
1671        assert_eq!(errors.len(), 1);
1672        assert_eq!(errors[0].category, WhitespaceErrorCategory::LineTooLong);
1673        assert_eq!(errors[0].message, "Line too long (155 > 120 characters)");
1674    }
1675
1676    #[test]
1677    fn test_whitespace_validation_disabled() {
1678        // Test with validation disabled
1679        let input_with_issues = "key: value\n\tindented: with_tabs\n";
1680        let config = ValidationConfig {
1681            enforce_consistent_line_endings: false,
1682            max_line_length: None,
1683        };
1684        let (tokens, errors) = lex_with_validation_config(input_with_issues, &config);
1685
1686        // Should still detect tab indentation (always enforced in YAML)
1687        assert_eq!(errors.len(), 1);
1688        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
1689
1690        // Should tokenize normally
1691        assert!(!tokens.is_empty());
1692    }
1693
1694    #[test]
1695    fn test_dash_in_flow_collections() {
1696        // Test dash in flow sequence
1697        let input = "[item-one, item-two]";
1698        let tokens = lex(input);
1699        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
1700        assert_eq!(tokens[1], (SyntaxKind::STRING, "item-one"));
1701        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
1702        assert_eq!(tokens[4], (SyntaxKind::STRING, "item-two"));
1703        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACKET, "]"));
1704
1705        // Test dash in flow mapping
1706        let input = "{kebab-key: kebab-value}";
1707        let tokens = lex(input);
1708        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
1709        assert_eq!(tokens[1], (SyntaxKind::STRING, "kebab-key"));
1710        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
1711        assert_eq!(tokens[4], (SyntaxKind::STRING, "kebab-value"));
1712        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACE, "}"));
1713    }
1714
1715    #[test]
1716    fn test_dash_with_quotes() {
1717        // Quoted strings should preserve everything inside as STRING tokens
1718        let input = r#"key: "- not a sequence marker""#;
1719        let tokens = lex(input);
1720        assert_eq!(
1721            tokens
1722                .iter()
1723                .filter(|(kind, text)| {
1724                    *kind == SyntaxKind::STRING && *text == "\"- not a sequence marker\""
1725                })
1726                .count(),
1727            1
1728        );
1729
1730        let input = r#"key: '- also not a sequence marker'"#;
1731        let tokens = lex(input);
1732        assert_eq!(
1733            tokens
1734                .iter()
1735                .filter(|(kind, text)| {
1736                    *kind == SyntaxKind::STRING && *text == "'- also not a sequence marker'"
1737                })
1738                .count(),
1739            1
1740        );
1741    }
1742
1743    #[test]
1744    fn test_dash_in_multiline_values() {
1745        // Test multiline with dashes: plain scalars absorb intra-line
1746        // whitespace, so the first line becomes one STRING token ending
1747        // in the trailing hyphen (hyphen-at-end-of-word is scalar content,
1748        // not a sequence marker mid-scalar).
1749        let input = "description: This is a multi-\n  line value with dashes";
1750        let tokens = lex(input);
1751        assert!(tokens
1752            .iter()
1753            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "This is a multi-"));
1754
1755        // Test continuation with sequence-like line
1756        let input = "text: value\n  - but this is not a sequence";
1757        let tokens = lex(input);
1758        // The dash after indentation should be treated as a sequence marker
1759        let indent_dash: Vec<_> = tokens
1760            .windows(2)
1761            .filter(|w| w[0].0 == SyntaxKind::INDENT && w[1].0 == SyntaxKind::DASH)
1762            .collect();
1763        assert_eq!(indent_dash.len(), 1);
1764    }
1765
1766    #[test]
1767    fn test_dash_special_yaml_values() {
1768        // Test that special YAML values with dashes work
1769        let input = "date: 2024-01-15";
1770        let tokens = lex(input);
1771        assert!(tokens
1772            .iter()
1773            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15"));
1774
1775        // Test ISO timestamp - gets tokenized as multiple parts due to hyphens
1776        let input = "timestamp: 2024-01-15T10:30:00-05:00";
1777        let tokens = lex(input);
1778        // The timestamp is split into multiple tokens but parses correctly
1779        assert!(tokens.iter().any(
1780            |(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15T10:30:00-05:00"
1781        ));
1782
1783        // Test version strings
1784        let input = "version: 1.0.0-beta.1";
1785        let tokens = lex(input);
1786        assert!(tokens
1787            .iter()
1788            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1.0.0-beta.1"));
1789    }
1790
1791    #[test]
1792    fn test_flow_indicators_in_block_scalar() {
1793        // Flow indicators should be allowed in block context scalars
1794        // This is valid YAML: the curly braces are part of the scalar value
1795        let input = "key: unix:///Users/${metadata.username}/path";
1796        let tokens = lex(input);
1797        assert_eq!(tokens.len(), 4);
1798        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1799        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1800        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1801        assert_eq!(
1802            tokens[3],
1803            (
1804                SyntaxKind::STRING,
1805                "unix:///Users/${metadata.username}/path"
1806            )
1807        );
1808    }
1809}