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\n");
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//! ```
91//!
92//! ## Path-based Access
93//!
94//! ```rust
95//! use yaml_edit::{Document, path::YamlPath};
96//! use std::str::FromStr;
97//!
98//! let yaml = Document::from_str("server:\n  host: localhost").unwrap();
99//!
100//! // Get nested values
101//! let host = yaml.try_get_path("server.host").ok();
102//! assert!(host.is_some());
103//!
104//! // Set nested values (creates intermediate mappings)
105//! yaml.try_set_path("server.port", 8080).unwrap();
106//! yaml.try_set_path("database.host", "db.example.com").unwrap();
107//! ```
108//!
109//! ## Iterating Over Collections
110//!
111//! ```rust
112//! use yaml_edit::Document;
113//! use std::str::FromStr;
114//!
115//! let yaml = Document::from_str("a: 1\nb: 2\nc: 3").unwrap();
116//! let mapping = yaml.as_mapping().unwrap();
117//!
118//! // Iterate over key-value pairs
119//! for (key, value) in &mapping {
120//!     println!("{:?}: {:?}", key, value);
121//! }
122//!
123//! // Use iterator methods
124//! let count = (&mapping).into_iter().count();
125//! assert_eq!(count, 3);
126//! ```
127//!
128//! ## Working with Sequences
129//!
130//! ```rust
131//! use yaml_edit::Document;
132//! use std::str::FromStr;
133//!
134//! let yaml = Document::from_str("items:\n  - apple\n  - banana").unwrap();
135//! let mapping = yaml.as_mapping().unwrap();
136//! let sequence = mapping.get_sequence("items").unwrap();
137//!
138//! // Iterate over items
139//! for item in &sequence {
140//!     println!("{:?}", item);
141//! }
142//!
143//! // Get specific item
144//! let first = sequence.get(0);
145//! assert!(first.is_some());
146//! ```
147//!
148//! ## Schema Validation
149//!
150//! ```rust
151//! use yaml_edit::{Document, SchemaValidator};
152//! use std::str::FromStr;
153//!
154//! let yaml = Document::from_str("name: Alice\nage: 30").unwrap();
155//!
156//! // Validate against JSON schema (no custom types)
157//! let result = SchemaValidator::json().validate(&yaml);
158//! assert!(result.is_ok());
159//! ```
160//!
161//! ## Position Tracking
162//!
163//! ```rust
164//! use yaml_edit::Document;
165//! use std::str::FromStr;
166//!
167//! let text = "name: Alice\nage: 30";
168//! let doc = Document::from_str(text).unwrap();
169//!
170//! // Get line/column positions
171//! let start = doc.start_position(text);
172//! assert_eq!(start.line, 1);
173//! assert_eq!(start.column, 1);
174//! ```
175
176pub mod anchor_resolution;
177#[doc(inline)]
178pub use anchor_resolution::{
179    AnchorRegistry, DocumentMergedExt, DocumentResolvedExt, MappingMergedExt, MergedMapping,
180    MergedView,
181};
182pub mod mapping_view;
183#[doc(inline)]
184pub use mapping_view::MappingView;
185mod as_yaml;
186mod builder;
187pub mod custom_tags;
188pub mod debug;
189mod error;
190pub mod error_recovery;
191mod lex;
192pub mod migrate;
193mod nodes;
194mod parse;
195mod parser;
196pub mod path;
197mod scalar;
198mod schema;
199pub mod validator;
200mod value;
201pub mod visitor;
202mod yaml;
203
204pub use as_yaml::{yaml_eq, AsYaml, YamlKind, YamlNode};
205pub use builder::{MappingBuilder, SequenceBuilder, YamlBuilder};
206pub use error::{YamlError, YamlResult};
207pub use lex::{
208    lex, lex_with_validation, lex_with_validation_config, SyntaxKind, ValidationConfig,
209    WhitespaceError, WhitespaceErrorCategory,
210};
211pub use parse::Parse;
212pub use scalar::{CoreScalarType, ScalarStyle, ScalarType, ScalarValue};
213// `CustomTagHandler` is public and its methods take and return this, so
214// without the re-export no caller outside the crate can implement the trait.
215pub use schema::{
216    CustomSchema, CustomValidationResult, Schema, SchemaValidator, ValidationError,
217    ValidationErrorKind, ValidationResult,
218};
219pub use value::YamlValue;
220pub use yaml::{
221    Alias, Comment, Directive, Document, Entry, Lang, Mapping, MappingEntry, OccupiedEntry, Scalar,
222    ScalarConversionError, Sequence, Set, TaggedNode, VacantEntry, YamlFile,
223};
224
225/// Advanced API for power users who need direct access to the underlying syntax tree.
226///
227/// This module provides low-level access to the rowan syntax tree implementation.
228/// Most users should not need this module - the main API provides high-level
229/// wrappers that are easier to use and don't expose implementation details.
230///
231/// # Example
232///
233/// ```rust
234/// use yaml_edit::{Document, advanced};
235/// use std::str::FromStr;
236///
237/// let doc = Document::from_str("key: value").unwrap();
238/// let mapping = doc.as_mapping().unwrap();
239///
240/// // Get a value node
241/// if let Some(value) = mapping.get("key") {
242///     // YamlNode provides access to the underlying structure
243///     println!("Found value: {}", value.to_string());
244/// }
245/// ```
246pub mod advanced {
247    pub use rowan::TextRange;
248
249    use crate::yaml::SyntaxNode;
250    use crate::TextPosition;
251
252    /// Get the text range of a syntax node
253    pub fn syntax_node_range(node: &SyntaxNode) -> TextRange {
254        node.text_range()
255    }
256
257    /// Convert a TextPosition to rowan's TextRange
258    pub fn text_position_to_range(pos: TextPosition) -> TextRange {
259        pos.into()
260    }
261
262    /// Convert rowan's TextRange to TextPosition
263    pub fn text_range_to_position(range: TextRange) -> TextPosition {
264        range.into()
265    }
266}
267
268// Re-export custom tags API
269pub use custom_tags::{
270    // Built-in handlers
271    CompressedBinaryHandler,
272    CustomTagError,
273    CustomTagHandler,
274    CustomTagParser,
275    CustomTagRegistry,
276    EnvVarHandler,
277    JsonHandler,
278    TimestampHandler,
279};
280
281/// A text position in a YAML document, represented as byte offsets.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
283pub struct TextPosition {
284    /// The start byte offset
285    pub start: u32,
286    /// The end byte offset (exclusive)
287    pub end: u32,
288}
289
290impl TextPosition {
291    /// Create a new text position
292    pub fn new(start: u32, end: u32) -> Self {
293        Self { start, end }
294    }
295
296    /// Get the length of this text range
297    pub fn len(&self) -> u32 {
298        self.end - self.start
299    }
300
301    /// Check if this range is empty
302    pub fn is_empty(&self) -> bool {
303        self.start == self.end
304    }
305}
306
307/// A line and column position in a YAML document (1-indexed).
308///
309/// Line and column numbers are both 1-indexed (first line is line 1, first column is column 1).
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
311pub struct LineColumn {
312    /// Line number (1-indexed)
313    pub line: usize,
314    /// Column number (1-indexed, counts Unicode scalar values)
315    pub column: usize,
316}
317
318impl LineColumn {
319    /// Create a new line/column position
320    pub fn new(line: usize, column: usize) -> Self {
321        Self { line, column }
322    }
323}
324
325impl std::fmt::Display for LineColumn {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "{}:{}", self.line, self.column)
328    }
329}
330
331/// Convert a byte offset to line and column numbers in the given text.
332///
333/// Line and column numbers are 1-indexed. Column numbers count Unicode scalar values,
334/// not bytes or grapheme clusters.
335///
336/// # Arguments
337///
338/// * `text` - The full source text
339/// * `byte_offset` - Byte offset into the text
340///
341/// # Returns
342///
343/// `LineColumn` with 1-indexed line and column numbers, or line 1, column 1 if offset is out of bounds.
344///
345/// # Examples
346///
347/// ```
348/// use yaml_edit::byte_offset_to_line_column;
349///
350/// let text = "line 1\nline 2\nline 3";
351/// let pos = byte_offset_to_line_column(text, 7); // Start of "line 2"
352/// assert_eq!(pos.line, 2);
353/// assert_eq!(pos.column, 1);
354/// ```
355pub fn byte_offset_to_line_column(text: &str, byte_offset: usize) -> LineColumn {
356    let mut line = 1;
357    let mut column = 1;
358
359    for (i, ch) in text.char_indices() {
360        if i >= byte_offset {
361            break;
362        }
363
364        if ch == '\n' {
365            line += 1;
366            column = 1;
367        } else {
368            column += 1;
369        }
370    }
371
372    LineColumn { line, column }
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    /// if let Some(err) = parse.positioned_errors().first() {
437    ///     let pos = err.start_position(text);
438    ///     assert_eq!(pos.line, 2);
439    /// }
440    /// ```
441    pub fn start_position(&self, source_text: &str) -> LineColumn {
442        byte_offset_to_line_column(source_text, self.range.start as usize)
443    }
444
445    /// Get the line and column where this error ends (if source text is available).
446    ///
447    /// # Arguments
448    ///
449    /// * `source_text` - The original YAML source text
450    ///
451    /// # Returns
452    ///
453    /// `LineColumn` with 1-indexed line and column numbers.
454    pub fn end_position(&self, source_text: &str) -> LineColumn {
455        byte_offset_to_line_column(source_text, self.range.end as usize)
456    }
457}
458
459impl std::fmt::Display for PositionedParseError {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        write!(f, "{}", self.message)
462    }
463}
464
465impl std::error::Error for PositionedParseError {}
466
467/// The indentation to use when writing a YAML file.
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum Indentation {
470    /// Use the same indentation as the original line for the value.
471    FieldNameLength,
472
473    /// The number of spaces to use for indentation.
474    Spaces(u32),
475}
476
477impl Default for Indentation {
478    fn default() -> Self {
479        Indentation::Spaces(2)
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use std::str::FromStr;
487
488    #[test]
489    fn test_byte_offset_to_line_column_basic() {
490        let text = "line 1\nline 2\nline 3";
491
492        let pos = byte_offset_to_line_column(text, 0);
493        assert_eq!(pos.line, 1);
494        assert_eq!(pos.column, 1);
495
496        let pos = byte_offset_to_line_column(text, 7);
497        assert_eq!(pos.line, 2);
498        assert_eq!(pos.column, 1);
499
500        let pos = byte_offset_to_line_column(text, 10);
501        assert_eq!(pos.line, 2);
502        assert_eq!(pos.column, 4);
503
504        let pos = byte_offset_to_line_column(text, 14);
505        assert_eq!(pos.line, 3);
506        assert_eq!(pos.column, 1);
507    }
508
509    #[test]
510    fn test_byte_offset_to_line_column_unicode() {
511        let text = "hello\n世界\nworld";
512
513        let pos = byte_offset_to_line_column(text, 0);
514        assert_eq!(pos.line, 1);
515        assert_eq!(pos.column, 1);
516
517        let pos = byte_offset_to_line_column(text, 6);
518        assert_eq!(pos.line, 2);
519        assert_eq!(pos.column, 1);
520
521        // After first Chinese character "世" (3 bytes)
522        let pos = byte_offset_to_line_column(text, 9);
523        assert_eq!(pos.line, 2);
524        assert_eq!(pos.column, 2);
525    }
526
527    #[test]
528    fn test_line_column_display() {
529        let pos = LineColumn::new(42, 17);
530        assert_eq!(format!("{}", pos), "42:17");
531
532        let pos2 = LineColumn::new(1, 1);
533        assert_eq!(format!("{}", pos2), "1:1");
534    }
535
536    #[test]
537    fn test_document_position() {
538        let text = "name: Alice\nage: 30";
539        let doc = Document::from_str(text).unwrap();
540
541        let start = doc.start_position(text);
542        assert_eq!(start.line, 1);
543        assert_eq!(start.column, 1);
544
545        let range = doc.byte_range();
546        assert_eq!(range.start, 0);
547        assert!(range.end > 0);
548    }
549
550    #[test]
551    fn test_mapping_position() {
552        let text = "server:\n  host: localhost\n  port: 8080";
553        let doc = Document::from_str(text).unwrap();
554        let mapping = doc.as_mapping().unwrap();
555
556        let start = mapping.start_position(text);
557        assert_eq!(start.line, 1);
558        assert_eq!(start.column, 1);
559
560        let server_mapping = mapping.get_mapping("server").unwrap();
561        let server_start = server_mapping.start_position(text);
562        assert_eq!(server_start.line, 2);
563    }
564
565    #[test]
566    fn test_scalar_position_via_nodes() {
567        let text = "name: Alice\nage: 30";
568        let doc = Document::from_str(text).unwrap();
569        let mapping = doc.as_mapping().unwrap();
570
571        let entries: Vec<_> = mapping.entries().collect();
572        assert!(entries.len() >= 2);
573
574        let first_entry = &entries[0];
575        let key_node = first_entry.key_node().unwrap();
576        assert_eq!(key_node.to_string().trim(), "name");
577
578        let value_node = first_entry.value_node().unwrap();
579        assert_eq!(value_node.to_string().trim(), "Alice");
580    }
581
582    #[test]
583    fn test_sequence_position() {
584        let text = "items:\n  - apple\n  - banana";
585        let doc = Document::from_str(text).unwrap();
586        let mapping = doc.as_mapping().unwrap();
587
588        let items_node = mapping.get("items").unwrap();
589        assert!(items_node.as_sequence().is_some());
590    }
591
592    #[test]
593    fn test_positioned_parse_error() {
594        let text = "invalid:\n  - [unclosed";
595        let parse = Parse::parse_yaml(text);
596
597        let errors = parse.positioned_errors();
598        if errors.is_empty() {
599            return;
600        }
601
602        let err = &errors[0];
603        let start = err.start_position(text);
604        assert_eq!(start.line, 2);
605    }
606
607    #[test]
608    fn test_multiline_document_byte_offsets() {
609        // Leading comment is stream-level and can't be attached to a
610        // `Document`, so parse via `YamlFile` to reach the document.
611        let text = "# Comment\nname: Alice\n\nage: 30";
612        let doc = YamlFile::from_str(text).unwrap().document().unwrap();
613
614        let range = doc.byte_range();
615        assert_eq!(range.start, 10);
616        assert_eq!(range.end, 30);
617
618        let start = doc.start_position(text);
619        assert_eq!(start.line, 2);
620        assert_eq!(start.column, 1);
621    }
622
623    #[test]
624    fn test_nested_mapping_byte_ranges() {
625        let text = "server:\n  database:\n    host: localhost";
626        let doc = Document::from_str(text).unwrap();
627        let mapping = doc.as_mapping().unwrap();
628
629        let server_mapping = mapping.get_mapping("server").unwrap();
630        let server_range = server_mapping.byte_range();
631
632        assert!(server_range.end > server_range.start);
633
634        let server_pos = server_mapping.start_position(text);
635        assert!(server_pos.line > 0);
636    }
637
638    #[test]
639    fn test_empty_lines_positions() {
640        let text = "a: 1\n\n\nb: 2";
641
642        let pos1 = byte_offset_to_line_column(text, 0);
643        assert_eq!(pos1.line, 1);
644
645        let pos2 = byte_offset_to_line_column(text, 7);
646        assert_eq!(pos2.line, 4);
647    }
648
649    #[test]
650    fn test_document_end_position() {
651        let text = "key: value";
652        let doc = Document::from_str(text).unwrap();
653
654        let start = doc.start_position(text);
655        let end = doc.end_position(text);
656
657        assert_eq!(start.line, 1);
658        assert!(end.column >= start.column);
659    }
660
661    #[test]
662    fn test_mapping_end_position() {
663        let text = "a: 1\nb: 2";
664        let doc = Document::from_str(text).unwrap();
665        let mapping = doc.as_mapping().unwrap();
666
667        let start = mapping.start_position(text);
668        let end = mapping.end_position(text);
669
670        assert_eq!(start.line, 1);
671        assert_eq!(end.line, 2);
672    }
673}