Skip to main content

yaml_edit/nodes/mapping/
entry.rs

1//! `impl MappingEntry` -- the key/value/set_value/discard/remove API
2//! on a single MAPPING_ENTRY node, plus its private layout helpers
3//! (indent_of_line, insert_at, set_block_value, rebuild_with_inline_value).
4//!
5//! Split out of `nodes/mapping/mod.rs` unchanged.
6
7use super::helpers::{
8    build_block_value_node, find_key_line_comment, source_anchor_text, value_is_block,
9};
10use super::{key_content_matches, MappingEntry};
11use crate::lex::SyntaxKind;
12use crate::nodes::{entry_key, entry_value, fresh_token, Lang, SyntaxNode};
13use crate::yaml::add_node_children_to;
14use rowan::GreenNodeBuilder;
15
16impl MappingEntry {
17    /// Return the raw `KEY` wrapper node of this entry.
18    ///
19    /// The returned node has kind `KEY` and wraps the actual key content
20    /// (a scalar, mapping, or sequence node). Returns `None` for malformed
21    /// entries that have no key node.
22    ///
23    /// To compare the key against a value, prefer [`key_matches`](Self::key_matches).
24    pub(crate) fn key(&self) -> Option<SyntaxNode> {
25        entry_key(&self.0)
26    }
27
28    /// Return `true` if the key of this entry matches `key`.
29    ///
30    /// Uses semantic YAML equality, so quoting style differences are ignored:
31    /// `"foo"`, `'foo'`, and `foo` all match the scalar `"foo"`. Returns
32    /// `false` if this entry has no key node.
33    pub fn key_matches(&self, key: impl crate::AsYaml) -> bool {
34        self.key().is_some_and(|k| key_content_matches(&k, key))
35    }
36
37    /// Return the raw `VALUE` wrapper node of this entry.
38    ///
39    /// The returned node has kind `VALUE` and wraps the actual value content
40    /// (a scalar, mapping, or sequence node). Returns `None` for malformed
41    /// entries that have no value node.
42    pub(crate) fn value(&self) -> Option<SyntaxNode> {
43        entry_value(&self.0)
44    }
45
46    /// Get the key of this entry as a [`YamlNode`](crate::as_yaml::YamlNode).
47    ///
48    /// Returns `None` for malformed entries that have no key.
49    pub fn key_node(&self) -> Option<crate::as_yaml::YamlNode> {
50        self.key()
51            .and_then(|k| k.children().next())
52            .and_then(crate::as_yaml::YamlNode::from_syntax)
53    }
54
55    /// Get the value of this entry as a [`YamlNode`](crate::as_yaml::YamlNode).
56    ///
57    /// Returns `None` for malformed entries that have no value.
58    pub fn value_node(&self) -> Option<crate::as_yaml::YamlNode> {
59        self.value()
60            .and_then(|v| v.children().next())
61            .and_then(crate::as_yaml::YamlNode::from_syntax)
62    }
63
64    /// Create a new mapping entry (key-value pair) not yet attached to any mapping.
65    ///
66    /// The entry is built as a standalone CST node; attach it to a mapping with
67    /// one of the `insert_*` methods. Block-style values (mappings, sequences)
68    /// are indented with 2 spaces relative to the key.
69    ///
70    /// This builds the entry assuming the key will sit at column 0. If the
71    /// entry will be inserted into a mapping at a deeper column, use
72    /// [`new_at_indent`](Self::new_at_indent) instead so a node-backed block
73    /// value's interior indentation lines up with the target column.
74    pub fn new(
75        key: impl crate::AsYaml,
76        value: impl crate::AsYaml,
77        flow_context: bool,
78        use_explicit_key: bool,
79    ) -> Self {
80        Self::new_at_indent(key, value, flow_context, use_explicit_key, 0)
81    }
82
83    /// Like [`new`](Self::new) but builds the entry as if its key were
84    /// placed at column `key_indent`. Only affects how a node-backed block
85    /// collection value is re-indented; other value kinds render the same
86    /// as with [`new`](Self::new).
87    pub fn new_at_indent(
88        key: impl crate::AsYaml,
89        value: impl crate::AsYaml,
90        flow_context: bool,
91        use_explicit_key: bool,
92        key_indent: usize,
93    ) -> Self {
94        let mut builder = GreenNodeBuilder::new();
95        builder.start_node(SyntaxKind::MAPPING_ENTRY.into());
96
97        if use_explicit_key {
98            // Add explicit key indicator as child of MAPPING_ENTRY
99            builder.token(SyntaxKind::QUESTION.into(), "?");
100            builder.token(SyntaxKind::WHITESPACE.into(), " ");
101        }
102
103        // Build KEY
104        builder.start_node(SyntaxKind::KEY.into());
105        crate::nodes::build_key_content(&mut builder, &key);
106        let key_has_newline = false;
107        debug_assert!(!key_has_newline, "Keys should not end with newlines");
108        // The `:` goes on its own line at the entry's own column. Where that
109        // column is indented, the parser consumes the line break and the
110        // indent together as a multi-line key continuation and keeps both
111        // inside KEY; at column 0 there is no indent to consume, so the
112        // break stays a sibling of KEY. Match whichever shape applies, or
113        // rename_key -- which replaces the whole KEY node -- drops the break
114        // and collapses the entry onto one line.
115        if use_explicit_key && key_indent > 0 {
116            builder.token(SyntaxKind::NEWLINE.into(), "\n");
117            builder.token(SyntaxKind::INDENT.into(), &" ".repeat(key_indent));
118        }
119        builder.finish_node();
120
121        if use_explicit_key && key_indent == 0 {
122            builder.token(SyntaxKind::NEWLINE.into(), "\n");
123        }
124
125        builder.token(SyntaxKind::COLON.into(), ":");
126
127        // Node-backed block collections and block scalars need the same
128        // re-indent treatment that MappingEntry::set_value applies when
129        // the source's own base indent doesn't already put it at the
130        // correct target column: put the value on the next line and shift
131        // its interior indentation to line up under this entry.
132        //
133        // When key_indent == 0 (root-level insertion), col-0 dashes render
134        // as valid compact-style block content, so we leave sources with
135        // source_base == 0 alone to preserve existing formatting choices.
136        // For nested insertion (key_indent > 0), col-0 content would be
137        // parsed as escaping the parent's scope, so we must re-indent.
138        let block_source = (!flow_context && !value.is_inline())
139            .then(|| value.as_node())
140            .flatten()
141            .filter(|_| {
142                matches!(
143                    value.kind(),
144                    crate::as_yaml::YamlKind::Mapping
145                        | crate::as_yaml::YamlKind::Sequence
146                        | crate::as_yaml::YamlKind::Scalar
147                        | crate::as_yaml::YamlKind::Tagged(_)
148                )
149            })
150            // Route through build_block_value_node whenever indentation
151            // needs to shift, OR when the source is empty (so a future
152            // insertion has an INDENT hint to drop into rather than
153            // landing at column 0).
154            .filter(|src| {
155                key_indent > 0
156                    || crate::as_yaml::source_base_indent(src) > 0
157                    || src.children().next().is_none()
158            });
159
160        let value_ends_with_newline = if let Some(source) = block_source {
161            let (value_node, ends_with_newline) = build_block_value_node(source, key_indent + 2);
162            builder.start_node(value_node.kind().into());
163            add_node_children_to(&mut builder, &value_node);
164            builder.finish_node();
165            ends_with_newline
166        } else {
167            // For inline values, put the separator WHITESPACE at the
168            // MAPPING_ENTRY level (between COLON and VALUE), matching
169            // the parser convention. That way `set_value`'s targeted
170            // splice, which only replaces the VALUE node in place,
171            // preserves the space between key and new value.
172            let inline = value.is_inline();
173            if inline {
174                builder.token(SyntaxKind::WHITESPACE.into(), " ");
175            }
176            builder.start_node(SyntaxKind::VALUE.into());
177            let ends_with_newline = match (inline, value.kind()) {
178                (true, _) => {
179                    // TAGGED_NODE values (!!set, !!omap, !!pairs) are
180                    // inline but may end with newlines from their
181                    // block-style content.
182                    value.build_content(&mut builder, 0, flow_context)
183                }
184                // Block mappings and sequences start on new line but don't get pre-indented
185                // They handle their own indentation via copy_node_content_with_indent
186                (false, crate::as_yaml::YamlKind::Mapping | crate::as_yaml::YamlKind::Sequence) => {
187                    builder.token(SyntaxKind::NEWLINE.into(), "\n");
188                    value.build_content(&mut builder, 0, flow_context)
189                }
190                // Block scalars (literal/folded) get newline and indent
191                (false, _) => {
192                    builder.token(SyntaxKind::NEWLINE.into(), "\n");
193                    builder.token(SyntaxKind::INDENT.into(), "  ");
194                    value.build_content(&mut builder, 2, flow_context)
195                }
196            };
197            builder.finish_node(); // VALUE
198            ends_with_newline
199        };
200
201        // Every block-style MAPPING_ENTRY ends with NEWLINE (newline ownership
202        // model). Flow-style entries live inside `{}` on a single line, so
203        // adding a NEWLINE there would break the container.
204        if !flow_context && !value_ends_with_newline {
205            builder.token(SyntaxKind::NEWLINE.into(), "\n");
206        }
207
208        builder.finish_node(); // MAPPING_ENTRY
209        MappingEntry(SyntaxNode::new_root_mut(builder.finish()))
210    }
211
212    /// Column at which this entry's key sits on its line.
213    ///
214    /// Returns 0 for root-level entries or entries that share a line with
215    /// something else (an explicit-key form, a flow-style parent, etc.).
216    fn indent_of_line(&self) -> usize {
217        crate::as_yaml::source_base_indent(&self.0)
218    }
219
220    /// Index of the entry's VALUE child in its own children list, if any.
221    fn value_index(&self) -> Option<usize> {
222        self.0
223            .children_with_tokens()
224            .position(|c| c.as_node().is_some_and(|n| n.kind() == SyntaxKind::VALUE))
225    }
226
227    /// Insert `tokens` at position `at` in `self.0`.
228    fn insert_at(&self, at: usize, tokens: Vec<rowan::SyntaxToken<Lang>>) {
229        let elems: Vec<_> = tokens.into_iter().map(Into::into).collect();
230        self.0.splice_children(at..at, elems);
231    }
232
233    /// Append `tokens` at the end of `self.0`.
234    fn append(&self, tokens: Vec<rowan::SyntaxToken<Lang>>) {
235        super::super::append_children(&self.0, tokens.into_iter().map(Into::into).collect());
236    }
237
238    /// Detach the last child of `self.0` if it's a NEWLINE token.
239    fn detach_last_if_newline(&self) {
240        if let Some(last) = self.0.last_child_or_token() {
241            if last
242                .as_token()
243                .is_some_and(|t| t.kind() == SyntaxKind::NEWLINE)
244            {
245                last.detach();
246            }
247        }
248    }
249
250    /// True if the entry ends with a NEWLINE (anywhere inside).
251    fn ends_with_newline(&self) -> bool {
252        self.0
253            .last_token()
254            .is_some_and(|t| t.kind() == SyntaxKind::NEWLINE)
255    }
256
257    /// Replace this entry's value with a block sequence or mapping from
258    /// another CST. Emits the value on the line after the key, indented to
259    /// this entry's nesting depth + 2, with the source's interior
260    /// indentation shifted to match. Called only from [`set_value`] for
261    /// non-flow parents whose new value is a node-backed block collection.
262    ///
263    /// Uses targeted CST edits (splice the VALUE, detach specific
264    /// surrounding tokens) rather than rebuilding the whole entry, so
265    /// unrelated tokens (leading comments, alignment whitespace) survive.
266    fn set_block_value(&self, source: &SyntaxNode) {
267        let (new_value_node, value_ends_with_newline) =
268            build_block_value_node(source, self.indent_of_line() + 2);
269
270        // For explicit-key entries the parser sometimes stores the entry's
271        // terminating NEWLINE at the MAPPING level (as a sibling of this
272        // MAPPING_ENTRY) rather than inside it. Track this so we can drop
273        // the sibling NEWLINE below if the new block value provides its own.
274        let old_entry_owned_trailing_nl = self.ends_with_newline();
275
276        let Some(value_idx) = self.value_index() else {
277            return;
278        };
279        let trailing_comment = find_key_line_comment(&self.0);
280        // Grab the WHITESPACE between COLON and VALUE before we splice so
281        // we can detach it later without re-walking. It's the only shape
282        // that "COLON followed by WHITESPACE followed by VALUE" takes.
283        let ws_between = self
284            .0
285            .children_with_tokens()
286            .nth(value_idx.wrapping_sub(1))
287            .and_then(|c| c.into_token())
288            .filter(|t| t.kind() == SyntaxKind::WHITESPACE);
289
290        self.0
291            .splice_children(value_idx..value_idx + 1, vec![new_value_node.into()]);
292        // Block content lives on the next line, so drop the inline space.
293        if let Some(ws) = ws_between {
294            ws.detach();
295        }
296        // Slot the rescued comment (`WHITESPACE COMMENT`) right before the
297        // new VALUE so it stays on the key line.
298        if let Some((ws, comment)) = trailing_comment {
299            if let Some(idx) = self.value_index() {
300                self.insert_at(
301                    idx,
302                    vec![
303                        fresh_token(SyntaxKind::WHITESPACE, &ws),
304                        fresh_token(SyntaxKind::COMMENT, &comment),
305                    ],
306                );
307            }
308        }
309
310        // Keep exactly one trailing NEWLINE on the entry, either inside
311        // VALUE (block content usually provides one), at MAPPING_ENTRY
312        // level, or at MAPPING level (for explicit-key entries).
313        if value_ends_with_newline {
314            // Drop the MAPPING_ENTRY-level trailing NEWLINE that was the
315            // OLD entry's terminator - the new VALUE has its own.
316            self.detach_last_if_newline();
317        } else if !self.ends_with_newline() {
318            // New value doesn't provide one and the entry doesn't have
319            // one - append so we don't glue onto the next entry.
320            self.append(vec![fresh_token(SyntaxKind::NEWLINE, "\n")]);
321        }
322        let entry_ends_with_nl = self.ends_with_newline();
323        if entry_ends_with_nl && !old_entry_owned_trailing_nl {
324            if let Some(next) = self.0.next_sibling_or_token() {
325                if next
326                    .as_token()
327                    .is_some_and(|t| t.kind() == SyntaxKind::NEWLINE)
328                {
329                    next.detach();
330                }
331            }
332        }
333    }
334
335    /// Replace the value of this entry in place, preserving the key and surrounding whitespace.
336    pub fn set_value(&self, new_value: impl crate::AsYaml, flow_context: bool) {
337        use crate::as_yaml::YamlKind;
338
339        // A block collection or block scalar coming from another CST goes
340        // through set_block_value, which does targeted CST edits to swap
341        // the VALUE and adjust the surrounding tokens (drop the WHITESPACE
342        // between COLON and VALUE, drop the redundant trailing NEWLINE,
343        // rescue any inline comment on the old value line). Flow context
344        // (parent mapping is flow-style) means block content isn't valid
345        // there, so we fall through to the default path.
346        let block_source = (!flow_context && !new_value.is_inline())
347            .then(|| new_value.as_node())
348            .flatten()
349            .filter(|_| {
350                matches!(
351                    new_value.kind(),
352                    YamlKind::Mapping | YamlKind::Sequence | YamlKind::Scalar | YamlKind::Tagged(_)
353                )
354            });
355        if let Some(source) = block_source {
356            self.set_block_value(source);
357            return;
358        }
359
360        // Build the new VALUE. If the source is a node-backed inline value
361        // preceded by an `&anchor` in its parent VALUE, preserve that
362        // anchor so aliases pointing at the moved value still resolve.
363        // Any key-line comment from the old entry (whether it lived inside
364        // the old VALUE or at MAPPING_ENTRY level between COLON and VALUE)
365        // is embedded inside the new inline VALUE so it stays on the key
366        // line and doesn't swallow anything.
367        let anchor = new_value.as_node().and_then(source_anchor_text);
368        let trailing_comment = find_key_line_comment(&self.0);
369        let mut value_builder = GreenNodeBuilder::new();
370        value_builder.start_node(SyntaxKind::VALUE.into());
371        if let Some(a) = anchor.as_deref() {
372            value_builder.token(SyntaxKind::ANCHOR.into(), a);
373            value_builder.token(SyntaxKind::WHITESPACE.into(), " ");
374        }
375        // A block scalar rendered here has to clear this entry's own
376        // column, so hand it that rather than the document's.
377        new_value.build_content(&mut value_builder, self.indent_of_line(), flow_context);
378        if let Some((ws, comment)) = &trailing_comment {
379            value_builder.token(SyntaxKind::WHITESPACE.into(), ws);
380            value_builder.token(SyntaxKind::COMMENT.into(), comment);
381        }
382        value_builder.finish_node();
383        let new_value_node = SyntaxNode::new_root_mut(value_builder.finish());
384
385        let old_was_block = self.value().is_some_and(|v| value_is_block(&v));
386        if old_was_block {
387            self.rebuild_with_inline_value(&new_value_node);
388            return;
389        }
390
391        // Targeted splice: replace the VALUE child in place; siblings and
392        // surrounding tokens (comments, alignment WS) survive untouched.
393        if let Some(value_idx) = self
394            .0
395            .children_with_tokens()
396            .position(|c| c.as_node().is_some_and(|n| n.kind() == SyntaxKind::VALUE))
397        {
398            self.0
399                .splice_children(value_idx..value_idx + 1, vec![new_value_node.into()]);
400            return;
401        }
402
403        // No VALUE node in this entry (malformed input). Insert one right
404        // after the COLON with a leading WHITESPACE.
405        if let Some(colon_idx) = self
406            .0
407            .children_with_tokens()
408            .position(|c| c.as_token().is_some_and(|t| t.kind() == SyntaxKind::COLON))
409        {
410            let insert_at = colon_idx + 1;
411            let ws = fresh_token(SyntaxKind::WHITESPACE, " ");
412            self.0
413                .splice_children(insert_at..insert_at, vec![ws.into(), new_value_node.into()]);
414        }
415    }
416
417    /// Replace this entry's block value with `new_value_node` (an inline
418    /// VALUE) that sits on the same line as the key. Uses targeted CST
419    /// edits: splice the VALUE, ensure a single WHITESPACE sits between
420    /// COLON and it, rescue any inline comment on the key line so it
421    /// survives, and append a trailing NEWLINE if the entry doesn't
422    /// already end with one.
423    fn rebuild_with_inline_value(&self, new_value_node: &SyntaxNode) {
424        let Some(value_idx) = self.value_index() else {
425            return;
426        };
427
428        // If the parser stored a key-line comment as a sibling of the old
429        // block VALUE (`k:  # note\n  - block`), detach it: the new inline
430        // `new_value_node` already carries the rescued comment inside
431        // itself. Leaving the old tokens would duplicate the comment and
432        // - since comments extend to end of line - swallow the new value.
433        let stale: Vec<_> = self
434            .0
435            .children_with_tokens()
436            .take(value_idx)
437            .filter_map(|c| c.into_token())
438            .filter(|t| matches!(t.kind(), SyntaxKind::WHITESPACE | SyntaxKind::COMMENT))
439            .collect();
440        if stale.iter().any(|t| t.kind() == SyntaxKind::COMMENT) {
441            for tok in stale {
442                tok.detach();
443            }
444        }
445
446        // Swap VALUE (its index may have moved if we detached above).
447        let value_idx = self.value_index().expect("VALUE was here a moment ago");
448        self.0.splice_children(
449            value_idx..value_idx + 1,
450            vec![new_value_node.clone().into()],
451        );
452
453        // Ensure a single WHITESPACE sits between COLON and VALUE - the
454        // old block VALUE typically had none.
455        if let Some(idx) = self.value_index() {
456            let prev_is_ws = idx.checked_sub(1).is_some_and(|p| {
457                self.0
458                    .children_with_tokens()
459                    .nth(p)
460                    .and_then(|c| c.into_token())
461                    .is_some_and(|t| t.kind() == SyntaxKind::WHITESPACE)
462            });
463            if !prev_is_ws {
464                self.insert_at(idx, vec![fresh_token(SyntaxKind::WHITESPACE, " ")]);
465            }
466        }
467
468        // Inline values don't own a trailing NEWLINE; ensure the entry has
469        // one.
470        if !self.ends_with_newline() {
471            self.append(vec![fresh_token(SyntaxKind::NEWLINE, "\n")]);
472        }
473    }
474
475    /// Detach this entry from its parent mapping, effectively removing it.
476    ///
477    /// The entry node is detached from the tree; the `MappingEntry` value is
478    /// consumed. To retrieve the removed entry from a mapping (and get back a
479    /// `MappingEntry` you can inspect), use [`Mapping::remove`] instead.
480    pub fn discard(self) {
481        self.0.detach();
482    }
483
484    /// Remove this entry from its parent mapping.
485    ///
486    /// This is a convenience method that calls [`discard`](Self::discard)
487    /// internally. It's useful when you have a [`MappingEntry`] (e.g., from
488    /// [`find_all_entries_by_key`](Mapping::find_all_entries_by_key)) and want
489    /// to remove it without retrieving it from the mapping again.
490    ///
491    /// Consumes `self` and detaches the entry from the parent mapping.
492    pub fn remove(self) {
493        self.discard();
494    }
495}