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 a document
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 is_white_or_break(ch) || 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    is_colon_followed_by_separator(input, colon_idx, flow_depth)
336}
337
338/// Is the `:` at `input[colon_idx]` followed by what makes it a mapping
339/// indicator after content that is not a JSON-like node, such as a plain
340/// scalar's own text?
341fn is_colon_followed_by_separator(input: &str, colon_idx: usize, flow_depth: u32) -> bool {
342    let after = input[colon_idx + 1..].chars().next();
343    match after {
344        None => true,
345        Some(ch) if is_white_or_break(ch) => true,
346        Some(ch) if flow_depth > 0 && matches!(ch, ',' | ']' | '}') => true,
347        _ => false,
348    }
349}
350
351/// Is a `#` at `input[hash_idx]` the start of a comment (rather than
352/// scalar content)?
353///
354/// Per YAML 1.2 section 6.6, `#` starts a comment only when preceded
355/// by whitespace (or is at the start of the input / a fresh line).
356/// Inside a plain scalar with no preceding whitespace, `#` is scalar
357/// content (URLs, `#frag`, etc.).
358fn is_hash_a_comment_start(input: &str, hash_idx: usize) -> bool {
359    hash_idx == 0
360        || input[..hash_idx]
361            .chars()
362            .next_back()
363            .is_some_and(is_white_or_break)
364}
365
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/// Whether the flow collection opening at `open_idx` starts an implicit
422/// key: it closes on its line, with the brackets matching, and a `:`
423/// mapping indicator follows on that line.
424fn flow_collection_is_implicit_key(input: &str, open_idx: usize) -> bool {
425    let line = input[open_idx..]
426        .split(['\n', '\r'])
427        .next()
428        .unwrap_or_default();
429    let mut chars = line.char_indices().peekable();
430    let mut closers = Vec::new();
431    let mut prev = ' ';
432    while let Some((idx, ch)) = chars.next() {
433        match ch {
434            '[' => closers.push(']'),
435            '{' => closers.push('}'),
436            ']' | '}' => {
437                if closers.pop() != Some(ch) {
438                    return false;
439                }
440                if closers.is_empty() {
441                    // The `:` need not follow straight away: `[q] x: v` is a
442                    // key holding two nodes, which the validator reports.
443                    let rest = &line[idx + 1..];
444                    let comment = [" #", "\t#"].iter().filter_map(|c| rest.find(c)).min();
445                    return rest[..comment.unwrap_or(rest.len())]
446                        .match_indices(':')
447                        .any(|(i, _)| is_colon_a_mapping_indicator(line, idx + 1 + i, 0));
448                }
449            }
450            // A quote mid-scalar (`[a'b]`) is content.
451            '"' | '\'' if matches!(prev, ' ' | '\t' | '[' | '{' | ',' | ':') => {
452                let (_, closed) = if ch == '"' {
453                    scan_double_quoted(&mut chars, idx)
454                } else {
455                    scan_single_quoted(&mut chars, idx)
456                };
457                if !closed {
458                    return false;
459                }
460            }
461            '#' if matches!(prev, ' ' | '\t') => return false,
462            _ => {}
463        }
464        prev = ch;
465    }
466    false
467}
468
469/// Whether a `-` or `+` following `line_prefix`, the text of its line before
470/// it, is a block scalar header's chomping indicator: the last character
471/// other than an indentation digit is the header's `|` or `>`.
472fn is_chomping_indicator(line_prefix: &str) -> bool {
473    line_prefix
474        .bytes()
475        .rev()
476        .find(|b| !b.is_ascii_digit())
477        .is_some_and(|b| b == b'|' || b == b'>')
478}
479
480/// Tokenize YAML input with whitespace validation
481pub fn lex(input: &str) -> Vec<(SyntaxKind, &str)> {
482    let (tokens, _) = lex_with_validation(input);
483    tokens
484}
485
486/// Configuration for whitespace and formatting validation
487pub struct ValidationConfig {
488    /// Maximum line length (None = no limit)
489    pub max_line_length: Option<usize>,
490    /// Whether to enforce consistent line endings
491    pub enforce_consistent_line_endings: bool,
492}
493
494impl Default for ValidationConfig {
495    fn default() -> Self {
496        Self {
497            max_line_length: Some(120), // Default to 120 characters
498            enforce_consistent_line_endings: true,
499        }
500    }
501}
502
503/// Tokenize YAML input with whitespace and formatting validation
504pub fn lex_with_validation(input: &str) -> (Vec<(SyntaxKind, &str)>, Vec<WhitespaceError>) {
505    lex_with_validation_config(input, &ValidationConfig::default())
506}
507
508/// Record a "line too long" error if the line ending at `end` exceeds the
509/// configured maximum.
510fn check_line_length(
511    errors: &mut Vec<WhitespaceError>,
512    config: &ValidationConfig,
513    line_start: usize,
514    end: usize,
515) {
516    let Some(max_len) = config.max_line_length else {
517        return;
518    };
519    let line_length = end - line_start;
520    if line_length > max_len {
521        errors.push(WhitespaceError {
522            message: format!("Line too long ({line_length} > {max_len} characters)"),
523            range: line_start..end,
524            category: WhitespaceErrorCategory::LineTooLong,
525        });
526    }
527}
528
529/// Record the line ending style on first sight, and flag any later one that
530/// disagrees with it.
531fn note_line_ending<'a>(
532    errors: &mut Vec<WhitespaceError>,
533    config: &ValidationConfig,
534    detected: &mut Option<&'a str>,
535    line_ending: &'a str,
536    range: std::ops::Range<usize>,
537) {
538    if !config.enforce_consistent_line_endings {
539        return;
540    }
541    match *detected {
542        Some(seen) if seen != line_ending => errors.push(WhitespaceError {
543            message: "Inconsistent line endings detected".to_string(),
544            range,
545            category: WhitespaceErrorCategory::MixedLineEndings,
546        }),
547        Some(_) => {}
548        None => *detected = Some(line_ending),
549    }
550}
551
552/// Tokenize YAML input with custom validation configuration
553pub fn lex_with_validation_config<'a>(
554    input: &'a str,
555    config: &ValidationConfig,
556) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
557    let mut state = LexState::new(input, config);
558    while let Some((start_idx, ch)) = state.chars.next() {
559        state.lex_char(start_idx, ch);
560    }
561    state.finish()
562}
563
564/// The lexer's state while it walks the input.
565struct LexState<'a, 'c> {
566    input: &'a str,
567    config: &'c ValidationConfig,
568    chars: std::iter::Peekable<std::str::CharIndices<'a>>,
569    tokens: Vec<(SyntaxKind, &'a str)>,
570    errors: Vec<WhitespaceError>,
571    /// Offset of the start of the current line.
572    line_start: usize,
573    detected_line_ending: Option<&'a str>,
574    /// Depth of the flow collections the lexer is inside.
575    flow_depth: u32,
576    /// The block scalar whose body is still running. All of the body is
577    /// literal text, so a `{` or `[` there must not open a flow collection:
578    /// the depth would never come back down and every later `,` in the file
579    /// would lex as a delimiter.
580    block_scalar: Option<BlockScalarBody>,
581    /// Number of tokens when the last block scalar body ended. The text
582    /// tokens before it may be body lines, which no line continues.
583    block_scalar_end: usize,
584}
585
586impl<'a, 'c> LexState<'a, 'c> {
587    fn new(input: &'a str, config: &'c ValidationConfig) -> Self {
588        Self {
589            input,
590            config,
591            chars: input.char_indices().peekable(),
592            tokens: Vec::with_capacity(input.len() / 8), // Pre-allocate based on estimate
593            errors: Vec::new(),
594            line_start: 0,
595            detected_line_ending: None,
596            flow_depth: 0,
597            block_scalar: None,
598            block_scalar_end: 0,
599        }
600    }
601
602    /// Push a token for `input[start..end]`.
603    fn push(&mut self, kind: SyntaxKind, start: usize, end: usize) {
604        self.tokens.push((kind, &self.input[start..end]));
605    }
606
607    /// Push `input[start..end]` as a plain scalar token.
608    fn push_scalar(&mut self, start: usize, end: usize) {
609        let text = &self.input[start..end];
610        self.tokens.push((classify_scalar(text), text));
611    }
612
613    /// Push a quoted-string token, marking it UNTERMINATED_STRING when the
614    /// closing quote was never reached.
615    fn push_quoted_string(&mut self, start: usize, end: usize, found_closing: bool) {
616        let kind = if found_closing {
617            SyntaxKind::STRING
618        } else {
619            SyntaxKind::UNTERMINATED_STRING
620        };
621        self.push(kind, start, end);
622    }
623
624    /// Read a plain scalar that starts at `start`, whose body begins at
625    /// `body_start`, classify it, and push it as one token.
626    ///
627    /// The characters before `body_start` have already been consumed by the
628    /// caller (`-`, `+`, `.`, ...) and stay part of the scalar text.
629    fn push_plain_scalar(&mut self, start: usize, body_start: usize, absorb_spaces: bool) {
630        let end = self.read_plain_scalar_body(body_start, absorb_spaces, false);
631        self.push_scalar(start, end);
632    }
633
634    /// Read a plain-scalar body starting at `start_idx`, returning where it
635    /// ends. `:` is content unless it is a mapping indicator, `#` unless it
636    /// starts a comment, and the indicators that only count at the start of
637    /// a node are content here.
638    ///
639    /// `absorb_spaces` folds an internal whitespace run into the scalar when
640    /// more scalar content follows, as a plain scalar spanning several words
641    /// requires. A block scalar's chomping indicator (`|-`, `>-`) is a token
642    /// in its own right, so that caller passes false.
643    ///
644    /// `stop_at_chomping_plus` ends the body at a `+` after nothing but
645    /// digits, for a scalar that is a block scalar header's indentation
646    /// indicator, so that `|2+` keeps the `+` as its own token.
647    fn read_plain_scalar_body(
648        &mut self,
649        start_idx: usize,
650        absorb_spaces: bool,
651        stop_at_chomping_plus: bool,
652    ) -> usize {
653        let input = self.input;
654        let flow_depth = self.flow_depth;
655        let mut end_idx = start_idx;
656        while let Some((idx, ch)) = self.chars.peek().copied() {
657            // Intra-line whitespace belongs to the scalar when more scalar
658            // content follows on the same line. Without this a space ended
659            // the body, so `-{ [a]: v }` lexed `[` as a flow collection
660            // rather than scalar content.
661            if absorb_spaces && (ch == ' ' || ch == '\t') {
662                if !plain_scalar_continues_past_whitespace(input, idx, flow_depth) {
663                    break;
664                }
665                while let Some((wi, wc)) = self.chars.peek().copied() {
666                    if wc != ' ' && wc != '\t' {
667                        break;
668                    }
669                    end_idx = wi + wc.len_utf8();
670                    self.chars.next();
671                }
672                continue;
673            }
674            if is_white_or_break(ch) {
675                break;
676            }
677            // What comes before the `:` is this scalar's text, even a quote
678            // in it: `[p":a]` holds the scalar `p":a`.
679            if ch == ':' && is_colon_followed_by_separator(input, idx, flow_depth) {
680                break;
681            }
682            if ch == '#' && is_hash_a_comment_start(input, idx) {
683                break;
684            }
685            if stop_at_chomping_plus
686                && ch == '+'
687                && input[start_idx..idx].bytes().all(|b| b.is_ascii_digit())
688            {
689                break;
690            }
691            // `-` and `+` are indicators only where a node starts (a sequence
692            // entry, or a block-scalar chomping suffix); inside a body they
693            // are content, so `++` is the single scalar `++`.
694            // `?`, `|`, `>`, `&`, `*`, `!` and `%` are indicators only at the
695            // start of a node (YAML 1.2 section 7.1), so they are content
696            // here too: `+?`, `a|b` and `a: b%c` are each one scalar.
697            if is_yaml_special_except(ch, "-+:#'\"?|>&*!%")
698                && !(flow_depth == 0 && matches!(ch, ',' | '[' | ']' | '{' | '}'))
699            {
700                break;
701            }
702            end_idx = idx + ch.len_utf8();
703            self.chars.next();
704        }
705        end_idx
706    }
707
708    /// Whether a node, and so a node property, block scalar header or
709    /// explicit key, may start here. Nothing starts inside a block scalar's
710    /// body, which is literal text: `a: |\n  >'\nb: ''\n` holds `>'`, where
711    /// reading the `>` as a header left the quote to open a quoted scalar
712    /// that ran on into `b`.
713    fn node_can_start(&self) -> bool {
714        !in_block_scalar_body(self.block_scalar, self.line_start)
715            && node_property_can_start(&self.tokens)
716    }
717
718    /// Whether a tag, anchor or alias may start at `idx`. A line continuing
719    /// a plain scalar is inside that node already, so there they are
720    /// content, read by plain-scalar rules: as a tag `!b:` in
721    /// `{ a\n  !b: 1 }` took the `:` that follows the key.
722    fn property_can_start(&self, idx: usize) -> bool {
723        self.node_can_start() && !self.opens_plain_scalar_continuation_line(idx)
724    }
725
726    /// Whether a block scalar header may start at `idx`.
727    fn block_scalar_header_can_start(&self, idx: usize) -> bool {
728        self.flow_depth == 0
729            && self.node_can_start()
730            && !self.opens_plain_scalar_continuation_line(idx)
731    }
732
733    /// Whether the character at `idx` opens a line following a plain scalar.
734    ///
735    /// Such a line either continues that scalar or starts a new implicit
736    /// key, and an implicit key fits on one line.
737    /// In block context the line has to be indented past the collection
738    /// the scalar is in: at its column a line starts the next entry, as in
739    /// `a: 1\n'b: 2\n`.
740    fn opens_plain_scalar_continuation_line(&self, idx: usize) -> bool {
741        use SyntaxKind::*;
742        self.input[self.line_start..idx]
743            .chars()
744            .all(|c| c == ' ' || c == '\t')
745            && (self.flow_depth > 0
746                || parent_column(&self.tokens)
747                    .map_or(true, |column| idx - self.line_start > column))
748            && self
749                .tokens
750                .iter()
751                .enumerate()
752                .rev()
753                .find(|(_, (kind, _))| !matches!(kind, WHITESPACE | INDENT | NEWLINE))
754                .is_some_and(|(i, (kind, _))| {
755                    i >= self.block_scalar_end
756                        && matches!(kind, STRING | INT | FLOAT | BOOL | NULL | MERGE_KEY)
757                })
758    }
759
760    /// Lex the token starting with `ch`, which has just been consumed.
761    fn lex_char(&mut self, start_idx: usize, ch: char) {
762        use SyntaxKind::*;
763
764        match ch {
765            // A BOM is not content and takes no column, so the line starts
766            // after it.
767            '\u{FEFF}'
768                if self.flow_depth == 0
769                    && start_idx == self.line_start
770                    && bom_starts_document(&self.tokens, self.input, start_idx) =>
771            {
772                self.push(BOM, start_idx, start_idx + ch.len_utf8());
773                self.line_start = start_idx + ch.len_utf8();
774            }
775            // Context-aware hyphen handling
776            '-' => self.lex_dash(start_idx),
777            '+' => self.lex_plus(start_idx),
778            ':' => self.lex_colon(start_idx),
779            // An explicit-key indicator, like the node properties below, only
780            // at the start of a node: `a ?b` is the scalar `a ?b`, not a key
781            // indicator inside it, and so is `a\n? b`.
782            //
783            // Per YAML 1.2 `c-complex-mapping-key` the `?` must also be
784            // followed by a space or a line break to be an indicator. Without
785            // that test `a: ?!!r{2}x` opened an explicit key and then read
786            // `!!r` as a tag, which stranded the rest of the line; PyYAML
787            // reads the value as the plain scalar `?!!r{2}x`.
788            //
789            // In a flow collection a `?` before a flow indicator cannot
790            // start a plain scalar (`ns-plain-first`), so `{?, a}` has an
791            // empty explicit key, as PyYAML reads it. The validator reports
792            // the space missing after it.
793            '?' if self.property_can_start(start_idx)
794                && self.chars.peek().map_or(true, |(_, next)| {
795                    is_white_or_break(*next)
796                        || (self.flow_depth > 0 && matches!(next, ',' | '[' | ']' | '{' | '}'))
797                }) =>
798            {
799                self.push(QUESTION, start_idx, start_idx + 1)
800            }
801            // A flow indicator inside a block scalar's body is literal
802            // text, so it neither opens nor closes a collection. Read the
803            // rest of the line as the scalar content it is.
804            //
805            // The same goes for a `#`: the body is taken verbatim, so
806            // `- >\n  # detected\n` holds the content `# detected` rather
807            // than a comment (test suite 4QFQ). A line that does not reach
808            // the body's column has left it, where a comment is a comment
809            // again (T26H).
810            '[' | ']' | '{' | '}' | ',' | '#'
811                if in_block_scalar_body(self.block_scalar, self.line_start) =>
812            {
813                self.push_plain_scalar(start_idx, start_idx + 1, true);
814            }
815            // So is one opening a line after a plain scalar, unless it opens
816            // a collection that is an implicit key: opening a collection in
817            // `f\n[\n---\n` hid the `---` that ends the document, as one
818            // after a `]` did in `f\n]{\n---\n` and after `[]` in
819            // `f\n[][\n---\n`.
820            '[' | '{' | ']' | '}'
821                if self.flow_depth == 0
822                    && self.opens_plain_scalar_continuation_line(start_idx)
823                    && !(matches!(ch, '[' | '{')
824                        && flow_collection_is_implicit_key(self.input, start_idx)) =>
825            {
826                self.push_plain_scalar(start_idx, start_idx + 1, true);
827            }
828            '[' => {
829                self.flow_depth += 1;
830                self.push(LEFT_BRACKET, start_idx, start_idx + 1);
831            }
832            ']' => {
833                self.flow_depth = self.flow_depth.saturating_sub(1);
834                self.push(RIGHT_BRACKET, start_idx, start_idx + 1);
835            }
836            '{' => {
837                self.flow_depth += 1;
838                self.push(LEFT_BRACE, start_idx, start_idx + 1);
839            }
840            '}' => {
841                self.flow_depth = self.flow_depth.saturating_sub(1);
842                self.push(RIGHT_BRACE, start_idx, start_idx + 1);
843            }
844            // A `,` separates entries only inside a flow collection. In block
845            // context it is ordinary plain-scalar content (`a: x\n  ,y`), so
846            // fall through to the catch-all arm, which already keeps flow
847            // indicators inside a block scalar.
848            ',' if self.flow_depth > 0 => self
849                .tokens
850                .push((COMMA, &self.input[start_idx..start_idx + 1])),
851            // A block-scalar header, like the node properties below, only at
852            // the start of a node: `a|b` is the scalar `a|b`, not a header
853            // inside it. `>` reaches this arm only at a node start already,
854            // because the catch-all treats it as scalar content.
855            //
856            // And only in block context: YAML 1.2 section 8.1 gives block
857            // scalars a block-context production only, so inside a flow
858            // collection a `|` is plain content and `[|, x]` has two
859            // entries, as saphyr reads it.
860            //
861            // A line after a plain scalar continues it, as a block scalar
862            // cannot be an implicit key: `1\n|x\n  # t\n` is `1 |x`.
863            '|' if self.block_scalar_header_can_start(start_idx) => {
864                self.block_scalar = Some(BlockScalarBody::new(
865                    &self.tokens,
866                    self.input,
867                    self.line_start,
868                ));
869                self.push(PIPE, start_idx, start_idx + 1)
870            }
871            '>' if self.block_scalar_header_can_start(start_idx) => {
872                self.block_scalar = Some(BlockScalarBody::new(
873                    &self.tokens,
874                    self.input,
875                    self.line_start,
876                ));
877                self.push(GREATER, start_idx, start_idx + 1)
878            }
879            // `<<` is a merge key only when it is the whole plain scalar, per
880            // the YAML 1.1 merge type: `<<foo`, `<<#` and `<< x` are scalars
881            // of their own.
882            '<' => {
883                self.lex_plain_scalar(start_idx, ch);
884                if let Some(token) = self.tokens.last_mut().filter(|t| t.1 == "<<") {
885                    token.0 = MERGE_KEY;
886                }
887            }
888            // Like tags, only at the start of a node: `a: x &anc` is the
889            // scalar `x &anc`, not an anchored value.
890            '&' if self.property_can_start(start_idx) => {
891                // Check if this is an anchor definition
892                let name = read_anchor_name_from(&mut self.chars, self.input, start_idx + 1);
893                if !name.is_empty() {
894                    self.push(ANCHOR, start_idx, start_idx + 1 + name.len());
895                } else {
896                    self.push(AMPERSAND, start_idx, start_idx + 1);
897                }
898            }
899            '*' if self.property_can_start(start_idx) => {
900                // Check if this is an alias reference
901                let name = read_anchor_name_from(&mut self.chars, self.input, start_idx + 1);
902                if !name.is_empty() {
903                    self.push(REFERENCE, start_idx, start_idx + 1 + name.len());
904                } else {
905                    self.push(ASTERISK, start_idx, start_idx + 1);
906                }
907            }
908            // A quote is literal text in a block scalar's body, and content on
909            // a line continuing a plain scalar, as the flow indicators above:
910            // `a: b\n  'c\nd: 1\n` holds `b 'c`, where scanning it as quoted
911            // ran on to the next quote in the file, and `[b\n "x", c]` holds
912            // `b "x"`.
913            '"' | '\''
914                if in_block_scalar_body(self.block_scalar, self.line_start)
915                    || self.opens_plain_scalar_continuation_line(start_idx) =>
916            {
917                self.push_plain_scalar(start_idx, start_idx + 1, true);
918            }
919            '"' => {
920                let (end_idx, found_closing) = scan_double_quoted(&mut self.chars, start_idx);
921                self.push_quoted_string(start_idx, end_idx, found_closing);
922            }
923            '\'' => {
924                let (end_idx, found_closing) = scan_single_quoted(&mut self.chars, start_idx);
925                self.push_quoted_string(start_idx, end_idx, found_closing);
926            }
927
928            // Document end
929            '.' => self.lex_dot(start_idx),
930
931            // Comments.
932            //
933            // Per YAML 1.2 section 6.6, `#` starts a comment only when
934            // preceded by whitespace. At outer-token boundary we have
935            // one of two situations:
936            //   - `#` at the start of the document / a fresh line, or
937            //     after a whitespace/newline: legitimate comment.
938            //   - `#` glued to the tail of a non-whitespace token
939            //     (e.g., `]#foo`): the input violates 4.6.6. The
940            //     validator's check_comment_token_whitespace flags
941            //     this at validation time; the lexer still emits a
942            //     COMMENT token so downstream error reporting has
943            //     something to point at.
944            //
945            // A `#` glued to plain-scalar content never gets here: the
946            // scalar reader takes it in (`a#: v`, `:#: v`).
947            '#' => self.lex_comment(start_idx),
948
949            // Tags
950            //
951            // Only at the start of a node: mid-scalar a `!` is ordinary
952            // content (`a: x !!b` is the scalar `x !!b`), which the
953            // catch-all arm below reads with plain-scalar rules.
954            '!' if self.property_can_start(start_idx) => self.lex_tag(start_idx),
955
956            '%' => self.lex_percent(start_idx),
957
958            // Newlines
959            '\n' | '\r' => self.lex_line_break(start_idx, ch),
960
961            // Whitespace (spaces and tabs)
962            ' ' | '\t' => self.lex_whitespace(start_idx, ch),
963
964            // Everything else is scalar content
965            _ => self.lex_plain_scalar(start_idx, ch),
966        }
967    }
968
969    fn lex_dash(&mut self, start_idx: usize) {
970        use SyntaxKind::*;
971        if self.flow_depth == 0
972            && start_idx == self.line_start
973            && self.input[start_idx..].starts_with("---")
974            && self.input[start_idx + 3..]
975                .chars()
976                .next()
977                .map_or(true, ends_document_marker)
978        {
979            self.chars.next();
980            self.chars.next();
981            self.push(DOC_START, start_idx, start_idx + 3);
982        } else {
983            // Check if this hyphen should be treated as a sequence marker
984            // It's a sequence marker if:
985            // 1. It's at the beginning of a line (after optional indentation)
986            // 2. OR it follows a value context (after ? or : plus whitespace)
987            // AND it's followed by whitespace or end of input
988
989            // Check if preceded only by whitespace from start of line
990            let line_start_pos = self.line_start;
991            let before_dash = &self.input[line_start_pos..start_idx];
992            let only_whitespace_before = before_dash.chars().all(|c| c == ' ' || c == '\t');
993
994            // Check if the previous non-whitespace token was ? or :
995            // indicating a value context where sequences are allowed.
996            //
997            // A DASH counts too: an entry's own node may be a nested
998            // sequence, so `- - x` is `[[x]]`, as saphyr and PyYAML
999            // both read it and as value.rs already writes it.
1000            let after_value_indicator = self
1001                .tokens
1002                .iter()
1003                .rev()
1004                .find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT))
1005                .is_some_and(|(kind, _)| matches!(kind, QUESTION | COLON | DASH | DOC_START));
1006
1007            // Check if followed by whitespace or end of input
1008            let followed_by_whitespace_or_end = self
1009                .chars
1010                .peek()
1011                .map_or(true, |(_, next_ch)| is_white_or_break(*next_ch));
1012
1013            // A line continuing a plain scalar holds content: `e\n- [` is
1014            // the scalar `e - [`.
1015            let is_sequence_marker = ((only_whitespace_before
1016                && !self.opens_plain_scalar_continuation_line(start_idx))
1017                || after_value_indicator)
1018                && followed_by_whitespace_or_end;
1019
1020            if is_sequence_marker {
1021                self.push(DASH, start_idx, start_idx + 1);
1022            } else {
1023                // This hyphen is part of a scalar value. Use the
1024                // plain-scalar body reader so embedded `:` (not
1025                // followed by whitespace) stays inside the scalar.
1026                //
1027                // A `-` closing a block-scalar header (`|-`, `>2-`)
1028                // is a chomping indicator and stands alone, so it
1029                // must not swallow the space that follows it. Same
1030                // test as the `+` arm below.
1031                self.push_plain_scalar(
1032                    start_idx,
1033                    start_idx + 1,
1034                    !is_chomping_indicator(&self.input[self.line_start..start_idx]),
1035                );
1036            }
1037        }
1038    }
1039
1040    fn lex_plus(&mut self, start_idx: usize) {
1041        // `+` is a chomping indicator when it closes a block-scalar header
1042        // (`|+`, `|2+`, `>+`, `>2+`). Anywhere else it is content, so it
1043        // starts a plain scalar: `+.INF` is one FLOAT, `+ x: v` is keyed
1044        // `+ x`, and `+: v` is keyed `+`. A bare PLUS token no parse rule
1045        // claims would leave the `+` invisible.
1046        if is_chomping_indicator(&self.input[self.line_start..start_idx]) {
1047            self.push(SyntaxKind::PLUS, start_idx, start_idx + 1);
1048        } else {
1049            self.push_plain_scalar(start_idx, start_idx + 1, true);
1050        }
1051    }
1052
1053    fn lex_colon(&mut self, start_idx: usize) {
1054        use SyntaxKind::*;
1055        // Colon at token boundary. It's a mapping indicator when
1056        // its lookahead matches is_colon_a_mapping_indicator, OR
1057        // when we're in flow context and the immediately
1058        // preceding meaningful token was a JSON-like key (a quoted
1059        // scalar or a flow collection), which YAML 1.2 lets an
1060        // adjacent value follow:
1061        //     { "foo"\n  :bar }    -- COLON after "foo"
1062        // vs.
1063        //     [ ::vector ]         -- `:` starts a plain scalar
1064        //     [ a\n :b ]           -- `:b` continues the scalar `a`
1065        let preceded_by_json_key = self.flow_depth > 0
1066            && self
1067                .tokens
1068                .iter()
1069                .rev()
1070                .find(|(k, _)| !matches!(k, WHITESPACE | INDENT | NEWLINE | COMMENT))
1071                .is_some_and(|(k, text)| {
1072                    matches!(k, RIGHT_BRACKET | RIGHT_BRACE)
1073                        || (*k == STRING && text.starts_with(['"', '\'']))
1074                });
1075        if preceded_by_json_key
1076            || is_colon_a_mapping_indicator(self.input, start_idx, self.flow_depth)
1077        {
1078            self.push(COLON, start_idx, start_idx + 1);
1079        } else {
1080            // This colon starts (or continues) a plain scalar such
1081            // as a URL, `::vector`, or a timestamp. The shared body
1082            // reader knows the rules: a `:` not followed by space is
1083            // content, flow indicators are content in block context,
1084            // and a `#` glued to content is not a comment.
1085            self.push_plain_scalar(start_idx, start_idx + 1, true);
1086        }
1087    }
1088
1089    fn lex_dot(&mut self, start_idx: usize) {
1090        use SyntaxKind::*;
1091        // Check for three dots (document end marker)
1092        // `...` is the document-end marker only when it is the whole
1093        // line, in block context: the same three conditions the `---`
1094        // arm applies. `...w` is the plain scalar `...w`, as both
1095        // saphyr and PyYAML read it, and `---x` already was.
1096        let is_doc_end = self.flow_depth == 0
1097            && start_idx == self.line_start
1098            && self.input[start_idx..].starts_with("...")
1099            && self.input[start_idx + 3..]
1100                .chars()
1101                .next()
1102                .map_or(true, ends_document_marker);
1103        if is_doc_end && self.chars.peek() == Some(&(start_idx + 1, '.')) {
1104            self.chars.next(); // consume second .
1105            if self.chars.peek() == Some(&(start_idx + 2, '.')) {
1106                self.chars.next(); // consume third .
1107                self.push(DOC_END, start_idx, start_idx + 3);
1108            } else {
1109                // Two dots -- continue as plain scalar body (allows
1110                // embedded `-` and `:` per YAML plain-scalar rules).
1111                self.push_plain_scalar(start_idx, start_idx + 2, true);
1112            }
1113        } else {
1114            // Single dot -- part of plain scalar body.
1115            self.push_plain_scalar(start_idx, start_idx + 1, true);
1116        }
1117    }
1118
1119    fn lex_comment(&mut self, start_idx: usize) {
1120        use SyntaxKind::*;
1121        let mut end_idx = start_idx + 1;
1122        while let Some((idx, ch)) = self.chars.peek() {
1123            if *ch == '\n' || *ch == '\r' {
1124                break;
1125            }
1126            end_idx = *idx + ch.len_utf8();
1127            self.chars.next();
1128        }
1129        self.push(COMMENT, start_idx, end_idx);
1130    }
1131
1132    fn lex_tag(&mut self, start_idx: usize) {
1133        use SyntaxKind::*;
1134        // Handle tag indicators - both ! and !!
1135        let mut end_idx = start_idx + 1;
1136
1137        // A verbatim tag `!<uri>` runs to its closing `>` and may hold
1138        // any URI character, commas included, so take it whole.
1139        if let Some((_, '<')) = self.chars.peek() {
1140            self.chars.next(); // consume '<'
1141            end_idx = start_idx + 2;
1142            // An unterminated `!<x` stops at the line break, which
1143            // the main loop then handles; the text still round-trips
1144            // and the parser reports the malformed tag.
1145            while let Some((idx, ch)) = self.chars.peek() {
1146                if matches!(ch, '\n' | '\r') {
1147                    break;
1148                }
1149                end_idx = *idx + ch.len_utf8();
1150                let closing = *ch == '>';
1151                self.chars.next();
1152                if closing {
1153                    break;
1154                }
1155            }
1156            self.push(TAG, start_idx, end_idx);
1157            return;
1158        }
1159
1160        // Check for double exclamation (global tag)
1161        if let Some((_, '!')) = self.chars.peek() {
1162            self.chars.next(); // consume the second !
1163            end_idx = start_idx + 2;
1164        } else {
1165            // A named handle `!e!` is a run of word characters
1166            // closed by another `!`.
1167            let word = self.input[start_idx + 1..]
1168                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
1169                .map_or(self.input.len(), |i| start_idx + 1 + i);
1170            if word > start_idx + 1 && self.input[word..].starts_with('!') {
1171                while self.chars.peek().is_some_and(|(idx, _)| *idx <= word) {
1172                    self.chars.next();
1173                }
1174                end_idx = word + 1;
1175            }
1176        }
1177
1178        // Read the tag name after the ! or !!
1179        while let Some((idx, ch)) = self.chars.peek() {
1180            if !is_tag_char(*ch) {
1181                break;
1182            }
1183            end_idx = *idx + ch.len_utf8();
1184            self.chars.next();
1185        }
1186
1187        self.push(TAG, start_idx, end_idx);
1188    }
1189
1190    fn lex_percent(&mut self, start_idx: usize) {
1191        use SyntaxKind::*;
1192        // A directive is only a `%` that opens a line. Anywhere else,
1193        // inside a flow collection, or in a block scalar's body
1194        // (`--- |\n%!PS-Adobe-2.0\n`), it is ordinary scalar content.
1195        if self.flow_depth > 0
1196            || start_idx != self.line_start
1197            || in_block_scalar_body(self.block_scalar, self.line_start)
1198        {
1199            // Treat as part of a plain scalar. The shared body reader
1200            // knows the rules this needs: flow indicators are content
1201            // in block context, and an internal whitespace run stays
1202            // in the scalar when more content follows on the line, so
1203            // `r % {` is the single scalar `r % {`.
1204            self.push_plain_scalar(start_idx, start_idx + 1, true);
1205        } else {
1206            // At the start of a line in block context, % starts a directive
1207            let mut end_idx = start_idx + 1;
1208            while let Some((idx, ch)) = self.chars.peek() {
1209                if *ch == '\n' || *ch == '\r' {
1210                    break;
1211                }
1212                end_idx = *idx + ch.len_utf8();
1213                self.chars.next();
1214            }
1215            self.push(DIRECTIVE, start_idx, end_idx);
1216        }
1217    }
1218
1219    /// Lex a line break: `\n`, `\r\n` or a lone `\r`.
1220    fn lex_line_break(&mut self, start_idx: usize, ch: char) {
1221        check_line_length(&mut self.errors, self.config, self.line_start, start_idx);
1222
1223        let (line_ending, end_pos) = if ch == '\n' {
1224            ("\n", start_idx + 1)
1225        } else if let Some((_, '\n')) = self.chars.peek() {
1226            self.chars.next();
1227            ("\r\n", start_idx + 2)
1228        } else {
1229            ("\r", start_idx + 1)
1230        };
1231
1232        note_line_ending(
1233            &mut self.errors,
1234            self.config,
1235            &mut self.detected_line_ending,
1236            line_ending,
1237            start_idx..end_pos,
1238        );
1239
1240        self.push(SyntaxKind::NEWLINE, start_idx, end_pos);
1241        self.line_start = end_pos;
1242        let in_body = self.block_scalar.is_some();
1243        track_block_scalar_body(
1244            &self.tokens,
1245            self.input,
1246            self.line_start,
1247            &mut self.block_scalar,
1248        );
1249        if in_body && self.block_scalar.is_none() {
1250            self.block_scalar_end = self.tokens.len();
1251        }
1252    }
1253
1254    fn lex_whitespace(&mut self, start_idx: usize, ch: char) {
1255        use SyntaxKind::*;
1256        let mut end_idx = start_idx + 1;
1257        let mut has_tabs = ch == '\t';
1258
1259        while let Some((idx, ch)) = self.chars.peek() {
1260            if *ch != ' ' && *ch != '\t' {
1261                break;
1262            }
1263            if *ch == '\t' {
1264                has_tabs = true;
1265            }
1266            end_idx = *idx + 1;
1267            self.chars.next();
1268        }
1269
1270        // White space starting a line is its indentation. A line also starts
1271        // after a BOM, which takes no column.
1272        let is_indentation = start_idx == self.line_start;
1273
1274        if is_indentation {
1275            // Check for tab characters in indentation (forbidden in YAML)
1276            if has_tabs {
1277                self.errors.push(WhitespaceError {
1278                    message: "Tab character used for indentation (forbidden in YAML)".to_string(),
1279                    range: start_idx..end_idx,
1280                    category: WhitespaceErrorCategory::TabIndentation,
1281                });
1282            }
1283            self.push(INDENT, start_idx, end_idx);
1284        } else {
1285            self.push(WHITESPACE, start_idx, end_idx);
1286        }
1287    }
1288
1289    fn lex_plain_scalar(&mut self, start_idx: usize, ch: char) {
1290        // Only a header's indentation indicator can precede its chomping
1291        // `+`. A `|` reaching here is content: `1\n|+\n` is `1 |+`.
1292        let after_header = ch.is_ascii_digit()
1293            && matches!(
1294                self.tokens.last(),
1295                Some((SyntaxKind::PIPE | SyntaxKind::GREATER, _))
1296            );
1297        let end = self.read_plain_scalar_body(start_idx + ch.len_utf8(), true, after_header);
1298        self.push_scalar(start_idx, end);
1299    }
1300
1301    fn finish(self) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
1302        let mut whitespace_errors = self.errors;
1303        // Check the final line length if there's no trailing newline
1304        if let Some(max_len) = self.config.max_line_length {
1305            let final_line_length = self.input.len() - self.line_start;
1306            if final_line_length > max_len && final_line_length > 0 {
1307                whitespace_errors.push(WhitespaceError {
1308                    message: format!("Line too long ({final_line_length} > {max_len} characters)"),
1309                    range: self.line_start..self.input.len(),
1310                    category: WhitespaceErrorCategory::LineTooLong,
1311                });
1312            }
1313        }
1314        (self.tokens, whitespace_errors)
1315    }
1316}
1317
1318/// Map a plain-scalar's semantic classification onto the concrete
1319/// [`SyntaxKind`] the tokenizer emits. The classification itself lives
1320/// in [`ScalarValue::classify_plain`](crate::ScalarValue::classify_plain);
1321/// this function only translates enum variants.
1322fn classify_scalar(text: &str) -> SyntaxKind {
1323    use crate::CoreScalarType;
1324    match crate::scalar::ScalarValue::classify_plain(text) {
1325        CoreScalarType::Null => SyntaxKind::NULL,
1326        CoreScalarType::Boolean => SyntaxKind::BOOL,
1327        CoreScalarType::Integer => SyntaxKind::INT,
1328        CoreScalarType::Float => SyntaxKind::FLOAT,
1329        CoreScalarType::String => SyntaxKind::STRING,
1330    }
1331}
1332
1333/// Common set of YAML special characters
1334const YAML_SPECIAL_CHARS: &str = ":+-?[]{},'|>&*!%\"#";
1335
1336/// Check if a character has special meaning in YAML
1337/// Whether a node property (`!tag`, `&anchor`, `*alias`) may start here.
1338///
1339/// Per YAML 1.2 §7.1 these apply to the start of a node. Once a plain
1340/// scalar has begun on the line, a `!` or `&` is ordinary content, so
1341/// `a: x !!b` is the scalar `x !!b` rather than a tagged node. A preceding
1342/// space is not enough to start a new node: what matters is whether the
1343/// last token was scalar content.
1344/// Whether the character ends a `---` or `...` marker.
1345///
1346/// YAML 1.2 `s-white` is space and tab only, and a marker may also end at a
1347/// line break. Rust's `char::is_whitespace` is Unicode-wide and counts
1348/// U+00A0 and friends, which are ordinary scalar content here: `"...\u{a0}-"`
1349/// is one plain scalar, as saphyr and PyYAML both read it.
1350fn ends_document_marker(ch: char) -> bool {
1351    matches!(ch, ' ' | '\t' | '\n' | '\r')
1352}
1353
1354/// Whether a BOM at the start of a line is a document prefix.
1355///
1356/// YAML 1.2 allows a BOM at the start of any document in a stream, not only
1357/// the first, so one may follow a `---` or `...` line or precede a `---`.
1358/// Anywhere else it is ordinary content.
1359fn bom_starts_document(tokens: &[(SyntaxKind, &str)], input: &str, bom_idx: usize) -> bool {
1360    use SyntaxKind::*;
1361    let after_marker = tokens
1362        .iter()
1363        .rev()
1364        .find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT | NEWLINE | COMMENT))
1365        .map_or(true, |(kind, _)| matches!(kind, DOC_START | DOC_END));
1366    let rest = &input[bom_idx + '\u{FEFF}'.len_utf8()..];
1367    let before_marker =
1368        rest.starts_with("---") && rest[3..].chars().next().map_or(true, ends_document_marker);
1369    after_marker || before_marker
1370}
1371
1372fn node_property_can_start(tokens: &[(SyntaxKind, &str)]) -> bool {
1373    for (kind, _) in tokens.iter().rev() {
1374        match kind {
1375            // Skip the layout between the property and what precedes it.
1376            SyntaxKind::WHITESPACE | SyntaxKind::INDENT => continue,
1377            // A node starts after these.
1378            SyntaxKind::NEWLINE
1379            | SyntaxKind::COLON
1380            | SyntaxKind::DASH
1381            | SyntaxKind::QUESTION
1382            | SyntaxKind::COMMA
1383            | SyntaxKind::LEFT_BRACKET
1384            | SyntaxKind::LEFT_BRACE
1385            | SyntaxKind::DOC_START
1386            | SyntaxKind::BOM
1387            | SyntaxKind::TAG
1388            | SyntaxKind::ANCHOR
1389            // A bare `&` or `*` is punctuation rather than content, so a
1390            // property may still follow it.
1391            | SyntaxKind::AMPERSAND
1392            | SyntaxKind::ASTERISK => return true,
1393            // Anything else is content the property would sit inside.
1394            _ => return false,
1395        }
1396    }
1397    // Nothing before it: the very start of the input is a node start.
1398    true
1399}
1400
1401/// Whether the line starting at `line_start` carries no content.
1402fn line_is_blank(input: &str, line_start: usize) -> bool {
1403    input[line_start..]
1404        .split(['\n', '\r'])
1405        .next()
1406        .unwrap_or_default()
1407        .trim_matches([' ', '\t'])
1408        .is_empty()
1409}
1410
1411/// Leading whitespace of the line starting at `line_start`.
1412fn line_indent(input: &str, line_start: usize) -> usize {
1413    let line = &input[line_start..];
1414    line.len() - line.trim_start_matches([' ', '\t']).len()
1415}
1416
1417/// A block scalar whose body the lexer is reading.
1418#[derive(Clone, Copy)]
1419struct BlockScalarBody {
1420    /// Start of the line carrying the header; the body begins on the next.
1421    header_line_start: usize,
1422    /// Index of the header's `|` or `>` token.
1423    header_token: usize,
1424    /// Column an explicit indentation indicator counts from.
1425    parent_column: usize,
1426    /// Leftmost column a body line may start at. A document's root block
1427    /// scalar sits at indentation -1, so its body may start at column 0;
1428    /// any other body has to be indented past its header's line and the
1429    /// collection it is a node of: in `- a: |\n  b: 1\n` the `b` line is
1430    /// the mapping's next entry.
1431    min_indent: usize,
1432    /// The body's own column, set by an explicit indentation indicator or
1433    /// else by its first content line.
1434    indent: Option<usize>,
1435}
1436
1437impl BlockScalarBody {
1438    /// Start tracking for a header found on the line at `line_start`, with
1439    /// `tokens` holding everything lexed before it.
1440    fn new(tokens: &[(SyntaxKind, &str)], input: &str, line_start: usize) -> Self {
1441        // Only `---` and node properties before the header, with nothing
1442        // indenting the line, make it the document's root node.
1443        let is_root = tokens
1444            .iter()
1445            .rev()
1446            .take_while(|(kind, _)| !matches!(kind, SyntaxKind::NEWLINE | SyntaxKind::BOM))
1447            .all(|(kind, _)| {
1448                matches!(
1449                    kind,
1450                    SyntaxKind::DOC_START
1451                        | SyntaxKind::WHITESPACE
1452                        | SyntaxKind::TAG
1453                        | SyntaxKind::ANCHOR
1454                )
1455            });
1456        let parent_column = parent_column(tokens).unwrap_or(0);
1457        let min_indent = if is_root {
1458            0
1459        } else {
1460            line_indent(input, line_start).max(parent_column) + 1
1461        };
1462        Self {
1463            header_line_start: line_start,
1464            header_token: tokens.len(),
1465            parent_column,
1466            min_indent,
1467            indent: None,
1468        }
1469    }
1470}
1471
1472/// The column of the block collection a node starting after `tokens` is a
1473/// node of, or `None` at the document root.
1474///
1475/// This is the column as the parser records it: the `-` or `?`
1476/// introducing the node, or for a mapping value the start of its key (the
1477/// `:` itself when the key is empty or explicit, as an explicit key's
1478/// mapping sits at its `?` and `:`). When the node opens its line, or
1479/// continues a plain scalar, the collection is the one the last line with
1480/// an indicator leaves open. A block scalar's indentation indicator counts
1481/// from this column, taking the root's as 0 as the parser and libyaml do,
1482/// although YAML 1.2 puts the root at -1.
1483fn parent_column(tokens: &[(SyntaxKind, &str)]) -> Option<usize> {
1484    use SyntaxKind::*;
1485    for line in tokens.rsplit(|(kind, _)| matches!(kind, NEWLINE | BOM)) {
1486        let Some(last) = line.iter().rposition(|(kind, _)| {
1487            !matches!(
1488                kind,
1489                WHITESPACE
1490                    | INDENT
1491                    | COMMENT
1492                    | TAG
1493                    | ANCHOR
1494                    | STRING
1495                    | INT
1496                    | FLOAT
1497                    | BOOL
1498                    | NULL
1499                    | MERGE_KEY
1500            )
1501        }) else {
1502            continue;
1503        };
1504        let column = |index: usize| -> usize {
1505            line[..index]
1506                .iter()
1507                .map(|(_, text)| text.chars().count())
1508                .sum()
1509        };
1510        return match line[last].0 {
1511            DASH | QUESTION => Some(column(last)),
1512            COLON => {
1513                // The key starts after the block indicators opening the
1514                // line. An explicit `:` is one of them, as its value may be
1515                // a compact mapping: in `: b: |1` that sits at `b`.
1516                let key = line[..last]
1517                    .iter()
1518                    .position(|(kind, _)| {
1519                        !matches!(kind, WHITESPACE | INDENT | DASH | QUESTION | COLON)
1520                    })
1521                    .unwrap_or(last);
1522                Some(column(key))
1523            }
1524            DOC_START | DOC_END => None,
1525            _ => Some(0),
1526        };
1527    }
1528    None
1529}
1530
1531/// Read a block scalar header's indicators from the tokens following its
1532/// `|` or `>`, returning the indentation indicator if there is one, and how
1533/// many tokens the indicators and the whitespace between them take up. The
1534/// header's comment and line break are not counted.
1535///
1536/// Format: `|<indent><chomp>` or `|<chomp><indent>`, as in `|2`, `|-`,
1537/// `|2-` and `|-2`.
1538pub(crate) fn block_scalar_header<'a>(
1539    tokens: impl IntoIterator<Item = (SyntaxKind, &'a str)>,
1540) -> (Option<usize>, usize) {
1541    let mut explicit_indent = None;
1542    let mut len = 0;
1543    for (kind, text) in tokens {
1544        match kind {
1545            SyntaxKind::INT => {
1546                // The spec allows a single digit 1-9, but the lexer hands
1547                // over the whole run, so `|10` arrives as "10". Take it as
1548                // part of the header rather than leaving it for the body,
1549                // which would strand it.
1550                //
1551                // A chomping indicator before the digit lexes into the same
1552                // token (`|-2` is INT("-2")), so allow one sign.
1553                let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
1554                if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
1555                    break;
1556                }
1557                // A longer run is out of range, so keep only the first digit.
1558                explicit_indent = digits
1559                    .chars()
1560                    .next()
1561                    .and_then(|c| c.to_digit(10))
1562                    .filter(|d| *d > 0)
1563                    .map(|d| d as usize);
1564            }
1565            SyntaxKind::STRING => {
1566                // Could be a chomping indicator, or an indentation digit and
1567                // chomping indicator lexed together (`|3-`, `|-3`). A
1568                // header's indicators are ASCII, so compare characters
1569                // rather than bytes: `text.len()` is 2 for a single no-break
1570                // space.
1571                let mut chars = text.chars();
1572                let is_header = match (chars.next(), chars.next(), chars.next()) {
1573                    (Some(a), None, _) => a == '+' || a == '-',
1574                    (Some(a), Some(b), None) => {
1575                        let digit = |c: char| c.is_ascii_digit() && c != '0';
1576                        let chomp = |c: char| c == '+' || c == '-';
1577                        (digit(a) && chomp(b)) || (chomp(a) && digit(b))
1578                    }
1579                    _ => false,
1580                };
1581                if !is_header {
1582                    break;
1583                }
1584                explicit_indent = text
1585                    .chars()
1586                    .find_map(|c| c.to_digit(10))
1587                    .filter(|d| *d > 0)
1588                    .map(|d| d as usize);
1589            }
1590            // A `+` straight after the `|` or `>` lexes as PLUS rather than
1591            // STRING, but it is the same chomping indicator.
1592            SyntaxKind::PLUS | SyntaxKind::WHITESPACE => {}
1593            _ => break,
1594        }
1595        len += 1;
1596    }
1597    (explicit_indent, len)
1598}
1599
1600/// Whether the line starting at `line_start` is a `---` or `...` marker.
1601fn line_is_document_marker(input: &str, line_start: usize) -> bool {
1602    let line = &input[line_start..];
1603    (line.starts_with("---") || line.starts_with("..."))
1604        && line[3..].chars().next().map_or(true, ends_document_marker)
1605}
1606
1607/// Update the lexer's block-scalar tracking at the start of a new line.
1608///
1609/// A block scalar's body ends at the first following line that is not blank
1610/// and starts left of the body's column (or, before a content line has set
1611/// that, left of the leftmost column allowed), or at a document marker,
1612/// which is the only thing that ends a root body at column 0. This runs for
1613/// every line ending, CRLF and lone CR included: left to the `\n` arm
1614/// alone, a CRLF file kept the tracking set and read a later `[` as literal
1615/// body text rather than a flow collection.
1616fn track_block_scalar_body(
1617    tokens: &[(SyntaxKind, &str)],
1618    input: &str,
1619    line_start: usize,
1620    body: &mut Option<BlockScalarBody>,
1621) {
1622    let Some(current) = body else {
1623        return;
1624    };
1625    // An explicit indentation indicator fixes the body's column, so a body
1626    // line less indented than the first one still belongs to it:
1627    // `- |2\n   x\n  y # z\n` holds the text `y # z`.
1628    if current.indent.is_none() {
1629        if let (Some(indicator), _) =
1630            block_scalar_header(tokens[current.header_token + 1..].iter().copied())
1631        {
1632            current.indent = Some(current.parent_column + indicator);
1633        }
1634    }
1635    if line_is_blank(input, line_start) {
1636        return;
1637    }
1638    let indent = line_indent(input, line_start);
1639    if indent < current.indent.unwrap_or(current.min_indent)
1640        || line_is_document_marker(input, line_start)
1641    {
1642        *body = None;
1643    } else if current.indent.is_none() {
1644        // Only spaces indent, so a tab after them is content. As in the
1645        // parser, tabs count when the spaces alone don't reach the body.
1646        let spaces = crate::column::space_indent(&input[line_start..]);
1647        current.indent = Some(if spaces >= current.min_indent {
1648            spaces
1649        } else {
1650            indent
1651        });
1652    }
1653}
1654
1655/// Whether the line starting at `line_start` is part of a block scalar's
1656/// body, all of which is literal text: `a: |\n  {\nb,c: 1\n` holds the
1657/// scalar `{`, `- >\n  # detected\n` the text `# detected` (test suite
1658/// 4QFQ), and `--- |\n%!PS-Adobe-2.0\n` a line that is not a directive
1659/// (W4TN).
1660///
1661/// [`track_block_scalar_body`] drops the tracking once a line ends the body,
1662/// so this only has to rule out the header's own line.
1663fn in_block_scalar_body(body: Option<BlockScalarBody>, line_start: usize) -> bool {
1664    body.is_some_and(|body| body.header_line_start != line_start)
1665}
1666
1667/// Whether `ch` may appear in a tag shorthand's suffix.
1668///
1669/// YAML 1.2 `ns-tag-char` is any URI character except the flow indicators
1670/// `,`, `[`, `]`, `{`, `}` and the `!` that would start another tag. That
1671/// admits plenty the lexer treats as special elsewhere -- `:` in `!!ss:eq`,
1672/// `-` in `!!ss---` -- so a tag cannot be scanned with the general
1673/// [`YAML_SPECIAL_CHARS`] set.
1674fn is_tag_char(ch: char) -> bool {
1675    if ch.is_whitespace() {
1676        return false;
1677    }
1678    !matches!(ch, ',' | '[' | ']' | '{' | '}' | '!')
1679}
1680
1681/// Whether the character is YAML whitespace (`s-white`, space and tab
1682/// only) or a line break. Unlike `char::is_whitespace` this leaves U+00A0
1683/// and friends as content, as [`ends_document_marker`] does.
1684fn is_white_or_break(ch: char) -> bool {
1685    matches!(ch, ' ' | '\t' | '\n' | '\r')
1686}
1687
1688fn is_yaml_special(ch: char) -> bool {
1689    YAML_SPECIAL_CHARS.contains(ch)
1690}
1691
1692/// Check if character is YAML special, with optional exclusions
1693fn is_yaml_special_except(ch: char, exclude: &str) -> bool {
1694    YAML_SPECIAL_CHARS.contains(ch) && !exclude.contains(ch)
1695}
1696
1697/// Check whether `name` is a well-formed anchor/alias name (`ns-anchor-name`).
1698///
1699/// Per YAML 1.2 that is one or more `ns-anchor-char`s: printable characters
1700/// other than whitespace, line breaks, the byte order mark and the flow
1701/// indicators. The lexer reads a name up to whitespace or a flow
1702/// indicator (see [`read_anchor_name_from`]), so such a name round-trips.
1703pub(crate) fn is_valid_anchor_name(name: &str) -> bool {
1704    !name.is_empty() && name.chars().all(is_anchor_char)
1705}
1706
1707/// `ns-anchor-char`: `ns-char` minus the flow indicators.
1708fn is_anchor_char(ch: char) -> bool {
1709    // c-printable, less s-white, b-char and the byte order mark.
1710    let ns_char = matches!(ch,
1711        '\u{21}'..='\u{7E}'
1712        | '\u{85}'
1713        | '\u{A0}'..='\u{D7FF}'
1714        | '\u{E000}'..='\u{FFFD}'
1715        | '\u{10000}'..='\u{10FFFF}')
1716        && ch != '\u{FEFF}';
1717    ns_char && !matches!(ch, ',' | '[' | ']' | '{' | '}')
1718}
1719
1720#[cfg(test)]
1721mod tests {
1722    use super::*;
1723
1724    #[test]
1725    fn test_is_valid_anchor_name() {
1726        assert!(is_valid_anchor_name("shared"));
1727        assert!(is_valid_anchor_name("shared_1"));
1728        assert!(is_valid_anchor_name("anchor.dotted"));
1729        assert!(is_valid_anchor_name("naïve"));
1730        // Only the flow indicators are excluded from the indicators.
1731        for name in [
1732            "a:",
1733            "colon:name",
1734            "*star",
1735            "&amp",
1736            "-",
1737            "x?y",
1738            "#",
1739            "!t",
1740            "|>",
1741            "%'\"",
1742            "@`",
1743            "no\u{A0}break",
1744            "nel\u{85}",
1745        ] {
1746            assert!(is_valid_anchor_name(name), "{name:?}");
1747        }
1748
1749        for name in [
1750            "",
1751            "has space",
1752            "has\ttab",
1753            "has\nnewline",
1754            "has\rreturn",
1755            "comma,name",
1756            "bracket]name",
1757            "[",
1758            "{",
1759            "}",
1760            "bom\u{FEFF}",
1761            "bell\u{7}",
1762            "del\u{7F}",
1763            "c1\u{80}",
1764        ] {
1765            assert!(!is_valid_anchor_name(name), "{name:?}");
1766        }
1767    }
1768
1769    #[test]
1770    fn test_embedded_hyphens_stay_in_one_plain_scalar() {
1771        let input = format!("k: {}\n", "a-".repeat(200));
1772        let kinds: Vec<_> = lex(&input).into_iter().map(|(k, _)| k).collect();
1773        assert!(kinds.contains(&SyntaxKind::STRING) || kinds.contains(&SyntaxKind::SCALAR));
1774        let dash_count = kinds.iter().filter(|k| **k == SyntaxKind::DASH).count();
1775        assert_eq!(dash_count, 0);
1776        let seq = lex("- a\n- b\n");
1777        let seq_dashes = seq.iter().filter(|(k, _)| *k == SyntaxKind::DASH).count();
1778        assert_eq!(seq_dashes, 2);
1779    }
1780
1781    /// Names accepted by [`is_valid_anchor_name`] must round-trip through the
1782    /// lexer as a single `REFERENCE` token, so `Alias::new` and the lexer stay
1783    /// in agreement.
1784    #[test]
1785    fn test_valid_anchor_names_round_trip_through_lexer() {
1786        for name in [
1787            "shared",
1788            "shared_1",
1789            "anchor.dotted",
1790            "naïve",
1791            "a:",
1792            "*star",
1793            "x#y",
1794            "no\u{A0}break",
1795        ] {
1796            let input = format!("*{name}");
1797            let tokens = lex(&input);
1798            assert_eq!(
1799                tokens,
1800                vec![(SyntaxKind::REFERENCE, input.as_str())],
1801                "name {name:?} did not round-trip as a single REFERENCE token"
1802            );
1803        }
1804    }
1805
1806    #[test]
1807    fn test_simple_mapping() {
1808        let input = "key: value";
1809        let tokens = lex(input);
1810
1811        assert_eq!(tokens.len(), 4);
1812        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1813        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1814        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1815        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
1816    }
1817
1818    #[test]
1819    fn test_scalar_types() {
1820        // Test integer
1821        let tokens = lex("age: 42");
1822        assert_eq!(tokens[0], (SyntaxKind::STRING, "age"));
1823        assert_eq!(tokens[3], (SyntaxKind::INT, "42"));
1824
1825        // Test float
1826        let tokens = lex("pi: 3.14");
1827        assert_eq!(tokens[0], (SyntaxKind::STRING, "pi"));
1828        assert_eq!(tokens[3], (SyntaxKind::FLOAT, "3.14"));
1829
1830        // Test boolean true
1831        let tokens = lex("enabled: true");
1832        assert_eq!(tokens[0], (SyntaxKind::STRING, "enabled"));
1833        assert_eq!(tokens[3], (SyntaxKind::BOOL, "true"));
1834
1835        // Test boolean false
1836        let tokens = lex("disabled: false");
1837        assert_eq!(tokens[3], (SyntaxKind::BOOL, "false"));
1838
1839        // Test null
1840        let tokens = lex("value: null");
1841        assert_eq!(tokens[3], (SyntaxKind::NULL, "null"));
1842
1843        // Test tilde as null
1844        let tokens = lex("value: ~");
1845        assert_eq!(tokens[3], (SyntaxKind::NULL, "~"));
1846    }
1847
1848    #[test]
1849    fn test_sequences() {
1850        let input = "- item1\n- item2";
1851        let tokens = lex(input);
1852
1853        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1854        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1855        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
1856        assert_eq!(tokens[3], (SyntaxKind::NEWLINE, "\n"));
1857        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
1858        assert_eq!(tokens[5], (SyntaxKind::WHITESPACE, " "));
1859        assert_eq!(tokens[6], (SyntaxKind::STRING, "item2"));
1860    }
1861
1862    #[test]
1863    fn test_hyphen_in_scalars() {
1864        // Test hyphens in scalar values should not be treated as sequence markers
1865        let input = "Name: example-project";
1866        let tokens = lex(input);
1867
1868        println!("Hyphen test tokens:");
1869        for (i, (kind, text)) in tokens.iter().enumerate() {
1870            println!("  {}: {:?} = {:?}", i, kind, text);
1871        }
1872
1873        // Should get: STRING("Name"), COLON(":"), WHITESPACE(" "), STRING("example-project")
1874        assert_eq!(tokens.len(), 4);
1875        assert_eq!(tokens[0], (SyntaxKind::STRING, "Name"));
1876        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1877        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1878        assert_eq!(tokens[3], (SyntaxKind::STRING, "example-project"));
1879    }
1880
1881    #[test]
1882    fn test_hyphen_sequence_vs_scalar() {
1883        // Test that sequence markers are still recognized correctly
1884        let sequence_input = "- example-item";
1885        let tokens = lex(sequence_input);
1886
1887        println!("Sequence hyphen tokens:");
1888        for (i, (kind, text)) in tokens.iter().enumerate() {
1889            println!("  {}: {:?} = {:?}", i, kind, text);
1890        }
1891
1892        // Should get: DASH("-"), WHITESPACE(" "), STRING("example-item")
1893        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1894        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1895        assert_eq!(tokens[2], (SyntaxKind::STRING, "example-item"));
1896
1897        // Test scalar with hyphens in different contexts
1898        let scalar_input = "package-name: my-awesome-package";
1899        let tokens = lex(scalar_input);
1900
1901        println!("Package hyphen tokens:");
1902        for (i, (kind, text)) in tokens.iter().enumerate() {
1903            println!("  {}: {:?} = {:?}", i, kind, text);
1904        }
1905
1906        // Should get: STRING("package-name"), COLON(":"), WHITESPACE(" "), STRING("my-awesome-package")
1907        assert_eq!(tokens.len(), 4);
1908        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
1909        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package"));
1910    }
1911
1912    #[test]
1913    fn test_flow_style() {
1914        // Flow sequence
1915        let tokens = lex("[1, 2, 3]");
1916        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
1917        assert_eq!(tokens[1], (SyntaxKind::INT, "1"));
1918        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
1919        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1920        assert_eq!(tokens[4], (SyntaxKind::INT, "2"));
1921        assert_eq!(tokens[5], (SyntaxKind::COMMA, ","));
1922        assert_eq!(tokens[6], (SyntaxKind::WHITESPACE, " "));
1923        assert_eq!(tokens[7], (SyntaxKind::INT, "3"));
1924        assert_eq!(tokens[8], (SyntaxKind::RIGHT_BRACKET, "]"));
1925
1926        // Flow mapping
1927        let tokens = lex("{a: 1, b: 2}");
1928        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
1929        assert_eq!(tokens[1], (SyntaxKind::STRING, "a"));
1930        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
1931        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1932        assert_eq!(tokens[4], (SyntaxKind::INT, "1"));
1933    }
1934
1935    #[test]
1936    fn test_comments() {
1937        let input = "key: value # this is a comment\n# full line comment";
1938        let tokens = lex(input);
1939
1940        // Find comment tokens
1941        let comments: Vec<_> = tokens
1942            .iter()
1943            .filter(|(kind, _)| *kind == SyntaxKind::COMMENT)
1944            .collect();
1945
1946        assert_eq!(comments.len(), 2);
1947        assert_eq!(comments[0].1, "# this is a comment");
1948        assert_eq!(comments[1].1, "# full line comment");
1949    }
1950
1951    #[test]
1952    fn test_multiline_scalar() {
1953        let input = "key: value\n  continued";
1954        let tokens = lex(input);
1955
1956        // Check for indent token
1957        let indents: Vec<_> = tokens
1958            .iter()
1959            .filter(|(kind, _)| *kind == SyntaxKind::INDENT)
1960            .collect();
1961        assert_eq!(indents.len(), 1);
1962        assert_eq!(indents[0].1, "  ");
1963    }
1964
1965    #[test]
1966    fn test_quoted_strings() {
1967        let input = r#"single: 'quoted'
1968double: "quoted""#;
1969        let tokens = lex(input);
1970
1971        // Find quoted string tokens - after fix, quotes are included in STRING tokens
1972        let quoted_strings: Vec<_> = tokens
1973            .iter()
1974            .filter(|(kind, text)| {
1975                *kind == SyntaxKind::STRING && (text.starts_with('\'') || text.starts_with('"'))
1976            })
1977            .collect();
1978        assert_eq!(quoted_strings.len(), 2); // single and double quoted strings
1979
1980        // Verify content (order depends on which appears first in the source)
1981        let quoted_texts: Vec<&str> = {
1982            let mut v: Vec<&str> = quoted_strings.iter().map(|(_, t)| *t).collect();
1983            v.sort();
1984            v
1985        };
1986        assert_eq!(quoted_texts, ["\"quoted\"", "'quoted'"]);
1987    }
1988
1989    #[test]
1990    fn test_document_markers() {
1991        let input = "---\nkey: value\n...";
1992        let tokens = lex(input);
1993
1994        println!("Document tokens:");
1995        for (i, (kind, text)) in tokens.iter().enumerate() {
1996            println!("  {}: {:?} = {:?}", i, kind, text);
1997        }
1998
1999        // Check for document start and end markers
2000        let doc_start_count = tokens
2001            .iter()
2002            .filter(|(kind, _)| *kind == SyntaxKind::DOC_START)
2003            .count();
2004        let doc_end_count = tokens
2005            .iter()
2006            .filter(|(kind, _)| *kind == SyntaxKind::DOC_END)
2007            .count();
2008        assert_eq!(doc_start_count, 1);
2009        assert_eq!(doc_end_count, 1);
2010    }
2011
2012    #[test]
2013    fn test_empty_input() {
2014        let input = "";
2015        let tokens = lex(input);
2016        println!("Empty input tokens: {:?}", tokens);
2017        assert_eq!(tokens.len(), 0);
2018    }
2019
2020    #[test]
2021    fn test_anchors_and_aliases() {
2022        // Test anchor definition
2023        let input = "key: &anchor_name value";
2024        let tokens = lex(input);
2025        println!("Anchor tokens: {:?}", tokens);
2026
2027        let anchors: Vec<_> = tokens
2028            .iter()
2029            .filter(|(kind, _)| *kind == SyntaxKind::ANCHOR)
2030            .collect();
2031        assert_eq!(anchors.len(), 1);
2032        assert_eq!(anchors[0].1, "&anchor_name");
2033
2034        // Test alias reference
2035        let input = "key: *reference_name";
2036        let tokens = lex(input);
2037        println!("Reference tokens: {:?}", tokens);
2038
2039        let references: Vec<_> = tokens
2040            .iter()
2041            .filter(|(kind, _)| *kind == SyntaxKind::REFERENCE)
2042            .collect();
2043        assert_eq!(references.len(), 1);
2044        assert_eq!(references[0].1, "*reference_name");
2045
2046        // Test bare ampersand and asterisk (should not be treated as anchors/references)
2047        let input = "key: & *";
2048        let tokens = lex(input);
2049
2050        let ampersands: Vec<_> = tokens
2051            .iter()
2052            .filter(|(kind, _)| *kind == SyntaxKind::AMPERSAND)
2053            .collect();
2054        assert_eq!(ampersands.len(), 1);
2055
2056        let asterisks: Vec<_> = tokens
2057            .iter()
2058            .filter(|(kind, _)| *kind == SyntaxKind::ASTERISK)
2059            .collect();
2060        assert_eq!(asterisks.len(), 1);
2061    }
2062
2063    #[test]
2064    fn test_indicators_mid_plain_scalar_are_scalar_content() {
2065        // Per YAML 1.2 §7.1 the node-property indicators `&`, `*`, `!`
2066        // only apply when a scalar starts with them (followed by an
2067        // identifier). Mid-scalar they are ordinary content and must
2068        // not create phantom ANCHOR / REFERENCE / TAG tokens.
2069        // Including space-separated: a preceding space does not start a new
2070        // node, so `k: a !b` is still one plain scalar.
2071        for input in [
2072            "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",
2073            "k: a *b", "- a !b",
2074        ] {
2075            let tokens = lex(input);
2076            let bad: Vec<_> = tokens
2077                .iter()
2078                .filter(|(kind, _)| {
2079                    matches!(
2080                        kind,
2081                        SyntaxKind::ANCHOR
2082                            | SyntaxKind::REFERENCE
2083                            | SyntaxKind::TAG
2084                            | SyntaxKind::QUESTION
2085                    )
2086                })
2087                .collect();
2088            assert!(
2089                bad.is_empty(),
2090                "{:?} produced structural tokens mid-scalar: {:?}",
2091                input,
2092                bad
2093            );
2094        }
2095        // And a real anchor still tokenizes as ANCHOR.
2096        let tokens = lex("a: &real 1");
2097        assert!(
2098            tokens.iter().any(|(k, _)| *k == SyntaxKind::ANCHOR),
2099            "real anchor should still be recognized: {:?}",
2100            tokens
2101        );
2102    }
2103
2104    #[test]
2105    fn test_merge_key_token() {
2106        // Test merge key '<<'
2107        let input = "<<: *defaults";
2108        let tokens = lex(input);
2109
2110        let merge_keys: Vec<_> = tokens
2111            .iter()
2112            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2113            .collect();
2114        assert_eq!(merge_keys.len(), 1);
2115        assert_eq!(merge_keys[0].1, "<<");
2116
2117        // Test single '<' is not a merge key
2118        let input2 = "key: < value";
2119        let tokens2 = lex(input2);
2120
2121        let merge_keys2: Vec<_> = tokens2
2122            .iter()
2123            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2124            .collect();
2125        assert_eq!(merge_keys2.len(), 0, "Single < should not be a merge key");
2126    }
2127
2128    #[test]
2129    fn test_angle_prefix_is_plain_scalar_not_merge_key() {
2130        let tokens = lex("<<foo: 1");
2131        let merge_keys: Vec<_> = tokens
2132            .iter()
2133            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2134            .collect();
2135        assert_eq!(
2136            merge_keys.len(),
2137            0,
2138            "<<foo should be a plain key, got {:?}",
2139            tokens
2140        );
2141        assert!(
2142            tokens
2143                .iter()
2144                .any(|(kind, text)| *kind != SyntaxKind::MERGE_KEY && *text == "<<foo"),
2145            "expected <<foo scalar, got {:?}",
2146            tokens
2147        );
2148
2149        let tokens = lex("{<<foo: 1, more: x}");
2150        assert_eq!(
2151            tokens
2152                .iter()
2153                .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2154                .count(),
2155            0,
2156            "flow <<foo should not be MERGE_KEY: {:?}",
2157            tokens
2158        );
2159
2160        let tokens = lex("<<: *defaults");
2161        assert_eq!(
2162            tokens
2163                .iter()
2164                .filter(|(kind, text)| *kind == SyntaxKind::MERGE_KEY && *text == "<<")
2165                .count(),
2166            1
2167        );
2168
2169        use crate::YamlFile;
2170        use std::str::FromStr;
2171        let file = YamlFile::from_str("<<foo: 1\nbar: 2\n").unwrap();
2172        let mapping = file.document().unwrap().as_mapping().unwrap();
2173        assert_eq!(mapping.keys().count(), 2);
2174        assert_eq!(
2175            mapping
2176                .get("<<foo")
2177                .unwrap()
2178                .as_scalar()
2179                .unwrap()
2180                .as_string(),
2181            "1"
2182        );
2183    }
2184
2185    /// `<<` is a merge key only when the plain scalar there is exactly
2186    /// `<<`: a `#` or `:` glued to it, or more words after a space, are
2187    /// part of the scalar.
2188    #[test]
2189    fn test_merge_key_is_a_whole_plain_scalar() {
2190        use SyntaxKind::*;
2191        assert_eq!(
2192            lex("<<#: 1"),
2193            [(STRING, "<<#"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
2194        );
2195        assert_eq!(
2196            lex("<<:x: 1"),
2197            [
2198                (STRING, "<<:x"),
2199                (COLON, ":"),
2200                (WHITESPACE, " "),
2201                (INT, "1")
2202            ]
2203        );
2204        assert_eq!(
2205            lex("<< x: 1"),
2206            [
2207                (STRING, "<< x"),
2208                (COLON, ":"),
2209                (WHITESPACE, " "),
2210                (INT, "1")
2211            ]
2212        );
2213        assert_eq!(
2214            lex("<<]: 1"),
2215            [(STRING, "<<]"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
2216        );
2217        assert_eq!(
2218            lex("<< #c\n"),
2219            [
2220                (MERGE_KEY, "<<"),
2221                (WHITESPACE, " "),
2222                (COMMENT, "#c"),
2223                (NEWLINE, "\n")
2224            ]
2225        );
2226        assert_eq!(
2227            lex("{<<,<<: 1}"),
2228            [
2229                (LEFT_BRACE, "{"),
2230                (MERGE_KEY, "<<"),
2231                (COMMA, ","),
2232                (MERGE_KEY, "<<"),
2233                (COLON, ":"),
2234                (WHITESPACE, " "),
2235                (INT, "1"),
2236                (RIGHT_BRACE, "}")
2237            ]
2238        );
2239    }
2240
2241    #[test]
2242    fn test_angle_prefixed_scalar_uses_plain_scalar_rules() {
2243        // A key starting with `<` follows the same plain-scalar rules as any
2244        // other key: `-`, `!`, `&` and a non-indicator `:` are ordinary
2245        // content, not token breaks.
2246        use crate::YamlFile;
2247        use std::str::FromStr;
2248
2249        for key in [
2250            "<foo-bar",
2251            "<<foo-bar",
2252            "<foo!x",
2253            "<<foo!x",
2254            "<foo&x",
2255            "<<foo&x",
2256            "<foo:x",
2257            "<<foo:x",
2258        ] {
2259            let src = format!("{}: 1\n", key);
2260            let tokens = lex(&src);
2261            assert_eq!(
2262                tokens
2263                    .iter()
2264                    .filter(|(kind, _)| *kind != SyntaxKind::WHITESPACE)
2265                    .map(|(_, text)| *text)
2266                    .collect::<Vec<_>>(),
2267                vec![key, ":", "1", "\n"],
2268                "unexpected tokens for {:?}",
2269                src
2270            );
2271
2272            let file = YamlFile::from_str(&src).unwrap();
2273            assert_eq!(file.to_string(), src);
2274            let mapping = file.document().unwrap().as_mapping().unwrap();
2275            assert_eq!(
2276                mapping.get(key).unwrap().as_scalar().unwrap().as_string(),
2277                "1"
2278            );
2279        }
2280    }
2281
2282    #[test]
2283    fn test_plus_token() {
2284        // Test plus as standalone token
2285        let input = "key: |+ value";
2286        let tokens = lex(input);
2287
2288        let plus_tokens: Vec<_> = tokens
2289            .iter()
2290            .filter(|(kind, _)| *kind == SyntaxKind::PLUS)
2291            .collect();
2292        assert_eq!(plus_tokens.len(), 1);
2293        assert_eq!(plus_tokens[0].1, "+");
2294    }
2295
2296    /// A `+` is a chomping indicator only where it closes a block scalar
2297    /// header, and otherwise starts a plain scalar.
2298    #[test]
2299    fn test_plus_tokens() {
2300        use SyntaxKind::*;
2301        let cases: Vec<(&str, Vec<(SyntaxKind, &str)>)> = vec![
2302            (
2303                "+ x: v\n",
2304                vec![
2305                    (STRING, "+ x"),
2306                    (COLON, ":"),
2307                    (WHITESPACE, " "),
2308                    (STRING, "v"),
2309                    (NEWLINE, "\n"),
2310                ],
2311            ),
2312            (
2313                "+\tx: v\n",
2314                vec![
2315                    (STRING, "+\tx"),
2316                    (COLON, ":"),
2317                    (WHITESPACE, " "),
2318                    (STRING, "v"),
2319                    (NEWLINE, "\n"),
2320                ],
2321            ),
2322            (
2323                "+ \n",
2324                vec![(STRING, "+"), (WHITESPACE, " "), (NEWLINE, "\n")],
2325            ),
2326            (
2327                "+ #c\n",
2328                vec![
2329                    (STRING, "+"),
2330                    (WHITESPACE, " "),
2331                    (COMMENT, "#c"),
2332                    (NEWLINE, "\n"),
2333                ],
2334            ),
2335            ("+[b]\n", vec![(STRING, "+[b]"), (NEWLINE, "\n")]),
2336            (
2337                "[+[b]]\n",
2338                vec![
2339                    (LEFT_BRACKET, "["),
2340                    (STRING, "+"),
2341                    (LEFT_BRACKET, "["),
2342                    (STRING, "b"),
2343                    (RIGHT_BRACKET, "]"),
2344                    (RIGHT_BRACKET, "]"),
2345                    (NEWLINE, "\n"),
2346                ],
2347            ),
2348            ("+'a'\n", vec![(STRING, "+'a'"), (NEWLINE, "\n")]),
2349            ("+?\n", vec![(STRING, "+?"), (NEWLINE, "\n")]),
2350            ("+|\n", vec![(STRING, "+|"), (NEWLINE, "\n")]),
2351            (
2352                "+#: v\n",
2353                vec![
2354                    (STRING, "+#"),
2355                    (COLON, ":"),
2356                    (WHITESPACE, " "),
2357                    (STRING, "v"),
2358                    (NEWLINE, "\n"),
2359                ],
2360            ),
2361            (
2362                "+ #: v\n",
2363                vec![
2364                    (STRING, "+"),
2365                    (WHITESPACE, " "),
2366                    (COMMENT, "#: v"),
2367                    (NEWLINE, "\n"),
2368                ],
2369            ),
2370            (
2371                "+: v\n",
2372                vec![
2373                    (STRING, "+"),
2374                    (COLON, ":"),
2375                    (WHITESPACE, " "),
2376                    (STRING, "v"),
2377                    (NEWLINE, "\n"),
2378                ],
2379            ),
2380            (
2381                "+ : v\n",
2382                vec![
2383                    (STRING, "+"),
2384                    (WHITESPACE, " "),
2385                    (COLON, ":"),
2386                    (WHITESPACE, " "),
2387                    (STRING, "v"),
2388                    (NEWLINE, "\n"),
2389                ],
2390            ),
2391            ("+:x\n", vec![(STRING, "+:x"), (NEWLINE, "\n")]),
2392            (
2393                "+\nx\n",
2394                vec![
2395                    (STRING, "+"),
2396                    (NEWLINE, "\n"),
2397                    (STRING, "x"),
2398                    (NEWLINE, "\n"),
2399                ],
2400            ),
2401            (
2402                "+\r\nx\n",
2403                vec![
2404                    (STRING, "+"),
2405                    (NEWLINE, "\r\n"),
2406                    (STRING, "x"),
2407                    (NEWLINE, "\n"),
2408                ],
2409            ),
2410            (
2411                "- |+\n  x\n",
2412                vec![
2413                    (DASH, "-"),
2414                    (WHITESPACE, " "),
2415                    (PIPE, "|"),
2416                    (PLUS, "+"),
2417                    (NEWLINE, "\n"),
2418                    (INDENT, "  "),
2419                    (STRING, "x"),
2420                    (NEWLINE, "\n"),
2421                ],
2422            ),
2423            (
2424                "/|+: v\n",
2425                vec![
2426                    (STRING, "/|+"),
2427                    (COLON, ":"),
2428                    (WHITESPACE, " "),
2429                    (STRING, "v"),
2430                    (NEWLINE, "\n"),
2431                ],
2432            ),
2433            ("i:|+\n", vec![(STRING, "i:|+"), (NEWLINE, "\n")]),
2434            ("+.INF\n", vec![(FLOAT, "+.INF"), (NEWLINE, "\n")]),
2435            (
2436                "{+, a}\n",
2437                vec![
2438                    (LEFT_BRACE, "{"),
2439                    (STRING, "+"),
2440                    (COMMA, ","),
2441                    (WHITESPACE, " "),
2442                    (STRING, "a"),
2443                    (RIGHT_BRACE, "}"),
2444                    (NEWLINE, "\n"),
2445                ],
2446            ),
2447            ("+,\n", vec![(STRING, "+,"), (NEWLINE, "\n")]),
2448            (
2449                "a: 1+2\n",
2450                vec![
2451                    (STRING, "a"),
2452                    (COLON, ":"),
2453                    (WHITESPACE, " "),
2454                    (STRING, "1+2"),
2455                    (NEWLINE, "\n"),
2456                ],
2457            ),
2458            ("i:|2+\n", vec![(STRING, "i:|2+"), (NEWLINE, "\n")]),
2459            (
2460                "x: a |2+\n",
2461                vec![
2462                    (STRING, "x"),
2463                    (COLON, ":"),
2464                    (WHITESPACE, " "),
2465                    (STRING, "a |2+"),
2466                    (NEWLINE, "\n"),
2467                ],
2468            ),
2469            (
2470                "k: |2+\n",
2471                vec![
2472                    (STRING, "k"),
2473                    (COLON, ":"),
2474                    (WHITESPACE, " "),
2475                    (PIPE, "|"),
2476                    (INT, "2"),
2477                    (PLUS, "+"),
2478                    (NEWLINE, "\n"),
2479                ],
2480            ),
2481            (
2482                "- |2+\n",
2483                vec![
2484                    (DASH, "-"),
2485                    (WHITESPACE, " "),
2486                    (PIPE, "|"),
2487                    (INT, "2"),
2488                    (PLUS, "+"),
2489                    (NEWLINE, "\n"),
2490                ],
2491            ),
2492        ];
2493        for (input, expected) in cases {
2494            assert_eq!(lex(input), expected, "lexing {input:?}");
2495        }
2496    }
2497
2498    /// A header's chomping `+` stands alone after node properties and
2499    /// an explicit key's `?` too.
2500    #[test]
2501    fn test_chomping_plus_after_properties() {
2502        use SyntaxKind::*;
2503        for (input, before) in [
2504            ("? |2+\n", vec![(QUESTION, "?")]),
2505            ("!t |2+\n", vec![(TAG, "!t")]),
2506            ("&a |2+\n", vec![(ANCHOR, "&a")]),
2507        ] {
2508            let mut expected = before;
2509            expected.extend([
2510                (WHITESPACE, " "),
2511                (PIPE, "|"),
2512                (INT, "2"),
2513                (PLUS, "+"),
2514                (NEWLINE, "\n"),
2515            ]);
2516            assert_eq!(lex(input), expected, "lexing {input:?}");
2517        }
2518    }
2519
2520    #[test]
2521    fn test_block_scalar_indicators() {
2522        // Test literal with chomping indicators
2523        let input1 = "key: |+ content";
2524        let tokens1 = lex(input1);
2525
2526        assert!(tokens1
2527            .iter()
2528            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2529        assert!(tokens1
2530            .iter()
2531            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2532
2533        // Test folded with chomping indicators
2534        let input2 = "key: >- content";
2535        let tokens2 = lex(input2);
2536
2537        assert!(tokens2
2538            .iter()
2539            .any(|(kind, text)| *kind == SyntaxKind::GREATER && *text == ">"));
2540        assert!(tokens2
2541            .iter()
2542            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
2543
2544        // Test with explicit indentation
2545        let input3 = "key: |2+ content";
2546        let tokens3 = lex(input3);
2547
2548        assert!(tokens3
2549            .iter()
2550            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2551        assert!(tokens3
2552            .iter()
2553            .any(|(kind, text)| *kind == SyntaxKind::INT && *text == "2"));
2554        assert!(tokens3
2555            .iter()
2556            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2557    }
2558
2559    #[test]
2560    fn test_special_characters_in_block_content() {
2561        let input = "line with - and + and : characters";
2562        let tokens = lex(input);
2563
2564        // The `-` and `+` are content, so the whole key is one plain scalar:
2565        // saphyr reads this as `line with - and + and` mapped to
2566        // `characters`.
2567        assert!(tokens
2568            .iter()
2569            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "line with - and + and"));
2570        assert!(!tokens.iter().any(|(kind, _)| *kind == SyntaxKind::PLUS));
2571
2572        // The mapping colon still stands alone.
2573        assert!(tokens
2574            .iter()
2575            .any(|(kind, text)| *kind == SyntaxKind::COLON && *text == ":"));
2576
2577        // Round-trip: concatenating tokens reproduces input.
2578        let joined: String = tokens.iter().map(|(_, t)| *t).collect();
2579        assert_eq!(joined, input);
2580    }
2581
2582    #[test]
2583    fn test_token_recognition() {
2584        let input = "key: |2+ \n  content with - and : and > chars\n  more content";
2585        let tokens = lex(input);
2586
2587        // Print tokens for debugging
2588        println!("Tokens:");
2589        for (i, (kind, text)) in tokens.iter().enumerate() {
2590            println!("  {}: {:?} = {:?}", i, kind, text);
2591        }
2592
2593        // Verify all expected token kinds are present (input: "key: |2+ \n  content with - and : and > chars\n  more content")
2594        let count = |k: SyntaxKind| tokens.iter().filter(|(kind, _)| *kind == k).count();
2595        // Two colons: one for the mapping ("key:"), one in the value content ("and :")
2596        assert_eq!(count(SyntaxKind::COLON), 2);
2597        assert_eq!(count(SyntaxKind::PIPE), 1); // "|"
2598        assert_eq!(count(SyntaxKind::INT), 1); // "2"
2599        assert_eq!(count(SyntaxKind::PLUS), 1); // "+"
2600                                                // The `>` here sits inside the block scalar's content, not at a
2601                                                // node start, so it is scalar text rather than a folded header.
2602        assert_eq!(count(SyntaxKind::GREATER), 0);
2603        assert_eq!(count(SyntaxKind::NEWLINE), 2); // after "|2+" line and after first content line
2604        assert_eq!(count(SyntaxKind::INDENT), 2); // "  " before each content line
2605                                                  // Multiple STRING tokens: "key", content words, and the hyphen
2606        assert!(count(SyntaxKind::STRING) >= 1, "expected STRING tokens");
2607
2608        assert_eq!(
2609            tokens
2610                .iter()
2611                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "content with - and")
2612                .count(),
2613            1
2614        );
2615    }
2616
2617    #[test]
2618    fn test_dash_handling() {
2619        // Test 1: Document start marker
2620        let input = "---\nkey: value";
2621        let tokens = lex(input);
2622        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2623
2624        // Test 2: Document with just three dashes
2625        let input = "---";
2626        let tokens = lex(input);
2627        assert_eq!(tokens.len(), 1);
2628        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2629
2630        // Test 3: Two dashes (not a document marker)
2631        let input = "--";
2632        let tokens = lex(input);
2633        assert_eq!(tokens, vec![(SyntaxKind::STRING, "--")]);
2634
2635        // Test 4: Four dashes
2636        let input = "----";
2637        let tokens = lex(input);
2638        assert_eq!(tokens, vec![(SyntaxKind::STRING, "----")]);
2639    }
2640
2641    #[test]
2642    fn test_dash_in_different_scalar_contexts() {
2643        // Test kebab-case identifiers
2644        let input = "package-name: my-awesome-package-v2";
2645        let tokens = lex(input);
2646        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
2647        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2648        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2649        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package-v2"));
2650
2651        // Test UUID-like strings
2652        let input = "id: 123e4567-e89b-12d3-a456-426614174000";
2653        let tokens = lex(input);
2654        assert_eq!(tokens[0], (SyntaxKind::STRING, "id"));
2655        assert_eq!(
2656            tokens[3],
2657            (SyntaxKind::STRING, "123e4567-e89b-12d3-a456-426614174000")
2658        );
2659
2660        // Test command-line arguments. The two words are one plain
2661        // scalar, so they lex as a single token rather than being
2662        // rejoined by the parser.
2663        let input = "args: --verbose --log-level=debug";
2664        let tokens = lex(input);
2665        assert_eq!(
2666            tokens[3],
2667            (SyntaxKind::STRING, "--verbose --log-level=debug")
2668        );
2669
2670        // Test negative numbers
2671        let input = "temperature: -40";
2672        let tokens = lex(input);
2673        // Negative numbers are tokenized as INT tokens
2674        assert_eq!(
2675            tokens
2676                .iter()
2677                .filter(|(kind, text)| *kind == SyntaxKind::INT && *text == "-40")
2678                .count(),
2679            1
2680        );
2681
2682        // Test ranges
2683        let input = "range: 1-10";
2684        let tokens = lex(input);
2685        assert_eq!(
2686            tokens
2687                .iter()
2688                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1-10")
2689                .count(),
2690            1
2691        );
2692    }
2693
2694    #[test]
2695    fn test_sequence_markers_with_indentation() {
2696        // Test basic sequence
2697        let input = "- item1\n- item2";
2698        let tokens = lex(input);
2699        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2700        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
2701        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
2702
2703        // Test indented sequence
2704        let input = "  - item1\n  - item2";
2705        let tokens = lex(input);
2706        assert_eq!(tokens[0], (SyntaxKind::INDENT, "  "));
2707        assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
2708
2709        // The indented lines continue the plain scalar `item1`, so their
2710        // dashes are content: the value is `item1 - nested1 - nested2`.
2711        let input = "- item1\n  - nested1\n  - nested2\n- item2";
2712        let tokens = lex(input);
2713        let dash_tokens: Vec<_> = tokens
2714            .iter()
2715            .filter(|(kind, _)| *kind == SyntaxKind::DASH)
2716            .collect();
2717        assert_eq!(dash_tokens.len(), 2);
2718
2719        // Test sequence with hyphenated values
2720        let input = "- first-item\n- second-item";
2721        let tokens = lex(input);
2722        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2723        assert_eq!(tokens[2], (SyntaxKind::STRING, "first-item"));
2724        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
2725        assert_eq!(tokens[6], (SyntaxKind::STRING, "second-item"));
2726    }
2727
2728    #[test]
2729    fn test_dash_after_colon() {
2730        // Test hyphen immediately after colon
2731        // According to YAML spec, "key:-value" is a single plain scalar
2732        // because the colon is not followed by whitespace
2733        let input = "key:-value";
2734        let tokens = lex(input);
2735        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:-value"));
2736
2737        // Test with space - this creates a mapping
2738        let input = "key: -value";
2739        let tokens = lex(input);
2740        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2741        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2742        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2743        assert_eq!(tokens[3], (SyntaxKind::STRING, "-value"));
2744    }
2745
2746    #[test]
2747    fn test_yaml_spec_compliant_colon_handling() {
2748        // Test that colons are handled according to YAML spec:
2749        // - Colon followed by whitespace indicates mapping
2750        // - Colon not followed by whitespace is part of plain scalar
2751
2752        // URLs should be single scalars (no space after colon)
2753        let input = "http://example.com:8080/path";
2754        let tokens = lex(input);
2755        assert_eq!(tokens.len(), 1);
2756        assert_eq!(
2757            tokens[0],
2758            (SyntaxKind::STRING, "http://example.com:8080/path")
2759        );
2760
2761        // Timestamps should be single scalars
2762        let input = "2024:12:31:23:59:59";
2763        let tokens = lex(input);
2764        assert_eq!(tokens.len(), 1);
2765        assert_eq!(tokens[0], (SyntaxKind::STRING, "2024:12:31:23:59:59"));
2766
2767        // Key-value pairs need space after colon
2768        let input = "key: value";
2769        let tokens = lex(input);
2770        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2771        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2772        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2773        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
2774
2775        // Without space, it's a single scalar
2776        let input = "key:value";
2777        let tokens = lex(input);
2778        assert_eq!(tokens.len(), 1);
2779        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:value"));
2780
2781        // Multiple colons without spaces
2782        let input = "a:b:c:d";
2783        let tokens = lex(input);
2784        assert_eq!(tokens.len(), 1);
2785        assert_eq!(tokens[0], (SyntaxKind::STRING, "a:b:c:d"));
2786    }
2787
2788    #[test]
2789    fn test_block_scalar_with_chomping() {
2790        // Helper to count tokens by kind
2791        let count_kind = |toks: &[(SyntaxKind, &str)], k: SyntaxKind| {
2792            toks.iter().filter(|(kind, _)| *kind == k).count()
2793        };
2794
2795        // Test literal block scalar with strip chomping
2796        let input = "text: |-\n  content";
2797        let tokens = lex(input);
2798        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2799        assert_eq!(
2800            tokens
2801                .iter()
2802                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2803                .count(),
2804            1
2805        );
2806
2807        // Test literal block scalar with keep chomping
2808        let input = "text: |+\n  content";
2809        let tokens = lex(input);
2810        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2811        assert_eq!(count_kind(&tokens, SyntaxKind::PLUS), 1);
2812
2813        // Test folded block scalar with strip chomping
2814        let input = "text: >-\n  content";
2815        let tokens = lex(input);
2816        assert_eq!(count_kind(&tokens, SyntaxKind::GREATER), 1);
2817        assert_eq!(
2818            tokens
2819                .iter()
2820                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2821                .count(),
2822            1
2823        );
2824
2825        // Test with explicit indentation and chomping
2826        let input = "text: |2-\n  content";
2827        let tokens = lex(input);
2828        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2829        // The "2-" after pipe gets read as one token because hyphens in scalars are included
2830        let has_2_token = tokens.iter().any(|(kind, text)| {
2831            (*kind == SyntaxKind::STRING || *kind == SyntaxKind::INT) && text.contains("2")
2832        });
2833        assert!(has_2_token, "expected a token containing '2'");
2834    }
2835
2836    #[test]
2837    fn test_dash_edge_cases() {
2838        // Test trailing hyphen
2839        let input = "value-";
2840        let tokens = lex(input);
2841        assert_eq!(tokens[0], (SyntaxKind::STRING, "value-"));
2842
2843        // Test leading hyphen (not a sequence marker)
2844        let input = "-value";
2845        let tokens = lex(input);
2846        assert_eq!(tokens[0], (SyntaxKind::STRING, "-value"));
2847
2848        // Test multiple consecutive hyphens in scalar
2849        let input = "key: a---b";
2850        let tokens = lex(input);
2851        assert_eq!(
2852            tokens
2853                .iter()
2854                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "a---b")
2855                .count(),
2856            1
2857        );
2858
2859        // Test hyphen at end of line
2860        let input = "key: value-\nnext: item";
2861        let tokens = lex(input);
2862        assert!(tokens
2863            .iter()
2864            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "value-"));
2865
2866        // Test mix of dashes and underscores
2867        let input = "snake_case-with-dash_mix";
2868        let tokens = lex(input);
2869        assert_eq!(tokens[0], (SyntaxKind::STRING, "snake_case-with-dash_mix"));
2870    }
2871
2872    #[test]
2873    fn test_whitespace_validation_tab_indentation() {
2874        // Test tab character validation
2875        let input_with_tabs = "key: value\n\tindented_key: indented_value";
2876        let (tokens, errors) = lex_with_validation(input_with_tabs);
2877
2878        // Should have detected tab indentation error
2879        assert_eq!(errors.len(), 1);
2880        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2881        assert_eq!(
2882            errors[0].message,
2883            "Tab character used for indentation (forbidden in YAML)"
2884        );
2885
2886        // But should still tokenize correctly
2887        assert!(tokens
2888            .iter()
2889            .any(|(kind, text)| *kind == SyntaxKind::INDENT && text.contains('\t')));
2890    }
2891
2892    #[test]
2893    fn test_whitespace_validation_line_endings() {
2894        // Test mixed line ending detection
2895        let input_mixed = "line1\nline2\r\nline3\rline4";
2896        let config = ValidationConfig {
2897            enforce_consistent_line_endings: true,
2898            max_line_length: None,
2899        };
2900        let (tokens, errors) = lex_with_validation_config(input_mixed, &config);
2901
2902        // Should detect mixed line endings
2903        assert!(errors
2904            .iter()
2905            .any(|e| e.category == WhitespaceErrorCategory::MixedLineEndings));
2906
2907        // Should still tokenize all line endings
2908        let newlines: Vec<_> = tokens
2909            .iter()
2910            .filter(|(kind, _)| *kind == SyntaxKind::NEWLINE)
2911            .collect();
2912        assert_eq!(newlines.len(), 3); // Three line endings
2913        assert_eq!(newlines[0].1, "\n");
2914        assert_eq!(newlines[1].1, "\r\n");
2915        assert_eq!(newlines[2].1, "\r");
2916    }
2917
2918    #[test]
2919    fn test_whitespace_validation_line_length() {
2920        // Test line length validation
2921        let long_line = format!("key: {}", "a".repeat(150));
2922        let config = ValidationConfig {
2923            enforce_consistent_line_endings: false,
2924            max_line_length: Some(120),
2925        };
2926        let (_, errors) = lex_with_validation_config(&long_line, &config);
2927
2928        // Should detect line too long
2929        assert_eq!(errors.len(), 1);
2930        assert_eq!(errors[0].category, WhitespaceErrorCategory::LineTooLong);
2931        assert_eq!(errors[0].message, "Line too long (155 > 120 characters)");
2932    }
2933
2934    #[test]
2935    fn test_whitespace_validation_disabled() {
2936        // Test with validation disabled
2937        let input_with_issues = "key: value\n\tindented: with_tabs\n";
2938        let config = ValidationConfig {
2939            enforce_consistent_line_endings: false,
2940            max_line_length: None,
2941        };
2942        let (tokens, errors) = lex_with_validation_config(input_with_issues, &config);
2943
2944        // Should still detect tab indentation (always enforced in YAML)
2945        assert_eq!(errors.len(), 1);
2946        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2947
2948        // Should tokenize normally
2949        assert!(!tokens.is_empty());
2950    }
2951
2952    #[test]
2953    fn test_dash_in_flow_collections() {
2954        // Test dash in flow sequence
2955        let input = "[item-one, item-two]";
2956        let tokens = lex(input);
2957        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
2958        assert_eq!(tokens[1], (SyntaxKind::STRING, "item-one"));
2959        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
2960        assert_eq!(tokens[4], (SyntaxKind::STRING, "item-two"));
2961        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACKET, "]"));
2962
2963        // Test dash in flow mapping
2964        let input = "{kebab-key: kebab-value}";
2965        let tokens = lex(input);
2966        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
2967        assert_eq!(tokens[1], (SyntaxKind::STRING, "kebab-key"));
2968        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
2969        assert_eq!(tokens[4], (SyntaxKind::STRING, "kebab-value"));
2970        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACE, "}"));
2971    }
2972
2973    #[test]
2974    fn test_dash_with_quotes() {
2975        // Quoted strings should preserve everything inside as STRING tokens
2976        let input = r#"key: "- not a sequence marker""#;
2977        let tokens = lex(input);
2978        assert_eq!(
2979            tokens
2980                .iter()
2981                .filter(|(kind, text)| {
2982                    *kind == SyntaxKind::STRING && *text == "\"- not a sequence marker\""
2983                })
2984                .count(),
2985            1
2986        );
2987
2988        let input = r#"key: '- also not a sequence marker'"#;
2989        let tokens = lex(input);
2990        assert_eq!(
2991            tokens
2992                .iter()
2993                .filter(|(kind, text)| {
2994                    *kind == SyntaxKind::STRING && *text == "'- also not a sequence marker'"
2995                })
2996                .count(),
2997            1
2998        );
2999    }
3000
3001    #[test]
3002    fn test_dash_in_multiline_values() {
3003        // Test multiline with dashes: plain scalars absorb intra-line
3004        // whitespace, so the first line becomes one STRING token ending
3005        // in the trailing hyphen (hyphen-at-end-of-word is scalar content,
3006        // not a sequence marker mid-scalar).
3007        let input = "description: This is a multi-\n  line value with dashes";
3008        let tokens = lex(input);
3009        assert!(tokens
3010            .iter()
3011            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "This is a multi-"));
3012
3013        // A line continuing a plain scalar is content, dash included.
3014        let input = "text: value\n  - but this is not a sequence";
3015        let tokens = lex(input);
3016        assert_eq!(
3017            &tokens[tokens.len() - 2..],
3018            [
3019                (SyntaxKind::INDENT, "  "),
3020                (SyntaxKind::STRING, "- but this is not a sequence"),
3021            ]
3022        );
3023    }
3024
3025    #[test]
3026    fn test_dash_special_yaml_values() {
3027        // Test that special YAML values with dashes work
3028        let input = "date: 2024-01-15";
3029        let tokens = lex(input);
3030        assert!(tokens
3031            .iter()
3032            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15"));
3033
3034        // Test ISO timestamp - gets tokenized as multiple parts due to hyphens
3035        let input = "timestamp: 2024-01-15T10:30:00-05:00";
3036        let tokens = lex(input);
3037        // The timestamp is split into multiple tokens but parses correctly
3038        assert!(tokens.iter().any(
3039            |(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15T10:30:00-05:00"
3040        ));
3041
3042        // Test version strings
3043        let input = "version: 1.0.0-beta.1";
3044        let tokens = lex(input);
3045        assert!(tokens
3046            .iter()
3047            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1.0.0-beta.1"));
3048    }
3049
3050    #[test]
3051    fn test_block_scalar_indentation_indicator_sets_body_column() {
3052        use SyntaxKind::*;
3053        // With `|2` the body sits at column 2, so the `y # z` and `[a` lines
3054        // belong to it even though the first line is indented further.
3055        assert_eq!(
3056            lex("- |2\n   x\n  y # z\n  [a\n"),
3057            vec![
3058                (DASH, "-"),
3059                (WHITESPACE, " "),
3060                (PIPE, "|"),
3061                (INT, "2"),
3062                (NEWLINE, "\n"),
3063                (INDENT, "   "),
3064                (STRING, "x"),
3065                (NEWLINE, "\n"),
3066                (INDENT, "  "),
3067                (STRING, "y"),
3068                (WHITESPACE, " "),
3069                (STRING, "# z"),
3070                (NEWLINE, "\n"),
3071                (INDENT, "  "),
3072                (STRING, "[a"),
3073                (NEWLINE, "\n"),
3074            ]
3075        );
3076        assert_eq!(
3077            lex("- |2\r\n   x\r\n  y # z\r\n  [a\r\n"),
3078            vec![
3079                (DASH, "-"),
3080                (WHITESPACE, " "),
3081                (PIPE, "|"),
3082                (INT, "2"),
3083                (NEWLINE, "\r\n"),
3084                (INDENT, "   "),
3085                (STRING, "x"),
3086                (NEWLINE, "\r\n"),
3087                (INDENT, "  "),
3088                (STRING, "y"),
3089                (WHITESPACE, " "),
3090                (STRING, "# z"),
3091                (NEWLINE, "\r\n"),
3092                (INDENT, "  "),
3093                (STRING, "[a"),
3094                (NEWLINE, "\r\n"),
3095            ]
3096        );
3097    }
3098
3099    #[test]
3100    fn test_block_scalar_indentation_indicator_counts_from_parent() {
3101        use SyntaxKind::*;
3102        // The indicator counts from the key's column, 2 here, not from the
3103        // line's indentation or the dash.
3104        assert_eq!(
3105            lex("- k: >-2\n     x\n    [a\n"),
3106            vec![
3107                (DASH, "-"),
3108                (WHITESPACE, " "),
3109                (STRING, "k"),
3110                (COLON, ":"),
3111                (WHITESPACE, " "),
3112                (GREATER, ">"),
3113                (INT, "-2"),
3114                (NEWLINE, "\n"),
3115                (INDENT, "     "),
3116                (STRING, "x"),
3117                (NEWLINE, "\n"),
3118                (INDENT, "    "),
3119                (STRING, "[a"),
3120                (NEWLINE, "\n"),
3121            ]
3122        );
3123        // A header on its own line counts from the key on the line before.
3124        assert_eq!(
3125            lex("k:\n  |1\n   x\n # z\n"),
3126            vec![
3127                (STRING, "k"),
3128                (COLON, ":"),
3129                (NEWLINE, "\n"),
3130                (INDENT, "  "),
3131                (PIPE, "|"),
3132                (INT, "1"),
3133                (NEWLINE, "\n"),
3134                (INDENT, "   "),
3135                (STRING, "x"),
3136                (NEWLINE, "\n"),
3137                (INDENT, " "),
3138                (STRING, "# z"),
3139                (NEWLINE, "\n"),
3140            ]
3141        );
3142        // A line left of the indicated column ends the body.
3143        assert_eq!(
3144            lex("- k: |1\n   x\n  j: [a]\n"),
3145            vec![
3146                (DASH, "-"),
3147                (WHITESPACE, " "),
3148                (STRING, "k"),
3149                (COLON, ":"),
3150                (WHITESPACE, " "),
3151                (PIPE, "|"),
3152                (INT, "1"),
3153                (NEWLINE, "\n"),
3154                (INDENT, "   "),
3155                (STRING, "x"),
3156                (NEWLINE, "\n"),
3157                (INDENT, "  "),
3158                (STRING, "j"),
3159                (COLON, ":"),
3160                (WHITESPACE, " "),
3161                (LEFT_BRACKET, "["),
3162                (STRING, "a"),
3163                (RIGHT_BRACKET, "]"),
3164                (NEWLINE, "\n"),
3165            ]
3166        );
3167    }
3168
3169    #[test]
3170    fn test_block_scalar_indentation_indicator_in_explicit_value() {
3171        use SyntaxKind::*;
3172        // The value of an explicit key counts from the mapping's column, 2
3173        // here, whether the header follows the `:` or sits on its own line.
3174        let head = [
3175            (STRING, "x"),
3176            (COLON, ":"),
3177            (NEWLINE, "\n"),
3178            (INDENT, "  "),
3179            (QUESTION, "?"),
3180            (WHITESPACE, " "),
3181            (STRING, "a"),
3182            (NEWLINE, "\n"),
3183            (INDENT, "  "),
3184            (COLON, ":"),
3185        ];
3186        let tail = [
3187            (INDENT, "     "),
3188            (STRING, "x"),
3189            (NEWLINE, "\n"),
3190            (INDENT, "  "),
3191            (LEFT_BRACKET, "["),
3192            (STRING, "a"),
3193            (RIGHT_BRACKET, "]"),
3194            (NEWLINE, "\n"),
3195        ];
3196        assert_eq!(
3197            lex("x:\n  ? a\n  : |1\n     x\n  [a]\n"),
3198            [
3199                &head[..],
3200                &[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
3201                &tail,
3202            ]
3203            .concat()
3204        );
3205        assert_eq!(
3206            lex("x:\n  ? a\n  :\n    |1\n     x\n  [a]\n"),
3207            [
3208                &head[..],
3209                &[
3210                    (NEWLINE, "\n"),
3211                    (INDENT, "    "),
3212                    (PIPE, "|"),
3213                    (INT, "1"),
3214                    (NEWLINE, "\n"),
3215                ],
3216                &tail,
3217            ]
3218            .concat()
3219        );
3220    }
3221
3222    #[test]
3223    fn test_block_scalar_indentation_indicator_in_compact_explicit_value() {
3224        use SyntaxKind::*;
3225        // A compact mapping after an explicit `:` sits at its first key's
3226        // column, 2 here, and the indicator counts from there.
3227        let head = [
3228            (QUESTION, "?"),
3229            (WHITESPACE, " "),
3230            (STRING, "a"),
3231            (NEWLINE, "\n"),
3232            (COLON, ":"),
3233            (WHITESPACE, " "),
3234            (STRING, "b"),
3235            (COLON, ":"),
3236        ];
3237        let tail = [
3238            (INDENT, "     "),
3239            (STRING, "x"),
3240            (NEWLINE, "\n"),
3241            (INDENT, "  "),
3242            (STRING, "c"),
3243            (COLON, ":"),
3244            (WHITESPACE, " "),
3245            (LEFT_BRACKET, "["),
3246            (STRING, "d"),
3247            (RIGHT_BRACKET, "]"),
3248            (NEWLINE, "\n"),
3249        ];
3250        assert_eq!(
3251            lex("? a\n: b: |1\n     x\n  c: [d]\n"),
3252            [
3253                &head[..],
3254                &[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
3255                &tail,
3256            ]
3257            .concat()
3258        );
3259        assert_eq!(
3260            lex("? a\n: b:\n    |1\n     x\n  c: [d]\n"),
3261            [
3262                &head[..],
3263                &[
3264                    (NEWLINE, "\n"),
3265                    (INDENT, "    "),
3266                    (PIPE, "|"),
3267                    (INT, "1"),
3268                    (NEWLINE, "\n"),
3269                ],
3270                &tail,
3271            ]
3272            .concat()
3273        );
3274        // Inside a sequence entry the compact mapping is at column 4.
3275        assert_eq!(
3276            lex("- ? a\n  : b: |1\n       x\n    c: [d]\n"),
3277            vec![
3278                (DASH, "-"),
3279                (WHITESPACE, " "),
3280                (QUESTION, "?"),
3281                (WHITESPACE, " "),
3282                (STRING, "a"),
3283                (NEWLINE, "\n"),
3284                (INDENT, "  "),
3285                (COLON, ":"),
3286                (WHITESPACE, " "),
3287                (STRING, "b"),
3288                (COLON, ":"),
3289                (WHITESPACE, " "),
3290                (PIPE, "|"),
3291                (INT, "1"),
3292                (NEWLINE, "\n"),
3293                (INDENT, "       "),
3294                (STRING, "x"),
3295                (NEWLINE, "\n"),
3296                (INDENT, "    "),
3297                (STRING, "c"),
3298                (COLON, ":"),
3299                (WHITESPACE, " "),
3300                (LEFT_BRACKET, "["),
3301                (STRING, "d"),
3302                (RIGHT_BRACKET, "]"),
3303                (NEWLINE, "\n"),
3304            ]
3305        );
3306    }
3307
3308    #[test]
3309    fn test_flow_indicators_in_block_scalar() {
3310        // Flow indicators should be allowed in block context scalars
3311        // This is valid YAML: the curly braces are part of the scalar value
3312        let input = "key: unix:///Users/${metadata.username}/path";
3313        let tokens = lex(input);
3314        assert_eq!(tokens.len(), 4);
3315        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
3316        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
3317        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
3318        assert_eq!(
3319            tokens[3],
3320            (
3321                SyntaxKind::STRING,
3322                "unix:///Users/${metadata.username}/path"
3323            )
3324        );
3325    }
3326
3327    #[test]
3328    fn test_block_scalar_body_tab_is_not_indentation() {
3329        // The body is indented two, so the tab is content and the next line
3330        // is still body: its `"` must not start a quoted scalar.
3331        use SyntaxKind::*;
3332        assert_eq!(
3333            lex("- |-\n  \t[\n  ]\"\n- b\n"),
3334            vec![
3335                (DASH, "-"),
3336                (WHITESPACE, " "),
3337                (PIPE, "|"),
3338                (STRING, "-"),
3339                (NEWLINE, "\n"),
3340                (INDENT, "  \t"),
3341                (STRING, "["),
3342                (NEWLINE, "\n"),
3343                (INDENT, "  "),
3344                (STRING, "]\""),
3345                (NEWLINE, "\n"),
3346                (DASH, "-"),
3347                (WHITESPACE, " "),
3348                (STRING, "b"),
3349                (NEWLINE, "\n"),
3350            ]
3351        );
3352    }
3353
3354    #[test]
3355    fn test_unterminated_verbatim_tag_ends_at_line_break() {
3356        use SyntaxKind::*;
3357        for line_break in ["\n", "\r\n", "\r"] {
3358            let input = format!("!<x{line_break}a");
3359            assert_eq!(
3360                lex(&input),
3361                vec![(TAG, "!<x"), (NEWLINE, line_break), (STRING, "a")]
3362            );
3363        }
3364    }
3365}