Skip to main content

yaml_edit/validator/
mod.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
25mod directives;
26mod documents;
27mod flow;
28mod keys;
29mod properties;
30mod scalars;
31mod structure;
32
33use crate::as_yaml::SharedIdentities;
34use crate::column::ColumnIndex;
35use crate::nodes::{in_flow_collection, starts_a_flow_collection};
36use crate::yaml::{Document, SyntaxNode};
37use rowan::ast::AstNode;
38use std::fmt;
39
40use directives::{
41    check_directive_after_document, check_directive_in_content, check_directives,
42    check_tag_handles_declared, check_tag_shorthand_scope,
43};
44use documents::{
45    check_doc_start_token_content, check_document_level_anchors, check_document_marker_placement,
46    check_single_content_node,
47};
48use flow::{
49    check_block_scalar_in_flow, check_flow_collection_commas, check_flow_continuation_indent,
50    check_flow_pair_placement, check_tag_followed_by_comma,
51};
52use keys::{
53    check_duplicate_keys, check_merge_operands, check_omap_keys, check_pairs_items,
54    check_set_values, DocumentRegistry,
55};
56use properties::{
57    check_anchor_and_alias, check_collection_tag_content, check_multiple_anchors,
58    check_multiple_tags, check_tag_characters, check_tag_on_alias, check_undefined_aliases,
59};
60use scalars::{
61    check_block_scalar_content_indent, check_block_scalar_header_indent,
62    check_block_scalar_indicator, check_colon_in_plain_scalar, check_escape_sequences,
63    check_flow_indicator_in_plain_scalar, check_legacy_yaml_1_1, check_plain_scalar_start,
64    check_quoted_string_indentation, check_trailing_content_after_quoted,
65};
66use structure::{
67    check_block_mapping_after_properties, check_block_mapping_entries_on_same_line,
68    check_collection_on_same_line_as_key, check_comment_token_whitespace,
69    check_explicit_key_separation, check_explicit_value_column, check_implicit_key_multiline,
70    check_mapping_entry_indentation, check_sequence_indentation, check_tab_usage,
71};
72
73/// A YAML specification violation found during validation
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Violation {
76    /// Human-readable description of the violation
77    pub message: String,
78    /// Location in the source, as 1-based `line:column` of the range start
79    pub location: Option<String>,
80    /// Byte range in the source text where the violation occurred
81    pub text_range: Option<crate::TextPosition>,
82    /// Severity of the violation
83    pub severity: Severity,
84    /// Specific rule that was violated
85    pub rule: Rule,
86}
87
88/// Severity level of a spec violation
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90#[non_exhaustive]
91pub enum Severity {
92    /// Error: Strictly invalid per YAML 1.2 spec
93    Error,
94    /// Warning: Deprecated or discouraged but technically valid
95    Warning,
96}
97
98/// Specific YAML spec rules that can be violated
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum Rule {
102    /// Invalid indentation
103    InvalidIndentation,
104    /// Document markers in wrong context
105    InvalidDocumentMarker,
106    /// Invalid tab usage
107    InvalidTabUsage,
108    /// Missing required syntax elements
109    MissingSyntax,
110    /// Invalid escape sequence
111    InvalidEscape,
112    /// Duplicate keys in mapping
113    DuplicateKeys,
114    /// Invalid anchor/alias usage
115    InvalidAnchor,
116    /// Invalid tag
117    InvalidTag,
118    /// A character YAML does not allow in a stream, such as a control
119    /// character
120    InvalidCharacter,
121    /// A construct that YAML 1.1 read differently from YAML 1.2
122    LegacyYaml11,
123    /// Other spec violations
124    Other,
125}
126
127impl fmt::Display for Violation {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        match &self.location {
130            Some(loc) => write!(
131                f,
132                "[{}] {}: {} ({:?})",
133                match self.severity {
134                    Severity::Error => "ERROR",
135                    Severity::Warning => "WARN",
136                },
137                loc,
138                self.message,
139                self.rule
140            ),
141            None => write!(
142                f,
143                "[{}] {} ({:?})",
144                match self.severity {
145                    Severity::Error => "ERROR",
146                    Severity::Warning => "WARN",
147                },
148                self.message,
149                self.rule
150            ),
151        }
152    }
153}
154
155/// YAML 1.2 specification validator
156///
157/// Performs strict validation checks on parsed YAML documents.
158/// The parser itself is lenient and focuses on error recovery,
159/// while this validator enforces strict spec compliance.
160#[derive(Debug, Clone)]
161pub struct Validator {
162    /// Configuration options
163    config: ValidatorConfig,
164}
165
166/// Configuration for the validator
167#[derive(Debug, Clone)]
168pub struct ValidatorConfig {
169    /// Check for duplicate keys in mappings
170    pub check_duplicate_keys: bool,
171    /// Check indentation rules
172    pub check_indentation: bool,
173    /// Check tab usage restrictions
174    pub check_tabs: bool,
175    /// Check document marker placement
176    pub check_document_markers: bool,
177    /// Check anchor/alias validity
178    pub check_anchors: bool,
179}
180
181impl Default for ValidatorConfig {
182    fn default() -> Self {
183        Self {
184            check_duplicate_keys: true,
185            check_indentation: true,
186            check_tabs: true,
187            check_document_markers: true,
188            check_anchors: true,
189        }
190    }
191}
192
193/// Check for characters YAML does not allow, reporting the first in each
194/// token.
195fn check_printable(node: &SyntaxNode, violations: &mut Vec<Violation>) {
196    for token in node
197        .descendants_with_tokens()
198        .filter_map(|element| element.into_token())
199        .filter(|token| token.kind() != crate::SyntaxKind::BOM)
200    {
201        let text = token.text();
202        if let Some((offset, c)) = text
203            .char_indices()
204            .find(|&(_, c)| !crate::lex::is_printable(c))
205        {
206            let start = token.text_range().start() + rowan::TextSize::new(offset as u32);
207            violations.push(Violation::error_at(
208                Rule::InvalidCharacter,
209                rowan::TextRange::at(start, rowan::TextSize::of(c)),
210                format!("Non-printable character U+{:04X}", c as u32),
211            ));
212        }
213    }
214}
215
216/// Walk up to the ROOT node from the given node, or return the node itself if it is already ROOT.
217fn find_root(node: &SyntaxNode) -> SyntaxNode {
218    if node.kind() == crate::SyntaxKind::ROOT {
219        return node.clone();
220    }
221    node.ancestors()
222        .find(|n| n.kind() == crate::SyntaxKind::ROOT)
223        .unwrap_or_else(|| node.clone())
224}
225
226/// The stream's directives, grouped by the document they introduce. Every
227/// document gets a group, possibly empty; directives that no document
228/// follows form a last group with `None`.
229fn directive_groups(root: &SyntaxNode) -> Vec<(Vec<SyntaxNode>, Option<SyntaxNode>)> {
230    let mut groups = Vec::new();
231    let mut directives = Vec::new();
232    for child in root.children() {
233        match child.kind() {
234            crate::SyntaxKind::DIRECTIVE => directives.push(child),
235            crate::SyntaxKind::DOCUMENT => {
236                groups.push((std::mem::take(&mut directives), Some(child)));
237            }
238            _ => {}
239        }
240    }
241    if !directives.is_empty() {
242        groups.push((directives, None));
243    }
244    groups
245}
246
247/// The least indentation a continuation line of `node` needs: one more
248/// than the column of the innermost block collection entry holding it, or
249/// `None` outside any block collection.
250fn continuation_indent(node: &SyntaxNode, columns: &ColumnIndex) -> Option<usize> {
251    let entry = node.ancestors().skip(1).find(is_block_entry)?;
252    Some(columns.column(&entry.first_token()?) + 1)
253}
254
255/// Whether `node` is an entry of a collection without flow delimiters.
256fn is_block_entry(node: &SyntaxNode) -> bool {
257    matches!(
258        node.kind(),
259        crate::SyntaxKind::MAPPING_ENTRY | crate::SyntaxKind::SEQUENCE_ENTRY
260    ) && node.parent().is_some_and(|c| !starts_a_flow_collection(&c))
261}
262
263/// What a node being walked passes on to the nodes in it.
264#[derive(Clone, Copy)]
265struct Scope {
266    /// Whether they sit inside a flow collection.
267    in_flow: bool,
268    /// The least indentation their continuation lines need, as
269    /// [`continuation_indent`] gives it.
270    continuation_indent: Option<usize>,
271}
272
273impl Scope {
274    /// The scope of the nodes in `node`, which sits in this one.
275    fn enter(self, node: &SyntaxNode, columns: &ColumnIndex) -> Self {
276        let opens_flow = matches!(
277            node.kind(),
278            crate::SyntaxKind::MAPPING | crate::SyntaxKind::SEQUENCE
279        ) && starts_a_flow_collection(node);
280        Scope {
281            in_flow: self.in_flow || opens_flow,
282            continuation_indent: if is_block_entry(node) {
283                columns.column_at(node.text_range().start()).map(|c| c + 1)
284            } else {
285                self.continuation_indent
286            },
287        }
288    }
289}
290
291/// Set each violation's `line:column` location from its range, using the
292/// text of the tree `node` belongs to.
293fn fill_locations(node: &SyntaxNode, violations: &mut [Violation]) {
294    // Indexing the whole stream for each of its documents made validating
295    // them one by one quadratic, even when they were all valid.
296    if violations.is_empty() {
297        return;
298    }
299    let tree = node.ancestors().last().unwrap_or_else(|| node.clone());
300    let index = crate::line_index::LineIndex::new(tree.text().to_string());
301    for v in violations.iter_mut() {
302        let Some(range) = v.text_range else {
303            continue;
304        };
305        v.location = Some(index.line_column(range.start as usize).to_string());
306    }
307}
308
309impl Violation {
310    /// An error-severity violation covering `range` in the source.
311    ///
312    /// `location` is filled in once validation knows the source text.
313    fn error_at(rule: Rule, range: rowan::TextRange, message: impl Into<String>) -> Self {
314        Violation {
315            message: message.into(),
316            location: None,
317            text_range: Some(range.into()),
318            severity: Severity::Error,
319            rule,
320        }
321    }
322
323    /// A warning-severity violation covering `range` in the source.
324    ///
325    /// The document is valid YAML 1.2; a warning says only that some other
326    /// implementation may read it differently.
327    fn warning_at(rule: Rule, range: rowan::TextRange, message: impl Into<String>) -> Self {
328        Violation {
329            severity: Severity::Warning,
330            ..Violation::error_at(rule, range, message)
331        }
332    }
333}
334
335impl Validator {
336    /// Create a new validator with default configuration
337    pub fn new() -> Self {
338        Self {
339            config: ValidatorConfig::default(),
340        }
341    }
342
343    /// Create a validator with custom configuration
344    pub fn with_config(config: ValidatorConfig) -> Self {
345        Self { config }
346    }
347
348    /// Validate a YAML document against YAML 1.2 spec
349    ///
350    /// Returns a list of spec violations. Empty list means strictly compliant.
351    pub fn validate(&self, doc: &Document) -> Vec<Violation> {
352        let mut violations = Vec::new();
353        let doc = crate::nodes::immutable_copy(doc.syntax());
354
355        // Only the directives that introduce this document apply to it,
356        // which are those since the document before it.
357        let mut directives: Vec<SyntaxNode> = doc
358            .siblings(rowan::Direction::Prev)
359            .skip(1)
360            .take_while(|n| n.kind() != crate::SyntaxKind::DOCUMENT)
361            .filter(|n| n.kind() == crate::SyntaxKind::DIRECTIVE)
362            .collect();
363        directives.reverse();
364        check_directives(&directives, Some(&doc), &mut violations);
365        check_tag_handles_declared(&directives, &doc, &mut violations);
366
367        // Walk the syntax tree and check for violations
368        // This will catch ERROR nodes created by parser (including content after doc end)
369        self.validate_node(&doc, &mut violations);
370
371        fill_locations(&doc, &mut violations);
372        violations
373    }
374
375    /// Validate from a syntax node (can be ROOT, DOCUMENT, or any node)
376    ///
377    /// This is useful when you need to validate the full parse tree including directives
378    /// that may not be attached to a specific document.
379    pub fn validate_syntax(&self, node: &SyntaxNode) -> Vec<Violation> {
380        let mut violations = Vec::new();
381        let node = &crate::nodes::immutable_copy(node);
382
383        for (directives, document) in directive_groups(&find_root(node)) {
384            check_directives(&directives, document.as_ref(), &mut violations);
385        }
386
387        // Check for directives after documents without document end marker
388        check_directive_after_document(node, &mut violations);
389
390        // Check that a `%TAG` shorthand is only used in its own document
391        check_tag_shorthand_scope(node, &mut violations);
392
393        check_explicit_key_separation(node, &mut violations);
394
395        // Walk the syntax tree and check for violations
396        self.validate_node(node, &mut violations);
397
398        fill_locations(node, &mut violations);
399        violations
400    }
401
402    /// Check `node` and everything below it, in document order.
403    ///
404    /// The walk is iterative, so deep nesting cannot overflow the stack.
405    fn validate_node(&self, node: &SyntaxNode, violations: &mut Vec<Violation>) {
406        check_printable(node, violations);
407        let columns = ColumnIndex::new(node);
408        let mut identities = SharedIdentities::default();
409        let mut registry = DocumentRegistry::default();
410        // The scope of each node being walked; asking each node for its
411        // ancestors made the walk quadratic in the nesting depth.
412        let mut scopes = vec![Scope {
413            in_flow: in_flow_collection(node),
414            continuation_indent: continuation_indent(node, &columns),
415        }];
416        for event in node.preorder() {
417            match event {
418                rowan::WalkEvent::Enter(n) => {
419                    let scope = *scopes.last().expect("the walk's own scope stays");
420                    self.check_node(
421                        &n,
422                        &columns,
423                        &mut identities,
424                        &mut registry,
425                        scope,
426                        violations,
427                    );
428                    scopes.push(scope.enter(&n, &columns));
429                }
430                rowan::WalkEvent::Leave(_) => {
431                    scopes.pop();
432                }
433            }
434        }
435    }
436
437    /// Run the checks for `node` itself, not those of its children.
438    fn check_node(
439        &self,
440        node: &SyntaxNode,
441        columns: &ColumnIndex,
442        identities: &mut SharedIdentities,
443        registry: &mut DocumentRegistry,
444        scope: Scope,
445        violations: &mut Vec<Violation>,
446    ) {
447        use crate::SyntaxKind;
448        let in_flow = scope.in_flow;
449
450        // Check for tabs in any node's tokens
451        if self.config.check_tabs {
452            check_tab_usage(node, violations);
453        }
454
455        // Check current node type
456        // Check for multiple anchor tokens on any node (anchors are tokens, not nodes)
457        if self.config.check_anchors {
458            check_multiple_anchors(node, violations);
459        }
460
461        match node.kind() {
462            SyntaxKind::ERROR => {
463                // Parser has marked this as erroneous content
464                // Report it as a validation error
465                let content = node.text().to_string();
466                let preview = match content.char_indices().nth(50) {
467                    Some((end, _)) => format!("{}...", &content[..end]),
468                    None => content,
469                };
470                violations.push(Violation::error_at(
471                    Rule::Other,
472                    node.text_range(),
473                    format!("Invalid content in document: {preview:?}"),
474                ));
475            }
476            SyntaxKind::MAPPING_ENTRY => {
477                // Check for multiline implicit keys
478                check_implicit_key_multiline(node, columns, violations);
479                // Check for block collection on same line as mapping key
480                check_collection_on_same_line_as_key(node, in_flow, violations);
481                check_explicit_value_column(node, columns, in_flow, violations);
482            }
483            SyntaxKind::SCALAR => {
484                // Check for invalid escape sequences in quoted strings
485                check_escape_sequences(node, violations);
486                // Check for content on same line as block scalar indicator
487                check_block_scalar_indicator(node, violations);
488                check_block_scalar_header_indent(node, columns, scope, violations);
489                // Check block scalar content indentation consistency
490                check_block_scalar_content_indent(node, violations);
491                // Check for trailing content after quoted strings
492                check_trailing_content_after_quoted(node, violations);
493                // Check for colons in plain scalar values
494                check_colon_in_plain_scalar(node, violations);
495                check_plain_scalar_start(node, in_flow, violations);
496                check_flow_indicator_in_plain_scalar(node, in_flow, violations);
497                check_block_scalar_in_flow(node, in_flow, violations);
498                // Check for directives inside document content (e.g. %YAML after ---)
499                check_directive_in_content(node, violations);
500                // Warn about constructs YAML 1.1 read differently
501                check_legacy_yaml_1_1(node, violations);
502            }
503            SyntaxKind::DOC_START | SyntaxKind::DOC_END if self.config.check_document_markers => {
504                check_document_marker_placement(node, violations);
505            }
506            SyntaxKind::MAPPING => {
507                check_flow_collection_commas(node, violations);
508                check_flow_pair_placement(node, in_flow, violations);
509                check_flow_continuation_indent(node, scope, violations);
510                check_block_mapping_entries_on_same_line(node, violations);
511                check_block_mapping_after_properties(node, violations);
512                check_mapping_entry_indentation(node, columns, violations);
513                if self.config.check_duplicate_keys {
514                    check_duplicate_keys(node, identities, violations);
515                }
516                check_merge_operands(node, registry, violations);
517            }
518            SyntaxKind::SEQUENCE => {
519                check_flow_collection_commas(node, violations);
520                check_flow_continuation_indent(node, scope, violations);
521            }
522            SyntaxKind::KEY | SyntaxKind::SEQUENCE_ENTRY | SyntaxKind::VALUE => {
523                check_anchor_and_alias(node, violations);
524                check_single_content_node(node, violations);
525            }
526            SyntaxKind::TAGGED_NODE => {
527                check_anchor_and_alias(node, violations);
528                check_single_content_node(node, violations);
529                check_tag_on_alias(node, violations);
530                check_multiple_tags(node, violations);
531                check_collection_tag_content(node, violations);
532                check_pairs_items(node, violations);
533                check_omap_keys(node, identities, violations);
534                check_set_values(node, violations);
535            }
536            SyntaxKind::DOCUMENT => {
537                check_anchor_and_alias(node, violations);
538                check_single_content_node(node, violations);
539                check_document_level_anchors(node, violations);
540                if self.config.check_anchors {
541                    check_undefined_aliases(node, violations);
542                }
543            }
544            _ => {}
545        }
546
547        // Check tokens (like COMMENT, DOC_START, TAG) that are children but not nodes
548        for element in node.children_with_tokens() {
549            if let Some(token) = element.as_token() {
550                // Check COMMENT tokens for whitespace separation
551                if token.kind() == crate::SyntaxKind::COMMENT {
552                    check_comment_token_whitespace(token, violations);
553                }
554                // Check DOC_START tokens for content on same line
555                if token.kind() == crate::SyntaxKind::DOC_START {
556                    check_doc_start_token_content(token, violations);
557                }
558                // The lexer leaves a `&` or `*` with no name after it as
559                // such a token, where a node may start.
560                match token.kind() {
561                    crate::SyntaxKind::AMPERSAND => violations.push(Violation::error_at(
562                        Rule::InvalidAnchor,
563                        token.text_range(),
564                        "Anchor without a name",
565                    )),
566                    crate::SyntaxKind::ASTERISK => violations.push(Violation::error_at(
567                        Rule::InvalidAnchor,
568                        token.text_range(),
569                        "Alias without a name",
570                    )),
571                    _ => {}
572                }
573                // Check TAG tokens for invalid characters
574                if token.kind() == crate::SyntaxKind::TAG {
575                    check_tag_characters(token, violations);
576                    check_tag_followed_by_comma(token, violations);
577                }
578            }
579        }
580
581        // Check indentation rules
582        check_sequence_indentation(node, columns, violations);
583        check_quoted_string_indentation(node, scope, violations);
584    }
585}
586
587impl Default for Validator {
588    fn default() -> Self {
589        Self::new()
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use rowan::ast::AstNode;
597    use std::str::FromStr;
598
599    #[test]
600    fn test_deep_nesting_fits_a_small_stack() {
601        // The walk used to recurse once per node, which overflowed the stack
602        // under the fuzzer's address sanitizer on 200 nested `[`.
603        let text = format!("{}{}", "[{".repeat(127), "}]".repeat(127));
604        let green = crate::YamlFile::from_str(&text)
605            .unwrap()
606            .syntax()
607            .green()
608            .into_owned();
609        // Dropping the last reference to a tree recurses through it, so
610        // keep one out here.
611        let tree = green.clone();
612        let violations = std::thread::Builder::new()
613            .stack_size(SMALL_STACK)
614            .spawn(move || {
615                Validator::new()
616                    .validate_syntax(&SyntaxNode::new_root_mut(tree))
617                    .len()
618            })
619            .unwrap()
620            .join()
621            .unwrap();
622        assert_eq!(violations, 0);
623        drop(green);
624    }
625
626    /// A stack small enough to overflow when walking a tree recurses
627    /// through all of its levels.
628    const SMALL_STACK: usize = 64 * 1024;
629}