Skip to main content

yaml_edit/
validator.rs

1//! YAML specification validator
2//!
3//! This module provides strict YAML 1.2 specification validation.
4//! While the parser is lenient and focuses on error recovery,
5//! this validator enforces strict spec compliance.
6//!
7//! ## Usage
8//!
9//! ```ignore
10//! use yaml_edit::{Yaml, validator::Validator};
11//!
12//! let yaml = Yaml::parse("some: yaml");
13//! let validator = Validator::new();
14//! let violations = validator.validate(&yaml);
15//!
16//! if violations.is_empty() {
17//!     println!("Strictly spec-compliant!");
18//! } else {
19//!     for violation in violations {
20//!         println!("{}", violation);
21//!     }
22//! }
23//! ```
24
25use crate::nodes::has_child_token;
26use crate::yaml::{Document, SyntaxNode};
27use rowan::ast::AstNode;
28use std::fmt;
29
30/// A YAML specification violation found during validation
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Violation {
33    /// Human-readable description of the violation
34    pub message: String,
35    /// Location in the source (line:column format)
36    pub location: Option<String>,
37    /// Byte range in the source text where the violation occurred
38    pub text_range: Option<crate::TextPosition>,
39    /// Severity of the violation
40    pub severity: Severity,
41    /// Specific rule that was violated
42    pub rule: Rule,
43}
44
45/// Severity level of a spec violation
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47#[non_exhaustive]
48pub enum Severity {
49    /// Error: Strictly invalid per YAML 1.2 spec
50    Error,
51    /// Warning: Deprecated or discouraged but technically valid
52    Warning,
53}
54
55/// Specific YAML spec rules that can be violated
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[non_exhaustive]
58pub enum Rule {
59    /// Invalid indentation
60    InvalidIndentation,
61    /// Document markers in wrong context
62    InvalidDocumentMarker,
63    /// Invalid tab usage
64    InvalidTabUsage,
65    /// Missing required syntax elements
66    MissingSyntax,
67    /// Invalid escape sequence
68    InvalidEscape,
69    /// Duplicate keys in mapping
70    DuplicateKeys,
71    /// Invalid anchor/alias usage
72    InvalidAnchor,
73    /// Invalid tag
74    InvalidTag,
75    /// A construct that YAML 1.1 read differently from YAML 1.2
76    LegacyYaml11,
77    /// Other spec violations
78    Other,
79}
80
81impl fmt::Display for Violation {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match &self.location {
84            Some(loc) => write!(
85                f,
86                "[{}] {}: {} ({:?})",
87                match self.severity {
88                    Severity::Error => "ERROR",
89                    Severity::Warning => "WARN",
90                },
91                loc,
92                self.message,
93                self.rule
94            ),
95            None => write!(
96                f,
97                "[{}] {} ({:?})",
98                match self.severity {
99                    Severity::Error => "ERROR",
100                    Severity::Warning => "WARN",
101                },
102                self.message,
103                self.rule
104            ),
105        }
106    }
107}
108
109/// YAML 1.2 specification validator
110///
111/// Performs strict validation checks on parsed YAML documents.
112/// The parser itself is lenient and focuses on error recovery,
113/// while this validator enforces strict spec compliance.
114#[derive(Debug, Clone)]
115pub struct Validator {
116    /// Configuration options
117    config: ValidatorConfig,
118}
119
120/// Configuration for the validator
121#[derive(Debug, Clone)]
122pub struct ValidatorConfig {
123    /// Check for duplicate keys in mappings
124    pub check_duplicate_keys: bool,
125    /// Check indentation rules
126    pub check_indentation: bool,
127    /// Check tab usage restrictions
128    pub check_tabs: bool,
129    /// Check document marker placement
130    pub check_document_markers: bool,
131    /// Check anchor/alias validity
132    pub check_anchors: bool,
133}
134
135impl Default for ValidatorConfig {
136    fn default() -> Self {
137        Self {
138            check_duplicate_keys: true,
139            check_indentation: true,
140            check_tabs: true,
141            check_document_markers: true,
142            check_anchors: true,
143        }
144    }
145}
146
147/// Walk up to the ROOT node from the given node, or return the node itself if it is already ROOT.
148fn find_root(node: &SyntaxNode) -> SyntaxNode {
149    if node.kind() == crate::SyntaxKind::ROOT {
150        return node.clone();
151    }
152    node.ancestors()
153        .find(|n| n.kind() == crate::SyntaxKind::ROOT)
154        .unwrap_or_else(|| node.clone())
155}
156
157/// Does this subtree hold real YAML content, as opposed to only whitespace,
158/// newlines and document markers?
159fn has_content(node: &SyntaxNode) -> bool {
160    node.descendants().any(|n| {
161        matches!(
162            n.kind(),
163            crate::SyntaxKind::MAPPING
164                | crate::SyntaxKind::SEQUENCE
165                | crate::SyntaxKind::SCALAR
166                | crate::SyntaxKind::STRING
167                | crate::SyntaxKind::TAGGED_NODE
168        )
169    })
170}
171
172/// Convert a rowan TextRange to a TextPosition.
173/// The zero-based column `token` starts at.
174///
175/// Walks back through preceding tokens to the last NEWLINE rather than
176/// serializing the document: this runs once per DASH, and rebuilding the whole
177/// text each time made validation quadratic in the file size.
178fn token_column(token: &crate::nodes::SyntaxToken) -> usize {
179    let mut col = 0;
180    let mut cur = token.prev_token();
181    while let Some(t) = cur {
182        let text = t.text();
183        if let Some(i) = text.rfind('\n') {
184            col += text[i + 1..].chars().count();
185            return col;
186        }
187        col += text.chars().count();
188        cur = t.prev_token();
189    }
190    col
191}
192
193/// Is this flow entry an omitted one, i.e. does its value consist solely of
194/// the zero-width implicit-null scalar the parser emits for an empty slot?
195///
196/// See the implicit-null section of the CST invariants in `nodes/mod.rs`.
197fn entry_is_empty(entry: &SyntaxNode) -> bool {
198    entry.text_range().is_empty()
199        || entry
200            .text()
201            .to_string()
202            .trim_matches(|c: char| c.is_whitespace() || c == ',')
203            .is_empty()
204}
205
206fn range_to_text_position(range: rowan::TextRange) -> crate::TextPosition {
207    crate::TextPosition::new(u32::from(range.start()), u32::from(range.end()))
208}
209
210impl Violation {
211    /// An error-severity violation with no associated source range.
212    fn error(rule: Rule, message: impl Into<String>) -> Self {
213        Violation {
214            message: message.into(),
215            location: None,
216            text_range: None,
217            severity: Severity::Error,
218            rule,
219        }
220    }
221
222    /// An error-severity violation covering `range` in the source.
223    fn error_at(rule: Rule, range: rowan::TextRange, message: impl Into<String>) -> Self {
224        Violation {
225            text_range: Some(range_to_text_position(range)),
226            ..Violation::error(rule, message)
227        }
228    }
229
230    /// A warning-severity violation covering `range` in the source.
231    ///
232    /// The document is valid YAML 1.2; a warning says only that some other
233    /// implementation may read it differently.
234    fn warning_at(rule: Rule, range: rowan::TextRange, message: impl Into<String>) -> Self {
235        Violation {
236            severity: Severity::Warning,
237            ..Violation::error_at(rule, range, message)
238        }
239    }
240}
241
242impl Validator {
243    /// Create a new validator with default configuration
244    pub fn new() -> Self {
245        Self {
246            config: ValidatorConfig::default(),
247        }
248    }
249
250    /// Create a validator with custom configuration
251    pub fn with_config(config: ValidatorConfig) -> Self {
252        Self { config }
253    }
254
255    /// Validate a YAML document against YAML 1.2 spec
256    ///
257    /// Returns a list of spec violations. Empty list means strictly compliant.
258    pub fn validate(&self, doc: &Document) -> Vec<Violation> {
259        let mut violations = Vec::new();
260
261        // Check for duplicate directives at document level
262        self.check_duplicate_directives(doc.syntax(), &mut violations);
263
264        // Check for directive without document content
265        self.check_directive_without_document(doc.syntax(), &mut violations);
266
267        // Walk the syntax tree and check for violations
268        // This will catch ERROR nodes created by parser (including content after doc end)
269        self.validate_node(doc.syntax(), &mut violations);
270
271        violations
272    }
273
274    /// Validate from a syntax node (can be ROOT, DOCUMENT, or any node)
275    ///
276    /// This is useful when you need to validate the full parse tree including directives
277    /// that may not be attached to a specific document.
278    pub fn validate_syntax(&self, node: &SyntaxNode) -> Vec<Violation> {
279        let mut violations = Vec::new();
280
281        // Check for duplicate directives
282        self.check_duplicate_directives(node, &mut violations);
283
284        // Check for directives without document content
285        self.check_directives_at_root(node, &mut violations);
286
287        // Check for directives after documents without document end marker
288        self.check_directive_after_document(node, &mut violations);
289
290        // Check that a `%TAG` shorthand is only used in its own document
291        self.check_tag_shorthand_scope(node, &mut violations);
292
293        // Walk the syntax tree and check for violations
294        self.validate_node(node, &mut violations);
295
296        violations
297    }
298
299    /// Check that a `%TAG` shorthand is only used in the document that
300    /// declares it.
301    ///
302    /// A directive applies to the document it introduces, not to the whole
303    /// stream, so `!prefix!` declared before the first `---` is undefined in
304    /// every later document. The YAML test suite's QLJ7 is exactly that.
305    fn check_tag_shorthand_scope(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
306        let mut handles: Vec<String> = Vec::new();
307        let mut documents_seen = 0usize;
308
309        for child in node.children_with_tokens() {
310            match child {
311                rowan::NodeOrToken::Node(ref n) if n.kind() == crate::SyntaxKind::DIRECTIVE => {
312                    // `%TAG !handle! prefix` -- collect the handle it defines.
313                    let text = n.text().to_string();
314                    let mut words = text.split_whitespace();
315                    if words.next() == Some("%TAG") {
316                        if let Some(handle) = words.next() {
317                            if documents_seen == 0 {
318                                handles.push(handle.to_string());
319                            }
320                        }
321                    }
322                }
323                rowan::NodeOrToken::Node(ref n) if n.kind() == crate::SyntaxKind::DOCUMENT => {
324                    documents_seen += 1;
325                    if documents_seen < 2 || handles.is_empty() {
326                        continue;
327                    }
328                    // Any tag in a later document using a first-document
329                    // handle is undefined there.
330                    for tag in n
331                        .descendants_with_tokens()
332                        .filter_map(|c| c.into_token())
333                        .filter(|t| t.kind() == crate::SyntaxKind::TAG)
334                    {
335                        // The lexer splits `!prefix!A` into the TAG tokens
336                        // `!prefix` and `!A`, so match the handle without
337                        // its closing `!`.
338                        if let Some(handle) = handles.iter().find(|h| {
339                            tag.text() == h.as_str() || tag.text() == h.trim_end_matches('!')
340                        }) {
341                            violations.push(Violation::error_at(
342                                Rule::Other,
343                                tag.text_range(),
344                                format!(
345                                    "Tag shorthand {handle} is only defined in the document that declares it"
346                                ),
347                            ));
348                        }
349                    }
350                }
351                _ => {}
352            }
353        }
354    }
355
356    fn validate_node(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
357        use crate::SyntaxKind;
358
359        // Check for tabs in any node's tokens
360        if self.config.check_tabs {
361            self.check_tab_usage(node, violations);
362        }
363
364        // Check current node type
365        // Check for multiple anchor tokens on any node (anchors are tokens, not nodes)
366        if self.config.check_anchors {
367            self.check_multiple_anchors(node, violations);
368        }
369
370        match node.kind() {
371            SyntaxKind::ERROR => {
372                // Parser has marked this as erroneous content
373                // Report it as a validation error
374                let content = node.text().to_string();
375                let preview = if content.len() > 50 {
376                    format!("{}...", &content[..50])
377                } else {
378                    content
379                };
380                violations.push(Violation::error_at(
381                    Rule::Other,
382                    node.text_range(),
383                    format!("Invalid content in document: {preview:?}"),
384                ));
385            }
386            SyntaxKind::MAPPING_ENTRY => {
387                // Check for multiline implicit keys
388                self.check_implicit_key_multiline(node, violations);
389                // Check for block sequence on same line as mapping key
390                self.check_sequence_on_same_line_as_key(node, violations);
391            }
392            SyntaxKind::SCALAR => {
393                // Check for invalid escape sequences in quoted strings
394                self.check_escape_sequences(node, violations);
395                // Check for content on same line as block scalar indicator
396                self.check_block_scalar_indicator(node, violations);
397                // Check block scalar content indentation consistency
398                self.check_block_scalar_content_indent(node, violations);
399                // Check for trailing content after quoted strings
400                self.check_trailing_content_after_quoted(node, violations);
401                // Check for colons in plain scalar values
402                self.check_colon_in_plain_scalar(node, violations);
403                // Check for document markers inside quoted strings
404                self.check_document_marker_in_string(node, violations);
405                // Check for directives inside document content (e.g. %YAML after ---)
406                self.check_directive_in_content(node, violations);
407                // Warn about constructs YAML 1.1 read differently
408                self.check_legacy_yaml_1_1(node, violations);
409            }
410            SyntaxKind::DOC_START | SyntaxKind::DOC_END if self.config.check_document_markers => {
411                self.check_document_marker_placement(node, violations);
412            }
413            SyntaxKind::MAPPING => {
414                self.check_flow_collection_commas(node, violations);
415                self.check_flow_continuation_indent(node, violations);
416                self.check_block_mapping_entries_on_same_line(node, violations);
417                self.check_mapping_entry_indentation(node, violations);
418                if self.config.check_duplicate_keys {
419                    self.check_duplicate_keys(node, violations);
420                }
421            }
422            SyntaxKind::SEQUENCE => {
423                self.check_flow_collection_commas(node, violations);
424                self.check_flow_continuation_indent(node, violations);
425                self.check_sequence_entry_in_flow(node, violations);
426            }
427            SyntaxKind::VALUE => {
428                self.check_anchor_and_alias(node, violations);
429            }
430            SyntaxKind::DOCUMENT => {
431                self.check_document_level_anchors(node, violations);
432            }
433            _ => {}
434        }
435
436        // Check tokens (like COMMENT, DOC_START, TAG) that are children but not nodes
437        for element in node.children_with_tokens() {
438            if let Some(token) = element.as_token() {
439                // Check COMMENT tokens for whitespace separation
440                if token.kind() == crate::SyntaxKind::COMMENT {
441                    self.check_comment_token_whitespace(token, violations);
442                }
443                // Check DOC_START tokens for content on same line
444                if token.kind() == crate::SyntaxKind::DOC_START {
445                    self.check_doc_start_token_content(token, violations);
446                }
447                // Check TAG tokens for invalid characters
448                if token.kind() == crate::SyntaxKind::TAG {
449                    self.check_tag_characters(token, violations);
450                    self.check_tag_followed_by_comma(token, violations);
451                }
452            }
453        }
454
455        // Check indentation rules
456        self.check_sequence_indentation(node, violations);
457        self.check_quoted_string_indentation(node, violations);
458
459        // Recursively validate child nodes
460        for child in node.children() {
461            self.validate_node(&child, violations);
462        }
463    }
464
465    /// Check for directive without document content
466    fn check_directive_without_document(
467        &self,
468        doc_node: &SyntaxNode,
469        violations: &mut Vec<Violation>,
470    ) {
471        let root = find_root(doc_node);
472
473        // Check if there are any DIRECTIVE nodes
474        let has_directives = root
475            .descendants()
476            .any(|n| n.kind() == crate::SyntaxKind::DIRECTIVE);
477
478        if !has_directives {
479            return;
480        }
481
482        // Check if the document has any actual content
483        // A document with only whitespace, newlines, or document markers is considered empty
484        let has_content = has_content(doc_node);
485
486        if !has_content {
487            violations.push(Violation::error(
488                Rule::Other,
489                "Directive requires a document with content",
490            ));
491        }
492    }
493
494    /// Check for directives at root level without following document
495    ///
496    /// This checks if the ROOT node has DIRECTIVE children but no DOCUMENT children with content.
497    fn check_directives_at_root(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
498        use crate::SyntaxKind;
499
500        let check_node = find_root(node);
501
502        // Check if there are any DIRECTIVE children
503        let has_directives = check_node
504            .children()
505            .any(|child| child.kind() == SyntaxKind::DIRECTIVE);
506
507        if !has_directives {
508            return;
509        }
510
511        // Check if there's a DOCUMENT child with actual content
512        let has_document_with_content = check_node
513            .children()
514            .any(|child| child.kind() == SyntaxKind::DOCUMENT && has_content(&child));
515
516        if !has_document_with_content {
517            violations.push(Violation::error(
518                Rule::Other,
519                "Directive without document content",
520            ));
521        }
522    }
523
524    /// Check for directives appearing after documents without document end marker (...)
525    ///
526    /// Per YAML spec, if a directive appears after document content, the document
527    /// must be explicitly ended with `...` before the directive.
528    fn check_directive_after_document(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
529        use crate::SyntaxKind;
530
531        let check_node = find_root(node);
532
533        // Track if we've seen a document with content
534        let mut seen_document_with_content = false;
535
536        for child in check_node.children() {
537            match child.kind() {
538                SyntaxKind::DOCUMENT => {
539                    // Whether this document carries a DOC_END is rechecked from
540                    // the DIRECTIVE arm below, by walking back to this sibling.
541                    if has_content(&child) {
542                        seen_document_with_content = true;
543                    }
544                }
545                SyntaxKind::DIRECTIVE if seen_document_with_content => {
546                    // If we've seen a document with content and the last document didn't have DOC_END
547                    // Check if the previous DOCUMENT had a DOC_END
548                    let mut prev_sibling = child.prev_sibling();
549                    let mut found_doc_with_end = false;
550
551                    while let Some(prev) = prev_sibling {
552                        if prev.kind() == SyntaxKind::DOCUMENT {
553                            // Check if this document has DOC_END
554                            let has_doc_end = prev
555                                .children_with_tokens()
556                                .any(|t| t.kind() == SyntaxKind::DOC_END);
557
558                            if has_doc_end {
559                                found_doc_with_end = true;
560                            }
561                            break;
562                        }
563                        prev_sibling = prev.prev_sibling();
564                    }
565
566                    if !found_doc_with_end {
567                        violations.push(Violation::error(
568                            Rule::Other,
569                            "Directive after document requires document end marker (...)",
570                        ));
571                    }
572                }
573                _ => {}
574            }
575        }
576    }
577
578    /// Check for directive tokens inside document content.
579    ///
580    /// When the parser encounters `%YAML 1.2` after a `---` without a preceding `...`,
581    /// it parses the directive as scalar content. This check catches that case.
582    fn check_directive_in_content(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
583        use crate::SyntaxKind;
584
585        let has_directive = node
586            .children_with_tokens()
587            .any(|c| c.kind() == SyntaxKind::DIRECTIVE);
588        // Only when a document really follows. With none, the `%` line is
589        // just more of this scalar -- the test suite's XLQ9 reads
590        // `---\nscalar\n%YAML 1.2\n` as the scalar `scalar %YAML 1.2`,
591        // where EB22 and RHX7 go on to open a second document with `---`.
592        let a_document_follows = node
593            .ancestors()
594            .find(|a| a.kind() == SyntaxKind::DOCUMENT)
595            .is_some_and(|d| {
596                d.siblings_with_tokens(rowan::Direction::Next)
597                    .skip(1)
598                    .filter(|c| {
599                        !c.as_token().is_some_and(|t| {
600                            matches!(
601                                t.kind(),
602                                SyntaxKind::NEWLINE
603                                    | SyntaxKind::WHITESPACE
604                                    | SyntaxKind::INDENT
605                                    | SyntaxKind::COMMENT
606                            )
607                        })
608                    })
609                    .any(|c| {
610                        c.as_node()
611                            .is_some_and(|n| n.kind() == SyntaxKind::DOCUMENT)
612                            || c.as_token()
613                                .is_some_and(|t| t.kind() == SyntaxKind::DOC_START)
614                    })
615            });
616        if has_directive && a_document_follows {
617            violations.push(Violation::error_at(Rule::Other, node.text_range(), "Directive in document content (missing document end marker `...` before directive)".to_string()));
618        }
619    }
620
621    /// Check for duplicate YAML directives
622    fn check_duplicate_directives(&self, doc_node: &SyntaxNode, violations: &mut Vec<Violation>) {
623        use std::collections::HashMap;
624
625        let root = find_root(doc_node);
626
627        // Collect all directives and count by type
628        let mut directive_counts: HashMap<String, usize> = HashMap::new();
629
630        for node in root.descendants() {
631            if node.kind() == crate::SyntaxKind::DIRECTIVE {
632                // Get the directive text (e.g., "%YAML 1.2" or "%TAG ! tag:yaml.org,2002:")
633                let text = node.text().to_string();
634
635                // Extract directive type (YAML, TAG, etc.)
636                if let Some(directive_type) = text.split_whitespace().next() {
637                    *directive_counts
638                        .entry(directive_type.to_string())
639                        .or_insert(0) += 1;
640                }
641            }
642        }
643
644        // Check for duplicates
645        for (directive_type, count) in directive_counts {
646            if count > 1 {
647                violations.push(Violation::error(
648                    Rule::Other,
649                    format!("Duplicate {directive_type} directive"),
650                ));
651            }
652        }
653    }
654
655    /// Check for multiple anchors on the same node
656    fn check_multiple_anchors(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
657        // Count ANCHOR tokens (not nodes) in this node's children. An
658        // anchor that annotates a complex key now sits inside that KEY, so
659        // 6BFJ's `&mapping\n&key [ ... ]: v` leaves one here while 4JVG's
660        // two anchors on a single value still leave both.
661        let adjacent_anchors = node
662            .children_with_tokens()
663            .filter(|child| {
664                child
665                    .as_token()
666                    .is_some_and(|t| t.kind() == crate::SyntaxKind::ANCHOR)
667            })
668            .count();
669
670        if adjacent_anchors > 1 {
671            violations.push(Violation::error_at(
672                Rule::InvalidAnchor,
673                node.text_range(),
674                "Multiple anchors on the same node",
675            ));
676        }
677    }
678
679    /// Check for invalid escape sequences in quoted strings
680    fn check_escape_sequences(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
681        // Check first character to see if this is a quoted string - no allocation needed
682        let first_char = node.first_token().and_then(|t| t.text().chars().next());
683        if first_char != Some('"') {
684            return;
685        }
686
687        // Scan for escape sequences (requires one allocation for the text)
688        let text = node.text().to_string();
689        let mut chars = text.chars().peekable();
690
691        while let Some(ch) = chars.next() {
692            if ch == '\\' {
693                if let Some(&next) = chars.peek() {
694                    // Valid escape sequences in YAML 1.2
695                    // A `\\` before a line break escapes that break, folding
696                    // the line: the test suite's 565N wraps a long binary
697                    // value that way.
698                    let valid_escapes = [
699                        '0', 'a', 'b', 't', 'n', 'v', 'f', 'r', 'e', ' ', '"', '/', '\\', 'N', '_',
700                        'L', 'P', 'x', 'u', 'U', '\n', '\r', '\t',
701                    ];
702
703                    if !valid_escapes.contains(&next) {
704                        violations.push(Violation::error_at(
705                            Rule::InvalidEscape,
706                            node.text_range(),
707                            format!("Invalid escape sequence: \\{next}"),
708                        ));
709                        return; // Found one, no need to continue
710                    }
711                    // Consume the escaped character, or `\\$` reads as a
712                    // valid `\\` followed by an invalid `\$` (test suite
713                    // 6SLA).
714                    chars.next();
715                }
716            }
717        }
718    }
719
720    /// Check for content on the same line as block scalar indicator (| or >)
721    ///
722    /// Per YAML spec, block scalar content must start on the line after the indicator.
723    /// Only chomping indicators (+/-) and indentation indicators (1-9) are allowed
724    /// on the same line as the block scalar indicator.
725    fn check_block_scalar_indicator(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
726        // Check if this scalar has a GREATER (folded) or PIPE (literal) indicator
727        let has_block_indicator = has_child_token(node, |k| {
728            matches!(k, crate::SyntaxKind::GREATER | crate::SyntaxKind::PIPE)
729        });
730
731        if !has_block_indicator {
732            return;
733        }
734
735        // Check if any STRING tokens appear before the first NEWLINE after the indicator
736        let mut found_indicator = false;
737        let mut found_newline = false;
738
739        for child in node.children_with_tokens() {
740            if let rowan::NodeOrToken::Token(token) = child {
741                // Mark when we find the block indicator
742                if matches!(
743                    token.kind(),
744                    crate::SyntaxKind::GREATER | crate::SyntaxKind::PIPE
745                ) {
746                    found_indicator = true;
747                    continue;
748                }
749
750                // After indicator, before newline
751                if found_indicator && !found_newline {
752                    match token.kind() {
753                        crate::SyntaxKind::NEWLINE => {
754                            found_newline = true;
755                        }
756                        // A chomping indicator and an indentation digit may
757                        // share the indicator's line; the lexer gives `-`,
758                        // `+`, `2-` and `-2` as STRING, so they are not the
759                        // content this rule is looking for.
760                        crate::SyntaxKind::STRING
761                            if token.text().chars().all(|c| matches!(c, '+' | '-'))
762                                || (token.text().len() == 2
763                                    && token.text().chars().any(|c| c.is_ascii_digit())
764                                    && token.text().chars().any(|c| matches!(c, '+' | '-'))) => {}
765                        crate::SyntaxKind::STRING => {
766                            // Found content on same line as indicator
767                            violations.push(Violation::error(
768                                Rule::Other,
769                                "Block scalar content cannot appear on same line as indicator",
770                            ));
771                            return;
772                        }
773                        // An indentation indicator is a single digit 1-9;
774                        // `|0` and `|10` are errors the YAML test suite
775                        // expects (2G84).
776                        // `|2-` and `|-2` both carry the digit 2, so strip a
777                        // chomping indicator before checking it (D83L).
778                        crate::SyntaxKind::INT
779                            if !matches!(
780                                token.text().trim_matches(['+', '-']),
781                                "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"
782                            ) =>
783                        {
784                            violations.push(Violation::error(
785                                Rule::Other,
786                                "Block scalar indentation indicator must be a digit 1-9",
787                            ));
788                            return;
789                        }
790                        // WHITESPACE, COMMENT, and chomping/indentation indicators are OK
791                        _ => {}
792                    }
793                }
794            }
795        }
796    }
797
798    /// Check block scalar (`|` or `>`) content indentation.
799    ///
800    /// Once a blank line at indentation N has appeared, no later
801    /// non-blank content line may be at indentation < N (per YAML 1.2
802    /// example 5LLU). This flags the case where the content indent
803    /// implicitly established by empty-line whitespace runs is later
804    /// under-cut.
805    fn check_block_scalar_content_indent(
806        &self,
807        node: &SyntaxNode,
808        violations: &mut Vec<Violation>,
809    ) {
810        let has_block_indicator = has_child_token(node, |k| {
811            matches!(k, crate::SyntaxKind::GREATER | crate::SyntaxKind::PIPE)
812        });
813        if !has_block_indicator {
814            return;
815        }
816
817        // Walk tokens after the block indicator's NEWLINE. Track the
818        // deepest INDENT seen so far (from blank-only lines); flag any
819        // subsequent INDENT + non-blank content whose text is shorter.
820        //
821        // Indentation is spaces only (s-indent), so a tab in an INDENT
822        // token starts the line's content: R4YG's " \t" line is indented
823        // one, not two, and its expected events keep the tab.
824        fn space_indent(token: &rowan::SyntaxToken<crate::Lang>) -> usize {
825            token.text().chars().take_while(|c| *c == ' ').count()
826        }
827
828        let mut past_first_newline = false;
829        let mut max_blank_indent = 0usize;
830        let mut body_indent: Option<usize> = None;
831        let mut pending_indent: Option<rowan::SyntaxToken<crate::Lang>> = None;
832        for child in node.children_with_tokens() {
833            let Some(token) = child.as_token().cloned() else {
834                continue;
835            };
836            if !past_first_newline {
837                if token.kind() == crate::SyntaxKind::NEWLINE {
838                    past_first_newline = true;
839                }
840                continue;
841            }
842            match token.kind() {
843                crate::SyntaxKind::INDENT => {
844                    pending_indent = Some(token);
845                }
846                crate::SyntaxKind::NEWLINE => {
847                    // Blank line: the pending INDENT (if any) tells us
848                    // how far the blank line was padded. A tab makes the
849                    // line non-blank, so it carries no indentation claim.
850                    if let Some(ind) = pending_indent.take() {
851                        if !ind.text().contains('\t') {
852                            max_blank_indent = max_blank_indent.max(space_indent(&ind));
853                        }
854                    }
855                }
856                _ => {
857                    // Non-blank content. Once a content line has set the
858                    // body's indent, a deeper blank line is content rather
859                    // than indentation and cannot raise the bar: H2RW's
860                    // 4-space blank contributes two spaces to a body
861                    // indented 2, as its expected events show. Only a blank
862                    // deeper than the body's own indent, seen before any
863                    // content, is 5LLU's error.
864                    if let Some(ind) = pending_indent.take() {
865                        if body_indent.is_none() {
866                            let indent = space_indent(&ind);
867                            body_indent = Some(indent);
868                            if indent < max_blank_indent {
869                                violations.push(Violation::error_at(Rule::Other, ind.text_range(), format!( "Block scalar content under-indented ({indent} spaces) relative to preceding blank line ({max_blank_indent} spaces)" )));
870                                return;
871                            }
872                        }
873                    }
874                }
875            }
876        }
877    }
878
879    /// Warn about plain scalars that YAML 1.1 resolved differently.
880    ///
881    /// The document is valid either way, so these are warnings: they say the
882    /// text means one thing here and another to a 1.1 reader.
883    ///
884    /// Bare-octal is the case that changes a value silently. `0755` was 493
885    /// in YAML 1.1 and is 755 in 1.2, with no syntax error either way, so a
886    /// file carried across versions changes meaning without complaint. The
887    /// 1.1 booleans (`yes`, `no`, `on`, `off`) are already strings here, as
888    /// 1.2 requires, but a 1.1 reader still takes them as booleans.
889    fn check_legacy_yaml_1_1(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
890        // Only a plain scalar resolves by its text; a quoted one is a string
891        // in every version.
892        let Some(token) = node.children_with_tokens().find_map(|c| {
893            c.into_token().filter(|t| {
894                matches!(
895                    t.kind(),
896                    crate::SyntaxKind::INT | crate::SyntaxKind::STRING | crate::SyntaxKind::BOOL
897                )
898            })
899        }) else {
900            return;
901        };
902        let text = token.text();
903        if text.starts_with(['"', '\'']) {
904            return;
905        }
906
907        if crate::ScalarValue::is_legacy_octal(text) {
908            let sign = if text.starts_with('-') { "-" } else { "" };
909            let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
910            let Ok(as_octal) = i64::from_str_radix(digits, 8) else {
911                return;
912            };
913            let Ok(as_decimal) = digits.parse::<i64>() else {
914                return;
915            };
916            violations.push(Violation::warning_at(
917                Rule::LegacyYaml11,
918                token.text_range(),
919                format!(
920                    "`{text}` is YAML 1.1 octal, read as {sign}{as_octal}; YAML 1.2 reads it as {sign}{as_decimal}. Write `{sign}0o{}` to keep the octal meaning",
921                    digits.trim_start_matches('0')
922                ),
923            ));
924            return;
925        }
926
927        if matches!(
928            text,
929            "yes" | "no" | "on" | "off" | "Yes" | "No" | "On" | "Off" | "YES" | "NO" | "ON" | "OFF"
930        ) {
931            violations.push(Violation::warning_at(
932                Rule::LegacyYaml11,
933                token.text_range(),
934                format!("`{text}` is the string \"{text}\" in YAML 1.2, but a boolean to a YAML 1.1 reader; quote it or write true/false to be unambiguous"),
935            ));
936        }
937    }
938
939    /// Check for trailing content after quoted strings
940    ///
941    /// After a quoted string (double or single) closes, only whitespace, newlines,
942    /// or comments should follow. Additional content on the same line is invalid.
943    fn check_trailing_content_after_quoted(
944        &self,
945        node: &SyntaxNode,
946        violations: &mut Vec<Violation>,
947    ) {
948        let mut found_quoted = false;
949        let mut found_quote_end = false;
950        let mut found_newline = false;
951
952        for child in node.children_with_tokens() {
953            if let rowan::NodeOrToken::Token(token) = child {
954                match token.kind() {
955                    crate::SyntaxKind::STRING => {
956                        let text = token.text();
957
958                        // Check if this is a quoted string (starts with " or ')
959                        if !found_quoted && (text.starts_with('"') || text.starts_with('\'')) {
960                            found_quoted = true;
961
962                            // Check if quote ends in this same token
963                            if text.len() > 1 && (text.ends_with('"') || text.ends_with('\'')) {
964                                found_quote_end = true;
965                            }
966                        } else if found_quoted && !found_quote_end {
967                            // Still inside the quoted string
968                            if text.ends_with('"') || text.ends_with('\'') {
969                                found_quote_end = true;
970                            }
971                        } else if found_quote_end && !found_newline {
972                            // Found content after quoted string ended, before newline
973                            violations.push(Violation::error(
974                                Rule::Other,
975                                "Trailing content after quoted string",
976                            ));
977                            return;
978                        }
979                    }
980                    crate::SyntaxKind::NEWLINE => {
981                        found_newline = true;
982                    }
983                    crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::COMMENT => {
984                        // These are allowed after quoted strings
985                    }
986                    _ => {}
987                }
988            }
989        }
990    }
991
992    /// Check for colons in plain scalar values
993    ///
994    /// Plain scalars in block context cannot contain `: ` (colon followed by space)
995    /// without being quoted. This indicates an attempt to create a nested mapping
996    /// within a plain scalar, which is invalid.
997    fn check_colon_in_plain_scalar(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
998        // Check if this scalar contains COLON tokens
999        let has_colon = has_child_token(node, |k| k == crate::SyntaxKind::COLON);
1000
1001        if !has_colon {
1002            return;
1003        }
1004
1005        // Check if this is a quoted string (which can contain colons)
1006        let is_quoted = node.first_token().is_some_and(|t| {
1007            let text = t.text();
1008            text.starts_with('"') || text.starts_with('\'')
1009        });
1010
1011        // A block scalar's body is literal text, so a colon there is content
1012        // just as it is in a quoted scalar: the test suite's 4WA9 is
1013        // `- aaa: |2\n    xxx\n  bbb: |\n    xxx\n`.
1014        if node.first_token().is_some_and(|t| {
1015            matches!(
1016                t.kind(),
1017                crate::SyntaxKind::PIPE | crate::SyntaxKind::GREATER
1018            )
1019        }) {
1020            return;
1021        }
1022
1023        if is_quoted {
1024            return;
1025        }
1026
1027        // Check if this scalar is inside a VALUE node (not a KEY)
1028        // Keys can have plain text without issues, but values with colons need special handling
1029        let parent_is_value = node
1030            .parent()
1031            .is_some_and(|p| p.kind() == crate::SyntaxKind::VALUE);
1032
1033        if parent_is_value {
1034            violations.push(Violation::error(
1035                Rule::Other,
1036                "Plain scalar value cannot contain mapping syntax (colon)",
1037            ));
1038        }
1039    }
1040
1041    /// Check for document markers (--- or ...) appearing in quoted strings
1042    ///
1043    /// Document markers on their own line should always be recognized as document
1044    /// boundaries, even if they appear to be within a quoted string. A quoted string
1045    /// containing "\n---\n" or "\n...\n" is invalid.
1046    fn check_document_marker_in_string(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1047        // Get the text of the scalar
1048        let text = node.text().to_string();
1049
1050        // Check if this is a quoted string
1051        if !text.starts_with('"') && !text.starts_with('\'') {
1052            return;
1053        }
1054
1055        // Check for document markers on their own line within the string
1056        // Look for \n--- or \n... where the marker is followed by \n or end of string
1057        if text.contains("\n---\n")
1058            || text.contains("\n---\"")
1059            || text.contains("\n---'")
1060            || text.contains("\n...\n")
1061            || text.contains("\n...\"")
1062            || text.contains("\n...'")
1063        {
1064            violations.push(Violation::error_at(
1065                Rule::InvalidDocumentMarker,
1066                node.text_range(),
1067                "Document marker on its own line inside quoted string",
1068            ));
1069        }
1070    }
1071
1072    /// Check for tab usage in whitespace nodes
1073    fn check_tab_usage(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1074        // Only check whitespace nodes - more efficient than serializing everything
1075        // Check each token directly without allocation
1076        for token in node.children_with_tokens() {
1077            if let rowan::NodeOrToken::Token(token) = token {
1078                // A block scalar whose body holds no content has nothing to
1079                // continue, so a tab line there is the body's indentation,
1080                // which may not be one: the test suite's Y79Y/000 is the
1081                // error `foo: |\n\t\nbar: 1`.
1082                if token.kind() == crate::SyntaxKind::INDENT
1083                    // The tab has to *be* the indentation. After a space it
1084                    // is content, which is how the suite reads R4YG's
1085                    // `- >\n \t\n detected`.
1086                    && token.text().starts_with('\t')
1087                    && node.first_token().is_some_and(|t| {
1088                        matches!(
1089                            t.kind(),
1090                            crate::SyntaxKind::PIPE | crate::SyntaxKind::GREATER
1091                        )
1092                    })
1093                    // The body's first indentation, right after the header's
1094                    // line break.
1095                    && node
1096                        .children_with_tokens()
1097                        .filter_map(|c| c.into_token())
1098                        .find(|t| t.kind() == crate::SyntaxKind::INDENT)
1099                        .is_some_and(|first| first == token)
1100                {
1101                    violations.push(Violation::error_at(
1102                        Rule::InvalidTabUsage,
1103                        token.text_range(),
1104                        "Tabs are not allowed for indentation in YAML",
1105                    ));
1106                    return;
1107                }
1108                // A quoted scalar keeps its continuation lines inside one
1109                // token, so check its text directly: a continuation opening
1110                // with a tab supplies no indentation at all, which
1111                // DK95/01's `foo: "bar\n\tbaz"` needs. A blank tab line
1112                // (DK95/04) indents nothing and is fine.
1113                // Only where indentation is actually required: a quoted
1114                // scalar that is the document's own node needs none, so a
1115                // tab there is content (7A4E, PRH3), while one inside a
1116                // mapping value must clear the key's column (DK95/01).
1117                if node.kind() == crate::SyntaxKind::SCALAR
1118                    && node
1119                        .parent()
1120                        .is_some_and(|p| p.kind() == crate::SyntaxKind::VALUE)
1121                    && token.text().starts_with(['"', '\''])
1122                    && token
1123                        .text()
1124                        .split('\n')
1125                        .skip(1)
1126                        .any(|line| line.starts_with('\t') && !line.trim().is_empty())
1127                {
1128                    violations.push(Violation::error_at(
1129                        Rule::InvalidTabUsage,
1130                        token.text_range(),
1131                        "Tabs are not allowed for indentation in YAML",
1132                    ));
1133                    return;
1134                }
1135                // Only an INDENT that supplies a node's indentation can hold
1136                // an illegal tab. YAML 1.2 forbids a tab in `s-indent`, but
1137                // elsewhere -- inside a scalar's continuation line, or as
1138                // separation at the stream's own level -- it is content or
1139                // legal whitespace, which is how the test suite reads 4ZYM,
1140                // HS5T, NB6Z, UV7Q, 6CA3, K54U and T5N4.
1141                if !matches!(
1142                    node.kind(),
1143                    crate::SyntaxKind::VALUE
1144                        | crate::SyntaxKind::MAPPING
1145                        | crate::SyntaxKind::SEQUENCE
1146                        | crate::SyntaxKind::MAPPING_ENTRY
1147                        | crate::SyntaxKind::SEQUENCE_ENTRY
1148                ) {
1149                    continue;
1150                }
1151                // A flow collection at the stream's own level is not
1152                // indentation-sensitive, so a tab inside one is separation
1153                // whitespace (6CA3's `\t[\n\t]`). Nested in a block
1154                // collection it still has to clear that block's column, as
1155                // Y79Y/003's `- [\n\tfoo,` does not.
1156                let is_flow = node.first_token().is_some_and(|t| {
1157                    matches!(
1158                        t.kind(),
1159                        crate::SyntaxKind::LEFT_BRACKET | crate::SyntaxKind::LEFT_BRACE
1160                    )
1161                });
1162                let inside_a_block = node.ancestors().skip(1).any(|a| {
1163                    matches!(
1164                        a.kind(),
1165                        crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
1166                    )
1167                });
1168                if is_flow && !inside_a_block {
1169                    continue;
1170                }
1171                // A tab may separate an indicator from a scalar (`- \tx`),
1172                // but a block collection after it needs `s-indent`, which
1173                // forbids tabs: `-\t-` and `?\t-` are errors the test suite
1174                // expects (Y79Y), while `-\t-1` is the scalar `-1`.
1175                // The tab has to separate the indicator from the node on
1176                // the same line; trailing whitespace before a line break
1177                // (DC7X's `seq:\t`) indents nothing.
1178                if token.kind() == crate::SyntaxKind::WHITESPACE
1179                    && token.text().contains('\t')
1180                    && !token
1181                        .next_sibling_or_token()
1182                        .and_then(|n| n.into_node())
1183                        .and_then(|n| n.first_token())
1184                        .is_some_and(|t| t.kind() == crate::SyntaxKind::NEWLINE)
1185                    && token
1186                        .next_sibling_or_token()
1187                        .and_then(|n| n.into_node())
1188                        .is_some_and(|n| {
1189                            let opens_a_block = |k| {
1190                                matches!(
1191                                    k,
1192                                    crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
1193                                )
1194                            };
1195                            opens_a_block(n.kind())
1196                                || (matches!(
1197                                    n.kind(),
1198                                    crate::SyntaxKind::VALUE | crate::SyntaxKind::KEY
1199                                ) && n.children().any(|c| opens_a_block(c.kind())))
1200                        })
1201                {
1202                    violations.push(Violation::error_at(
1203                        Rule::InvalidTabUsage,
1204                        token.text_range(),
1205                        "Tabs are not allowed for indentation in YAML",
1206                    ));
1207                    return;
1208                }
1209                if token.kind() != crate::SyntaxKind::INDENT {
1210                    continue;
1211                }
1212                // Check the token text directly - this is a cheap slice operation
1213                if token.text().contains('\t') {
1214                    violations.push(Violation::error_at(
1215                        Rule::InvalidTabUsage,
1216                        token.text_range(),
1217                        "Tabs are not allowed for indentation in YAML",
1218                    ));
1219                    return; // Found one, no need to keep checking
1220                }
1221            }
1222        }
1223    }
1224
1225    /// Check document marker placement
1226    fn check_document_marker_placement(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1227        // Document markers should only appear at document boundaries
1228        // Check if marker is in inappropriate context (e.g., inside a quoted string)
1229        if let Some(parent) = node.parent() {
1230            if matches!(
1231                parent.kind(),
1232                crate::SyntaxKind::STRING | crate::SyntaxKind::SCALAR
1233            ) {
1234                violations.push(Violation::error_at(
1235                    Rule::InvalidDocumentMarker,
1236                    node.text_range(),
1237                    "Document marker inside string is invalid",
1238                ));
1239            }
1240        }
1241    }
1242
1243    /// Check for missing commas in flow collections
1244    fn check_flow_collection_commas(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1245        // Check first token to see if this is a flow collection - avoid full serialization
1246        let first_token = node.first_token();
1247        let is_flow_mapping = first_token
1248            .as_ref()
1249            .is_some_and(|t| t.kind() == crate::SyntaxKind::LEFT_BRACE);
1250        let is_flow_sequence = first_token
1251            .as_ref()
1252            .is_some_and(|t| t.kind() == crate::SyntaxKind::LEFT_BRACKET);
1253
1254        if !is_flow_mapping && !is_flow_sequence {
1255            return;
1256        }
1257
1258        // Count entries and commas
1259        let entry_kind = if is_flow_mapping {
1260            crate::SyntaxKind::MAPPING_ENTRY
1261        } else {
1262            crate::SyntaxKind::SEQUENCE_ENTRY
1263        };
1264
1265        // A flow separator is stored inside the entry it follows (see the
1266        // flow-separator invariant in nodes/mod.rs), and an omitted entry
1267        // shows up as one whose value is the zero-width implicit null. So
1268        // `[a, , b]` and `[a, b, ,]` are entries with an empty value rather
1269        // than adjacent COMMA tokens.
1270        let entries: Vec<_> = node.children().filter(|n| n.kind() == entry_kind).collect();
1271        let entry_count = entries.len();
1272        let comma_count = entries
1273            .iter()
1274            .filter(|e| crate::nodes::has_child_token(e, |k| k == crate::SyntaxKind::COMMA))
1275            .count();
1276
1277        for entry in &entries {
1278            if entry_is_empty(entry) {
1279                violations.push(Violation::error_at(
1280                    Rule::Other,
1281                    node.text_range(),
1282                    "Empty entry in flow collection",
1283                ));
1284                break;
1285            }
1286        }
1287
1288        // Flow collections need n-1 commas for n entries (except when trailing comma)
1289        if entry_count > 1 && comma_count < entry_count - 1 {
1290            violations.push(Violation::error(Rule::MissingSyntax, format!( "Flow collection missing commas: {entry_count} entries but only {comma_count} commas" )));
1291        }
1292    }
1293
1294    /// Check for multiple mapping entries on the same line in block mappings
1295    ///
1296    /// In block mappings (not flow mappings with {}), each mapping entry should
1297    /// be on its own line. Multiple entries on the same line are invalid.
1298    fn check_block_mapping_entries_on_same_line(
1299        &self,
1300        node: &SyntaxNode,
1301        violations: &mut Vec<Violation>,
1302    ) {
1303        // Check if this is a flow mapping (which allows same-line entries)
1304        let first_token = node.first_token();
1305        let is_flow_mapping = first_token.as_ref().is_some_and(|t| t.text() == "{");
1306
1307        if is_flow_mapping {
1308            return; // Flow mappings can have entries on same line
1309        }
1310
1311        // Check for consecutive MAPPING_ENTRY nodes without NEWLINE between them
1312        let mut prev_entry: Option<SyntaxNode> = None;
1313
1314        for child in node.children() {
1315            if child.kind() == crate::SyntaxKind::MAPPING_ENTRY {
1316                if let Some(prev) = prev_entry {
1317                    // Check if there's a NEWLINE between prev and current entry.
1318                    // The newline may be inside the previous entry (as its last
1319                    // token) or between entries as a sibling token.
1320                    let has_newline_between = {
1321                        // First check if the previous entry ends with a newline.
1322                        //
1323                        // An explicit key's entry ends with the zero-width
1324                        // implicit-null scalar of its VALUE, so look past
1325                        // tokens that render as nothing: `? a\n? b\n` has its
1326                        // newline inside the first entry, not between them.
1327                        let prev_ends_with_newline = prev
1328                            .descendants_with_tokens()
1329                            .filter_map(|c| c.into_token())
1330                            .filter(|t| !t.text().is_empty())
1331                            .last()
1332                            .is_some_and(|t| t.kind() == crate::SyntaxKind::NEWLINE);
1333
1334                        if prev_ends_with_newline {
1335                            true
1336                        } else {
1337                            // Check sibling tokens between the entries
1338                            let mut current_sibling = prev.next_sibling_or_token();
1339                            let mut found_newline = false;
1340
1341                            while let Some(sibling) = current_sibling {
1342                                if let rowan::NodeOrToken::Node(n) = &sibling {
1343                                    if n == &child {
1344                                        break;
1345                                    }
1346                                }
1347
1348                                if let rowan::NodeOrToken::Token(t) = &sibling {
1349                                    if t.kind() == crate::SyntaxKind::NEWLINE {
1350                                        found_newline = true;
1351                                        break;
1352                                    }
1353                                }
1354
1355                                current_sibling = sibling.next_sibling_or_token();
1356                            }
1357
1358                            found_newline
1359                        }
1360                    };
1361
1362                    if !has_newline_between {
1363                        violations.push(Violation::error(
1364                            Rule::Other,
1365                            "Block mapping entries must be on separate lines",
1366                        ));
1367                        return; // One violation is enough
1368                    }
1369                }
1370
1371                prev_entry = Some(child);
1372            }
1373        }
1374    }
1375
1376    /// Check that sibling MAPPING_ENTRY nodes inside a block mapping
1377    /// share a consistent indent.
1378    ///
1379    /// The parser is deliberately lenient: it accepts
1380    ///     k1: v1
1381    ///      k2: v2
1382    /// and produces two MAPPING_ENTRY children separated by an INDENT
1383    /// " " token as a direct child of MAPPING. Similarly for
1384    ///     key:
1385    ///       ok: 1
1386    ///      wrong: 2
1387    /// (yaml-test-suite EW3V / DMG6 / N4JP / U44R). Flag inconsistent
1388    /// leading indent tokens between block entries.
1389    fn check_mapping_entry_indentation(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1390        // Flow mappings ({...}) don't have INDENT tokens between entries.
1391        let first_token = node.first_token();
1392        let is_flow_mapping = first_token
1393            .as_ref()
1394            .is_some_and(|t| t.kind() == crate::SyntaxKind::LEFT_BRACE);
1395        if is_flow_mapping {
1396            return;
1397        }
1398
1399        // Determine the expected indent for entries in this mapping.
1400        //   - Nested mapping (parent VALUE has NEWLINE + INDENT before
1401        //     us): use that INDENT's text.
1402        //   - Root mapping (child of DOCUMENT): the empty string.
1403        //
1404        // Then walk direct children. Every INDENT token that precedes a
1405        // MAPPING_ENTRY (i.e. separates two entries) must equal the
1406        // expected indent. Otherwise the parser has admitted a
1407        // wrong-indented sibling that the YAML spec rejects (see
1408        // yaml-test-suite EW3V / DMG6 / N4JP / U44R).
1409        // A mapping that is a sequence entry's value has its INDENT in the
1410        // SEQUENCE_ENTRY rather than a VALUE, in the same position relative
1411        // to the MAPPING. Without it the expected indent fell back to the
1412        // empty string and every entry of `-\n  a: 1\n  b: 2\n` looked
1413        // wrongly indented.
1414        let expected_indent: String = node
1415            .parent()
1416            .filter(|p| {
1417                matches!(
1418                    p.kind(),
1419                    crate::SyntaxKind::VALUE | crate::SyntaxKind::SEQUENCE_ENTRY
1420                )
1421            })
1422            .and_then(|parent_value| {
1423                // Look for the INDENT token that immediately precedes
1424                // this MAPPING in the parent VALUE.
1425                let mut last_indent: Option<String> = None;
1426                for el in parent_value.children_with_tokens() {
1427                    match el {
1428                        rowan::NodeOrToken::Node(n) if &n == node => break,
1429                        rowan::NodeOrToken::Token(t) if t.kind() == crate::SyntaxKind::INDENT => {
1430                            last_indent = Some(t.text().to_string());
1431                        }
1432                        _ => {}
1433                    }
1434                }
1435                last_indent
1436            })
1437            .or_else(|| {
1438                // A compact mapping on a sequence entry's own line has no
1439                // INDENT of its own (`- key: value\n  key2: value2\n`); its
1440                // entries line up under the dash's gap. Take the column from
1441                // the node's offset within its line.
1442                let parent = node.parent()?;
1443                if parent.kind() != crate::SyntaxKind::SEQUENCE_ENTRY {
1444                    return None;
1445                }
1446                // Walk back over the preceding tokens to the line break,
1447                // summing their widths. Materialising the whole document to
1448                // find the line start made this quadratic in its size.
1449                let mut column = 0usize;
1450                let mut token = node.first_token()?.prev_token();
1451                while let Some(t) = token {
1452                    if t.kind() == crate::SyntaxKind::NEWLINE {
1453                        break;
1454                    }
1455                    column += t.text().len();
1456                    token = t.prev_token();
1457                }
1458                Some(" ".repeat(column))
1459            })
1460            .unwrap_or_default();
1461
1462        // Only an INDENT that introduces a sibling entry states that
1463        // entry's indentation. One followed by a NEWLINE pads a blank
1464        // line (H2RW), and a trailing one belongs to the enclosing
1465        // construct rather than to this mapping (V9D5's explicit key).
1466        let mut seen_entry = false;
1467        let mut pending: Option<rowan::SyntaxToken<crate::Lang>> = None;
1468        for child in node.children_with_tokens() {
1469            match child {
1470                rowan::NodeOrToken::Token(t) if t.kind() == crate::SyntaxKind::INDENT => {
1471                    pending = Some(t);
1472                }
1473                rowan::NodeOrToken::Node(n) if n.kind() == crate::SyntaxKind::MAPPING_ENTRY => {
1474                    if let Some(t) = pending.take() {
1475                        if seen_entry && t.text() != expected_indent {
1476                            violations.push(Violation::error_at(Rule::Other, t.text_range(), format!( "Sibling block mapping entries have inconsistent indentation (expected {:?}, found {:?})", expected_indent, t.text() )));
1477                        }
1478                    }
1479                    seen_entry = true;
1480                }
1481                _ => {
1482                    pending = None;
1483                }
1484            }
1485        }
1486    }
1487
1488    /// Check for SEQUENCE_ENTRY nodes in flow sequences
1489    ///
1490    /// Flow sequences (using []) should not have SEQUENCE_ENTRY children.
1491    /// SEQUENCE_ENTRY is only for block sequences (using -). In flow sequences,
1492    /// values appear directly without the - marker.
1493    /// Check that a multi-line flow collection indents its continuation lines.
1494    ///
1495    /// A flow collection may span lines, but each continuation has to be
1496    /// indented more than the block context it sits in; at column zero the
1497    /// content would be read as a new block node instead (yaml-test-suite
1498    /// 9C9N).
1499    fn check_flow_continuation_indent(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1500        let first = node.first_token();
1501        let is_flow = first.as_ref().is_some_and(|t| {
1502            matches!(
1503                t.kind(),
1504                crate::SyntaxKind::LEFT_BRACE | crate::SyntaxKind::LEFT_BRACKET
1505            )
1506        });
1507        if !is_flow {
1508            return;
1509        }
1510        // Only the outermost flow collection needs checking; a nested one
1511        // shares its lines.
1512        if node.ancestors().skip(1).any(|a| {
1513            matches!(
1514                a.kind(),
1515                crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
1516            ) && a.first_token().is_some_and(|t| {
1517                matches!(
1518                    t.kind(),
1519                    crate::SyntaxKind::LEFT_BRACE | crate::SyntaxKind::LEFT_BRACKET
1520                )
1521            })
1522        }) {
1523            return;
1524        }
1525
1526        // A `---` or `...` at the start of a line ends the document, so it
1527        // cannot appear inside a flow collection: the test suite's N782 is
1528        // the error `[\n--- ,\n...\n]`. Inside the collection the lexer
1529        // gives them as plain scalars, so no document-marker rule sees them.
1530        let mut line_start = true;
1531        for el in node.descendants_with_tokens() {
1532            let rowan::NodeOrToken::Token(t) = el else {
1533                continue;
1534            };
1535            match t.kind() {
1536                crate::SyntaxKind::NEWLINE => line_start = true,
1537                crate::SyntaxKind::INDENT | crate::SyntaxKind::WHITESPACE => {}
1538                _ if line_start && matches!(t.text(), "---" | "...") => {
1539                    violations.push(Violation::error_at(
1540                        Rule::InvalidDocumentMarker,
1541                        t.text_range(),
1542                        "Document marker inside a flow collection",
1543                    ));
1544                    return;
1545                }
1546                _ => line_start = false,
1547            }
1548        }
1549
1550        // Only a flow collection sitting inside a block collection can be
1551        // confused with one: at column zero its continuation would read as a
1552        // new entry of that block (9C9N's `flow: [a,\nb,\nc]`). A flow
1553        // collection that is the document's own node has no such neighbour,
1554        // so its entries may start at column zero (4ABK).
1555        if !node.ancestors().skip(1).any(|a| {
1556            matches!(
1557                a.kind(),
1558                crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
1559            )
1560        }) {
1561            return;
1562        }
1563
1564        let mut after_newline = false;
1565        for el in node.descendants_with_tokens() {
1566            let rowan::NodeOrToken::Token(t) = el else {
1567                continue;
1568            };
1569            match t.kind() {
1570                crate::SyntaxKind::NEWLINE => after_newline = true,
1571                crate::SyntaxKind::INDENT | crate::SyntaxKind::WHITESPACE if after_newline => {
1572                    after_newline = false;
1573                }
1574                _ if after_newline => {
1575                    violations.push(Violation::error_at(
1576                        Rule::InvalidIndentation,
1577                        node.text_range(),
1578                        "Flow collection continuation line must be indented",
1579                    ));
1580                    return;
1581                }
1582                _ => {}
1583            }
1584        }
1585    }
1586
1587    fn check_sequence_entry_in_flow(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1588        // Check if this is a flow sequence
1589        let first_token = node.first_token();
1590        let is_flow_sequence = first_token.as_ref().is_some_and(|t| t.text() == "[");
1591
1592        if !is_flow_sequence {
1593            return;
1594        }
1595
1596        // Flow entries are SEQUENCE_ENTRY nodes too, so their mere presence
1597        // says nothing. Block syntax leaking in shows up as an entry whose
1598        // value is a bare `-`, which the lexer hands back as scalar text
1599        // rather than a DASH token.
1600        for child in node.children() {
1601            if child.kind() == crate::SyntaxKind::SEQUENCE_ENTRY
1602                && child
1603                    .text()
1604                    .to_string()
1605                    .trim_matches(|c: char| c.is_whitespace() || c == ',')
1606                    == "-"
1607            {
1608                violations.push(Violation::error_at(
1609                    Rule::Other,
1610                    node.text_range(),
1611                    "Flow sequence cannot use block sequence syntax (-)",
1612                ));
1613                return; // One violation is enough
1614            }
1615        }
1616    }
1617
1618    /// Check for anchors at document level without proper node attachment
1619    ///
1620    /// Anchors should be attached to nodes (values), not floating at document level.
1621    /// This is often the result of syntax errors like `&anchor - item`.
1622    fn check_document_level_anchors(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1623        // Check for ANCHOR tokens that are direct children of DOCUMENT
1624        for child in node.children_with_tokens() {
1625            if let rowan::NodeOrToken::Token(token) = child {
1626                if token.kind() == crate::SyntaxKind::ANCHOR {
1627                    // An anchor at document level annotates the document's
1628                    // own node, which is the ordinary spelling of
1629                    // `&sequence\n- a\n` and `&flowseq [ ... ]`. It is
1630                    // stranded only when nothing follows it, or when what
1631                    // follows on its own line is a sequence entry, which
1632                    // cannot sit there: `&anchor - sequence entry` is the
1633                    // test suite's SY6V error.
1634                    let mut annotates_a_node = false;
1635                    let mut same_line = true;
1636                    for sibling in token.siblings_with_tokens(rowan::Direction::Next).skip(1) {
1637                        match sibling {
1638                            rowan::NodeOrToken::Token(ref t)
1639                                if t.kind() == crate::SyntaxKind::NEWLINE =>
1640                            {
1641                                same_line = false;
1642                            }
1643                            rowan::NodeOrToken::Node(ref n) => {
1644                                let opens_an_entry = same_line
1645                                    && n.kind() == crate::SyntaxKind::SCALAR
1646                                    && n.text().to_string().starts_with("- ");
1647                                if !opens_an_entry
1648                                    && matches!(
1649                                        n.kind(),
1650                                        crate::SyntaxKind::MAPPING
1651                                            | crate::SyntaxKind::SEQUENCE
1652                                            | crate::SyntaxKind::SCALAR
1653                                            | crate::SyntaxKind::TAGGED_NODE
1654                                            | crate::SyntaxKind::ALIAS
1655                                    )
1656                                {
1657                                    annotates_a_node = true;
1658                                    break;
1659                                }
1660                            }
1661                            _ => {}
1662                        }
1663                    }
1664                    if !annotates_a_node {
1665                        violations.push(Violation::error_at(
1666                            Rule::Other,
1667                            token.text_range(),
1668                            "Anchor must be attached to a node, not at document level",
1669                        ));
1670                    }
1671                }
1672            }
1673        }
1674    }
1675
1676    /// Check for both anchor and alias on the same value
1677    ///
1678    /// Per YAML spec, a node can have an anchor (defining a reusable node) OR
1679    /// be an alias (referencing another node), but not both.
1680    fn check_anchor_and_alias(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
1681        let mut has_anchor = false;
1682        let mut has_alias = false;
1683
1684        // Check for ANCHOR tokens at this level
1685        for child in node.children_with_tokens() {
1686            if let rowan::NodeOrToken::Token(token) = child {
1687                if token.kind() == crate::SyntaxKind::ANCHOR {
1688                    has_anchor = true;
1689                }
1690            }
1691        }
1692
1693        // Check for REFERENCE tokens (used inside SCALAR-shaped values
1694        // and inside dedicated ALIAS nodes) anywhere among descendants.
1695        // We can't restrict to SCALAR-only because the parser emits
1696        // `&b *a` with the ALIAS node as a sibling of the ANCHOR token,
1697        // not wrapped in a SCALAR.
1698        // Not past a nested collection, though: `top3: &node3\n  *alias1 : v`
1699        // anchors the value's mapping while the alias is that mapping's key,
1700        // which are different nodes (test suite 26DV).
1701        for child in node.children_with_tokens() {
1702            match child {
1703                rowan::NodeOrToken::Token(t) if t.kind() == crate::SyntaxKind::REFERENCE => {
1704                    has_alias = true;
1705                    break;
1706                }
1707                rowan::NodeOrToken::Node(ref n)
1708                    if !matches!(
1709                        n.kind(),
1710                        crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
1711                    ) && n
1712                        .descendants_with_tokens()
1713                        .filter_map(|el| el.into_token())
1714                        .any(|t| t.kind() == crate::SyntaxKind::REFERENCE) =>
1715                {
1716                    has_alias = true;
1717                    break;
1718                }
1719                _ => {}
1720            }
1721        }
1722
1723        if has_anchor && has_alias {
1724            violations.push(Violation::error(
1725                Rule::Other,
1726                "Node cannot have both an anchor and be an alias",
1727            ));
1728        }
1729    }
1730
1731    /// Check that comment tokens have whitespace separation
1732    ///
1733    /// YAML spec requires that comment markers (#) must be separated from other content
1734    /// by whitespace. This checks if a COMMENT token appears without preceding whitespace.
1735    fn check_comment_token_whitespace(
1736        &self,
1737        token: &rowan::SyntaxToken<crate::Lang>,
1738        violations: &mut Vec<Violation>,
1739    ) {
1740        // What matters is the token before the comment in document order,
1741        // not the previous sibling: a comment opening a VALUE (`hr: # c`)
1742        // has no sibling before it, and the separating whitespace sits one
1743        // level up. Walking siblings alone reported 5 files the test suite
1744        // marks valid.
1745        let Some(prev) = token.prev_token() else {
1746            return;
1747        };
1748        if !matches!(
1749            prev.kind(),
1750            crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE | crate::SyntaxKind::INDENT
1751        ) {
1752            violations.push(Violation::error_at(
1753                Rule::Other,
1754                token.text_range(),
1755                "Comment without whitespace separation",
1756            ));
1757        }
1758    }
1759
1760    /// Check that content doesn't appear on same line as document start marker
1761    ///
1762    /// According to YAML spec, content should not appear on the same line as
1763    /// a document start marker (---).
1764    fn check_doc_start_token_content(
1765        &self,
1766        token: &rowan::SyntaxToken<crate::Lang>,
1767        violations: &mut Vec<Violation>,
1768    ) {
1769        // Look at siblings after DOC_START token
1770        let mut found_newline = false;
1771        let mut found_content = false;
1772
1773        // Check if there's content before a newline
1774        let mut current = token.next_sibling_or_token();
1775        while let Some(sibling) = current {
1776            let next = match &sibling {
1777                rowan::NodeOrToken::Token(t) => {
1778                    match t.kind() {
1779                        crate::SyntaxKind::NEWLINE => {
1780                            found_newline = true;
1781                            break;
1782                        }
1783                        crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::COMMENT => {
1784                            // Whitespace and comments are OK
1785                        }
1786                        _ => {}
1787                    }
1788                    t.next_sibling_or_token()
1789                }
1790                rowan::NodeOrToken::Node(n) => {
1791                    // Only a *block* collection is illegal here. YAML 1.2
1792                    // `l-explicit-document` lets a node share the marker's
1793                    // line, so `--- a`, `--- >`, `--- {a: 1}` and a tagged
1794                    // node are all valid, as PyYAML reads them; a block
1795                    // mapping or sequence (`--- key: v`, `--- - a`) is not.
1796                    let is_flow = n.first_token().is_some_and(|t| {
1797                        matches!(
1798                            t.kind(),
1799                            crate::SyntaxKind::LEFT_BRACKET | crate::SyntaxKind::LEFT_BRACE
1800                        )
1801                    });
1802                    match n.kind() {
1803                        crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE if !is_flow => {
1804                            found_content = true;
1805                            break;
1806                        }
1807                        _ => {}
1808                    }
1809                    n.next_sibling_or_token()
1810                }
1811            };
1812            current = next;
1813        }
1814
1815        if found_content && !found_newline {
1816            violations.push(Violation::error(
1817                Rule::InvalidDocumentMarker,
1818                "Content on same line as document start marker",
1819            ));
1820        }
1821    }
1822
1823    /// Check that TAG tokens don't contain invalid characters
1824    ///
1825    /// YAML spec restricts which characters can appear in tags.
1826    /// Tags cannot contain: {, }, [, ], or comma (,)
1827    fn check_tag_characters(
1828        &self,
1829        token: &rowan::SyntaxToken<crate::Lang>,
1830        violations: &mut Vec<Violation>,
1831    ) {
1832        let tag_text = token.text();
1833
1834        // A verbatim tag `!<...>` carries a URI, where these are all legal:
1835        // `!<tag:yaml.org,2002:str>` is the test suite's 7FWL and UGM3. The
1836        // restriction is on the shorthand form, whose `ns-tag-char` excludes
1837        // the flow indicators.
1838        if tag_text.starts_with("!<") {
1839            return;
1840        }
1841
1842        // Check for invalid characters in tags
1843        let invalid_chars = ['{', '}', '[', ']', ','];
1844        for ch in invalid_chars {
1845            if tag_text.contains(ch) {
1846                violations.push(Violation::error_at(
1847                    Rule::InvalidTag,
1848                    token.text_range(),
1849                    format!("Invalid character '{ch}' in tag"),
1850                ));
1851                return; // Only report once per tag
1852            }
1853        }
1854    }
1855
1856    /// Check that a TAG token is not immediately followed by a comma
1857    ///
1858    /// Per YAML spec, a tag must be followed by whitespace and then the tagged value.
1859    /// A comma immediately after a tag (without whitespace and value) is invalid.
1860    /// Example: `!!str, xxx` is invalid; should be `!!str xxx` or `!!str "xxx"`
1861    fn check_tag_followed_by_comma(
1862        &self,
1863        token: &rowan::SyntaxToken<crate::Lang>,
1864        violations: &mut Vec<Violation>,
1865    ) {
1866        // Look at the next sibling after the TAG token
1867        let mut current = token.next_sibling_or_token();
1868
1869        // Skip whitespace to find the next meaningful element
1870        while let Some(sibling) = current {
1871            match &sibling {
1872                rowan::NodeOrToken::Token(t) => {
1873                    match t.kind() {
1874                        crate::SyntaxKind::WHITESPACE | crate::SyntaxKind::NEWLINE => {
1875                            // Whitespace is expected, continue to next
1876                            current = t.next_sibling_or_token();
1877                            continue;
1878                        }
1879                        crate::SyntaxKind::COMMA => {
1880                            // Found a comma directly after the tag - this is invalid
1881                            violations.push(Violation::error_at(
1882                                Rule::InvalidTag,
1883                                token.text_range(),
1884                                "Invalid comma after tag",
1885                            ));
1886                            return;
1887                        }
1888                        _ => {
1889                            // Found some other token - that's fine
1890                            return;
1891                        }
1892                    }
1893                }
1894                rowan::NodeOrToken::Node(n) => {
1895                    // Found a node - check if it's a SCALAR that starts with a comma
1896                    if n.kind() == crate::SyntaxKind::SCALAR {
1897                        // Check if the first token in this scalar is a comma
1898                        for child in n.children_with_tokens() {
1899                            if let rowan::NodeOrToken::Token(t) = child {
1900                                // In block context a `,` is scalar content, so
1901                                // the comma that makes this invalid shows up as
1902                                // the first character of the scalar rather than
1903                                // as its own COMMA token.
1904                                if t.kind() == crate::SyntaxKind::COMMA || t.text().starts_with(',')
1905                                {
1906                                    // The scalar starts with a comma - invalid after a tag
1907                                    violations.push(Violation::error(
1908                                        Rule::InvalidTag,
1909                                        "Invalid comma after tag",
1910                                    ));
1911                                    return;
1912                                } else if t.kind() != crate::SyntaxKind::WHITESPACE
1913                                    && t.kind() != crate::SyntaxKind::NEWLINE
1914                                {
1915                                    // Found a non-comma, non-whitespace token - that's fine
1916                                    return;
1917                                }
1918                            }
1919                        }
1920                    }
1921                    // Other node types are fine
1922                    return;
1923                }
1924            }
1925        }
1926    }
1927
1928    /// Check that implicit keys don't span multiple lines
1929    ///
1930    /// YAML spec restricts implicit keys (keys without explicit ? marker) to a single line.
1931    /// This checks if a KEY node in a MAPPING_ENTRY contains newline characters.
1932    /// Two variants of "multiline" show up:
1933    ///   1. A NEWLINE token as a direct descendant (unquoted key spanning
1934    ///      multiple lexed tokens, e.g. yaml-test-suite 8KB6 in flow context).
1935    ///   2. A quoted STRING token whose source text contains a raw '\n'
1936    ///      (yaml-test-suite 7LBH / D49Q / JKF3). The whole quoted body
1937    ///      is one lex token, so a NEWLINE-token check misses it.
1938    fn check_implicit_key_multiline(
1939        &self,
1940        entry_node: &SyntaxNode,
1941        violations: &mut Vec<Violation>,
1942    ) {
1943        // Explicit-key entries (`? key\n : value`) are allowed to span
1944        // multiple lines by construction; the QUESTION indicator makes
1945        // them explicit rather than implicit.
1946        // The `?` sits in the MAPPING_ENTRY for a block explicit key, but
1947        // inside the KEY for a flow one (`{\n? explicit: entry,\n?\n}`), so
1948        // look in both -- the test suite's DFF7 has the latter.
1949        let is_explicit = has_child_token(entry_node, |k| k == crate::SyntaxKind::QUESTION)
1950            || entry_node
1951                .children()
1952                .filter(|c| c.kind() == crate::SyntaxKind::KEY)
1953                .any(|key| has_child_token(&key, |k| k == crate::SyntaxKind::QUESTION));
1954        if is_explicit {
1955            return;
1956        }
1957
1958        for child in entry_node.children() {
1959            if child.kind() != crate::SyntaxKind::KEY {
1960                continue;
1961            }
1962            let spans_lines = child.descendants_with_tokens().any(|el| match el {
1963                rowan::NodeOrToken::Token(t) => {
1964                    t.kind() == crate::SyntaxKind::NEWLINE || t.text().contains('\n')
1965                }
1966                _ => false,
1967            });
1968            // A flow scalar may span lines, so a key inside a flow
1969            // collection is allowed to: the test suite's 8KB6, 9BXH, 9SA2
1970            // and NJ66 all key an entry with `{ multi\n  line: value}`. The
1971            // restriction is on an implicit key in block context (7LBH).
1972            // What matters is the collection this entry belongs to: a flow
1973            // one lets its keys span lines (8KB6's `- { multi\n  line: v}`),
1974            // a block one does not, even when the key is itself a flow
1975            // collection (C2SP's `[23\n]: 42`, DK4H).
1976            // A flow collection holds its own `{`/`[` as a direct child;
1977            // first_token() would instead reach into the key's flow
1978            // sequence, making C2SP's block mapping look like a flow one.
1979            let in_flow = entry_node
1980                .parent()
1981                .is_some_and(|m| starts_a_flow_collection(&m));
1982            if spans_lines && !in_flow {
1983                violations.push(Violation::error_at(
1984                    Rule::Other,
1985                    child.text_range(),
1986                    "Implicit key cannot span multiple lines",
1987                ));
1988                return; // Only report once per entry
1989            }
1990        }
1991
1992        // The key can also be separated from its own COLON by a line break
1993        // (`[ "key"\n  :value ]`): the NEWLINE is then a sibling of KEY
1994        // rather than part of it.
1995        //
1996        // Inside a flow *mapping* that is an ordinary entry spread over
1997        // lines, which the test suite's 5MUD and K3WX (`{ "foo"\n  :bar }`)
1998        // allow; only a flow sequence needs an implicit key there, which is
1999        // ZXT5's error.
2000        if entry_node
2001            .parent()
2002            .is_some_and(|m| m.kind() == crate::SyntaxKind::MAPPING && starts_a_flow_collection(&m))
2003        {
2004            return;
2005        }
2006        let mut seen_key = false;
2007        for el in entry_node.children_with_tokens() {
2008            match el {
2009                rowan::NodeOrToken::Node(n) if n.kind() == crate::SyntaxKind::KEY => {
2010                    seen_key = true;
2011                }
2012                rowan::NodeOrToken::Token(t) if seen_key => match t.kind() {
2013                    crate::SyntaxKind::NEWLINE => {
2014                        violations.push(Violation::error_at(
2015                            Rule::Other,
2016                            entry_node.text_range(),
2017                            "Implicit key cannot span multiple lines",
2018                        ));
2019                        return;
2020                    }
2021                    crate::SyntaxKind::COLON => return,
2022                    _ => {}
2023                },
2024                _ => {}
2025            }
2026        }
2027    }
2028
2029    /// Check for block sequence starting on same line as mapping key
2030    ///
2031    /// YAML 1.2 spec section 6.3.1 requires block sequences to start on a new line
2032    /// after the mapping key and colon. Example of invalid YAML:
2033    /// ```yaml
2034    /// key: - a
2035    ///      - b
2036    /// ```
2037    fn check_sequence_on_same_line_as_key(
2038        &self,
2039        entry_node: &SyntaxNode,
2040        violations: &mut Vec<Violation>,
2041    ) {
2042        use crate::SyntaxKind;
2043
2044        // Find the KEY and VALUE nodes within the MAPPING_ENTRY
2045        let mut key_node: Option<SyntaxNode> = None;
2046        let mut value_node: Option<SyntaxNode> = None;
2047
2048        for child in entry_node.children() {
2049            match child.kind() {
2050                SyntaxKind::KEY => key_node = Some(child),
2051                SyntaxKind::VALUE => value_node = Some(child),
2052                _ => {}
2053            }
2054        }
2055
2056        // If there's no value, nothing to check
2057        let Some(value) = value_node else { return };
2058
2059        // An explicit key's value may be a compact sequence on the `:` line
2060        // (`? k\n: - one\n  - two\n`), which YAML 1.2 allows and PyYAML
2061        // accepts. Only a plain key's `:` requires the line break, so this
2062        // rule does not apply to an entry introduced by `?`.
2063        if entry_node
2064            .children_with_tokens()
2065            .filter_map(|c| c.into_token())
2066            .any(|t| t.kind() == SyntaxKind::QUESTION)
2067        {
2068            return;
2069        }
2070
2071        // Check if the value is a block sequence
2072        let mut sequence_node: Option<SyntaxNode> = None;
2073        for child in value.children() {
2074            if child.kind() == SyntaxKind::SEQUENCE {
2075                sequence_node = Some(child);
2076                break;
2077            }
2078        }
2079
2080        let Some(sequence) = sequence_node else {
2081            return;
2082        };
2083
2084        // Check if this is a block sequence (not flow)
2085        let first_token = sequence.first_token();
2086        let is_flow_sequence = first_token
2087            .as_ref()
2088            .is_some_and(|t| t.kind() == crate::SyntaxKind::LEFT_BRACKET);
2089
2090        if is_flow_sequence {
2091            return; // Flow sequences can be on same line
2092        }
2093
2094        // Check for a NEWLINE between key (or COLON) and the sequence
2095
2096        // Find the COLON token that separates key and value
2097        let mut found_colon = false;
2098        let mut has_newline = false;
2099
2100        // The parser puts the line break inside the VALUE when the sequence
2101        // is indentless (`k:\n- a\n`), where the entries sit at the key's
2102        // own column. That is valid YAML, so look there first; the scan
2103        // below only sees tokens at the MAPPING_ENTRY level.
2104        for child in value.children_with_tokens() {
2105            match child {
2106                rowan::NodeOrToken::Token(t) if t.kind() == SyntaxKind::NEWLINE => {
2107                    has_newline = true;
2108                    break;
2109                }
2110                rowan::NodeOrToken::Node(ref n) if n == &sequence => break,
2111                _ => {}
2112            }
2113        }
2114
2115        if let Some(key) = key_node.filter(|_| !has_newline) {
2116            // Start from after the key
2117            let mut current = key.next_sibling_or_token();
2118
2119            while let Some(element) = current {
2120                if let rowan::NodeOrToken::Token(t) = &element {
2121                    if t.kind() == SyntaxKind::COLON {
2122                        found_colon = true;
2123                    } else if found_colon && t.kind() == SyntaxKind::NEWLINE {
2124                        has_newline = true;
2125                        break;
2126                    }
2127                }
2128
2129                // Stop if we reach the sequence node
2130                if let rowan::NodeOrToken::Node(n) = &element {
2131                    if n == &sequence {
2132                        break;
2133                    }
2134                }
2135
2136                current = element.next_sibling_or_token();
2137            }
2138        }
2139
2140        // If there's no newline between the colon and the sequence, it's invalid
2141        if !has_newline {
2142            violations.push(Violation::error(
2143                Rule::Other,
2144                "Block sequence cannot start on same line as mapping key",
2145            ));
2146        }
2147    }
2148
2149    /// Check sequence items have consistent indentation (ZVH3)
2150    fn check_sequence_indentation(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
2151        use crate::SyntaxKind;
2152
2153        // Only check SEQUENCE nodes
2154        if node.kind() != SyntaxKind::SEQUENCE {
2155            return;
2156        }
2157
2158        let mut dash_columns: Vec<usize> = Vec::new();
2159
2160        // Recursively collect all DASH tokens in this sequence and nested sequences
2161        fn collect_dashes(
2162            node: &rowan::SyntaxNode<crate::Lang>,
2163            dashes: &mut Vec<rowan::SyntaxToken<crate::Lang>>,
2164        ) {
2165            for child in node.children_with_tokens() {
2166                match child {
2167                    rowan::NodeOrToken::Token(token) if token.kind() == crate::SyntaxKind::DASH => {
2168                        dashes.push(token);
2169                    }
2170                    rowan::NodeOrToken::Node(n)
2171                        if n.kind() == crate::SyntaxKind::SEQUENCE_ENTRY =>
2172                    {
2173                        // Collect dashes from sequence entries
2174                        collect_dashes(&n, dashes);
2175                    }
2176                    _ => {}
2177                }
2178            }
2179        }
2180
2181        let mut dashes = Vec::new();
2182        collect_dashes(node, &mut dashes);
2183
2184        for token in dashes {
2185            dash_columns.push(token_column(&token));
2186        }
2187
2188        // Check if all dashes are at the same column (consistent indentation)
2189        if let Some(&first_col) = dash_columns.first() {
2190            for &col in &dash_columns[1..] {
2191                if col != first_col {
2192                    violations.push(Violation::error(
2193                        Rule::InvalidIndentation,
2194                        "Inconsistent sequence item indentation",
2195                    ));
2196                    return; // Only report once
2197                }
2198            }
2199        }
2200    }
2201
2202    /// Check multiline quoted strings have proper indentation (QB6E)
2203    fn check_quoted_string_indentation(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
2204        use crate::SyntaxKind;
2205
2206        // Only check SCALAR nodes
2207        if node.kind() != SyntaxKind::SCALAR {
2208            return;
2209        }
2210
2211        // Check if this is a quoted string that spans multiple lines
2212        let text = node.text().to_string();
2213        if !text.starts_with('"') && !text.starts_with('\'') {
2214            return; // Not a quoted string
2215        }
2216
2217        if !text.contains('\n') {
2218            return; // Single line, no indentation to check
2219        }
2220
2221        // A scalar that is the document's own node starts at column zero and
2222        // its continuations need clear no column, so there is nothing to
2223        // check: the test suite's 6WPF, TL85, Q8AD and NP9H all open a
2224        // quoted scalar at the document level. Inside a mapping value or a
2225        // sequence entry the continuation must clear the enclosing column.
2226        // A `---` or `...` at the start of a line ends the document, so it
2227        // cannot sit inside a quoted scalar: the test suite's 9MQT/01 is the
2228        // error `--- "a\n... x\nb"`. The lexer keeps the whole quoted body
2229        // in one token, so no document-marker rule sees it.
2230        if text
2231            .split('\n')
2232            .skip(1)
2233            .any(|line| line.starts_with("---") || line.starts_with("..."))
2234        {
2235            violations.push(Violation::error_at(
2236                Rule::InvalidDocumentMarker,
2237                node.text_range(),
2238                "Document marker inside a quoted scalar",
2239            ));
2240            return;
2241        }
2242
2243        match node.parent() {
2244            None => return,
2245            Some(parent) if parent.kind() == crate::SyntaxKind::DOCUMENT => return,
2246            Some(_) => {}
2247        }
2248
2249        // For multiline quoted strings, continuation lines should be indented
2250
2251        // Check each line after the first
2252        let lines: Vec<&str> = text.split('\n').collect();
2253        if lines.len() > 1 {
2254            // Continuation lines (between opening and closing quote) should have consistent indentation
2255            // In YAML, they should be indented at least as much as the opening line
2256            for (i, line) in lines.iter().enumerate().skip(1) {
2257                if i == lines.len() - 1 && line.trim().is_empty() {
2258                    // Last line might just be the closing quote
2259                    continue;
2260                }
2261
2262                // Count leading spaces
2263                let leading_spaces = line.len() - line.trim_start().len();
2264
2265                // Continuation lines starting at column 0 are invalid
2266                // (they should be indented at least to align with content)
2267                if leading_spaces == 0 && !line.trim().is_empty() {
2268                    violations.push(Violation::error(
2269                        Rule::InvalidIndentation,
2270                        "Wrong indented multiline quoted scalar",
2271                    ));
2272                    return;
2273                }
2274            }
2275        }
2276    }
2277
2278    /// Check for duplicate keys within a mapping node
2279    ///
2280    /// Uses semantic comparison via `yaml_eq()`:
2281    /// - `true` and `True` are duplicates (same boolean value)
2282    /// - `1` and `0x1` are duplicates (same integer value)
2283    /// - `"1"` and `1` are NOT duplicates (different types: string vs int)
2284    /// - `null`, `~`, and empty key are all duplicates (all null)
2285    /// - Works with complex keys (sequences, mappings) as well
2286    fn check_duplicate_keys(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
2287        use crate::nodes::{Mapping, Scalar, Sequence};
2288        use crate::yaml_eq;
2289        use crate::SyntaxKind;
2290        use std::collections::HashMap;
2291
2292        // Collect all KEY nodes with their text representation and parent entry range
2293        let keys: Vec<(SyntaxNode, String, rowan::TextRange)> = node
2294            .children()
2295            .filter(|child| child.kind() == SyntaxKind::MAPPING_ENTRY)
2296            .filter_map(|child| {
2297                let entry_range = child.text_range();
2298                child
2299                    .children()
2300                    .find(|n| n.kind() == SyntaxKind::KEY)
2301                    .map(|key_node| {
2302                        // Only allocate once: trim() returns &str, then to_string() once
2303                        let key_text = key_node.text().to_string();
2304                        let key_text = key_text.trim().to_string();
2305                        (key_node, key_text, entry_range)
2306                    })
2307            })
2308            .collect();
2309
2310        // Compare semantically, but bucket scalar keys by their normalized
2311        // value first so the common all-scalar mapping costs one hash per key
2312        // instead of a pairwise sweep. Collection keys are rare and have no
2313        // cheap normal form, so they keep the pairwise comparison.
2314        let format_key = |s: &str| {
2315            if s.is_empty() {
2316                "\"\"".to_string()
2317            } else {
2318                format!("{s:?}")
2319            }
2320        };
2321        let mut report = |first_text: &str, dup_text: &str, at: rowan::TextRange| {
2322            violations.push(Violation::error_at(
2323                Rule::DuplicateKeys,
2324                at,
2325                format!(
2326                    "Duplicate key: {} (semantically equal to {})",
2327                    format_key(dup_text),
2328                    format_key(first_text)
2329                ),
2330            ));
2331        };
2332
2333        // (normalized scalar key) -> index of the first key that produced it
2334        let mut seen_scalars: HashMap<(SyntaxKind, String), usize> = HashMap::new();
2335        let mut collection_keys: Vec<usize> = Vec::new();
2336
2337        for i in 0..keys.len() {
2338            let Some(inner) = keys[i].0.children().next() else {
2339                continue;
2340            };
2341            let normalized = Scalar::cast(inner.clone())
2342                .filter(|_| inner.kind() == SyntaxKind::SCALAR)
2343                .and_then(|s| crate::as_yaml::scalar_semantic_value(&s));
2344
2345            match normalized {
2346                Some(norm) => match seen_scalars.entry(norm) {
2347                    std::collections::hash_map::Entry::Occupied(first) => {
2348                        let first_idx = *first.get();
2349                        report(&keys[first_idx].1, &keys[i].1, keys[i].2);
2350                    }
2351                    std::collections::hash_map::Entry::Vacant(slot) => {
2352                        slot.insert(i);
2353                    }
2354                },
2355                None => {
2356                    // A collection key, or a scalar with no normal form.
2357                    for &j in &collection_keys {
2358                        let (Some(v1), Some(v2)) =
2359                            (keys[j].0.children().next(), keys[i].0.children().next())
2360                        else {
2361                            continue;
2362                        };
2363                        let are_equal = match (v1.kind(), v2.kind()) {
2364                            (SyntaxKind::SCALAR, SyntaxKind::SCALAR) => Scalar::cast(v1)
2365                                .zip(Scalar::cast(v2))
2366                                .is_some_and(|(s1, s2)| yaml_eq(&s1, &s2)),
2367                            (SyntaxKind::SEQUENCE, SyntaxKind::SEQUENCE) => Sequence::cast(v1)
2368                                .zip(Sequence::cast(v2))
2369                                .is_some_and(|(s1, s2)| yaml_eq(&s1, &s2)),
2370                            (SyntaxKind::MAPPING, SyntaxKind::MAPPING) => Mapping::cast(v1)
2371                                .zip(Mapping::cast(v2))
2372                                .is_some_and(|(m1, m2)| yaml_eq(&m1, &m2)),
2373                            _ => false,
2374                        };
2375                        if are_equal {
2376                            report(&keys[j].1, &keys[i].1, keys[i].2);
2377                            break;
2378                        }
2379                    }
2380                    collection_keys.push(i);
2381                }
2382            }
2383        }
2384    }
2385}
2386
2387impl Default for Validator {
2388    fn default() -> Self {
2389        Self::new()
2390    }
2391}
2392
2393/// Whether `node` is a flow collection, by its own delimiter.
2394///
2395/// A flow collection holds its `{`/`[` as a direct child; `first_token()`
2396/// instead descends the whole left spine, which made validating a large
2397/// mapping quadratic.
2398fn starts_a_flow_collection(node: &SyntaxNode) -> bool {
2399    // The delimiter opens the collection, so it is among the first children;
2400    // scanning them all is O(entries) and ran per entry.
2401    node.children_with_tokens()
2402        .take_while(|c| c.as_token().is_some())
2403        .filter_map(|c| c.into_token())
2404        .any(|t| {
2405            matches!(
2406                t.kind(),
2407                crate::SyntaxKind::LEFT_BRACE | crate::SyntaxKind::LEFT_BRACKET
2408            )
2409        })
2410}
2411
2412#[cfg(test)]
2413mod tests {
2414    use super::*;
2415    use std::str::FromStr;
2416
2417    #[test]
2418    fn test_validator_basic() {
2419        let doc = Document::from_str("key: value").unwrap();
2420        let validator = Validator::new();
2421        let violations = validator.validate(&doc);
2422
2423        // Simple valid YAML should have no violations
2424        assert_eq!(violations.len(), 0);
2425    }
2426
2427    #[test]
2428    fn test_duplicate_key_detection_is_semantic() {
2429        // Keys that differ textually but mean the same thing are duplicates;
2430        // bucketing them by normalized value must not lose these.
2431        for (src, expected) in [
2432            ("a: 1\n\"a\": 2\n", 1),
2433            ("1: a\n0x1: b\n", 1),
2434            ("n: 1\nnull: 2\n~: 3\n", 1),
2435            ("[1,2]: a\n[1, 2]: b\n", 1),
2436            ("a: 1\nb: 2\n", 0),
2437        ] {
2438            let doc = Document::from_str(src).unwrap();
2439            let dups = Validator::new()
2440                .validate(&doc)
2441                .into_iter()
2442                .filter(|v| v.message.contains("Duplicate key"))
2443                .count();
2444            assert_eq!(dups, expected, "{src:?}");
2445        }
2446    }
2447
2448    #[test]
2449    fn test_duplicate_key_detection_scales_linearly() {
2450        // This check used to compare every key against every other one, which
2451        // made validating a large generated mapping quadratic. Guard the
2452        // shape rather than a wall-clock number: 4x the keys should cost far
2453        // less than the 16x a pairwise sweep would.
2454        let build = |n: usize| {
2455            let mut s = String::new();
2456            for i in 0..n {
2457                s.push_str(&format!("key{i}: value{i}\n"));
2458            }
2459            Document::from_str(&s).unwrap()
2460        };
2461        let time = |doc: &Document| {
2462            let start = std::time::Instant::now();
2463            let _ = Validator::new().validate(doc);
2464            start.elapsed()
2465        };
2466
2467        let small = build(500);
2468        let large = build(2000);
2469        // Warm up so the first parse does not skew the comparison.
2470        let _ = time(&small);
2471        let small_t = time(&small);
2472        let large_t = time(&large);
2473
2474        assert!(
2475            large_t < small_t * 12,
2476            "validation looks superlinear: 500 keys took {small_t:?}, 2000 took {large_t:?}"
2477        );
2478    }
2479
2480    #[test]
2481    fn test_validator_accepts_well_formed_flow_collections() {
2482        // The separators in a flow collection live inside the entry they
2483        // follow, so counting only the collection's direct children found no
2484        // commas at all and every well-formed flow collection was reported as
2485        // missing them. Flow entries are also SEQUENCE_ENTRY nodes, which used
2486        // to read as block syntax leaking into the flow.
2487        for src in [
2488            "a: {x: 1, y: 2}\n",
2489            "a: [1, 2]\n",
2490            "[1, 2, 3]\n",
2491            "{a: 1, b: 2, c: 3}\n",
2492            "a: !!seq [1, 2]\n",
2493            "a: &anc [1, 2]\n",
2494        ] {
2495            let doc = Document::from_str(src).unwrap();
2496            assert_eq!(Validator::new().validate(&doc), vec![], "{src:?}");
2497        }
2498    }
2499
2500    #[test]
2501    fn test_validator_flags_malformed_flow_collections() {
2502        // Omitted entries and a stray block dash stay errors, and so do the
2503        // layout mistakes that the comma check used to catch only by
2504        // accident (yaml-test-suite 9C9N, ZXT5, 9MAG, CTN5, G5U8, YJV2).
2505        for src in [
2506            "[ , a, b ]\n",
2507            "[ a, b, , ]\n",
2508            "[-]\n",
2509            "- [-, -]\n",
2510            "flow: [a,\nb,\nc]\n",
2511            "[ \"key\"\n  :value ]\n",
2512        ] {
2513            let doc = Document::from_str(src).unwrap();
2514            assert_ne!(Validator::new().validate(&doc), vec![], "{src:?}");
2515        }
2516    }
2517
2518    #[test]
2519    fn test_validator_inconsistent_sequence_indent() {
2520        let even = Document::from_str("- a\n- b\n").unwrap();
2521        let even_v = Validator::new().validate(&even);
2522        assert!(
2523            !even_v
2524                .iter()
2525                .any(|v| v.message.contains("Inconsistent sequence item indentation")),
2526            "{even_v:?}"
2527        );
2528
2529        // `- a\n - b\n` is not two misaligned entries: a `-` indented past
2530        // the entry above it is plain-scalar content, so this is the single
2531        // item `a - b`, as both saphyr and PyYAML read it. The parser folds
2532        // it, leaving one DASH token and nothing for this rule to flag.
2533        let folded = Document::from_str("- a\n - b\n").unwrap();
2534        let folded_seq = folded.as_sequence().expect("sequence");
2535        assert_eq!(folded_seq.len(), 1);
2536        assert_eq!(
2537            folded_seq.get(0).unwrap().as_scalar().unwrap().as_string(),
2538            "a - b"
2539        );
2540        let folded_v = Validator::new().validate(&folded);
2541        assert!(
2542            !folded_v
2543                .iter()
2544                .any(|v| v.message.contains("Inconsistent sequence item indentation")),
2545            "{folded_v:?}"
2546        );
2547    }
2548
2549    #[test]
2550    fn test_validator_tabs_debug() {
2551        let yaml = "---\na:\n\tb:\n\t\tc: value";
2552        let doc = Document::from_str(yaml).unwrap();
2553        let validator = Validator::new();
2554
2555        // Walk the tree and check for tabs
2556        let mut found_tab = false;
2557        for child in doc.syntax().descendants_with_tokens() {
2558            if let rowan::NodeOrToken::Token(token) = child {
2559                if token.text().contains('\t') {
2560                    println!(
2561                        "Found tab in token: {:?} = {:?}",
2562                        token.kind(),
2563                        token.text()
2564                    );
2565                    found_tab = true;
2566                }
2567            }
2568        }
2569
2570        println!("Found tab in tree: {}", found_tab);
2571
2572        let violations = validator.validate(&doc);
2573        println!("Violations: {}", violations.len());
2574        for v in &violations {
2575            println!("  {}", v);
2576        }
2577
2578        assert!(found_tab, "Tabs should be in the syntax tree");
2579    }
2580
2581    #[test]
2582    fn test_validator_missing_comma() {
2583        let doc = Document::from_str("{foo: 1 bar: 2}").unwrap();
2584        let validator = Validator::new();
2585        let violations = validator.validate(&doc);
2586
2587        // Should detect missing comma in flow mapping
2588        println!("Found {} violations:", violations.len());
2589        for v in &violations {
2590            println!("  {}", v);
2591        }
2592
2593        assert!(
2594            !violations.is_empty(),
2595            "Expected violations for missing comma, got none"
2596        );
2597    }
2598
2599    #[test]
2600    fn test_validator_invalid_escape() {
2601        let doc = Document::from_str("\"\\.\"\n").unwrap();
2602        let validator = Validator::new();
2603        let violations = validator.validate(&doc);
2604
2605        // Should detect invalid escape sequence
2606        assert!(
2607            !violations.is_empty(),
2608            "Expected violations for invalid escape \\., got none"
2609        );
2610        assert_eq!(violations[0].rule, Rule::InvalidEscape);
2611    }
2612
2613    #[test]
2614    fn test_validator_multiple_anchors() {
2615        // Test simple case
2616        let doc = Document::from_str("&a &b key: value").unwrap();
2617        let validator = Validator::new();
2618        let violations = validator.validate(&doc);
2619
2620        assert!(
2621            !violations.is_empty(),
2622            "Expected violations for multiple anchors, got none"
2623        );
2624        assert_eq!(violations[0].rule, Rule::InvalidAnchor);
2625
2626        // Test 4JVG case
2627        let yaml = "top1: &node1\n  &k1 key1: val1\ntop2: &node2\n  &v2 val2\n";
2628        let doc2 = Document::from_str(yaml).unwrap();
2629        let violations2 = validator.validate(&doc2);
2630
2631        // Only the second entry really carries two anchors on one node: in
2632        // the first, `&node1` anchors the nested mapping and `&k1` its key,
2633        // which is why the test suite calls the same shape valid in 7BMT.
2634        assert_eq!(violations2.len(), 1, "{violations2:?}");
2635    }
2636
2637    #[test]
2638    fn test_validator_duplicate_directive() {
2639        let yaml = "%YAML 1.2\n%YAML 1.2\n---\nkey: value\n";
2640        let doc = crate::YamlFile::from_str(yaml).unwrap().document().unwrap();
2641        let validator = Validator::new();
2642        let violations = validator.validate(&doc);
2643
2644        assert_eq!(
2645            violations.len(),
2646            1,
2647            "Expected exactly one violation for duplicate YAML directive"
2648        );
2649        assert_eq!(violations[0].message, "Duplicate %YAML directive");
2650    }
2651
2652    #[test]
2653    fn test_validator_duplicate_keys() {
2654        let yaml = "a: 1\nb: 2\na: 3\n";
2655        let doc = Document::from_str(yaml).unwrap();
2656        let validator = Validator::new();
2657        let violations = validator.validate(&doc);
2658
2659        let dup_violations: Vec<_> = violations
2660            .iter()
2661            .filter(|v| v.rule == Rule::DuplicateKeys)
2662            .collect();
2663        assert_eq!(
2664            dup_violations.len(),
2665            1,
2666            "Expected exactly one DuplicateKeys violation, got: {:?}",
2667            dup_violations
2668        );
2669        assert_eq!(
2670            dup_violations[0].message,
2671            "Duplicate key: \"a\" (semantically equal to \"a\")"
2672        );
2673    }
2674
2675    #[test]
2676    fn test_validator_no_duplicate_keys() {
2677        let yaml = "a: 1\nb: 2\nc: 3\n";
2678        let doc = Document::from_str(yaml).unwrap();
2679        let validator = Validator::new();
2680        let violations = validator.validate(&doc);
2681
2682        let dup_violations: Vec<_> = violations
2683            .iter()
2684            .filter(|v| v.rule == Rule::DuplicateKeys)
2685            .collect();
2686        assert_eq!(
2687            dup_violations.len(),
2688            0,
2689            "Expected no DuplicateKeys violations"
2690        );
2691    }
2692
2693    #[test]
2694    fn test_validator_duplicate_keys_disabled() {
2695        let yaml = "a: 1\nb: 2\na: 3\n";
2696        let doc = Document::from_str(yaml).unwrap();
2697        let validator = Validator::with_config(ValidatorConfig {
2698            check_duplicate_keys: false,
2699            ..ValidatorConfig::default()
2700        });
2701        let violations = validator.validate(&doc);
2702
2703        let dup_violations: Vec<_> = violations
2704            .iter()
2705            .filter(|v| v.rule == Rule::DuplicateKeys)
2706            .collect();
2707        assert_eq!(
2708            dup_violations.len(),
2709            0,
2710            "Expected no violations when duplicate key check is disabled"
2711        );
2712    }
2713
2714    #[test]
2715    fn test_validator_semantic_duplicate_keys() {
2716        let validator = Validator::new();
2717
2718        // Test 1: Different quote styles - should be duplicates
2719        let yaml1 = "'a': 1\na: 2";
2720        let doc1 = Document::from_str(yaml1).unwrap();
2721        let violations1 = validator.validate(&doc1);
2722        assert_eq!(
2723            violations1
2724                .iter()
2725                .filter(|v| v.rule == Rule::DuplicateKeys)
2726                .count(),
2727            1,
2728            "Quoted 'a' and unquoted a should be duplicates"
2729        );
2730
2731        // Test 2: Different boolean representations - should be duplicates
2732        let yaml2 = "true: 1\nTrue: 2";
2733        let doc2 = Document::from_str(yaml2).unwrap();
2734        let violations2 = validator.validate(&doc2);
2735        assert_eq!(
2736            violations2
2737                .iter()
2738                .filter(|v| v.rule == Rule::DuplicateKeys)
2739                .count(),
2740            1,
2741            "true and True should be duplicates"
2742        );
2743
2744        // Test 3: Different integer representations - should be duplicates
2745        let yaml3 = "1: one\n0x1: hex";
2746        let doc3 = Document::from_str(yaml3).unwrap();
2747        let violations3 = validator.validate(&doc3);
2748        assert_eq!(
2749            violations3
2750                .iter()
2751                .filter(|v| v.rule == Rule::DuplicateKeys)
2752                .count(),
2753            1,
2754            "1 and 0x1 should be duplicates"
2755        );
2756
2757        // Test 4: Different null representations - should be duplicates
2758        let yaml4 = "null: 1\n~: 2";
2759        let doc4 = Document::from_str(yaml4).unwrap();
2760        let violations4 = validator.validate(&doc4);
2761        assert_eq!(
2762            violations4
2763                .iter()
2764                .filter(|v| v.rule == Rule::DuplicateKeys)
2765                .count(),
2766            1,
2767            "null and ~ should be duplicates"
2768        );
2769
2770        // Test 5: String vs int - should NOT be duplicates (different types)
2771        let yaml5 = "\"1\": string\n1: int";
2772        let doc5 = Document::from_str(yaml5).unwrap();
2773        let violations5 = validator.validate(&doc5);
2774        assert_eq!(
2775            violations5
2776                .iter()
2777                .filter(|v| v.rule == Rule::DuplicateKeys)
2778                .count(),
2779            0,
2780            "String '1' and int 1 should not be duplicates"
2781        );
2782
2783        // Test 6: Float vs int - should NOT be duplicates (different types)
2784        let yaml6 = "1.0: float\n1: int";
2785        let doc6 = Document::from_str(yaml6).unwrap();
2786        let violations6 = validator.validate(&doc6);
2787        assert_eq!(
2788            violations6
2789                .iter()
2790                .filter(|v| v.rule == Rule::DuplicateKeys)
2791                .count(),
2792            0,
2793            "Float 1.0 and int 1 should not be duplicates"
2794        );
2795    }
2796
2797    #[test]
2798    fn test_validator_directive_without_document() {
2799        // Test 9MMA: Directive without any document
2800        let yaml = "%YAML 1.2\n";
2801        let doc = crate::YamlFile::from_str(yaml)
2802            .unwrap()
2803            .document()
2804            .unwrap_or_default();
2805
2806        // Debug: check if directive exists in tree
2807        let root = doc
2808            .syntax()
2809            .parent()
2810            .unwrap_or_else(|| doc.syntax().clone());
2811        let directive_count = root
2812            .descendants()
2813            .filter(|n| n.kind() == crate::SyntaxKind::DIRECTIVE)
2814            .count();
2815        let content_count = doc
2816            .syntax()
2817            .descendants()
2818            .filter(|n| {
2819                matches!(
2820                    n.kind(),
2821                    crate::SyntaxKind::MAPPING
2822                        | crate::SyntaxKind::SEQUENCE
2823                        | crate::SyntaxKind::SCALAR
2824                        | crate::SyntaxKind::TAGGED_NODE
2825                )
2826            })
2827            .count();
2828
2829        let validator = Validator::new();
2830        let violations = validator.validate(&doc);
2831
2832        // Only check if directives are actually in the tree
2833        if directive_count > 0 && content_count == 0 {
2834            assert!(
2835                !violations.is_empty(),
2836                "Expected violation for directive without document (directives={}, content={})",
2837                directive_count,
2838                content_count
2839            );
2840        }
2841    }
2842
2843    #[test]
2844    fn test_validator_content_after_doc_end() {
2845        // Test 3HFZ: Content after document end marker. The suite marks it
2846        // an error case, and the parser now reports it, so take the tree
2847        // from parse(); the validator still detects it from the ERROR node.
2848        let yaml = "---\nkey: value\n... invalid\n";
2849        let parsed = crate::YamlFile::parse(yaml);
2850        assert_eq!(parsed.errors().len(), 1);
2851        let doc = parsed.tree().document().unwrap();
2852
2853        let validator = Validator::new();
2854        let violations = validator.validate(&doc);
2855
2856        let invalid_content_violations: Vec<_> = violations
2857            .iter()
2858            .filter(|v| v.message.starts_with("Invalid content in document:"))
2859            .collect();
2860        assert_eq!(
2861            invalid_content_violations.len(),
2862            1,
2863            "Expected exactly one 'Invalid content' violation for content after document end marker"
2864        );
2865    }
2866
2867    #[test]
2868    fn test_validator_directive_with_tagged_node_content() {
2869        // A document with a tagged scalar following a directive should NOT be
2870        // reported as "directive without content" - TAGGED_NODE is real content.
2871        let yaml = "%YAML 1.2\n---\n!custom foo\n";
2872        let doc = crate::YamlFile::from_str(yaml).unwrap().document().unwrap();
2873        let validator = Validator::new();
2874        let violations = validator.validate(&doc);
2875
2876        assert_eq!(
2877            violations.len(),
2878            0,
2879            "Tagged scalar is real content; valid document should have no violations"
2880        );
2881    }
2882
2883    #[test]
2884    fn test_validator_with_config() {
2885        let config = ValidatorConfig {
2886            check_duplicate_keys: false,
2887            ..Default::default()
2888        };
2889        let validator = Validator::with_config(config);
2890
2891        let doc = Document::from_str("key: value").unwrap();
2892        let violations = validator.validate(&doc);
2893
2894        assert_eq!(violations.len(), 0);
2895    }
2896
2897    #[test]
2898    fn test_violation_display() {
2899        let violation = Violation {
2900            message: "Test violation".to_string(),
2901            location: Some("1:5".to_string()),
2902            text_range: None,
2903            severity: Severity::Error,
2904            rule: Rule::InvalidIndentation,
2905        };
2906
2907        assert_eq!(
2908            format!("{}", violation),
2909            "[ERROR] 1:5: Test violation (InvalidIndentation)"
2910        );
2911    }
2912
2913    #[test]
2914    fn test_u99r_invalid_comma_in_tag() {
2915        // Test U99R: Invalid comma after tag
2916        let yaml = "- !!str, xxx\n";
2917        use crate::YamlFile;
2918        let file = YamlFile::from_str(yaml).unwrap();
2919        let validator = Validator::new();
2920
2921        // Print tree for debugging
2922        println!("\n=== Syntax tree ===");
2923        crate::debug::print_tree(file.syntax());
2924
2925        let violations = validator.validate_syntax(file.syntax());
2926        println!("\n=== Violations ({}) ===", violations.len());
2927        for v in &violations {
2928            println!("  {}", v);
2929        }
2930
2931        assert!(
2932            !violations.is_empty(),
2933            "Expected violation for invalid comma after tag"
2934        );
2935        assert_eq!(violations.len(), 1);
2936        assert_eq!(violations[0].message, "Invalid comma after tag");
2937        assert_eq!(violations[0].rule, Rule::InvalidTag);
2938    }
2939
2940    #[test]
2941    fn test_comment_whitespace() {
2942        use crate::YamlFile;
2943
2944        // Comment must be separated by whitespace
2945        let yaml = "key: \"value\"# invalid comment\n";
2946        let parsed = YamlFile::from_str(yaml).expect("Should parse");
2947
2948        let validator = Validator::new();
2949        let violations = validator.validate_syntax(parsed.syntax());
2950
2951        assert!(
2952            !violations.is_empty(),
2953            "Should catch comment without whitespace"
2954        );
2955        assert_eq!(
2956            violations[0].message,
2957            "Comment without whitespace separation"
2958        );
2959    }
2960
2961    #[test]
2962    fn test_doc_start_content() {
2963        use crate::YamlFile;
2964
2965        // Content should not appear on same line as document start marker
2966        let yaml = "--- key1: value1\n    key2: value2\n";
2967        let parsed = YamlFile::from_str(yaml).expect("Should parse");
2968
2969        let validator = Validator::new();
2970        let violations = validator.validate_syntax(parsed.syntax());
2971
2972        assert!(
2973            !violations.is_empty(),
2974            "Should catch content on doc start line"
2975        );
2976        assert_eq!(
2977            violations[0].message,
2978            "Content on same line as document start marker"
2979        );
2980    }
2981
2982    #[test]
2983    fn test_directive_in_document_content() {
2984        // %YAML directive after --- without preceding ... is invalid
2985        let input = "%YAML 1.2\n---\n%YAML 1.2\n---\n";
2986        let file = crate::YamlFile::from_str(input).unwrap();
2987        let validator = Validator::new();
2988        use rowan::ast::AstNode;
2989        let violations = validator.validate_syntax(file.syntax());
2990
2991        assert_eq!(
2992            violations.len(),
2993            1,
2994            "Expected one violation for directive in content, got: {:?}",
2995            violations
2996        );
2997        assert_eq!(
2998            violations[0].message,
2999            "Directive in document content (missing document end marker `...` before directive)"
3000        );
3001    }
3002}