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}