Skip to main content

yaml_edit/
lib.rs

1#![deny(missing_docs)]
2#![allow(clippy::type_complexity)]
3#![warn(clippy::unnecessary_to_owned)]
4#![warn(clippy::redundant_clone)]
5#![warn(clippy::inefficient_to_string)]
6#![warn(clippy::manual_string_new)]
7#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))]
8
9//! A lossless YAML parser and editor.
10//!
11//! This library provides a lossless parser for YAML files, preserving
12//! all whitespace, comments, and formatting. It is based on the [rowan] library.
13//!
14//! # Mutability Model
15//!
16//! **Important:** This library uses interior mutability through the rowan library.
17//! This means methods taking `&self` can still modify the underlying syntax tree.
18//!
19//! ## What This Means
20//!
21//! - Types like [`Mapping`], [`Sequence`], and [`Document`] can mutate even from `&self`
22//! - Changes are immediately visible to all holders of the syntax tree
23//! - You don't need to mark variables as `mut` to call mutation methods
24//!
25//! ## Example
26//!
27//! ```rust
28//! use yaml_edit::Document;
29//! use std::str::FromStr;
30//!
31//! let doc = Document::from_str("name: Alice").unwrap();  // Note: not `mut`
32//! let mapping = doc.as_mapping().unwrap();  // Note: not `mut`
33//!
34//! // Yet we can still mutate!
35//! mapping.set("age", 30);  // This works despite `mapping` not being `mut`
36//!
37//! assert_eq!(doc.to_string(), "name: Alice\nage: 30");
38//! ```
39//!
40//! ## Why This Design?
41//!
42//! This design enables:
43//! - **Efficient in-place mutations** without cloning the entire tree
44//! - **Sharing references** while still allowing modifications
45//! - **Lossless preservation** of formatting and comments during edits
46//!
47//! If you're familiar with `RefCell` or `Rc`, this is similar - the tree uses
48//! internal synchronization to allow shared mutable access.
49//!
50//! ## Migration Note
51//!
52//! If you're coming from other YAML libraries, this might seem unusual. In most
53//! libraries, you need `&mut` to modify data. Here, you don't. This is intentional
54//! and allows for a more flexible API while maintaining the guarantees of Rust's
55//! borrow checker.
56//!
57//! # Getting Started
58//!
59//! ## Parsing YAML
60//!
61//! ```rust
62//! use yaml_edit::Document;
63//! use std::str::FromStr;
64//!
65//! let yaml = Document::from_str("name: Alice\nage: 30").unwrap();
66//! let mapping = yaml.as_mapping().unwrap();
67//!
68//! // Get values
69//! let name = mapping.get("name").unwrap();
70//! assert_eq!(name.as_scalar().unwrap().to_string(), "Alice");
71//! ```
72//!
73//! ## Modifying YAML
74//!
75//! ```rust
76//! use yaml_edit::Document;
77//! use std::str::FromStr;
78//!
79//! let yaml = Document::from_str("name: Alice").unwrap();
80//! let mapping = yaml.as_mapping().unwrap();
81//!
82//! // Add a new field
83//! mapping.set("age", 30);
84//!
85//! // Update an existing field
86//! mapping.set("name", "Bob");
87//!
88//! // Remove a field
89//! mapping.remove("age");
90//! assert_eq!(yaml.to_string(), "name: Bob");
91//! ```
92//!
93//! ## Path-based Access
94//!
95//! ```rust
96//! use yaml_edit::{Document, path::YamlPath};
97//! use std::str::FromStr;
98//!
99//! let yaml = Document::from_str("server:\n  host: localhost").unwrap();
100//!
101//! // Get nested values
102//! let host = yaml.try_get_path("server.host").ok();
103//! assert!(host.is_some());
104//!
105//! // Set nested values (creates intermediate mappings)
106//! yaml.try_set_path("server.port", 8080).unwrap();
107//! yaml.try_set_path("database.host", "db.example.com").unwrap();
108//! ```
109//!
110//! ## Iterating Over Collections
111//!
112//! ```rust
113//! use yaml_edit::Document;
114//! use std::str::FromStr;
115//!
116//! let yaml = Document::from_str("a: 1\nb: 2\nc: 3").unwrap();
117//! let mapping = yaml.as_mapping().unwrap();
118//!
119//! // Iterate over key-value pairs
120//! for (key, value) in &mapping {
121//!     println!("{:?}: {:?}", key, value);
122//! }
123//!
124//! // Use iterator methods
125//! let count = (&mapping).into_iter().count();
126//! assert_eq!(count, 3);
127//! ```
128//!
129//! ## Working with Sequences
130//!
131//! ```rust
132//! use yaml_edit::Document;
133//! use std::str::FromStr;
134//!
135//! let yaml = Document::from_str("items:\n  - apple\n  - banana").unwrap();
136//! let mapping = yaml.as_mapping().unwrap();
137//! let sequence = mapping.get_sequence("items").unwrap();
138//!
139//! // Iterate over items
140//! for item in &sequence {
141//!     println!("{:?}", item);
142//! }
143//!
144//! // Get specific item
145//! let first = sequence.get(0);
146//! assert!(first.is_some());
147//! ```
148//!
149//! ## Schema Validation
150//!
151//! ```rust
152//! use yaml_edit::{Document, SchemaValidator};
153//! use std::str::FromStr;
154//!
155//! let yaml = Document::from_str("name: Alice\nage: 30").unwrap();
156//!
157//! // Validate against JSON schema (no custom types)
158//! let result = SchemaValidator::json().validate(&yaml);
159//! assert!(result.is_ok());
160//! ```
161//!
162//! ## Position Tracking
163//!
164//! ```rust
165//! use yaml_edit::Document;
166//! use std::str::FromStr;
167//!
168//! let text = "name: Alice\nage: 30";
169//! let doc = Document::from_str(text).unwrap();
170//!
171//! // Get line/column positions
172//! let start = doc.start_position(text);
173//! assert_eq!(start.line, 1);
174//! assert_eq!(start.column, 1);
175//! ```
176
177pub mod anchor_resolution;
178#[doc(inline)]
179pub use anchor_resolution::{
180    AnchorRegistry, DocumentMergedExt, DocumentResolvedExt, MappingMergedExt, MergedMapping,
181    MergedView, ResolveError,
182};
183pub mod mapping_view;
184#[doc(inline)]
185pub use mapping_view::MappingView;
186mod as_yaml;
187mod builder;
188mod column;
189pub mod custom_tags;
190pub mod debug;
191mod error;
192pub mod error_recovery;
193mod lex;
194mod line_index;
195pub mod migrate;
196mod nodes;
197mod parse;
198mod parser;
199pub mod path;
200mod scalar;
201mod schema;
202mod tag;
203pub mod validator;
204mod value;
205pub mod visitor;
206mod yaml;
207
208pub use as_yaml::{yaml_eq, yaml_hash, AsYaml, YamlKind, YamlNode};
209pub use builder::{MappingBuilder, SequenceBuilder, YamlBuilder};
210pub use error::{YamlError, YamlResult};
211pub use lex::{
212    lex, lex_with_validation, lex_with_validation_config, SyntaxKind, ValidationConfig,
213    WhitespaceError, WhitespaceErrorCategory,
214};
215pub use parse::Parse;
216pub use scalar::{CoreScalarType, ScalarStyle, ScalarType, ScalarValue};
217// `CustomTagHandler` is public and its methods take and return this, so
218// without the re-export no caller outside the crate can implement the trait.
219pub use schema::{
220    CustomSchema, CustomValidationResult, Schema, SchemaValidator, ValidationError,
221    ValidationErrorKind, ValidationResult,
222};
223pub use value::YamlValue;
224pub use yaml::{
225    Alias, Comment, CommentTextError, Directive, Document, Entry, Lang, Mapping, MappingEntry,
226    OccupiedEntry, Scalar, ScalarConversionError, Sequence, Set, TaggedNode, VacantEntry, YamlFile,
227};
228
229/// Advanced API for power users who need direct access to the underlying syntax tree.
230///
231/// This module provides low-level access to the rowan syntax tree implementation.
232/// Most users should not need this module - the main API provides high-level
233/// wrappers that are easier to use and don't expose implementation details.
234///
235/// # Example
236///
237/// ```rust
238/// use yaml_edit::{Document, advanced};
239/// use rowan::ast::AstNode;
240/// use std::str::FromStr;
241///
242/// let doc = Document::from_str("key: value").unwrap();
243/// let value = doc.as_mapping().unwrap().get("key").unwrap();
244///
245/// // The range of text a node spans
246/// let scalar = value.as_scalar().unwrap();
247/// let range = advanced::syntax_node_range(scalar.syntax());
248/// assert_eq!(&doc.to_string()[range], "value");
249/// ```
250pub mod advanced {
251    pub use rowan::TextRange;
252
253    use crate::yaml::SyntaxNode;
254    use crate::TextPosition;
255
256    /// Get the text range of a syntax node
257    pub fn syntax_node_range(node: &SyntaxNode) -> TextRange {
258        node.text_range()
259    }
260
261    /// Convert a TextPosition to rowan's TextRange
262    pub fn text_position_to_range(pos: TextPosition) -> TextRange {
263        pos.into()
264    }
265
266    /// Convert rowan's TextRange to TextPosition
267    pub fn text_range_to_position(range: TextRange) -> TextPosition {
268        range.into()
269    }
270}
271
272// Re-export custom tags API
273pub use custom_tags::{
274    // Built-in handlers
275    CompressedBinaryHandler,
276    CustomTagError,
277    CustomTagHandler,
278    CustomTagParser,
279    CustomTagRegistry,
280    EnvVarHandler,
281    JsonHandler,
282    TimestampHandler,
283};
284
285/// A text position in a YAML document, represented as byte offsets.
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287pub struct TextPosition {
288    /// The start byte offset
289    pub start: u32,
290    /// The end byte offset (exclusive)
291    pub end: u32,
292}
293
294impl TextPosition {
295    /// Create a new text position
296    pub fn new(start: u32, end: u32) -> Self {
297        Self { start, end }
298    }
299
300    /// Get the length of this text range
301    ///
302    /// # Panics
303    ///
304    /// Panics if `end` is before `start`, as converting such a position to
305    /// a [`rowan::TextRange`] does.
306    pub fn len(&self) -> u32 {
307        self.end.checked_sub(self.start).unwrap_or_else(|| {
308            panic!(
309                "TextPosition end {} is before its start {}",
310                self.end, self.start
311            )
312        })
313    }
314
315    /// Check if this range is empty
316    pub fn is_empty(&self) -> bool {
317        self.start == self.end
318    }
319}
320
321/// A line and column position in a YAML document (1-indexed).
322///
323/// Line and column numbers are both 1-indexed (first line is line 1, first column is column 1).
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325pub struct LineColumn {
326    /// Line number (1-indexed)
327    pub line: usize,
328    /// Column number (1-indexed, counts Unicode scalar values)
329    pub column: usize,
330}
331
332impl LineColumn {
333    /// Create a new line/column position
334    pub fn new(line: usize, column: usize) -> Self {
335        Self { line, column }
336    }
337}
338
339impl std::fmt::Display for LineColumn {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        write!(f, "{}:{}", self.line, self.column)
342    }
343}
344
345/// Convert a byte offset to line and column numbers in the given text.
346///
347/// Line and column numbers are 1-indexed. Column numbers count Unicode scalar values,
348/// not bytes or grapheme clusters. As in YAML 1.2, `\n`, `\r` and `\r\n` each end a
349/// line, and a byte order mark at the start of a line takes no column.
350///
351/// # Arguments
352///
353/// * `text` - The full source text
354/// * `byte_offset` - Byte offset into the text
355///
356/// # Returns
357///
358/// `LineColumn` with 1-indexed line and column numbers. An offset past the end of
359/// the text gives the position of the end of the text.
360///
361/// # Examples
362///
363/// ```
364/// use yaml_edit::byte_offset_to_line_column;
365///
366/// let text = "line 1\nline 2\nline 3";
367/// let pos = byte_offset_to_line_column(text, 7); // Start of "line 2"
368/// assert_eq!(pos.line, 2);
369/// assert_eq!(pos.column, 1);
370/// ```
371pub fn byte_offset_to_line_column(text: &str, byte_offset: usize) -> LineColumn {
372    line_index::LineIndex::new(text).line_column(byte_offset)
373}
374
375impl From<rowan::TextRange> for TextPosition {
376    fn from(range: rowan::TextRange) -> Self {
377        Self {
378            start: u32::from(range.start()),
379            end: u32::from(range.end()),
380        }
381    }
382}
383
384impl From<TextPosition> for rowan::TextRange {
385    fn from(pos: TextPosition) -> Self {
386        rowan::TextRange::new(pos.start.into(), pos.end.into())
387    }
388}
389
390/// The kind of parse error, enabling structured matching without string parsing.
391#[derive(Debug, Clone, PartialEq, Eq, Hash)]
392pub enum ParseErrorKind {
393    /// An unclosed flow sequence (missing `]`)
394    UnclosedFlowSequence,
395    /// An unclosed flow mapping (missing `}`)
396    UnclosedFlowMapping,
397    /// An unterminated quoted string (missing closing quote)
398    UnterminatedString,
399    /// Any other parse error
400    Other,
401}
402
403/// A positioned parse error containing location information.
404#[derive(Debug, Clone, PartialEq, Eq, Hash)]
405pub struct PositionedParseError {
406    /// The error message
407    pub message: String,
408    /// The text range where the error occurred
409    pub range: TextPosition,
410    /// Optional error code for categorization
411    pub code: Option<String>,
412    /// Structured error kind
413    pub kind: ParseErrorKind,
414}
415
416impl PositionedParseError {
417    /// Get the line and column where this error starts (if source text is available).
418    ///
419    /// # Arguments
420    ///
421    /// * `source_text` - The original YAML source text
422    ///
423    /// # Returns
424    ///
425    /// `LineColumn` with 1-indexed line and column numbers.
426    ///
427    /// # Examples
428    ///
429    /// ```
430    /// use yaml_edit::{YamlFile, Parse};
431    /// use std::str::FromStr;
432    ///
433    /// let text = "invalid:\n  - [unclosed";
434    /// let parse = Parse::parse_yaml(text);
435    ///
436    /// let err = &parse.positioned_errors()[0];
437    /// let pos = err.start_position(text);
438    /// assert_eq!(pos.line, 2);
439    /// ```
440    pub fn start_position(&self, source_text: &str) -> LineColumn {
441        byte_offset_to_line_column(source_text, self.range.start as usize)
442    }
443
444    /// Get the line and column where this error ends (if source text is available).
445    ///
446    /// # Arguments
447    ///
448    /// * `source_text` - The original YAML source text
449    ///
450    /// # Returns
451    ///
452    /// `LineColumn` with 1-indexed line and column numbers.
453    pub fn end_position(&self, source_text: &str) -> LineColumn {
454        byte_offset_to_line_column(source_text, self.range.end as usize)
455    }
456}
457
458impl std::fmt::Display for PositionedParseError {
459    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460        write!(f, "{}", self.message)
461    }
462}
463
464impl std::error::Error for PositionedParseError {}
465
466/// The indentation to use when writing a YAML file.
467#[derive(Debug, Clone, Copy, PartialEq, Eq)]
468pub enum Indentation {
469    /// Use the same indentation as the original line for the value.
470    FieldNameLength,
471
472    /// The number of spaces to use for indentation.
473    Spaces(u32),
474}
475
476impl Default for Indentation {
477    fn default() -> Self {
478        Indentation::Spaces(2)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use std::str::FromStr;
486
487    #[test]
488    fn test_byte_offset_to_line_column_basic() {
489        let text = "line 1\nline 2\nline 3";
490
491        let pos = byte_offset_to_line_column(text, 0);
492        assert_eq!(pos.line, 1);
493        assert_eq!(pos.column, 1);
494
495        let pos = byte_offset_to_line_column(text, 7);
496        assert_eq!(pos.line, 2);
497        assert_eq!(pos.column, 1);
498
499        let pos = byte_offset_to_line_column(text, 10);
500        assert_eq!(pos.line, 2);
501        assert_eq!(pos.column, 4);
502
503        let pos = byte_offset_to_line_column(text, 14);
504        assert_eq!(pos.line, 3);
505        assert_eq!(pos.column, 1);
506    }
507
508    #[test]
509    fn test_byte_offset_to_line_column_unicode() {
510        let text = "hello\n世界\nworld";
511
512        let pos = byte_offset_to_line_column(text, 0);
513        assert_eq!(pos.line, 1);
514        assert_eq!(pos.column, 1);
515
516        let pos = byte_offset_to_line_column(text, 6);
517        assert_eq!(pos.line, 2);
518        assert_eq!(pos.column, 1);
519
520        // After first Chinese character "世" (3 bytes)
521        let pos = byte_offset_to_line_column(text, 9);
522        assert_eq!(pos.line, 2);
523        assert_eq!(pos.column, 2);
524    }
525
526    #[test]
527    fn test_byte_offset_to_line_column_cr_line_endings() {
528        let text = "a: 1\rb: 2\r\nc: 3";
529        assert_eq!(byte_offset_to_line_column(text, 5), LineColumn::new(2, 1));
530        assert_eq!(byte_offset_to_line_column(text, 10), LineColumn::new(2, 6));
531        assert_eq!(byte_offset_to_line_column(text, 11), LineColumn::new(3, 1));
532    }
533
534    #[test]
535    fn test_byte_offset_to_line_column_bom() {
536        // A BOM is a document prefix, not content, so it takes no column.
537        let text = "\u{FEFF}a: 1\n\u{FEFF}b: 2";
538        assert_eq!(byte_offset_to_line_column(text, 3), LineColumn::new(1, 1));
539        assert_eq!(byte_offset_to_line_column(text, 4), LineColumn::new(1, 2));
540        assert_eq!(byte_offset_to_line_column(text, 8), LineColumn::new(2, 1));
541        assert_eq!(byte_offset_to_line_column(text, 11), LineColumn::new(2, 1));
542        assert_eq!(byte_offset_to_line_column(text, 12), LineColumn::new(2, 2));
543        // Inside a line it is an ordinary character.
544        assert_eq!(
545            byte_offset_to_line_column("a\u{FEFF}b", 4),
546            LineColumn::new(1, 3)
547        );
548    }
549
550    #[test]
551    fn test_line_column_display() {
552        let pos = LineColumn::new(42, 17);
553        assert_eq!(format!("{}", pos), "42:17");
554
555        let pos2 = LineColumn::new(1, 1);
556        assert_eq!(format!("{}", pos2), "1:1");
557    }
558
559    #[test]
560    fn test_text_position() {
561        let pos = TextPosition::new(3, 7);
562        assert_eq!(pos.len(), 4);
563        assert!(!pos.is_empty());
564        assert!(TextPosition::new(5, 5).is_empty());
565        let range = advanced::text_position_to_range(pos);
566        assert_eq!(range, rowan::TextRange::new(3.into(), 7.into()));
567        assert_eq!(advanced::text_range_to_position(range), pos);
568        let doc = Document::from_str("a: 1\nb: 2\n").unwrap();
569        let value = doc.as_mapping().unwrap().get("b").unwrap();
570        assert_eq!(
571            advanced::syntax_node_range(value.syntax()),
572            rowan::TextRange::new(8.into(), 9.into())
573        );
574    }
575
576    #[test]
577    fn test_indentation_default() {
578        assert_eq!(Indentation::default(), Indentation::Spaces(2));
579    }
580
581    #[test]
582    fn test_text_position_len() {
583        assert_eq!(TextPosition::new(3, 7).len(), 4);
584        assert_eq!(TextPosition::new(3, 3).len(), 0);
585    }
586
587    #[test]
588    #[should_panic(expected = "TextPosition end 2 is before its start 5")]
589    fn test_text_position_len_end_before_start() {
590        TextPosition::new(5, 2).len();
591    }
592
593    #[test]
594    fn test_empty_lines_positions() {
595        let text = "a: 1\n\n\nb: 2";
596
597        let pos1 = byte_offset_to_line_column(text, 0);
598        assert_eq!(pos1.line, 1);
599
600        let pos2 = byte_offset_to_line_column(text, 7);
601        assert_eq!(pos2.line, 4);
602    }
603}