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}