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