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}