yaml_edit/yaml.rs
1//! Lossless YAML parser and editor.
2
3use crate::{lex::SyntaxKind, parse::Parse, PositionedParseError};
4use rowan::ast::AstNode;
5use rowan::GreenNodeBuilder;
6use std::path::Path;
7use std::str::FromStr;
8
9/// The raw result of parsing a YAML file, before it is wrapped in a [`YamlFile`] node.
10///
11/// Contains the green CST root and any errors encountered during parsing.
12/// Most callers should use [`YamlFile::parse`] instead, which returns a [`Parse<YamlFile>`]
13/// wrapper with the same information in a more ergonomic form.
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub(crate) struct ParsedYaml {
16 /// The immutable green-tree root produced by the parser.
17 pub(crate) green_node: rowan::GreenNode,
18 /// Structured parse errors with source positions.
19 pub(crate) positioned_errors: Vec<PositionedParseError>,
20}
21
22// Import Lang, SyntaxNode, and ast_node! macro from nodes module
23use crate::nodes::{ast_node, has_child_token, SyntaxToken};
24pub use crate::nodes::{Lang, SyntaxNode};
25
26// Re-export extracted AST nodes from nodes module
27pub use crate::nodes::{
28 Alias, Comment, CommentTextError, Directive, Document, Entry, Mapping, MappingEntry,
29 OccupiedEntry, Scalar, ScalarConversionError, Sequence, TaggedNode, VacantEntry,
30};
31
32ast_node!(
33 YamlFile,
34 ROOT,
35 "A YAML file containing one or more documents"
36);
37
38impl YamlFile {
39 /// Capture an independent snapshot of this YAML file.
40 ///
41 /// The returned value shares the underlying immutable green-node data
42 /// with `self` at the time of the call, but lives in its own mutable
43 /// tree: subsequent mutations to `self` do not propagate to the snapshot.
44 /// Pair with [`Self::tree_eq`] to detect later mutations.
45 pub fn snapshot(&self) -> Self {
46 YamlFile(SyntaxNode::new_root_mut(self.0.green().into_owned()))
47 }
48
49 /// Returns true iff the syntax trees of `self` and `other` are
50 /// value-equal. An O(1) pointer-identity fast path makes this free for
51 /// trees that still share state with a recent `snapshot()`.
52 pub fn tree_eq(&self, other: &Self) -> bool {
53 let a = self.0.green();
54 let b = other.0.green();
55 let a_ref: &rowan::GreenNodeData = &a;
56 let b_ref: &rowan::GreenNodeData = &b;
57 std::ptr::eq(a_ref as *const _, b_ref as *const _) || a_ref == b_ref
58 }
59}
60
61/// Trait for value nodes (Mapping, Sequence, Scalar) with inline detection
62pub trait ValueNode: rowan::ast::AstNode<Language = Lang> {
63 /// Returns whether this value should be rendered inline
64 fn is_inline(&self) -> bool;
65}
66
67impl ValueNode for Mapping {
68 fn is_inline(&self) -> bool {
69 // Flow-style mappings contain brace tokens.
70 has_child_token(&self.0, |k| {
71 matches!(k, SyntaxKind::LEFT_BRACE | SyntaxKind::RIGHT_BRACE)
72 })
73 }
74}
75
76impl ValueNode for Sequence {
77 fn is_inline(&self) -> bool {
78 // Flow-style sequences contain bracket tokens.
79 has_child_token(&self.0, |k| {
80 matches!(k, SyntaxKind::LEFT_BRACKET | SyntaxKind::RIGHT_BRACKET)
81 })
82 }
83}
84
85impl ValueNode for Scalar {
86 fn is_inline(&self) -> bool {
87 // Block scalars (literal `|` and folded `>`) span multiple lines
88 // starting on a NEW line after their indicator, so they don't
89 // render inline. Everything else - plain, quoted, tagged scalars -
90 // sits on the same line as its key.
91 !crate::nodes::is_block_scalar(&self.0)
92 }
93}
94
95impl ValueNode for TaggedNode {
96 fn is_inline(&self) -> bool {
97 // A tagged node is inline iff the value it wraps is inline. Look
98 // for the first NEWLINE inside; if it appears before the wrapped
99 // node, the value sits on the next line (block form).
100 let mut seen_newline = false;
101 for child in self.0.children_with_tokens() {
102 match child {
103 rowan::NodeOrToken::Token(t) if t.kind() == SyntaxKind::NEWLINE => {
104 seen_newline = true;
105 }
106 rowan::NodeOrToken::Node(n) => {
107 if seen_newline {
108 return false;
109 }
110 return match n.kind() {
111 SyntaxKind::MAPPING => Mapping::cast(n).is_some_and(|m| m.is_inline()),
112 SyntaxKind::SEQUENCE => Sequence::cast(n).is_some_and(|s| s.is_inline()),
113 SyntaxKind::SCALAR => Scalar::cast(n).is_some_and(|s| s.is_inline()),
114 _ => true,
115 };
116 }
117 _ => {}
118 }
119 }
120 true
121 }
122}
123
124/// Move the comment and blank lines at the start of a block collection out
125/// in front of it, where the parser puts the lines before a collection's
126/// first entry. They are left there when the first entry is removed.
127///
128/// A collection that started on the line of a `- ` or `? ` indicator then
129/// starts on the line after it, so that the lines do not join the
130/// indicator's: `- a: 1\n # c\n b: 2\n` with `a` removed gives
131/// `-\n # c\n b: 2\n`.
132pub(crate) fn move_leading_lines_out(collection: &SyntaxNode) {
133 let leading: Vec<_> = collection
134 .children_with_tokens()
135 .map_while(|el| el.into_token())
136 .take_while(|t| {
137 matches!(
138 t.kind(),
139 SyntaxKind::COMMENT
140 | SyntaxKind::INDENT
141 | SyntaxKind::WHITESPACE
142 | SyntaxKind::NEWLINE
143 )
144 })
145 .collect();
146 if leading.is_empty() {
147 return;
148 }
149 // A detached collection has nothing to move the lines into.
150 let Some(parent) = collection.parent() else {
151 return;
152 };
153 let starts_with_comment = leading[0].kind() == SyntaxKind::COMMENT;
154 let mut lines: Vec<rowan::NodeOrToken<SyntaxNode, rowan::SyntaxToken<Lang>>> = Vec::new();
155 match leading[0].prev_token() {
156 // The space after a `- ` or `? ` indicator.
157 Some(space) if space.kind() == SyntaxKind::WHITESPACE => {
158 space.detach();
159 lines.push(crate::nodes::fresh_token(SyntaxKind::NEWLINE, "\n").into());
160 // A comment that ended the removed entry's line has no indent
161 // of its own; it takes the entries' column.
162 let column = leading
163 .iter()
164 .rev()
165 .find(|t| t.kind() == SyntaxKind::INDENT)
166 .filter(|_| starts_with_comment);
167 if let Some(column) = column {
168 lines.push(crate::nodes::fresh_token(SyntaxKind::INDENT, column.text()).into());
169 }
170 }
171 // The first line has an indent of its own, or is blank.
172 Some(indent) if indent.kind() == SyntaxKind::INDENT && !starts_with_comment => {
173 indent.detach();
174 }
175 _ => {}
176 }
177 lines.extend(
178 leading
179 .iter()
180 .map(|t| crate::nodes::fresh_token(t.kind(), t.text()).into()),
181 );
182 crate::nodes::replace_children(collection, 0..leading.len(), vec![]);
183 // Only the stream's first document holds the lines before its root;
184 // after a `...` or another document, they are the stream's unless a
185 // `---` comes first.
186 let (host, at) = match parent.parent() {
187 Some(root)
188 if parent.kind() == SyntaxKind::DOCUMENT
189 && parent.prev_sibling_or_token().is_some()
190 && collection.prev_sibling_or_token().is_none() =>
191 {
192 (root, parent.index())
193 }
194 _ => (parent, collection.index()),
195 };
196 crate::nodes::insert_children(&host, at, lines);
197}
198
199/// Whether `indent` starts a comment line.
200pub(crate) fn indents_comment_line(indent: &rowan::SyntaxToken<Lang>) -> bool {
201 indent
202 .next_token()
203 .is_some_and(|t| t.kind() == SyntaxKind::COMMENT)
204}
205
206/// The byte order mark `node` starts with, if any.
207fn leading_bom(node: &SyntaxNode) -> Option<SyntaxToken> {
208 node.first_child_or_token()?
209 .into_token()
210 .filter(|t| t.kind() == SyntaxKind::BOM)
211}
212
213/// A virtual AST node for YAML sets (!!set tagged scalars)
214#[derive(Debug, Clone, PartialEq, Eq, Hash)]
215pub struct Set(SyntaxNode);
216
217impl Set {
218 /// Cast a SyntaxNode to a Set only if it's a TAGGED_NODE with !!set tag
219 /// whose content is a mapping.
220 ///
221 /// A `!!set` on anything else is not a valid set and has no members;
222 /// the validator reports it.
223 pub fn cast(node: SyntaxNode) -> Option<Self> {
224 let tagged_node = TaggedNode::cast(node.clone())?;
225 if tagged_node.resolved_tag().as_deref() == Some(crate::tag::SET)
226 && tagged_node.as_mapping().is_some()
227 {
228 Some(Set(node))
229 } else {
230 None
231 }
232 }
233
234 /// Return the inner `Mapping` of this set (the !!set body).
235 fn inner_mapping(&self) -> Option<Mapping> {
236 self.0.children().find_map(Mapping::cast)
237 }
238
239 /// Iterate over the set members, preserving formatting.
240 ///
241 /// Each member is yielded as a [`YamlNode`](crate::YamlNode) wrapping the
242 /// key content node (the scalar, mapping, or sequence that forms the set
243 /// member). The iteration order follows the document order.
244 ///
245 /// To compare members semantically (ignoring quoting style), use
246 /// [`yaml_eq`](crate::yaml_eq) on the returned nodes.
247 pub fn members(&self) -> impl Iterator<Item = crate::as_yaml::YamlNode> + '_ {
248 // The members are the keys of the mapping, each with a null value.
249 self.inner_mapping()
250 .into_iter()
251 .flat_map(|m| m.keys().collect::<Vec<_>>())
252 }
253
254 /// Get the number of members in the set.
255 pub fn len(&self) -> usize {
256 self.members().count()
257 }
258
259 /// Check if the set is empty.
260 pub fn is_empty(&self) -> bool {
261 self.members().next().is_none()
262 }
263
264 /// Check if the set contains a specific value.
265 pub fn contains(&self, value: impl crate::AsYaml) -> bool {
266 self.members()
267 .any(|member| crate::as_yaml::yaml_eq(&member, &value))
268 }
269}
270
271// CST abstraction layer - hides wrapper node complexity
272
273/// Extract the actual content from a VALUE or KEY wrapper node
274fn extract_content_node(wrapper: &SyntaxNode) -> Option<SyntaxNode> {
275 use crate::lex::SyntaxKind;
276 match wrapper.kind() {
277 SyntaxKind::VALUE | SyntaxKind::KEY => wrapper.children().next(),
278 _ => Some(wrapper.clone()),
279 }
280}
281
282/// Cast the content of `node` to `T`, looking through a VALUE or KEY
283/// wrapper.
284pub(crate) fn extract<T: AstNode<Language = Lang>>(node: &SyntaxNode) -> Option<T> {
285 extract_content_node(node).and_then(T::cast)
286}
287
288/// Copy a syntax node and all its children recursively to a builder.
289pub(crate) fn copy_node_to_builder(builder: &mut GreenNodeBuilder, node: &SyntaxNode) {
290 builder.start_node(node.kind().into());
291 crate::as_yaml::copy_node_content(builder, node);
292 builder.finish_node();
293}
294
295// Debug helper to dump CST structure
296pub(crate) fn dump_cst_to_string(node: &SyntaxNode, indent: usize) -> String {
297 let mut result = String::new();
298 let indent_str = " ".repeat(indent);
299
300 for child in node.children_with_tokens() {
301 match child {
302 rowan::NodeOrToken::Node(n) => {
303 result.push_str(&format!("{}{:?}\n", indent_str, n.kind()));
304 result.push_str(&dump_cst_to_string(&n, indent + 1));
305 }
306 rowan::NodeOrToken::Token(t) => {
307 result.push_str(&format!("{} {:?} {:?}\n", indent_str, t.kind(), t.text()));
308 }
309 }
310 }
311 result
312}
313
314impl Default for YamlFile {
315 fn default() -> Self {
316 Self::new()
317 }
318}
319
320impl YamlFile {
321 /// Create a new empty YAML document.
322 pub fn new() -> YamlFile {
323 let mut builder = GreenNodeBuilder::new();
324 builder.start_node(SyntaxKind::ROOT.into());
325 builder.finish_node();
326 YamlFile(SyntaxNode::new_root_mut(builder.finish()))
327 }
328
329 /// Parse YAML text, returning a Parse result
330 pub fn parse(text: &str) -> Parse<YamlFile> {
331 Parse::parse_yaml(text)
332 }
333
334 /// Parse YAML from a file path
335 pub fn from_path<P: AsRef<Path>>(path: P) -> Result<YamlFile, crate::YamlError> {
336 let contents = std::fs::read_to_string(path)?;
337 Self::from_str(&contents)
338 }
339
340 /// Parse YAML text, allowing syntax errors.
341 ///
342 /// Returns the parsed tree even if there are parse errors, along with
343 /// a list of error messages. This allows for error-resilient tooling
344 /// that can work with partial or invalid input.
345 ///
346 /// # Example
347 /// ```
348 /// use yaml_edit::YamlFile;
349 ///
350 /// let (yaml_file, errors) = YamlFile::from_str_relaxed("key: [unclosed");
351 /// // Tree is usable even with errors
352 /// assert!(!errors.is_empty());
353 /// assert!(yaml_file.document().is_some());
354 /// ```
355 pub fn from_str_relaxed(s: &str) -> (YamlFile, Vec<String>) {
356 let parsed = YamlFile::parse(s);
357 let errors = parsed.errors();
358 (parsed.tree(), errors)
359 }
360
361 /// Parse YAML from a file path, allowing syntax errors.
362 ///
363 /// Returns the parsed tree even if there are parse errors, along with
364 /// a list of error messages. I/O errors are still returned as `Err`.
365 pub fn from_path_relaxed<P: AsRef<Path>>(
366 path: P,
367 ) -> Result<(YamlFile, Vec<String>), std::io::Error> {
368 let contents = std::fs::read_to_string(path)?;
369 Ok(Self::from_str_relaxed(&contents))
370 }
371
372 /// Read YAML from a `Read` object, allowing syntax errors.
373 ///
374 /// Returns the parsed tree even if there are parse errors, along with
375 /// a list of error messages. I/O errors are still returned as `Err`.
376 pub fn read_relaxed<R: std::io::Read>(
377 mut r: R,
378 ) -> Result<(YamlFile, Vec<String>), std::io::Error> {
379 let mut buf = String::new();
380 r.read_to_string(&mut buf)?;
381 Ok(Self::from_str_relaxed(&buf))
382 }
383
384 /// Get all documents in this YAML file
385 pub fn documents(&self) -> impl Iterator<Item = Document> {
386 self.0.children().filter_map(Document::cast)
387 }
388
389 /// Iterate over the comments in this file, in source order.
390 ///
391 /// This includes comments inside documents as well as any comments between
392 /// or outside documents.
393 ///
394 /// # Example
395 /// ```
396 /// use yaml_edit::YamlFile;
397 /// use std::str::FromStr;
398 ///
399 /// let file = YamlFile::from_str("# header\nkey: value # trailing\n").unwrap();
400 /// let comments: Vec<_> = file.comments().map(|c| c.content().to_string()).collect();
401 /// assert_eq!(comments, vec!["header", "trailing"]);
402 /// ```
403 pub fn comments(&self) -> impl Iterator<Item = Comment> {
404 crate::nodes::comment::comments(&self.0)
405 }
406
407 /// Get the first document in this YAML file, or `None` if there are none.
408 ///
409 /// Most YAML files have exactly one document. Use [`documents`](Self::documents)
410 /// to iterate over all documents in a multi-document file.
411 pub fn document(&self) -> Option<Document> {
412 self.documents().next()
413 }
414
415 /// Ensure this `YamlFile` contains at least one document, creating an empty mapping document if needed.
416 ///
417 /// Returns the first document.
418 pub fn ensure_document(&self) -> Document {
419 if self.documents().next().is_none() {
420 // No document exists, add an empty one
421 let doc = Document::new_mapping();
422 self.push_document(doc);
423 }
424 self.documents()
425 .next()
426 .expect("Document should exist after ensuring")
427 }
428
429 /// Iterate over all YAML directives (e.g. `%YAML 1.2`) in this file.
430 pub fn directives(&self) -> impl Iterator<Item = Directive> {
431 self.0.children().filter_map(Directive::cast)
432 }
433
434 /// Prepend a YAML directive to this file.
435 ///
436 /// `directive_text` should be the full directive line without a trailing
437 /// newline, e.g. `"%YAML 1.2"` or `"%TAG ! tag:example.com,2000:app/"`.
438 /// The directive is inserted before all existing content, and a `---`
439 /// marker is added to the first document if it does not have one, since
440 /// a document following directives must start explicitly.
441 ///
442 /// Note: the parser does not currently enforce that directives appear
443 /// before any document node; callers are responsible for ordering.
444 pub fn add_directive(&self, directive_text: &str) {
445 if let Some(doc) = self.documents().next() {
446 // A byte order mark must stay first in the stream, so one opening
447 // the first document moves up to the stream ahead of the directive.
448 if let Some(bom) = leading_bom(doc.syntax()) {
449 bom.detach();
450 crate::nodes::insert_children(&self.0, 0, vec![bom.into()]);
451 }
452 if !has_child_token(doc.syntax(), |k| k == SyntaxKind::DOC_START) {
453 crate::nodes::insert_children(
454 doc.syntax(),
455 0,
456 vec![
457 crate::nodes::fresh_token(SyntaxKind::DOC_START, "---").into(),
458 crate::nodes::fresh_token(SyntaxKind::NEWLINE, "\n").into(),
459 ],
460 );
461 }
462 }
463
464 let mut builder = GreenNodeBuilder::new();
465 builder.start_node(SyntaxKind::DIRECTIVE.into());
466 builder.token(SyntaxKind::DIRECTIVE.into(), directive_text);
467 builder.finish_node();
468 let directive_node = SyntaxNode::new_root_mut(builder.finish());
469
470 let index = usize::from(leading_bom(&self.0).is_some());
471 crate::nodes::insert_children(
472 &self.0,
473 index,
474 vec![
475 directive_node.into(),
476 crate::nodes::fresh_token(SyntaxKind::NEWLINE, "\n").into(),
477 ],
478 );
479 }
480
481 /// Add a new document to the end of this YAML file
482 ///
483 /// If the file already has documents or directives, the new document is
484 /// started with a `---` marker (unless it has one) on a line of its own.
485 ///
486 /// A byte order mark is only allowed at the start of the stream or in
487 /// front of a document's `---`, so a byte order mark opening `document`
488 /// is kept in front of its `---`. With no earlier document to follow, it
489 /// moves to the start of the stream, or is dropped if the stream already
490 /// starts with one.
491 pub fn push_document(&self, document: Document) {
492 let bom = leading_bom(document.syntax());
493 let needs_start = self
494 .0
495 .children()
496 .any(|n| matches!(n.kind(), SyntaxKind::DOCUMENT | SyntaxKind::DIRECTIVE));
497 if !self.0.text_range().is_empty() && !crate::nodes::trivia::ends_with_line_break(&self.0) {
498 match self.0.last_child_or_token() {
499 Some(rowan::NodeOrToken::Node(last)) if last.kind() == SyntaxKind::DOCUMENT => {
500 crate::nodes::trivia::terminate_last_line(&last, "\n")
501 }
502 _ => crate::nodes::append_children(
503 &self.0,
504 vec![crate::nodes::fresh_token(SyntaxKind::NEWLINE, "\n").into()],
505 ),
506 }
507 }
508 if needs_start && !has_child_token(document.syntax(), |k| k == SyntaxKind::DOC_START) {
509 let index = usize::from(bom.is_some());
510 crate::nodes::insert_children(
511 document.syntax(),
512 index,
513 vec![
514 crate::nodes::fresh_token(SyntaxKind::DOC_START, "---").into(),
515 crate::nodes::fresh_token(SyntaxKind::NEWLINE, "\n").into(),
516 ],
517 );
518 }
519 if let Some(bom) = bom.filter(|_| !self.0.text_range().is_empty()) {
520 bom.detach();
521 self.place_bom(bom);
522 }
523 // Before the first document, the stream's blank lines, and comments
524 // unless it starts with `---`, are that document's.
525 let prefix: Vec<_> = self.0.children_with_tokens().collect();
526 if !needs_start
527 && !(has_child_token(document.syntax(), |k| k == SyntaxKind::DOC_START)
528 && has_child_token(&self.0, |k| k == SyntaxKind::COMMENT))
529 {
530 for c in &prefix {
531 c.detach();
532 }
533 crate::nodes::insert_children(document.syntax(), 0, prefix);
534 }
535 crate::nodes::append_children(&self.0, vec![document.0.into()]);
536 }
537
538 /// Put the byte order mark of a document about to be appended where the
539 /// parser reads it.
540 fn place_bom(&self, bom: SyntaxToken) {
541 match self.0.last_child_or_token() {
542 // A marker in front of `---` ends the document before it, unless
543 // that one was closed with `...`.
544 Some(rowan::NodeOrToken::Node(last))
545 if last.kind() == SyntaxKind::DOCUMENT
546 && !has_child_token(&last, |k| k == SyntaxKind::DOC_END) =>
547 {
548 crate::nodes::append_children(&last, vec![bom.into()]);
549 }
550 _ if self.documents().next().is_some() => {
551 crate::nodes::append_children(&self.0, vec![bom.into()]);
552 }
553 // Directives or comments only: the marker belongs at the start of
554 // the stream, which needs only one.
555 _ if leading_bom(&self.0).is_some() => {}
556 _ => crate::nodes::insert_children(&self.0, 0, vec![bom.into()]),
557 }
558 }
559
560 /// Set a key-value pair in the first document's mapping.
561 ///
562 /// If the key exists its value is replaced; if not, a new entry is appended.
563 /// If the file has no document yet, one is added (see
564 /// [`ensure_document`](Self::ensure_document)).
565 ///
566 /// Returns `false`, leaving the file unchanged, if the first document's
567 /// root is a sequence or another non-mapping node (see [`Document::set`]).
568 ///
569 /// Mutates in place despite `&self` (see crate docs on interior mutability).
570 pub fn set(&self, key: impl crate::AsYaml, value: impl crate::AsYaml) -> bool {
571 self.ensure_document().set(key, value)
572 }
573
574 /// Insert a key-value pair immediately after `after_key` in the first document.
575 ///
576 /// Delegates to [`Document::insert_after`], which in turn calls
577 /// [`Mapping::insert_after`]. If `key` already exists it is updated
578 /// in-place rather than moved. Returns `false` if `after_key` is not found
579 /// or the document is not a mapping.
580 ///
581 /// Mutates in place despite `&self` (see crate docs on interior mutability).
582 pub fn insert_after(
583 &self,
584 after_key: impl crate::AsYaml,
585 key: impl crate::AsYaml,
586 value: impl crate::AsYaml,
587 ) -> bool {
588 self.document()
589 .is_some_and(|doc| doc.insert_after(after_key, key, value))
590 }
591
592 /// Insert a key-value pair immediately before `before_key` in the first document.
593 ///
594 /// Delegates to [`Document::insert_before`], which in turn calls
595 /// [`Mapping::insert_before`]. If `key` already exists it is updated
596 /// in-place rather than moved. Returns `false` if `before_key` is not found
597 /// or the document is not a mapping.
598 ///
599 /// Mutates in place despite `&self` (see crate docs on interior mutability).
600 pub fn insert_before(
601 &self,
602 before_key: impl crate::AsYaml,
603 key: impl crate::AsYaml,
604 value: impl crate::AsYaml,
605 ) -> bool {
606 self.document()
607 .is_some_and(|doc| doc.insert_before(before_key, key, value))
608 }
609
610 /// Move a key-value pair to immediately after `after_key` in the first document.
611 ///
612 /// Delegates to [`Document::move_after`]. If `key` already exists it is
613 /// **removed** from its current position and re-inserted after `after_key`.
614 /// Returns `false` if `after_key` is not found or the document is not a mapping.
615 ///
616 /// Use [`insert_after`](Self::insert_after) if you want an existing entry to be
617 /// updated in-place rather than moved.
618 ///
619 /// Mutates in place despite `&self` (see crate docs on interior mutability).
620 pub fn move_after(
621 &self,
622 after_key: impl crate::AsYaml,
623 key: impl crate::AsYaml,
624 value: impl crate::AsYaml,
625 ) -> bool {
626 self.document()
627 .is_some_and(|doc| doc.move_after(after_key, key, value))
628 }
629
630 /// Move a key-value pair to immediately before `before_key` in the first document.
631 ///
632 /// Delegates to [`Document::move_before`]. If `key` already exists it is
633 /// **removed** from its current position and re-inserted before `before_key`.
634 /// Returns `false` if `before_key` is not found or the document is not a mapping.
635 ///
636 /// Use [`insert_before`](Self::insert_before) if you want an existing entry to be
637 /// updated in-place rather than moved.
638 ///
639 /// Mutates in place despite `&self` (see crate docs on interior mutability).
640 pub fn move_before(
641 &self,
642 before_key: impl crate::AsYaml,
643 key: impl crate::AsYaml,
644 value: impl crate::AsYaml,
645 ) -> bool {
646 self.document()
647 .is_some_and(|doc| doc.move_before(before_key, key, value))
648 }
649
650 /// Insert a key-value pair at a specific index (0-based) in the first document.
651 ///
652 /// Delegates to [`Document::insert_at_index`]. If `key` already exists it
653 /// is updated in-place rather than moved. If `index` is out of bounds the
654 /// entry is appended at the end. If the first document has no root node,
655 /// a mapping is created, as is a document if the file has none (see
656 /// [`ensure_document`](Self::ensure_document)).
657 ///
658 /// Returns `false`, leaving the file unchanged, if the first document's
659 /// root is a sequence or another non-mapping node.
660 ///
661 /// Mutates in place despite `&self` (see crate docs on interior mutability).
662 pub fn insert_at_index(
663 &self,
664 index: usize,
665 key: impl crate::AsYaml,
666 value: impl crate::AsYaml,
667 ) -> bool {
668 self.ensure_document().insert_at_index(index, key, value)
669 }
670}
671
672impl FromStr for YamlFile {
673 type Err = crate::YamlError;
674
675 fn from_str(s: &str) -> Result<Self, Self::Err> {
676 let parsed = YamlFile::parse(s);
677 if !parsed.positioned_errors().is_empty() {
678 let first = &parsed.positioned_errors()[0];
679 let lc = crate::byte_offset_to_line_column(s, first.range.start as usize);
680 return Err(crate::YamlError::Parse {
681 message: first.message.clone(),
682 line: Some(lc.line),
683 column: Some(lc.column),
684 });
685 }
686 Ok(parsed.tree())
687 }
688}
689
690pub(crate) use crate::parser::parse;
691
692// Editing methods for Sequence
693
694#[cfg(test)]
695mod tests {
696 // Tests for next_flow_element_is_implicit_mapping lookahead function
697 mod lookahead_tests {
698 use crate::lex::lex;
699 use crate::parser::implicit_mapping_starts;
700
701 /// Helper to test lookahead without creating full parser
702 fn check_implicit_mapping(yaml: &str) -> bool {
703 let flow = format!("[{yaml}]");
704 let tokens = lex(&flow);
705 let kinds: Vec<_> = tokens[1..tokens.len() - 1]
706 .iter()
707 .map(|(kind, _)| *kind)
708 .collect();
709 implicit_mapping_starts(&kinds).first() == Some(&true)
710 }
711
712 #[test]
713 fn test_simple_implicit_mapping() {
714 // Looking at: 'key' : value (inside [ ... ])
715 // Should detect colon at depth 0
716 assert!(check_implicit_mapping("'key' : value"));
717 }
718
719 #[test]
720 fn test_simple_value_no_mapping() {
721 // Looking at: value, ... (stops at comma, no colon)
722 assert!(!check_implicit_mapping("value ,"));
723 }
724
725 #[test]
726 fn test_value_with_comma() {
727 // Looking at: value, more (comma at depth 0, not a mapping)
728 assert!(!check_implicit_mapping("value , more"));
729 }
730
731 #[test]
732 fn test_nested_sequence_as_key() {
733 // Looking at: [a, b]: value (colon after nested sequence completes)
734 assert!(check_implicit_mapping("[a, b]: value"));
735 }
736
737 #[test]
738 fn test_nested_mapping_not_implicit() {
739 // Looking at: {a: 1}, b (colon is inside braces at depth 1, not depth 0)
740 assert!(!check_implicit_mapping("{a: 1}, b"));
741 }
742
743 #[test]
744 fn test_deeply_nested_key() {
745 // Looking at: [[a]]: value (multiple levels of nesting)
746 assert!(check_implicit_mapping("[[a]]: value"));
747 }
748
749 #[test]
750 fn test_with_whitespace() {
751 // Looking at: 'key' : value (whitespace shouldn't affect detection)
752 assert!(check_implicit_mapping("'key' : value"));
753 }
754
755 #[test]
756 fn test_mapping_value_in_sequence() {
757 // Looking at: a, {key: value} (second element has colon but inside braces)
758 // First element is just "a" before comma
759 assert!(!check_implicit_mapping("a, {key: value}"));
760 }
761
762 #[test]
763 fn test_multiple_colons() {
764 // Looking at: key1: value1, key2: value2 (first element is mapping)
765 assert!(check_implicit_mapping("key1: value1, key2: value2"));
766 }
767
768 #[test]
769 fn test_url_not_mapping() {
770 // Looking at: http://example.com (colon in URL, but no space after)
771 // Lexer should tokenize this as single string token
772 assert!(!check_implicit_mapping("http://example.com"));
773 }
774 }
775}