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            // which runs to the end of the line or to a comment: a `#` after
1208            // white space. The white space and comment are lexed as usual.
1209            let line_end = self.input[start_idx..]
1210                .find(['\n', '\r'])
1211                .map_or(self.input.len(), |i| start_idx + i);
1212            let end_idx = self.input[start_idx..line_end]
1213                .match_indices('#')
1214                .map(|(i, _)| start_idx + i)
1215                .find(|&i| is_hash_a_comment_start(self.input, i))
1216                .map_or(line_end, |i| {
1217                    start_idx + self.input[start_idx..i].trim_end_matches([' ', '\t']).len()
1218                });
1219            while self.chars.next_if(|(idx, _)| *idx < end_idx).is_some() {}
1220            self.push(DIRECTIVE, start_idx, end_idx);
1221        }
1222    }
1223
1224    /// Lex a line break: `\n`, `\r\n` or a lone `\r`.
1225    fn lex_line_break(&mut self, start_idx: usize, ch: char) {
1226        check_line_length(&mut self.errors, self.config, self.line_start, start_idx);
1227
1228        let (line_ending, end_pos) = if ch == '\n' {
1229            ("\n", start_idx + 1)
1230        } else if let Some((_, '\n')) = self.chars.peek() {
1231            self.chars.next();
1232            ("\r\n", start_idx + 2)
1233        } else {
1234            ("\r", start_idx + 1)
1235        };
1236
1237        note_line_ending(
1238            &mut self.errors,
1239            self.config,
1240            &mut self.detected_line_ending,
1241            line_ending,
1242            start_idx..end_pos,
1243        );
1244
1245        self.push(SyntaxKind::NEWLINE, start_idx, end_pos);
1246        self.line_start = end_pos;
1247        let in_body = self.block_scalar.is_some();
1248        track_block_scalar_body(
1249            &self.tokens,
1250            self.input,
1251            self.line_start,
1252            &mut self.block_scalar,
1253        );
1254        if in_body && self.block_scalar.is_none() {
1255            self.block_scalar_end = self.tokens.len();
1256        }
1257    }
1258
1259    fn lex_whitespace(&mut self, start_idx: usize, ch: char) {
1260        use SyntaxKind::*;
1261        let mut end_idx = start_idx + 1;
1262        let mut has_tabs = ch == '\t';
1263
1264        while let Some((idx, ch)) = self.chars.peek() {
1265            if *ch != ' ' && *ch != '\t' {
1266                break;
1267            }
1268            if *ch == '\t' {
1269                has_tabs = true;
1270            }
1271            end_idx = *idx + 1;
1272            self.chars.next();
1273        }
1274
1275        // White space starting a line is its indentation. A line also starts
1276        // after a BOM, which takes no column.
1277        let is_indentation = start_idx == self.line_start;
1278
1279        if is_indentation {
1280            // Check for tab characters in indentation (forbidden in YAML)
1281            if has_tabs {
1282                self.errors.push(WhitespaceError {
1283                    message: "Tab character used for indentation (forbidden in YAML)".to_string(),
1284                    range: start_idx..end_idx,
1285                    category: WhitespaceErrorCategory::TabIndentation,
1286                });
1287            }
1288            self.push(INDENT, start_idx, end_idx);
1289        } else {
1290            self.push(WHITESPACE, start_idx, end_idx);
1291        }
1292    }
1293
1294    fn lex_plain_scalar(&mut self, start_idx: usize, ch: char) {
1295        // Only a header's indentation indicator can precede its chomping
1296        // `+`. A `|` reaching here is content: `1\n|+\n` is `1 |+`.
1297        let after_header = ch.is_ascii_digit()
1298            && matches!(
1299                self.tokens.last(),
1300                Some((SyntaxKind::PIPE | SyntaxKind::GREATER, _))
1301            );
1302        let end = self.read_plain_scalar_body(start_idx + ch.len_utf8(), true, after_header);
1303        self.push_scalar(start_idx, end);
1304    }
1305
1306    fn finish(self) -> (Vec<(SyntaxKind, &'a str)>, Vec<WhitespaceError>) {
1307        let mut whitespace_errors = self.errors;
1308        // Check the final line length if there's no trailing newline
1309        if let Some(max_len) = self.config.max_line_length {
1310            let final_line_length = self.input.len() - self.line_start;
1311            if final_line_length > max_len && final_line_length > 0 {
1312                whitespace_errors.push(WhitespaceError {
1313                    message: format!("Line too long ({final_line_length} > {max_len} characters)"),
1314                    range: self.line_start..self.input.len(),
1315                    category: WhitespaceErrorCategory::LineTooLong,
1316                });
1317            }
1318        }
1319        (self.tokens, whitespace_errors)
1320    }
1321}
1322
1323/// Map a plain-scalar's semantic classification onto the concrete
1324/// [`SyntaxKind`] the tokenizer emits. The classification itself lives
1325/// in [`ScalarValue::classify_plain`](crate::ScalarValue::classify_plain);
1326/// this function only translates enum variants.
1327fn classify_scalar(text: &str) -> SyntaxKind {
1328    use crate::CoreScalarType;
1329    match crate::scalar::ScalarValue::classify_plain(text) {
1330        CoreScalarType::Null => SyntaxKind::NULL,
1331        CoreScalarType::Boolean => SyntaxKind::BOOL,
1332        CoreScalarType::Integer => SyntaxKind::INT,
1333        CoreScalarType::Float => SyntaxKind::FLOAT,
1334        CoreScalarType::String => SyntaxKind::STRING,
1335    }
1336}
1337
1338/// Common set of YAML special characters
1339const YAML_SPECIAL_CHARS: &str = ":+-?[]{},'|>&*!%\"#";
1340
1341/// Check if a character has special meaning in YAML
1342/// Whether a node property (`!tag`, `&anchor`, `*alias`) may start here.
1343///
1344/// Per YAML 1.2 §7.1 these apply to the start of a node. Once a plain
1345/// scalar has begun on the line, a `!` or `&` is ordinary content, so
1346/// `a: x !!b` is the scalar `x !!b` rather than a tagged node. A preceding
1347/// space is not enough to start a new node: what matters is whether the
1348/// last token was scalar content.
1349/// Whether the character ends a `---` or `...` marker.
1350///
1351/// YAML 1.2 `s-white` is space and tab only, and a marker may also end at a
1352/// line break. Rust's `char::is_whitespace` is Unicode-wide and counts
1353/// U+00A0 and friends, which are ordinary scalar content here: `"...\u{a0}-"`
1354/// is one plain scalar, as saphyr and PyYAML both read it.
1355fn ends_document_marker(ch: char) -> bool {
1356    matches!(ch, ' ' | '\t' | '\n' | '\r')
1357}
1358
1359/// Whether a BOM at the start of a line is a document prefix.
1360///
1361/// YAML 1.2 allows a BOM at the start of any document in a stream, not only
1362/// the first, so one may follow a `---` or `...` line or precede a `---`.
1363/// Anywhere else it is ordinary content.
1364fn bom_starts_document(tokens: &[(SyntaxKind, &str)], input: &str, bom_idx: usize) -> bool {
1365    use SyntaxKind::*;
1366    let after_marker = tokens
1367        .iter()
1368        .rev()
1369        .find(|(kind, _)| !matches!(kind, WHITESPACE | INDENT | NEWLINE | COMMENT))
1370        .map_or(true, |(kind, _)| matches!(kind, DOC_START | DOC_END));
1371    let rest = &input[bom_idx + '\u{FEFF}'.len_utf8()..];
1372    let before_marker =
1373        rest.starts_with("---") && rest[3..].chars().next().map_or(true, ends_document_marker);
1374    after_marker || before_marker
1375}
1376
1377fn node_property_can_start(tokens: &[(SyntaxKind, &str)]) -> bool {
1378    for (kind, _) in tokens.iter().rev() {
1379        match kind {
1380            // Skip the layout between the property and what precedes it.
1381            SyntaxKind::WHITESPACE | SyntaxKind::INDENT => continue,
1382            // A node starts after these.
1383            SyntaxKind::NEWLINE
1384            | SyntaxKind::COLON
1385            | SyntaxKind::DASH
1386            | SyntaxKind::QUESTION
1387            | SyntaxKind::COMMA
1388            | SyntaxKind::LEFT_BRACKET
1389            | SyntaxKind::LEFT_BRACE
1390            | SyntaxKind::DOC_START
1391            | SyntaxKind::BOM
1392            | SyntaxKind::TAG
1393            | SyntaxKind::ANCHOR
1394            // A bare `&` or `*` is punctuation rather than content, so a
1395            // property may still follow it.
1396            | SyntaxKind::AMPERSAND
1397            | SyntaxKind::ASTERISK => return true,
1398            // Anything else is content the property would sit inside.
1399            _ => return false,
1400        }
1401    }
1402    // Nothing before it: the very start of the input is a node start.
1403    true
1404}
1405
1406/// Whether the line starting at `line_start` carries no content.
1407fn line_is_blank(input: &str, line_start: usize) -> bool {
1408    input[line_start..]
1409        .split(['\n', '\r'])
1410        .next()
1411        .unwrap_or_default()
1412        .trim_matches([' ', '\t'])
1413        .is_empty()
1414}
1415
1416/// Leading whitespace of the line starting at `line_start`.
1417fn line_indent(input: &str, line_start: usize) -> usize {
1418    let line = &input[line_start..];
1419    line.len() - line.trim_start_matches([' ', '\t']).len()
1420}
1421
1422/// A block scalar whose body the lexer is reading.
1423#[derive(Clone, Copy)]
1424struct BlockScalarBody {
1425    /// Start of the line carrying the header; the body begins on the next.
1426    header_line_start: usize,
1427    /// Index of the header's `|` or `>` token.
1428    header_token: usize,
1429    /// Column an explicit indentation indicator counts from.
1430    parent_column: usize,
1431    /// Leftmost column a body line may start at. A document's root block
1432    /// scalar sits at indentation -1, so its body may start at column 0;
1433    /// any other body has to be indented past its header's line and the
1434    /// collection it is a node of: in `- a: |\n  b: 1\n` the `b` line is
1435    /// the mapping's next entry.
1436    min_indent: usize,
1437    /// The body's own column, set by an explicit indentation indicator or
1438    /// else by its first content line.
1439    indent: Option<usize>,
1440}
1441
1442impl BlockScalarBody {
1443    /// Start tracking for a header found on the line at `line_start`, with
1444    /// `tokens` holding everything lexed before it.
1445    fn new(tokens: &[(SyntaxKind, &str)], input: &str, line_start: usize) -> Self {
1446        // Only `---` and node properties before the header, with nothing
1447        // indenting the line, make it the document's root node.
1448        let is_root = tokens
1449            .iter()
1450            .rev()
1451            .take_while(|(kind, _)| !matches!(kind, SyntaxKind::NEWLINE | SyntaxKind::BOM))
1452            .all(|(kind, _)| {
1453                matches!(
1454                    kind,
1455                    SyntaxKind::DOC_START
1456                        | SyntaxKind::WHITESPACE
1457                        | SyntaxKind::TAG
1458                        | SyntaxKind::ANCHOR
1459                )
1460            });
1461        let parent_column = parent_column(tokens).unwrap_or(0);
1462        let min_indent = if is_root {
1463            0
1464        } else {
1465            line_indent(input, line_start).max(parent_column) + 1
1466        };
1467        Self {
1468            header_line_start: line_start,
1469            header_token: tokens.len(),
1470            parent_column,
1471            min_indent,
1472            indent: None,
1473        }
1474    }
1475}
1476
1477/// The column of the block collection a node starting after `tokens` is a
1478/// node of, or `None` at the document root.
1479///
1480/// This is the column as the parser records it: the `-` or `?`
1481/// introducing the node, or for a mapping value the start of its key (the
1482/// `:` itself when the key is empty or explicit, as an explicit key's
1483/// mapping sits at its `?` and `:`). When the node opens its line, or
1484/// continues a plain scalar, the collection is the one the last line with
1485/// an indicator leaves open. A block scalar's indentation indicator counts
1486/// from this column, taking the root's as 0 as the parser and libyaml do,
1487/// although YAML 1.2 puts the root at -1.
1488fn parent_column(tokens: &[(SyntaxKind, &str)]) -> Option<usize> {
1489    use SyntaxKind::*;
1490    for line in tokens.rsplit(|(kind, _)| matches!(kind, NEWLINE | BOM)) {
1491        let Some(last) = line.iter().rposition(|(kind, _)| {
1492            !matches!(
1493                kind,
1494                WHITESPACE
1495                    | INDENT
1496                    | COMMENT
1497                    | TAG
1498                    | ANCHOR
1499                    | STRING
1500                    | INT
1501                    | FLOAT
1502                    | BOOL
1503                    | NULL
1504                    | MERGE_KEY
1505            )
1506        }) else {
1507            continue;
1508        };
1509        let column = |index: usize| -> usize {
1510            line[..index]
1511                .iter()
1512                .map(|(_, text)| text.chars().count())
1513                .sum()
1514        };
1515        return match line[last].0 {
1516            DASH | QUESTION => Some(column(last)),
1517            COLON => {
1518                // The key starts after the block indicators opening the
1519                // line. An explicit `:` is one of them, as its value may be
1520                // a compact mapping: in `: b: |1` that sits at `b`.
1521                let key = line[..last]
1522                    .iter()
1523                    .position(|(kind, _)| {
1524                        !matches!(kind, WHITESPACE | INDENT | DASH | QUESTION | COLON)
1525                    })
1526                    .unwrap_or(last);
1527                Some(column(key))
1528            }
1529            DOC_START | DOC_END => None,
1530            _ => Some(0),
1531        };
1532    }
1533    None
1534}
1535
1536/// Read a block scalar header's indicators from the tokens following its
1537/// `|` or `>`, returning the indentation indicator if there is one, and how
1538/// many tokens the indicators and the whitespace between them take up. The
1539/// header's comment and line break are not counted.
1540///
1541/// Format: `|<indent><chomp>` or `|<chomp><indent>`, as in `|2`, `|-`,
1542/// `|2-` and `|-2`.
1543pub(crate) fn block_scalar_header<'a>(
1544    tokens: impl IntoIterator<Item = (SyntaxKind, &'a str)>,
1545) -> (Option<usize>, usize) {
1546    let mut explicit_indent = None;
1547    let mut len = 0;
1548    for (kind, text) in tokens {
1549        match kind {
1550            SyntaxKind::INT => {
1551                // The spec allows a single digit 1-9, but the lexer hands
1552                // over the whole run, so `|10` arrives as "10". Take it as
1553                // part of the header rather than leaving it for the body,
1554                // which would strand it.
1555                //
1556                // A chomping indicator before the digit lexes into the same
1557                // token (`|-2` is INT("-2")), so allow one sign.
1558                let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
1559                if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
1560                    break;
1561                }
1562                // A longer run is out of range, so keep only the first digit.
1563                explicit_indent = digits
1564                    .chars()
1565                    .next()
1566                    .and_then(|c| c.to_digit(10))
1567                    .filter(|d| *d > 0)
1568                    .map(|d| d as usize);
1569            }
1570            SyntaxKind::STRING => {
1571                // Could be a chomping indicator, or an indentation digit and
1572                // chomping indicator lexed together (`|3-`, `|-3`). A
1573                // header's indicators are ASCII, so compare characters
1574                // rather than bytes: `text.len()` is 2 for a single no-break
1575                // space.
1576                let mut chars = text.chars();
1577                let is_header = match (chars.next(), chars.next(), chars.next()) {
1578                    (Some(a), None, _) => a == '+' || a == '-',
1579                    (Some(a), Some(b), None) => {
1580                        let digit = |c: char| c.is_ascii_digit() && c != '0';
1581                        let chomp = |c: char| c == '+' || c == '-';
1582                        (digit(a) && chomp(b)) || (chomp(a) && digit(b))
1583                    }
1584                    _ => false,
1585                };
1586                if !is_header {
1587                    break;
1588                }
1589                explicit_indent = text
1590                    .chars()
1591                    .find_map(|c| c.to_digit(10))
1592                    .filter(|d| *d > 0)
1593                    .map(|d| d as usize);
1594            }
1595            // A `+` straight after the `|` or `>` lexes as PLUS rather than
1596            // STRING, but it is the same chomping indicator.
1597            SyntaxKind::PLUS | SyntaxKind::WHITESPACE => {}
1598            _ => break,
1599        }
1600        len += 1;
1601    }
1602    (explicit_indent, len)
1603}
1604
1605/// Whether the line starting at `line_start` is a `---` or `...` marker.
1606fn line_is_document_marker(input: &str, line_start: usize) -> bool {
1607    let line = &input[line_start..];
1608    (line.starts_with("---") || line.starts_with("..."))
1609        && line[3..].chars().next().map_or(true, ends_document_marker)
1610}
1611
1612/// Update the lexer's block-scalar tracking at the start of a new line.
1613///
1614/// A block scalar's body ends at the first following line that is not blank
1615/// and starts left of the body's column (or, before a content line has set
1616/// that, left of the leftmost column allowed), or at a document marker,
1617/// which is the only thing that ends a root body at column 0. This runs for
1618/// every line ending, CRLF and lone CR included: left to the `\n` arm
1619/// alone, a CRLF file kept the tracking set and read a later `[` as literal
1620/// body text rather than a flow collection.
1621fn track_block_scalar_body(
1622    tokens: &[(SyntaxKind, &str)],
1623    input: &str,
1624    line_start: usize,
1625    body: &mut Option<BlockScalarBody>,
1626) {
1627    let Some(current) = body else {
1628        return;
1629    };
1630    // An explicit indentation indicator fixes the body's column, so a body
1631    // line less indented than the first one still belongs to it:
1632    // `- |2\n   x\n  y # z\n` holds the text `y # z`.
1633    if current.indent.is_none() {
1634        if let (Some(indicator), _) =
1635            block_scalar_header(tokens[current.header_token + 1..].iter().copied())
1636        {
1637            current.indent = Some(current.parent_column + indicator);
1638        }
1639    }
1640    if line_is_blank(input, line_start) {
1641        return;
1642    }
1643    let indent = line_indent(input, line_start);
1644    if indent < current.indent.unwrap_or(current.min_indent)
1645        || line_is_document_marker(input, line_start)
1646    {
1647        *body = None;
1648    } else if current.indent.is_none() {
1649        // Only spaces indent, so a tab after them is content. As in the
1650        // parser, tabs count when the spaces alone don't reach the body.
1651        let spaces = crate::column::space_indent(&input[line_start..]);
1652        current.indent = Some(if spaces >= current.min_indent {
1653            spaces
1654        } else {
1655            indent
1656        });
1657    }
1658}
1659
1660/// Whether the line starting at `line_start` is part of a block scalar's
1661/// body, all of which is literal text: `a: |\n  {\nb,c: 1\n` holds the
1662/// scalar `{`, `- >\n  # detected\n` the text `# detected` (test suite
1663/// 4QFQ), and `--- |\n%!PS-Adobe-2.0\n` a line that is not a directive
1664/// (W4TN).
1665///
1666/// [`track_block_scalar_body`] drops the tracking once a line ends the body,
1667/// so this only has to rule out the header's own line.
1668fn in_block_scalar_body(body: Option<BlockScalarBody>, line_start: usize) -> bool {
1669    body.is_some_and(|body| body.header_line_start != line_start)
1670}
1671
1672/// Whether `ch` may appear in a tag shorthand's suffix.
1673///
1674/// YAML 1.2 `ns-tag-char` is any URI character except the flow indicators
1675/// `,`, `[`, `]`, `{`, `}` and the `!` that would start another tag. That
1676/// admits plenty the lexer treats as special elsewhere -- `:` in `!!ss:eq`,
1677/// `-` in `!!ss---` -- so a tag cannot be scanned with the general
1678/// [`YAML_SPECIAL_CHARS`] set.
1679fn is_tag_char(ch: char) -> bool {
1680    if ch.is_whitespace() {
1681        return false;
1682    }
1683    !matches!(ch, ',' | '[' | ']' | '{' | '}' | '!')
1684}
1685
1686/// Whether the character is YAML whitespace (`s-white`, space and tab
1687/// only) or a line break. Unlike `char::is_whitespace` this leaves U+00A0
1688/// and friends as content, as [`ends_document_marker`] does.
1689fn is_white_or_break(ch: char) -> bool {
1690    matches!(ch, ' ' | '\t' | '\n' | '\r')
1691}
1692
1693fn is_yaml_special(ch: char) -> bool {
1694    YAML_SPECIAL_CHARS.contains(ch)
1695}
1696
1697/// Check if character is YAML special, with optional exclusions
1698fn is_yaml_special_except(ch: char, exclude: &str) -> bool {
1699    YAML_SPECIAL_CHARS.contains(ch) && !exclude.contains(ch)
1700}
1701
1702/// Whether YAML allows `c` in a stream (c-printable). A BOM is not, except
1703/// at the start of a document, where the lexer gives it a token of its own.
1704pub(crate) fn is_printable(c: char) -> bool {
1705    matches!(c,
1706        '\t' | '\n' | '\r' | '\u{20}'..='\u{7e}' | '\u{85}' | '\u{a0}'..='\u{d7ff}'
1707        | '\u{e000}'..='\u{fffd}' | '\u{10000}'..)
1708        && c != '\u{feff}'
1709}
1710
1711/// Check whether `name` is a well-formed anchor/alias name (`ns-anchor-name`).
1712///
1713/// Per YAML 1.2 that is one or more `ns-anchor-char`s: printable characters
1714/// other than whitespace, line breaks, the byte order mark and the flow
1715/// indicators. The lexer reads a name up to whitespace or a flow
1716/// indicator (see [`read_anchor_name_from`]), so such a name round-trips.
1717pub(crate) fn is_valid_anchor_name(name: &str) -> bool {
1718    !name.is_empty() && name.chars().all(is_anchor_char)
1719}
1720
1721/// `ns-anchor-char`: `ns-char` minus the flow indicators.
1722fn is_anchor_char(ch: char) -> bool {
1723    // c-printable, less s-white, b-char and the byte order mark.
1724    let ns_char = matches!(ch,
1725        '\u{21}'..='\u{7E}'
1726        | '\u{85}'
1727        | '\u{A0}'..='\u{D7FF}'
1728        | '\u{E000}'..='\u{FFFD}'
1729        | '\u{10000}'..='\u{10FFFF}')
1730        && ch != '\u{FEFF}';
1731    ns_char && !matches!(ch, ',' | '[' | ']' | '{' | '}')
1732}
1733
1734#[cfg(test)]
1735mod tests {
1736    use super::*;
1737
1738    #[test]
1739    fn test_is_valid_anchor_name() {
1740        assert!(is_valid_anchor_name("shared"));
1741        assert!(is_valid_anchor_name("shared_1"));
1742        assert!(is_valid_anchor_name("anchor.dotted"));
1743        assert!(is_valid_anchor_name("naïve"));
1744        // Only the flow indicators are excluded from the indicators.
1745        for name in [
1746            "a:",
1747            "colon:name",
1748            "*star",
1749            "&amp",
1750            "-",
1751            "x?y",
1752            "#",
1753            "!t",
1754            "|>",
1755            "%'\"",
1756            "@`",
1757            "no\u{A0}break",
1758            "nel\u{85}",
1759        ] {
1760            assert!(is_valid_anchor_name(name), "{name:?}");
1761        }
1762
1763        for name in [
1764            "",
1765            "has space",
1766            "has\ttab",
1767            "has\nnewline",
1768            "has\rreturn",
1769            "comma,name",
1770            "bracket]name",
1771            "[",
1772            "{",
1773            "}",
1774            "bom\u{FEFF}",
1775            "bell\u{7}",
1776            "del\u{7F}",
1777            "c1\u{80}",
1778        ] {
1779            assert!(!is_valid_anchor_name(name), "{name:?}");
1780        }
1781    }
1782
1783    #[test]
1784    fn test_embedded_hyphens_stay_in_one_plain_scalar() {
1785        let input = format!("k: {}\n", "a-".repeat(200));
1786        let kinds: Vec<_> = lex(&input).into_iter().map(|(k, _)| k).collect();
1787        assert!(kinds.contains(&SyntaxKind::STRING) || kinds.contains(&SyntaxKind::SCALAR));
1788        let dash_count = kinds.iter().filter(|k| **k == SyntaxKind::DASH).count();
1789        assert_eq!(dash_count, 0);
1790        let seq = lex("- a\n- b\n");
1791        let seq_dashes = seq.iter().filter(|(k, _)| *k == SyntaxKind::DASH).count();
1792        assert_eq!(seq_dashes, 2);
1793    }
1794
1795    /// Names accepted by [`is_valid_anchor_name`] must round-trip through the
1796    /// lexer as a single `REFERENCE` token, so `Alias::new` and the lexer stay
1797    /// in agreement.
1798    #[test]
1799    fn test_valid_anchor_names_round_trip_through_lexer() {
1800        for name in [
1801            "shared",
1802            "shared_1",
1803            "anchor.dotted",
1804            "naïve",
1805            "a:",
1806            "*star",
1807            "x#y",
1808            "no\u{A0}break",
1809        ] {
1810            let input = format!("*{name}");
1811            let tokens = lex(&input);
1812            assert_eq!(
1813                tokens,
1814                vec![(SyntaxKind::REFERENCE, input.as_str())],
1815                "name {name:?} did not round-trip as a single REFERENCE token"
1816            );
1817        }
1818    }
1819
1820    #[test]
1821    fn test_simple_mapping() {
1822        let input = "key: value";
1823        let tokens = lex(input);
1824
1825        assert_eq!(tokens.len(), 4);
1826        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
1827        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1828        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1829        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
1830    }
1831
1832    #[test]
1833    fn test_scalar_types() {
1834        // Test integer
1835        let tokens = lex("age: 42");
1836        assert_eq!(tokens[0], (SyntaxKind::STRING, "age"));
1837        assert_eq!(tokens[3], (SyntaxKind::INT, "42"));
1838
1839        // Test float
1840        let tokens = lex("pi: 3.14");
1841        assert_eq!(tokens[0], (SyntaxKind::STRING, "pi"));
1842        assert_eq!(tokens[3], (SyntaxKind::FLOAT, "3.14"));
1843
1844        // Test boolean true
1845        let tokens = lex("enabled: true");
1846        assert_eq!(tokens[0], (SyntaxKind::STRING, "enabled"));
1847        assert_eq!(tokens[3], (SyntaxKind::BOOL, "true"));
1848
1849        // Test boolean false
1850        let tokens = lex("disabled: false");
1851        assert_eq!(tokens[3], (SyntaxKind::BOOL, "false"));
1852
1853        // Test null
1854        let tokens = lex("value: null");
1855        assert_eq!(tokens[3], (SyntaxKind::NULL, "null"));
1856
1857        // Test tilde as null
1858        let tokens = lex("value: ~");
1859        assert_eq!(tokens[3], (SyntaxKind::NULL, "~"));
1860    }
1861
1862    #[test]
1863    fn test_sequences() {
1864        let input = "- item1\n- item2";
1865        let tokens = lex(input);
1866
1867        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1868        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1869        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
1870        assert_eq!(tokens[3], (SyntaxKind::NEWLINE, "\n"));
1871        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
1872        assert_eq!(tokens[5], (SyntaxKind::WHITESPACE, " "));
1873        assert_eq!(tokens[6], (SyntaxKind::STRING, "item2"));
1874    }
1875
1876    #[test]
1877    fn test_hyphen_in_scalars() {
1878        // Test hyphens in scalar values should not be treated as sequence markers
1879        let input = "Name: example-project";
1880        let tokens = lex(input);
1881
1882        println!("Hyphen test tokens:");
1883        for (i, (kind, text)) in tokens.iter().enumerate() {
1884            println!("  {}: {:?} = {:?}", i, kind, text);
1885        }
1886
1887        // Should get: STRING("Name"), COLON(":"), WHITESPACE(" "), STRING("example-project")
1888        assert_eq!(tokens.len(), 4);
1889        assert_eq!(tokens[0], (SyntaxKind::STRING, "Name"));
1890        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
1891        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
1892        assert_eq!(tokens[3], (SyntaxKind::STRING, "example-project"));
1893    }
1894
1895    #[test]
1896    fn test_hyphen_sequence_vs_scalar() {
1897        // Test that sequence markers are still recognized correctly
1898        let sequence_input = "- example-item";
1899        let tokens = lex(sequence_input);
1900
1901        println!("Sequence hyphen tokens:");
1902        for (i, (kind, text)) in tokens.iter().enumerate() {
1903            println!("  {}: {:?} = {:?}", i, kind, text);
1904        }
1905
1906        // Should get: DASH("-"), WHITESPACE(" "), STRING("example-item")
1907        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
1908        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
1909        assert_eq!(tokens[2], (SyntaxKind::STRING, "example-item"));
1910
1911        // Test scalar with hyphens in different contexts
1912        let scalar_input = "package-name: my-awesome-package";
1913        let tokens = lex(scalar_input);
1914
1915        println!("Package hyphen tokens:");
1916        for (i, (kind, text)) in tokens.iter().enumerate() {
1917            println!("  {}: {:?} = {:?}", i, kind, text);
1918        }
1919
1920        // Should get: STRING("package-name"), COLON(":"), WHITESPACE(" "), STRING("my-awesome-package")
1921        assert_eq!(tokens.len(), 4);
1922        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
1923        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package"));
1924    }
1925
1926    #[test]
1927    fn test_flow_style() {
1928        // Flow sequence
1929        let tokens = lex("[1, 2, 3]");
1930        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
1931        assert_eq!(tokens[1], (SyntaxKind::INT, "1"));
1932        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
1933        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1934        assert_eq!(tokens[4], (SyntaxKind::INT, "2"));
1935        assert_eq!(tokens[5], (SyntaxKind::COMMA, ","));
1936        assert_eq!(tokens[6], (SyntaxKind::WHITESPACE, " "));
1937        assert_eq!(tokens[7], (SyntaxKind::INT, "3"));
1938        assert_eq!(tokens[8], (SyntaxKind::RIGHT_BRACKET, "]"));
1939
1940        // Flow mapping
1941        let tokens = lex("{a: 1, b: 2}");
1942        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
1943        assert_eq!(tokens[1], (SyntaxKind::STRING, "a"));
1944        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
1945        assert_eq!(tokens[3], (SyntaxKind::WHITESPACE, " "));
1946        assert_eq!(tokens[4], (SyntaxKind::INT, "1"));
1947    }
1948
1949    #[test]
1950    fn test_comments() {
1951        let input = "key: value # this is a comment\n# full line comment";
1952        let tokens = lex(input);
1953
1954        // Find comment tokens
1955        let comments: Vec<_> = tokens
1956            .iter()
1957            .filter(|(kind, _)| *kind == SyntaxKind::COMMENT)
1958            .collect();
1959
1960        assert_eq!(comments.len(), 2);
1961        assert_eq!(comments[0].1, "# this is a comment");
1962        assert_eq!(comments[1].1, "# full line comment");
1963    }
1964
1965    #[test]
1966    fn test_multiline_scalar() {
1967        let input = "key: value\n  continued";
1968        let tokens = lex(input);
1969
1970        // Check for indent token
1971        let indents: Vec<_> = tokens
1972            .iter()
1973            .filter(|(kind, _)| *kind == SyntaxKind::INDENT)
1974            .collect();
1975        assert_eq!(indents.len(), 1);
1976        assert_eq!(indents[0].1, "  ");
1977    }
1978
1979    #[test]
1980    fn test_quoted_strings() {
1981        let input = r#"single: 'quoted'
1982double: "quoted""#;
1983        let tokens = lex(input);
1984
1985        // Find quoted string tokens - after fix, quotes are included in STRING tokens
1986        let quoted_strings: Vec<_> = tokens
1987            .iter()
1988            .filter(|(kind, text)| {
1989                *kind == SyntaxKind::STRING && (text.starts_with('\'') || text.starts_with('"'))
1990            })
1991            .collect();
1992        assert_eq!(quoted_strings.len(), 2); // single and double quoted strings
1993
1994        // Verify content (order depends on which appears first in the source)
1995        let quoted_texts: Vec<&str> = {
1996            let mut v: Vec<&str> = quoted_strings.iter().map(|(_, t)| *t).collect();
1997            v.sort();
1998            v
1999        };
2000        assert_eq!(quoted_texts, ["\"quoted\"", "'quoted'"]);
2001    }
2002
2003    #[test]
2004    fn test_document_markers() {
2005        let input = "---\nkey: value\n...";
2006        let tokens = lex(input);
2007
2008        println!("Document tokens:");
2009        for (i, (kind, text)) in tokens.iter().enumerate() {
2010            println!("  {}: {:?} = {:?}", i, kind, text);
2011        }
2012
2013        // Check for document start and end markers
2014        let doc_start_count = tokens
2015            .iter()
2016            .filter(|(kind, _)| *kind == SyntaxKind::DOC_START)
2017            .count();
2018        let doc_end_count = tokens
2019            .iter()
2020            .filter(|(kind, _)| *kind == SyntaxKind::DOC_END)
2021            .count();
2022        assert_eq!(doc_start_count, 1);
2023        assert_eq!(doc_end_count, 1);
2024    }
2025
2026    #[test]
2027    fn test_empty_input() {
2028        let input = "";
2029        let tokens = lex(input);
2030        println!("Empty input tokens: {:?}", tokens);
2031        assert_eq!(tokens.len(), 0);
2032    }
2033
2034    #[test]
2035    fn test_anchors_and_aliases() {
2036        // Test anchor definition
2037        let input = "key: &anchor_name value";
2038        let tokens = lex(input);
2039        println!("Anchor tokens: {:?}", tokens);
2040
2041        let anchors: Vec<_> = tokens
2042            .iter()
2043            .filter(|(kind, _)| *kind == SyntaxKind::ANCHOR)
2044            .collect();
2045        assert_eq!(anchors.len(), 1);
2046        assert_eq!(anchors[0].1, "&anchor_name");
2047
2048        // Test alias reference
2049        let input = "key: *reference_name";
2050        let tokens = lex(input);
2051        println!("Reference tokens: {:?}", tokens);
2052
2053        let references: Vec<_> = tokens
2054            .iter()
2055            .filter(|(kind, _)| *kind == SyntaxKind::REFERENCE)
2056            .collect();
2057        assert_eq!(references.len(), 1);
2058        assert_eq!(references[0].1, "*reference_name");
2059
2060        // Test bare ampersand and asterisk (should not be treated as anchors/references)
2061        let input = "key: & *";
2062        let tokens = lex(input);
2063
2064        let ampersands: Vec<_> = tokens
2065            .iter()
2066            .filter(|(kind, _)| *kind == SyntaxKind::AMPERSAND)
2067            .collect();
2068        assert_eq!(ampersands.len(), 1);
2069
2070        let asterisks: Vec<_> = tokens
2071            .iter()
2072            .filter(|(kind, _)| *kind == SyntaxKind::ASTERISK)
2073            .collect();
2074        assert_eq!(asterisks.len(), 1);
2075    }
2076
2077    #[test]
2078    fn test_indicators_mid_plain_scalar_are_scalar_content() {
2079        // Per YAML 1.2 §7.1 the node-property indicators `&`, `*`, `!`
2080        // only apply when a scalar starts with them (followed by an
2081        // identifier). Mid-scalar they are ordinary content and must
2082        // not create phantom ANCHOR / REFERENCE / TAG tokens.
2083        // Including space-separated: a preceding space does not start a new
2084        // node, so `k: a !b` is still one plain scalar.
2085        for input in [
2086            "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",
2087            "k: a *b", "- a !b",
2088        ] {
2089            let tokens = lex(input);
2090            let bad: Vec<_> = tokens
2091                .iter()
2092                .filter(|(kind, _)| {
2093                    matches!(
2094                        kind,
2095                        SyntaxKind::ANCHOR
2096                            | SyntaxKind::REFERENCE
2097                            | SyntaxKind::TAG
2098                            | SyntaxKind::QUESTION
2099                    )
2100                })
2101                .collect();
2102            assert!(
2103                bad.is_empty(),
2104                "{:?} produced structural tokens mid-scalar: {:?}",
2105                input,
2106                bad
2107            );
2108        }
2109        // And a real anchor still tokenizes as ANCHOR.
2110        let tokens = lex("a: &real 1");
2111        assert!(
2112            tokens.iter().any(|(k, _)| *k == SyntaxKind::ANCHOR),
2113            "real anchor should still be recognized: {:?}",
2114            tokens
2115        );
2116    }
2117
2118    #[test]
2119    fn test_merge_key_token() {
2120        // Test merge key '<<'
2121        let input = "<<: *defaults";
2122        let tokens = lex(input);
2123
2124        let merge_keys: Vec<_> = tokens
2125            .iter()
2126            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2127            .collect();
2128        assert_eq!(merge_keys.len(), 1);
2129        assert_eq!(merge_keys[0].1, "<<");
2130
2131        // Test single '<' is not a merge key
2132        let input2 = "key: < value";
2133        let tokens2 = lex(input2);
2134
2135        let merge_keys2: Vec<_> = tokens2
2136            .iter()
2137            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2138            .collect();
2139        assert_eq!(merge_keys2.len(), 0, "Single < should not be a merge key");
2140    }
2141
2142    #[test]
2143    fn test_angle_prefix_is_plain_scalar_not_merge_key() {
2144        let tokens = lex("<<foo: 1");
2145        let merge_keys: Vec<_> = tokens
2146            .iter()
2147            .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2148            .collect();
2149        assert_eq!(
2150            merge_keys.len(),
2151            0,
2152            "<<foo should be a plain key, got {:?}",
2153            tokens
2154        );
2155        assert!(
2156            tokens
2157                .iter()
2158                .any(|(kind, text)| *kind != SyntaxKind::MERGE_KEY && *text == "<<foo"),
2159            "expected <<foo scalar, got {:?}",
2160            tokens
2161        );
2162
2163        let tokens = lex("{<<foo: 1, more: x}");
2164        assert_eq!(
2165            tokens
2166                .iter()
2167                .filter(|(kind, _)| *kind == SyntaxKind::MERGE_KEY)
2168                .count(),
2169            0,
2170            "flow <<foo should not be MERGE_KEY: {:?}",
2171            tokens
2172        );
2173
2174        let tokens = lex("<<: *defaults");
2175        assert_eq!(
2176            tokens
2177                .iter()
2178                .filter(|(kind, text)| *kind == SyntaxKind::MERGE_KEY && *text == "<<")
2179                .count(),
2180            1
2181        );
2182
2183        use crate::YamlFile;
2184        use std::str::FromStr;
2185        let file = YamlFile::from_str("<<foo: 1\nbar: 2\n").unwrap();
2186        let mapping = file.document().unwrap().as_mapping().unwrap();
2187        assert_eq!(mapping.keys().count(), 2);
2188        assert_eq!(
2189            mapping
2190                .get("<<foo")
2191                .unwrap()
2192                .as_scalar()
2193                .unwrap()
2194                .as_string(),
2195            "1"
2196        );
2197    }
2198
2199    /// `<<` is a merge key only when the plain scalar there is exactly
2200    /// `<<`: a `#` or `:` glued to it, or more words after a space, are
2201    /// part of the scalar.
2202    #[test]
2203    fn test_merge_key_is_a_whole_plain_scalar() {
2204        use SyntaxKind::*;
2205        assert_eq!(
2206            lex("<<#: 1"),
2207            [(STRING, "<<#"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
2208        );
2209        assert_eq!(
2210            lex("<<:x: 1"),
2211            [
2212                (STRING, "<<:x"),
2213                (COLON, ":"),
2214                (WHITESPACE, " "),
2215                (INT, "1")
2216            ]
2217        );
2218        assert_eq!(
2219            lex("<< x: 1"),
2220            [
2221                (STRING, "<< x"),
2222                (COLON, ":"),
2223                (WHITESPACE, " "),
2224                (INT, "1")
2225            ]
2226        );
2227        assert_eq!(
2228            lex("<<]: 1"),
2229            [(STRING, "<<]"), (COLON, ":"), (WHITESPACE, " "), (INT, "1")]
2230        );
2231        assert_eq!(
2232            lex("<< #c\n"),
2233            [
2234                (MERGE_KEY, "<<"),
2235                (WHITESPACE, " "),
2236                (COMMENT, "#c"),
2237                (NEWLINE, "\n")
2238            ]
2239        );
2240        assert_eq!(
2241            lex("{<<,<<: 1}"),
2242            [
2243                (LEFT_BRACE, "{"),
2244                (MERGE_KEY, "<<"),
2245                (COMMA, ","),
2246                (MERGE_KEY, "<<"),
2247                (COLON, ":"),
2248                (WHITESPACE, " "),
2249                (INT, "1"),
2250                (RIGHT_BRACE, "}")
2251            ]
2252        );
2253    }
2254
2255    #[test]
2256    fn test_angle_prefixed_scalar_uses_plain_scalar_rules() {
2257        // A key starting with `<` follows the same plain-scalar rules as any
2258        // other key: `-`, `!`, `&` and a non-indicator `:` are ordinary
2259        // content, not token breaks.
2260        use crate::YamlFile;
2261        use std::str::FromStr;
2262
2263        for key in [
2264            "<foo-bar",
2265            "<<foo-bar",
2266            "<foo!x",
2267            "<<foo!x",
2268            "<foo&x",
2269            "<<foo&x",
2270            "<foo:x",
2271            "<<foo:x",
2272        ] {
2273            let src = format!("{}: 1\n", key);
2274            let tokens = lex(&src);
2275            assert_eq!(
2276                tokens
2277                    .iter()
2278                    .filter(|(kind, _)| *kind != SyntaxKind::WHITESPACE)
2279                    .map(|(_, text)| *text)
2280                    .collect::<Vec<_>>(),
2281                vec![key, ":", "1", "\n"],
2282                "unexpected tokens for {:?}",
2283                src
2284            );
2285
2286            let file = YamlFile::from_str(&src).unwrap();
2287            assert_eq!(file.to_string(), src);
2288            let mapping = file.document().unwrap().as_mapping().unwrap();
2289            assert_eq!(
2290                mapping.get(key).unwrap().as_scalar().unwrap().as_string(),
2291                "1"
2292            );
2293        }
2294    }
2295
2296    #[test]
2297    fn test_plus_token() {
2298        // Test plus as standalone token
2299        let input = "key: |+ value";
2300        let tokens = lex(input);
2301
2302        let plus_tokens: Vec<_> = tokens
2303            .iter()
2304            .filter(|(kind, _)| *kind == SyntaxKind::PLUS)
2305            .collect();
2306        assert_eq!(plus_tokens.len(), 1);
2307        assert_eq!(plus_tokens[0].1, "+");
2308    }
2309
2310    /// A `+` is a chomping indicator only where it closes a block scalar
2311    /// header, and otherwise starts a plain scalar.
2312    #[test]
2313    fn test_plus_tokens() {
2314        use SyntaxKind::*;
2315        let cases: Vec<(&str, Vec<(SyntaxKind, &str)>)> = vec![
2316            (
2317                "+ x: v\n",
2318                vec![
2319                    (STRING, "+ x"),
2320                    (COLON, ":"),
2321                    (WHITESPACE, " "),
2322                    (STRING, "v"),
2323                    (NEWLINE, "\n"),
2324                ],
2325            ),
2326            (
2327                "+\tx: v\n",
2328                vec![
2329                    (STRING, "+\tx"),
2330                    (COLON, ":"),
2331                    (WHITESPACE, " "),
2332                    (STRING, "v"),
2333                    (NEWLINE, "\n"),
2334                ],
2335            ),
2336            (
2337                "+ \n",
2338                vec![(STRING, "+"), (WHITESPACE, " "), (NEWLINE, "\n")],
2339            ),
2340            (
2341                "+ #c\n",
2342                vec![
2343                    (STRING, "+"),
2344                    (WHITESPACE, " "),
2345                    (COMMENT, "#c"),
2346                    (NEWLINE, "\n"),
2347                ],
2348            ),
2349            ("+[b]\n", vec![(STRING, "+[b]"), (NEWLINE, "\n")]),
2350            (
2351                "[+[b]]\n",
2352                vec![
2353                    (LEFT_BRACKET, "["),
2354                    (STRING, "+"),
2355                    (LEFT_BRACKET, "["),
2356                    (STRING, "b"),
2357                    (RIGHT_BRACKET, "]"),
2358                    (RIGHT_BRACKET, "]"),
2359                    (NEWLINE, "\n"),
2360                ],
2361            ),
2362            ("+'a'\n", vec![(STRING, "+'a'"), (NEWLINE, "\n")]),
2363            ("+?\n", vec![(STRING, "+?"), (NEWLINE, "\n")]),
2364            ("+|\n", vec![(STRING, "+|"), (NEWLINE, "\n")]),
2365            (
2366                "+#: v\n",
2367                vec![
2368                    (STRING, "+#"),
2369                    (COLON, ":"),
2370                    (WHITESPACE, " "),
2371                    (STRING, "v"),
2372                    (NEWLINE, "\n"),
2373                ],
2374            ),
2375            (
2376                "+ #: v\n",
2377                vec![
2378                    (STRING, "+"),
2379                    (WHITESPACE, " "),
2380                    (COMMENT, "#: v"),
2381                    (NEWLINE, "\n"),
2382                ],
2383            ),
2384            (
2385                "+: v\n",
2386                vec![
2387                    (STRING, "+"),
2388                    (COLON, ":"),
2389                    (WHITESPACE, " "),
2390                    (STRING, "v"),
2391                    (NEWLINE, "\n"),
2392                ],
2393            ),
2394            (
2395                "+ : v\n",
2396                vec![
2397                    (STRING, "+"),
2398                    (WHITESPACE, " "),
2399                    (COLON, ":"),
2400                    (WHITESPACE, " "),
2401                    (STRING, "v"),
2402                    (NEWLINE, "\n"),
2403                ],
2404            ),
2405            ("+:x\n", vec![(STRING, "+:x"), (NEWLINE, "\n")]),
2406            (
2407                "+\nx\n",
2408                vec![
2409                    (STRING, "+"),
2410                    (NEWLINE, "\n"),
2411                    (STRING, "x"),
2412                    (NEWLINE, "\n"),
2413                ],
2414            ),
2415            (
2416                "+\r\nx\n",
2417                vec![
2418                    (STRING, "+"),
2419                    (NEWLINE, "\r\n"),
2420                    (STRING, "x"),
2421                    (NEWLINE, "\n"),
2422                ],
2423            ),
2424            (
2425                "- |+\n  x\n",
2426                vec![
2427                    (DASH, "-"),
2428                    (WHITESPACE, " "),
2429                    (PIPE, "|"),
2430                    (PLUS, "+"),
2431                    (NEWLINE, "\n"),
2432                    (INDENT, "  "),
2433                    (STRING, "x"),
2434                    (NEWLINE, "\n"),
2435                ],
2436            ),
2437            (
2438                "/|+: v\n",
2439                vec![
2440                    (STRING, "/|+"),
2441                    (COLON, ":"),
2442                    (WHITESPACE, " "),
2443                    (STRING, "v"),
2444                    (NEWLINE, "\n"),
2445                ],
2446            ),
2447            ("i:|+\n", vec![(STRING, "i:|+"), (NEWLINE, "\n")]),
2448            ("+.INF\n", vec![(FLOAT, "+.INF"), (NEWLINE, "\n")]),
2449            (
2450                "{+, a}\n",
2451                vec![
2452                    (LEFT_BRACE, "{"),
2453                    (STRING, "+"),
2454                    (COMMA, ","),
2455                    (WHITESPACE, " "),
2456                    (STRING, "a"),
2457                    (RIGHT_BRACE, "}"),
2458                    (NEWLINE, "\n"),
2459                ],
2460            ),
2461            ("+,\n", vec![(STRING, "+,"), (NEWLINE, "\n")]),
2462            (
2463                "a: 1+2\n",
2464                vec![
2465                    (STRING, "a"),
2466                    (COLON, ":"),
2467                    (WHITESPACE, " "),
2468                    (STRING, "1+2"),
2469                    (NEWLINE, "\n"),
2470                ],
2471            ),
2472            ("i:|2+\n", vec![(STRING, "i:|2+"), (NEWLINE, "\n")]),
2473            (
2474                "x: a |2+\n",
2475                vec![
2476                    (STRING, "x"),
2477                    (COLON, ":"),
2478                    (WHITESPACE, " "),
2479                    (STRING, "a |2+"),
2480                    (NEWLINE, "\n"),
2481                ],
2482            ),
2483            (
2484                "k: |2+\n",
2485                vec![
2486                    (STRING, "k"),
2487                    (COLON, ":"),
2488                    (WHITESPACE, " "),
2489                    (PIPE, "|"),
2490                    (INT, "2"),
2491                    (PLUS, "+"),
2492                    (NEWLINE, "\n"),
2493                ],
2494            ),
2495            (
2496                "- |2+\n",
2497                vec![
2498                    (DASH, "-"),
2499                    (WHITESPACE, " "),
2500                    (PIPE, "|"),
2501                    (INT, "2"),
2502                    (PLUS, "+"),
2503                    (NEWLINE, "\n"),
2504                ],
2505            ),
2506        ];
2507        for (input, expected) in cases {
2508            assert_eq!(lex(input), expected, "lexing {input:?}");
2509        }
2510    }
2511
2512    /// A header's chomping `+` stands alone after node properties and
2513    /// an explicit key's `?` too.
2514    #[test]
2515    fn test_chomping_plus_after_properties() {
2516        use SyntaxKind::*;
2517        for (input, before) in [
2518            ("? |2+\n", vec![(QUESTION, "?")]),
2519            ("!t |2+\n", vec![(TAG, "!t")]),
2520            ("&a |2+\n", vec![(ANCHOR, "&a")]),
2521        ] {
2522            let mut expected = before;
2523            expected.extend([
2524                (WHITESPACE, " "),
2525                (PIPE, "|"),
2526                (INT, "2"),
2527                (PLUS, "+"),
2528                (NEWLINE, "\n"),
2529            ]);
2530            assert_eq!(lex(input), expected, "lexing {input:?}");
2531        }
2532    }
2533
2534    #[test]
2535    fn test_block_scalar_indicators() {
2536        // Test literal with chomping indicators
2537        let input1 = "key: |+ content";
2538        let tokens1 = lex(input1);
2539
2540        assert!(tokens1
2541            .iter()
2542            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2543        assert!(tokens1
2544            .iter()
2545            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2546
2547        // Test folded with chomping indicators
2548        let input2 = "key: >- content";
2549        let tokens2 = lex(input2);
2550
2551        assert!(tokens2
2552            .iter()
2553            .any(|(kind, text)| *kind == SyntaxKind::GREATER && *text == ">"));
2554        assert!(tokens2
2555            .iter()
2556            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-"));
2557
2558        // Test with explicit indentation
2559        let input3 = "key: |2+ content";
2560        let tokens3 = lex(input3);
2561
2562        assert!(tokens3
2563            .iter()
2564            .any(|(kind, text)| *kind == SyntaxKind::PIPE && *text == "|"));
2565        assert!(tokens3
2566            .iter()
2567            .any(|(kind, text)| *kind == SyntaxKind::INT && *text == "2"));
2568        assert!(tokens3
2569            .iter()
2570            .any(|(kind, text)| *kind == SyntaxKind::PLUS && *text == "+"));
2571    }
2572
2573    #[test]
2574    fn test_special_characters_in_block_content() {
2575        let input = "line with - and + and : characters";
2576        let tokens = lex(input);
2577
2578        // The `-` and `+` are content, so the whole key is one plain scalar:
2579        // saphyr reads this as `line with - and + and` mapped to
2580        // `characters`.
2581        assert!(tokens
2582            .iter()
2583            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "line with - and + and"));
2584        assert!(!tokens.iter().any(|(kind, _)| *kind == SyntaxKind::PLUS));
2585
2586        // The mapping colon still stands alone.
2587        assert!(tokens
2588            .iter()
2589            .any(|(kind, text)| *kind == SyntaxKind::COLON && *text == ":"));
2590
2591        // Round-trip: concatenating tokens reproduces input.
2592        let joined: String = tokens.iter().map(|(_, t)| *t).collect();
2593        assert_eq!(joined, input);
2594    }
2595
2596    #[test]
2597    fn test_token_recognition() {
2598        let input = "key: |2+ \n  content with - and : and > chars\n  more content";
2599        let tokens = lex(input);
2600
2601        // Print tokens for debugging
2602        println!("Tokens:");
2603        for (i, (kind, text)) in tokens.iter().enumerate() {
2604            println!("  {}: {:?} = {:?}", i, kind, text);
2605        }
2606
2607        // Verify all expected token kinds are present (input: "key: |2+ \n  content with - and : and > chars\n  more content")
2608        let count = |k: SyntaxKind| tokens.iter().filter(|(kind, _)| *kind == k).count();
2609        // Two colons: one for the mapping ("key:"), one in the value content ("and :")
2610        assert_eq!(count(SyntaxKind::COLON), 2);
2611        assert_eq!(count(SyntaxKind::PIPE), 1); // "|"
2612        assert_eq!(count(SyntaxKind::INT), 1); // "2"
2613        assert_eq!(count(SyntaxKind::PLUS), 1); // "+"
2614                                                // The `>` here sits inside the block scalar's content, not at a
2615                                                // node start, so it is scalar text rather than a folded header.
2616        assert_eq!(count(SyntaxKind::GREATER), 0);
2617        assert_eq!(count(SyntaxKind::NEWLINE), 2); // after "|2+" line and after first content line
2618        assert_eq!(count(SyntaxKind::INDENT), 2); // "  " before each content line
2619                                                  // Multiple STRING tokens: "key", content words, and the hyphen
2620        assert!(count(SyntaxKind::STRING) >= 1, "expected STRING tokens");
2621
2622        assert_eq!(
2623            tokens
2624                .iter()
2625                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "content with - and")
2626                .count(),
2627            1
2628        );
2629    }
2630
2631    #[test]
2632    fn test_dash_handling() {
2633        // Test 1: Document start marker
2634        let input = "---\nkey: value";
2635        let tokens = lex(input);
2636        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2637
2638        // Test 2: Document with just three dashes
2639        let input = "---";
2640        let tokens = lex(input);
2641        assert_eq!(tokens.len(), 1);
2642        assert_eq!(tokens[0], (SyntaxKind::DOC_START, "---"));
2643
2644        // Test 3: Two dashes (not a document marker)
2645        let input = "--";
2646        let tokens = lex(input);
2647        assert_eq!(tokens, vec![(SyntaxKind::STRING, "--")]);
2648
2649        // Test 4: Four dashes
2650        let input = "----";
2651        let tokens = lex(input);
2652        assert_eq!(tokens, vec![(SyntaxKind::STRING, "----")]);
2653    }
2654
2655    #[test]
2656    fn test_dash_in_different_scalar_contexts() {
2657        // Test kebab-case identifiers
2658        let input = "package-name: my-awesome-package-v2";
2659        let tokens = lex(input);
2660        assert_eq!(tokens[0], (SyntaxKind::STRING, "package-name"));
2661        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2662        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2663        assert_eq!(tokens[3], (SyntaxKind::STRING, "my-awesome-package-v2"));
2664
2665        // Test UUID-like strings
2666        let input = "id: 123e4567-e89b-12d3-a456-426614174000";
2667        let tokens = lex(input);
2668        assert_eq!(tokens[0], (SyntaxKind::STRING, "id"));
2669        assert_eq!(
2670            tokens[3],
2671            (SyntaxKind::STRING, "123e4567-e89b-12d3-a456-426614174000")
2672        );
2673
2674        // Test command-line arguments. The two words are one plain
2675        // scalar, so they lex as a single token rather than being
2676        // rejoined by the parser.
2677        let input = "args: --verbose --log-level=debug";
2678        let tokens = lex(input);
2679        assert_eq!(
2680            tokens[3],
2681            (SyntaxKind::STRING, "--verbose --log-level=debug")
2682        );
2683
2684        // Test negative numbers
2685        let input = "temperature: -40";
2686        let tokens = lex(input);
2687        // Negative numbers are tokenized as INT tokens
2688        assert_eq!(
2689            tokens
2690                .iter()
2691                .filter(|(kind, text)| *kind == SyntaxKind::INT && *text == "-40")
2692                .count(),
2693            1
2694        );
2695
2696        // Test ranges
2697        let input = "range: 1-10";
2698        let tokens = lex(input);
2699        assert_eq!(
2700            tokens
2701                .iter()
2702                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1-10")
2703                .count(),
2704            1
2705        );
2706    }
2707
2708    #[test]
2709    fn test_sequence_markers_with_indentation() {
2710        // Test basic sequence
2711        let input = "- item1\n- item2";
2712        let tokens = lex(input);
2713        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2714        assert_eq!(tokens[1], (SyntaxKind::WHITESPACE, " "));
2715        assert_eq!(tokens[2], (SyntaxKind::STRING, "item1"));
2716
2717        // Test indented sequence
2718        let input = "  - item1\n  - item2";
2719        let tokens = lex(input);
2720        assert_eq!(tokens[0], (SyntaxKind::INDENT, "  "));
2721        assert_eq!(tokens[1], (SyntaxKind::DASH, "-"));
2722
2723        // The indented lines continue the plain scalar `item1`, so their
2724        // dashes are content: the value is `item1 - nested1 - nested2`.
2725        let input = "- item1\n  - nested1\n  - nested2\n- item2";
2726        let tokens = lex(input);
2727        let dash_tokens: Vec<_> = tokens
2728            .iter()
2729            .filter(|(kind, _)| *kind == SyntaxKind::DASH)
2730            .collect();
2731        assert_eq!(dash_tokens.len(), 2);
2732
2733        // Test sequence with hyphenated values
2734        let input = "- first-item\n- second-item";
2735        let tokens = lex(input);
2736        assert_eq!(tokens[0], (SyntaxKind::DASH, "-"));
2737        assert_eq!(tokens[2], (SyntaxKind::STRING, "first-item"));
2738        assert_eq!(tokens[4], (SyntaxKind::DASH, "-"));
2739        assert_eq!(tokens[6], (SyntaxKind::STRING, "second-item"));
2740    }
2741
2742    #[test]
2743    fn test_dash_after_colon() {
2744        // Test hyphen immediately after colon
2745        // According to YAML spec, "key:-value" is a single plain scalar
2746        // because the colon is not followed by whitespace
2747        let input = "key:-value";
2748        let tokens = lex(input);
2749        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:-value"));
2750
2751        // Test with space - this creates a mapping
2752        let input = "key: -value";
2753        let tokens = lex(input);
2754        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2755        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2756        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2757        assert_eq!(tokens[3], (SyntaxKind::STRING, "-value"));
2758    }
2759
2760    #[test]
2761    fn test_yaml_spec_compliant_colon_handling() {
2762        // Test that colons are handled according to YAML spec:
2763        // - Colon followed by whitespace indicates mapping
2764        // - Colon not followed by whitespace is part of plain scalar
2765
2766        // URLs should be single scalars (no space after colon)
2767        let input = "http://example.com:8080/path";
2768        let tokens = lex(input);
2769        assert_eq!(tokens.len(), 1);
2770        assert_eq!(
2771            tokens[0],
2772            (SyntaxKind::STRING, "http://example.com:8080/path")
2773        );
2774
2775        // Timestamps should be single scalars
2776        let input = "2024:12:31:23:59:59";
2777        let tokens = lex(input);
2778        assert_eq!(tokens.len(), 1);
2779        assert_eq!(tokens[0], (SyntaxKind::STRING, "2024:12:31:23:59:59"));
2780
2781        // Key-value pairs need space after colon
2782        let input = "key: value";
2783        let tokens = lex(input);
2784        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
2785        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
2786        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
2787        assert_eq!(tokens[3], (SyntaxKind::STRING, "value"));
2788
2789        // Without space, it's a single scalar
2790        let input = "key:value";
2791        let tokens = lex(input);
2792        assert_eq!(tokens.len(), 1);
2793        assert_eq!(tokens[0], (SyntaxKind::STRING, "key:value"));
2794
2795        // Multiple colons without spaces
2796        let input = "a:b:c:d";
2797        let tokens = lex(input);
2798        assert_eq!(tokens.len(), 1);
2799        assert_eq!(tokens[0], (SyntaxKind::STRING, "a:b:c:d"));
2800    }
2801
2802    #[test]
2803    fn test_block_scalar_with_chomping() {
2804        // Helper to count tokens by kind
2805        let count_kind = |toks: &[(SyntaxKind, &str)], k: SyntaxKind| {
2806            toks.iter().filter(|(kind, _)| *kind == k).count()
2807        };
2808
2809        // Test literal block scalar with strip chomping
2810        let input = "text: |-\n  content";
2811        let tokens = lex(input);
2812        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2813        assert_eq!(
2814            tokens
2815                .iter()
2816                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2817                .count(),
2818            1
2819        );
2820
2821        // Test literal block scalar with keep chomping
2822        let input = "text: |+\n  content";
2823        let tokens = lex(input);
2824        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2825        assert_eq!(count_kind(&tokens, SyntaxKind::PLUS), 1);
2826
2827        // Test folded block scalar with strip chomping
2828        let input = "text: >-\n  content";
2829        let tokens = lex(input);
2830        assert_eq!(count_kind(&tokens, SyntaxKind::GREATER), 1);
2831        assert_eq!(
2832            tokens
2833                .iter()
2834                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "-")
2835                .count(),
2836            1
2837        );
2838
2839        // Test with explicit indentation and chomping
2840        let input = "text: |2-\n  content";
2841        let tokens = lex(input);
2842        assert_eq!(count_kind(&tokens, SyntaxKind::PIPE), 1);
2843        // The "2-" after pipe gets read as one token because hyphens in scalars are included
2844        let has_2_token = tokens.iter().any(|(kind, text)| {
2845            (*kind == SyntaxKind::STRING || *kind == SyntaxKind::INT) && text.contains("2")
2846        });
2847        assert!(has_2_token, "expected a token containing '2'");
2848    }
2849
2850    #[test]
2851    fn test_dash_edge_cases() {
2852        // Test trailing hyphen
2853        let input = "value-";
2854        let tokens = lex(input);
2855        assert_eq!(tokens[0], (SyntaxKind::STRING, "value-"));
2856
2857        // Test leading hyphen (not a sequence marker)
2858        let input = "-value";
2859        let tokens = lex(input);
2860        assert_eq!(tokens[0], (SyntaxKind::STRING, "-value"));
2861
2862        // Test multiple consecutive hyphens in scalar
2863        let input = "key: a---b";
2864        let tokens = lex(input);
2865        assert_eq!(
2866            tokens
2867                .iter()
2868                .filter(|(kind, text)| *kind == SyntaxKind::STRING && *text == "a---b")
2869                .count(),
2870            1
2871        );
2872
2873        // Test hyphen at end of line
2874        let input = "key: value-\nnext: item";
2875        let tokens = lex(input);
2876        assert!(tokens
2877            .iter()
2878            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "value-"));
2879
2880        // Test mix of dashes and underscores
2881        let input = "snake_case-with-dash_mix";
2882        let tokens = lex(input);
2883        assert_eq!(tokens[0], (SyntaxKind::STRING, "snake_case-with-dash_mix"));
2884    }
2885
2886    #[test]
2887    fn test_whitespace_validation_tab_indentation() {
2888        // Test tab character validation
2889        let input_with_tabs = "key: value\n\tindented_key: indented_value";
2890        let (tokens, errors) = lex_with_validation(input_with_tabs);
2891
2892        // Should have detected tab indentation error
2893        assert_eq!(errors.len(), 1);
2894        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2895        assert_eq!(
2896            errors[0].message,
2897            "Tab character used for indentation (forbidden in YAML)"
2898        );
2899
2900        // But should still tokenize correctly
2901        assert!(tokens
2902            .iter()
2903            .any(|(kind, text)| *kind == SyntaxKind::INDENT && text.contains('\t')));
2904    }
2905
2906    #[test]
2907    fn test_whitespace_validation_line_endings() {
2908        // Test mixed line ending detection
2909        let input_mixed = "line1\nline2\r\nline3\rline4";
2910        let config = ValidationConfig {
2911            enforce_consistent_line_endings: true,
2912            max_line_length: None,
2913        };
2914        let (tokens, errors) = lex_with_validation_config(input_mixed, &config);
2915
2916        // Should detect mixed line endings
2917        assert!(errors
2918            .iter()
2919            .any(|e| e.category == WhitespaceErrorCategory::MixedLineEndings));
2920
2921        // Should still tokenize all line endings
2922        let newlines: Vec<_> = tokens
2923            .iter()
2924            .filter(|(kind, _)| *kind == SyntaxKind::NEWLINE)
2925            .collect();
2926        assert_eq!(newlines.len(), 3); // Three line endings
2927        assert_eq!(newlines[0].1, "\n");
2928        assert_eq!(newlines[1].1, "\r\n");
2929        assert_eq!(newlines[2].1, "\r");
2930    }
2931
2932    #[test]
2933    fn test_whitespace_validation_line_length() {
2934        // Test line length validation
2935        let long_line = format!("key: {}", "a".repeat(150));
2936        let config = ValidationConfig {
2937            enforce_consistent_line_endings: false,
2938            max_line_length: Some(120),
2939        };
2940        let (_, errors) = lex_with_validation_config(&long_line, &config);
2941
2942        // Should detect line too long
2943        assert_eq!(errors.len(), 1);
2944        assert_eq!(errors[0].category, WhitespaceErrorCategory::LineTooLong);
2945        assert_eq!(errors[0].message, "Line too long (155 > 120 characters)");
2946    }
2947
2948    #[test]
2949    fn test_whitespace_validation_disabled() {
2950        // Test with validation disabled
2951        let input_with_issues = "key: value\n\tindented: with_tabs\n";
2952        let config = ValidationConfig {
2953            enforce_consistent_line_endings: false,
2954            max_line_length: None,
2955        };
2956        let (tokens, errors) = lex_with_validation_config(input_with_issues, &config);
2957
2958        // Should still detect tab indentation (always enforced in YAML)
2959        assert_eq!(errors.len(), 1);
2960        assert_eq!(errors[0].category, WhitespaceErrorCategory::TabIndentation);
2961
2962        // Should tokenize normally
2963        assert!(!tokens.is_empty());
2964    }
2965
2966    #[test]
2967    fn test_dash_in_flow_collections() {
2968        // Test dash in flow sequence
2969        let input = "[item-one, item-two]";
2970        let tokens = lex(input);
2971        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACKET, "["));
2972        assert_eq!(tokens[1], (SyntaxKind::STRING, "item-one"));
2973        assert_eq!(tokens[2], (SyntaxKind::COMMA, ","));
2974        assert_eq!(tokens[4], (SyntaxKind::STRING, "item-two"));
2975        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACKET, "]"));
2976
2977        // Test dash in flow mapping
2978        let input = "{kebab-key: kebab-value}";
2979        let tokens = lex(input);
2980        assert_eq!(tokens[0], (SyntaxKind::LEFT_BRACE, "{"));
2981        assert_eq!(tokens[1], (SyntaxKind::STRING, "kebab-key"));
2982        assert_eq!(tokens[2], (SyntaxKind::COLON, ":"));
2983        assert_eq!(tokens[4], (SyntaxKind::STRING, "kebab-value"));
2984        assert_eq!(tokens[5], (SyntaxKind::RIGHT_BRACE, "}"));
2985    }
2986
2987    #[test]
2988    fn test_dash_with_quotes() {
2989        // Quoted strings should preserve everything inside as STRING tokens
2990        let input = r#"key: "- not a sequence marker""#;
2991        let tokens = lex(input);
2992        assert_eq!(
2993            tokens
2994                .iter()
2995                .filter(|(kind, text)| {
2996                    *kind == SyntaxKind::STRING && *text == "\"- not a sequence marker\""
2997                })
2998                .count(),
2999            1
3000        );
3001
3002        let input = r#"key: '- also not a sequence marker'"#;
3003        let tokens = lex(input);
3004        assert_eq!(
3005            tokens
3006                .iter()
3007                .filter(|(kind, text)| {
3008                    *kind == SyntaxKind::STRING && *text == "'- also not a sequence marker'"
3009                })
3010                .count(),
3011            1
3012        );
3013    }
3014
3015    #[test]
3016    fn test_dash_in_multiline_values() {
3017        // Test multiline with dashes: plain scalars absorb intra-line
3018        // whitespace, so the first line becomes one STRING token ending
3019        // in the trailing hyphen (hyphen-at-end-of-word is scalar content,
3020        // not a sequence marker mid-scalar).
3021        let input = "description: This is a multi-\n  line value with dashes";
3022        let tokens = lex(input);
3023        assert!(tokens
3024            .iter()
3025            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "This is a multi-"));
3026
3027        // A line continuing a plain scalar is content, dash included.
3028        let input = "text: value\n  - but this is not a sequence";
3029        let tokens = lex(input);
3030        assert_eq!(
3031            &tokens[tokens.len() - 2..],
3032            [
3033                (SyntaxKind::INDENT, "  "),
3034                (SyntaxKind::STRING, "- but this is not a sequence"),
3035            ]
3036        );
3037    }
3038
3039    #[test]
3040    fn test_dash_special_yaml_values() {
3041        // Test that special YAML values with dashes work
3042        let input = "date: 2024-01-15";
3043        let tokens = lex(input);
3044        assert!(tokens
3045            .iter()
3046            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15"));
3047
3048        // Test ISO timestamp - gets tokenized as multiple parts due to hyphens
3049        let input = "timestamp: 2024-01-15T10:30:00-05:00";
3050        let tokens = lex(input);
3051        // The timestamp is split into multiple tokens but parses correctly
3052        assert!(tokens.iter().any(
3053            |(kind, text)| *kind == SyntaxKind::STRING && *text == "2024-01-15T10:30:00-05:00"
3054        ));
3055
3056        // Test version strings
3057        let input = "version: 1.0.0-beta.1";
3058        let tokens = lex(input);
3059        assert!(tokens
3060            .iter()
3061            .any(|(kind, text)| *kind == SyntaxKind::STRING && *text == "1.0.0-beta.1"));
3062    }
3063
3064    #[test]
3065    fn test_block_scalar_indentation_indicator_sets_body_column() {
3066        use SyntaxKind::*;
3067        // With `|2` the body sits at column 2, so the `y # z` and `[a` lines
3068        // belong to it even though the first line is indented further.
3069        assert_eq!(
3070            lex("- |2\n   x\n  y # z\n  [a\n"),
3071            vec![
3072                (DASH, "-"),
3073                (WHITESPACE, " "),
3074                (PIPE, "|"),
3075                (INT, "2"),
3076                (NEWLINE, "\n"),
3077                (INDENT, "   "),
3078                (STRING, "x"),
3079                (NEWLINE, "\n"),
3080                (INDENT, "  "),
3081                (STRING, "y"),
3082                (WHITESPACE, " "),
3083                (STRING, "# z"),
3084                (NEWLINE, "\n"),
3085                (INDENT, "  "),
3086                (STRING, "[a"),
3087                (NEWLINE, "\n"),
3088            ]
3089        );
3090        assert_eq!(
3091            lex("- |2\r\n   x\r\n  y # z\r\n  [a\r\n"),
3092            vec![
3093                (DASH, "-"),
3094                (WHITESPACE, " "),
3095                (PIPE, "|"),
3096                (INT, "2"),
3097                (NEWLINE, "\r\n"),
3098                (INDENT, "   "),
3099                (STRING, "x"),
3100                (NEWLINE, "\r\n"),
3101                (INDENT, "  "),
3102                (STRING, "y"),
3103                (WHITESPACE, " "),
3104                (STRING, "# z"),
3105                (NEWLINE, "\r\n"),
3106                (INDENT, "  "),
3107                (STRING, "[a"),
3108                (NEWLINE, "\r\n"),
3109            ]
3110        );
3111    }
3112
3113    #[test]
3114    fn test_block_scalar_indentation_indicator_counts_from_parent() {
3115        use SyntaxKind::*;
3116        // The indicator counts from the key's column, 2 here, not from the
3117        // line's indentation or the dash.
3118        assert_eq!(
3119            lex("- k: >-2\n     x\n    [a\n"),
3120            vec![
3121                (DASH, "-"),
3122                (WHITESPACE, " "),
3123                (STRING, "k"),
3124                (COLON, ":"),
3125                (WHITESPACE, " "),
3126                (GREATER, ">"),
3127                (INT, "-2"),
3128                (NEWLINE, "\n"),
3129                (INDENT, "     "),
3130                (STRING, "x"),
3131                (NEWLINE, "\n"),
3132                (INDENT, "    "),
3133                (STRING, "[a"),
3134                (NEWLINE, "\n"),
3135            ]
3136        );
3137        // A header on its own line counts from the key on the line before.
3138        assert_eq!(
3139            lex("k:\n  |1\n   x\n # z\n"),
3140            vec![
3141                (STRING, "k"),
3142                (COLON, ":"),
3143                (NEWLINE, "\n"),
3144                (INDENT, "  "),
3145                (PIPE, "|"),
3146                (INT, "1"),
3147                (NEWLINE, "\n"),
3148                (INDENT, "   "),
3149                (STRING, "x"),
3150                (NEWLINE, "\n"),
3151                (INDENT, " "),
3152                (STRING, "# z"),
3153                (NEWLINE, "\n"),
3154            ]
3155        );
3156        // A line left of the indicated column ends the body.
3157        assert_eq!(
3158            lex("- k: |1\n   x\n  j: [a]\n"),
3159            vec![
3160                (DASH, "-"),
3161                (WHITESPACE, " "),
3162                (STRING, "k"),
3163                (COLON, ":"),
3164                (WHITESPACE, " "),
3165                (PIPE, "|"),
3166                (INT, "1"),
3167                (NEWLINE, "\n"),
3168                (INDENT, "   "),
3169                (STRING, "x"),
3170                (NEWLINE, "\n"),
3171                (INDENT, "  "),
3172                (STRING, "j"),
3173                (COLON, ":"),
3174                (WHITESPACE, " "),
3175                (LEFT_BRACKET, "["),
3176                (STRING, "a"),
3177                (RIGHT_BRACKET, "]"),
3178                (NEWLINE, "\n"),
3179            ]
3180        );
3181    }
3182
3183    #[test]
3184    fn test_block_scalar_indentation_indicator_in_explicit_value() {
3185        use SyntaxKind::*;
3186        // The value of an explicit key counts from the mapping's column, 2
3187        // here, whether the header follows the `:` or sits on its own line.
3188        let head = [
3189            (STRING, "x"),
3190            (COLON, ":"),
3191            (NEWLINE, "\n"),
3192            (INDENT, "  "),
3193            (QUESTION, "?"),
3194            (WHITESPACE, " "),
3195            (STRING, "a"),
3196            (NEWLINE, "\n"),
3197            (INDENT, "  "),
3198            (COLON, ":"),
3199        ];
3200        let tail = [
3201            (INDENT, "     "),
3202            (STRING, "x"),
3203            (NEWLINE, "\n"),
3204            (INDENT, "  "),
3205            (LEFT_BRACKET, "["),
3206            (STRING, "a"),
3207            (RIGHT_BRACKET, "]"),
3208            (NEWLINE, "\n"),
3209        ];
3210        assert_eq!(
3211            lex("x:\n  ? a\n  : |1\n     x\n  [a]\n"),
3212            [
3213                &head[..],
3214                &[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
3215                &tail,
3216            ]
3217            .concat()
3218        );
3219        assert_eq!(
3220            lex("x:\n  ? a\n  :\n    |1\n     x\n  [a]\n"),
3221            [
3222                &head[..],
3223                &[
3224                    (NEWLINE, "\n"),
3225                    (INDENT, "    "),
3226                    (PIPE, "|"),
3227                    (INT, "1"),
3228                    (NEWLINE, "\n"),
3229                ],
3230                &tail,
3231            ]
3232            .concat()
3233        );
3234    }
3235
3236    #[test]
3237    fn test_block_scalar_indentation_indicator_in_compact_explicit_value() {
3238        use SyntaxKind::*;
3239        // A compact mapping after an explicit `:` sits at its first key's
3240        // column, 2 here, and the indicator counts from there.
3241        let head = [
3242            (QUESTION, "?"),
3243            (WHITESPACE, " "),
3244            (STRING, "a"),
3245            (NEWLINE, "\n"),
3246            (COLON, ":"),
3247            (WHITESPACE, " "),
3248            (STRING, "b"),
3249            (COLON, ":"),
3250        ];
3251        let tail = [
3252            (INDENT, "     "),
3253            (STRING, "x"),
3254            (NEWLINE, "\n"),
3255            (INDENT, "  "),
3256            (STRING, "c"),
3257            (COLON, ":"),
3258            (WHITESPACE, " "),
3259            (LEFT_BRACKET, "["),
3260            (STRING, "d"),
3261            (RIGHT_BRACKET, "]"),
3262            (NEWLINE, "\n"),
3263        ];
3264        assert_eq!(
3265            lex("? a\n: b: |1\n     x\n  c: [d]\n"),
3266            [
3267                &head[..],
3268                &[(WHITESPACE, " "), (PIPE, "|"), (INT, "1"), (NEWLINE, "\n")],
3269                &tail,
3270            ]
3271            .concat()
3272        );
3273        assert_eq!(
3274            lex("? a\n: b:\n    |1\n     x\n  c: [d]\n"),
3275            [
3276                &head[..],
3277                &[
3278                    (NEWLINE, "\n"),
3279                    (INDENT, "    "),
3280                    (PIPE, "|"),
3281                    (INT, "1"),
3282                    (NEWLINE, "\n"),
3283                ],
3284                &tail,
3285            ]
3286            .concat()
3287        );
3288        // Inside a sequence entry the compact mapping is at column 4.
3289        assert_eq!(
3290            lex("- ? a\n  : b: |1\n       x\n    c: [d]\n"),
3291            vec![
3292                (DASH, "-"),
3293                (WHITESPACE, " "),
3294                (QUESTION, "?"),
3295                (WHITESPACE, " "),
3296                (STRING, "a"),
3297                (NEWLINE, "\n"),
3298                (INDENT, "  "),
3299                (COLON, ":"),
3300                (WHITESPACE, " "),
3301                (STRING, "b"),
3302                (COLON, ":"),
3303                (WHITESPACE, " "),
3304                (PIPE, "|"),
3305                (INT, "1"),
3306                (NEWLINE, "\n"),
3307                (INDENT, "       "),
3308                (STRING, "x"),
3309                (NEWLINE, "\n"),
3310                (INDENT, "    "),
3311                (STRING, "c"),
3312                (COLON, ":"),
3313                (WHITESPACE, " "),
3314                (LEFT_BRACKET, "["),
3315                (STRING, "d"),
3316                (RIGHT_BRACKET, "]"),
3317                (NEWLINE, "\n"),
3318            ]
3319        );
3320    }
3321
3322    #[test]
3323    fn test_flow_indicators_in_block_scalar() {
3324        // Flow indicators should be allowed in block context scalars
3325        // This is valid YAML: the curly braces are part of the scalar value
3326        let input = "key: unix:///Users/${metadata.username}/path";
3327        let tokens = lex(input);
3328        assert_eq!(tokens.len(), 4);
3329        assert_eq!(tokens[0], (SyntaxKind::STRING, "key"));
3330        assert_eq!(tokens[1], (SyntaxKind::COLON, ":"));
3331        assert_eq!(tokens[2], (SyntaxKind::WHITESPACE, " "));
3332        assert_eq!(
3333            tokens[3],
3334            (
3335                SyntaxKind::STRING,
3336                "unix:///Users/${metadata.username}/path"
3337            )
3338        );
3339    }
3340
3341    #[test]
3342    fn test_block_scalar_body_tab_is_not_indentation() {
3343        // The body is indented two, so the tab is content and the next line
3344        // is still body: its `"` must not start a quoted scalar.
3345        use SyntaxKind::*;
3346        assert_eq!(
3347            lex("- |-\n  \t[\n  ]\"\n- b\n"),
3348            vec![
3349                (DASH, "-"),
3350                (WHITESPACE, " "),
3351                (PIPE, "|"),
3352                (STRING, "-"),
3353                (NEWLINE, "\n"),
3354                (INDENT, "  \t"),
3355                (STRING, "["),
3356                (NEWLINE, "\n"),
3357                (INDENT, "  "),
3358                (STRING, "]\""),
3359                (NEWLINE, "\n"),
3360                (DASH, "-"),
3361                (WHITESPACE, " "),
3362                (STRING, "b"),
3363                (NEWLINE, "\n"),
3364            ]
3365        );
3366    }
3367
3368    #[test]
3369    fn test_unterminated_verbatim_tag_ends_at_line_break() {
3370        use SyntaxKind::*;
3371        for line_break in ["\n", "\r\n", "\r"] {
3372            let input = format!("!<x{line_break}a");
3373            assert_eq!(
3374                lex(&input),
3375                vec![(TAG, "!<x"), (NEWLINE, line_break), (STRING, "a")]
3376            );
3377        }
3378    }
3379}