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