Skip to main content

yamlpatch/
lib.rs

1//! Comment and format-preserving YAML patch operations.
2
3use std::borrow::Cow;
4
5use line_index::{LineCol, TextRange, TextSize};
6
7/// Error types for YAML patch operations
8#[derive(thiserror::Error, Debug)]
9pub enum Error {
10    #[error("YAML query error: {0}")]
11    Query(#[from] yamlpath::QueryError),
12    #[error("YAML serialization error: {0}")]
13    Serialization(#[from] serde_yaml::Error),
14    #[error("Invalid operation: {0}")]
15    InvalidOperation(String),
16}
17
18/// Represents different YAML styles for a feature.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum Style {
21    /// Block style mappings
22    BlockMapping,
23    /// Block style sequences
24    BlockSequence,
25    /// Multiline flow mapping style:
26    ///
27    /// ```yaml
28    /// {
29    ///   key: value,
30    ///   key2: value2
31    /// }
32    /// ```
33    MultilineFlowMapping,
34    /// Single-line flow mapping style: { key: value, key2: value2 }
35    FlowMapping,
36    /// Multiline flow sequence style:
37    /// ```yaml
38    /// [
39    ///   item1,
40    ///   item2,
41    /// ]
42    /// ```
43    MultilineFlowSequence,
44    /// Single-line flow sequence style: [ item1, item2, item3 ]
45    FlowSequence,
46    /// Literal scalar style: |
47    MultilineLiteralScalar,
48    /// Folded scalar style: >
49    MultilineFoldedScalar,
50    /// Double quoted scalar style: "value"
51    DoubleQuoted,
52    /// Single quoted scalar style: 'value'
53    SingleQuoted,
54    /// Plain scalar style: value
55    PlainScalar,
56}
57
58impl Style {
59    /// Given a feature and its document, determine the style of the feature.
60    pub fn from_feature(feature: &yamlpath::Feature, doc: &yamlpath::Document) -> Self {
61        let content = doc.extract(feature);
62        let trimmed = content.trim().as_bytes();
63        let multiline = trimmed.contains(&b'\n');
64
65        match feature.kind() {
66            yamlpath::FeatureKind::BlockMapping => Style::BlockMapping,
67            yamlpath::FeatureKind::BlockSequence => Style::BlockSequence,
68            yamlpath::FeatureKind::FlowMapping => {
69                if multiline {
70                    Style::MultilineFlowMapping
71                } else {
72                    Style::FlowMapping
73                }
74            }
75            yamlpath::FeatureKind::FlowSequence => {
76                if multiline {
77                    Style::MultilineFlowSequence
78                } else {
79                    Style::FlowSequence
80                }
81            }
82            yamlpath::FeatureKind::Scalar => match trimmed[0] {
83                b'|' => Style::MultilineLiteralScalar,
84                b'>' => Style::MultilineFoldedScalar,
85                b'"' => Style::DoubleQuoted,
86                b'\'' => Style::SingleQuoted,
87                _ => Style::PlainScalar,
88            },
89        }
90    }
91}
92
93/// Represents a single YAML patch.
94///
95/// A patch operation consists of a route to the feature to patch
96/// and the operation to perform on that feature.
97#[derive(Debug, Clone)]
98pub struct Patch<'doc> {
99    /// The route to the feature to patch.
100    pub route: yamlpath::Route<'doc>,
101    /// The operation to perform on the feature.
102    pub operation: Op<'doc>,
103}
104
105/// Represents a YAML patch operation.
106#[derive(Debug, Clone)]
107pub enum Op<'doc> {
108    /// Rewrites a fragment of a feature at the given path.
109    ///
110    /// This can be used to perform graceful rewrites of string values,
111    /// regardless of their nested position or single/multi-line nature.
112    ///
113    /// For example, the following:
114    ///
115    /// ```yaml
116    /// run: |
117    ///   echo "foo: ${{ foo }}"
118    /// ```
119    ///
120    /// can be rewritten to:
121    ///
122    /// ```yaml
123    /// run: |
124    ///   echo "foo ${FOO}"
125    /// ```
126    ///
127    /// via a `RewriteFragment` with:
128    ///
129    /// ```text
130    /// route: "/run",
131    /// from: "${{ foo }}",
132    /// to: "${FOO}",
133    /// ```
134    ///
135    /// This operation performs exactly one rewrite at a time, meaning
136    /// that the first match of `from` in the feature will be replaced.
137    ///
138    /// This can be made more precise by passing a `after` index,
139    /// which specifies that the rewrite should only occur on
140    /// the first match of `from` that occurs after the given byte index.
141    RewriteFragment {
142        from: subfeature::Subfeature<'doc>,
143        to: Cow<'doc, str>,
144    },
145    /// Replace a comment at the given path.
146    ///
147    /// This operation replaces the entire comment associated with the feature
148    /// at the given path with the new comment.
149    ///
150    /// The entire comment is replaced at once, and only one matching
151    /// comment is permitted. Features that don't have an associated comment
152    /// are ignored, while features with multiple comments will be rejected.
153    ReplaceComment { new: Cow<'doc, str> },
154    /// Emplace a comment at the given path.
155    ///
156    /// This is like `ReplaceComment`, but will insert a new comment
157    /// if none exists.
158    EmplaceComment { new: Cow<'doc, str> },
159    /// Replace the value at the given path
160    Replace(serde_yaml::Value),
161    /// Add a new key-value pair at the given path.
162    ///
163    /// The route should point to a mapping.
164    ///
165    /// Limitations:
166    ///
167    /// - The mapping must be a block mapping or single-line flow mapping.
168    ///   Multi-line flow mappings are not currently supported.
169    /// - The key must not already exist in the targeted mapping.
170    Add {
171        key: String,
172        value: serde_yaml::Value,
173    },
174    /// Update a mapping at the given path.
175    ///
176    /// If the mapping does not already exist, it will be created.
177    MergeInto {
178        key: String,
179        updates: indexmap::IndexMap<String, serde_yaml::Value>,
180    },
181    /// Remove the key at the given path
182    #[allow(dead_code)]
183    Remove,
184    /// Append a new item to a sequence at the given path.
185    ///
186    /// The sequence must be a block sequence; flow sequences are not supported.
187    Append { value: serde_yaml::Value },
188}
189
190/// Apply a sequence of YAML patch operations to a YAML document.
191/// Returns a new YAML document with the patches applied.
192///
193/// Returns an error if the given YAML input is not valid, if a patch
194/// operation fails, or if the resulting YAML is malformed.
195///
196/// Each patch is applied in the order given. The [`Patch`] APIs are
197/// designed to operate symbolically without absolute byte positions,
198/// so operations should not invalidate each other unless they actually
199/// conflict in terms of proposed changes.
200pub fn apply_yaml_patches(
201    document: &yamlpath::Document,
202    patches: &[Patch],
203) -> Result<yamlpath::Document, Error> {
204    let mut patches = patches.iter();
205
206    let mut next_document = {
207        let Some(patch) = patches.next() else {
208            return Err(Error::InvalidOperation("no patches provided".to_string()));
209        };
210
211        apply_single_patch(document, patch)?
212    };
213
214    for patch in patches {
215        next_document = apply_single_patch(&next_document, patch)?;
216    }
217
218    Ok(next_document)
219}
220
221/// Apply a single YAML patch operation
222fn apply_single_patch(
223    document: &yamlpath::Document,
224    patch: &Patch,
225) -> Result<yamlpath::Document, Error> {
226    let content = document.source();
227    let mut patched_content = match &patch.operation {
228        Op::RewriteFragment { from, to } => {
229            // HACK: If we have an empty route, we're trying to rewrite against the entire document.
230            // In an ideal world we'd use `top_feature` here (or indirectly in
231            // `route_to_feature_exact`), but we might have leading whitespace that isn't captured
232            // by the top feature, throwing off our spans. So we have this nastiness instead.
233            // TODO(ww): There are almost certainly other patch ops that have this same edge case.
234            let (extracted_feature, range) = if patch.route.is_empty() {
235                let source = document.source();
236                (source, 0..source.len())
237            } else {
238                let Some(feature) = route_to_feature_exact(&patch.route, document)? else {
239                    return Err(Error::InvalidOperation(format!(
240                        "no pre-existing value to patch at {route:?}",
241                        route = patch.route
242                    )));
243                };
244
245                (
246                    document.extract(&feature),
247                    feature.location.byte_span.0..feature.location.byte_span.1,
248                )
249            };
250
251            let bias = from.after;
252
253            if bias > extracted_feature.len() {
254                return Err(Error::InvalidOperation(format!(
255                    "replacement scan index {bias} is out of bounds for feature",
256                )));
257            }
258
259            let Some(span) = from.locate_within(extracted_feature) else {
260                return Err(Error::InvalidOperation(format!(
261                    "no match for '{from:?}' in feature",
262                )));
263            };
264
265            let mut patched_feature = extracted_feature.to_string();
266            patched_feature.replace_range(span.as_range(), to);
267
268            // Finally, put our patch back into the overall content.
269            let mut patched_content = content.to_string();
270            patched_content.replace_range(range, &patched_feature);
271
272            patched_content
273        }
274        Op::ReplaceComment { new } => {
275            let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
276                Error::InvalidOperation(format!(
277                    "no existing feature at {route:?}",
278                    route = patch.route
279                ))
280            })?;
281
282            let comment_features = document.feature_comments(&feature);
283            let comment_feature = match comment_features.len() {
284                0 => return Ok(document.clone()),
285                1 => &comment_features[0],
286                _ => {
287                    return Err(Error::InvalidOperation(format!(
288                        "multiple comments found at {route:?}",
289                        route = patch.route
290                    )));
291                }
292            };
293
294            let mut result = content.to_string();
295            result.replace_range(comment_feature, new);
296
297            result
298        }
299        Op::EmplaceComment { new } => {
300            // FIXME: We should gracefully handle empty features here,
301            // since `foo:` -> `foo: # comment` is a reasonable operation.
302            let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
303                Error::InvalidOperation(format!(
304                    "no existing feature at {route:?}",
305                    route = patch.route
306                ))
307            })?;
308
309            // FIXME: We can't emplace comments on non-block multi-line
310            // scalars with the technique below, since the comment
311            // would end up inside the scalar. We just exclude these for now.
312            if matches!(
313                Style::from_feature(&feature, document),
314                Style::SingleQuoted | Style::DoubleQuoted
315            ) && feature.is_multiline()
316            {
317                return Err(Error::InvalidOperation(format!(
318                    "cannot emplace comment on non-block multi-line scalar at {route:?}",
319                    route = patch.route
320                )));
321            }
322
323            let comment_features = document.feature_comments(&feature);
324            match comment_features.len() {
325                0 => {
326                    // No existing comment; emplace a new one.
327                    // The 'right' emplacement location is subjective;
328                    // we capriciously choose to emplace the new comment at the end
329                    // of the first line of the feature.
330                    let line_range = line_span(document, feature.location.byte_span.0);
331                    let mut insert_pos = line_range.end;
332                    if let Some(b'\n') = document.source().as_bytes().get(insert_pos - 1) {
333                        insert_pos -= 1;
334                    }
335                    if let Some(b'\r') = document.source().as_bytes().get(insert_pos - 1) {
336                        insert_pos -= 1;
337                    }
338
339                    let mut result = content.to_string();
340                    result.insert_str(insert_pos, &format!(" {new}"));
341
342                    result
343                }
344                1 => {
345                    return apply_single_patch(
346                        document,
347                        &Patch {
348                            route: patch.route.clone(),
349                            operation: Op::ReplaceComment { new: new.clone() },
350                        },
351                    );
352                }
353                _ => {
354                    return Err(Error::InvalidOperation(format!(
355                        "multiple comments found at {route:?}",
356                        route = patch.route
357                    )));
358                }
359            }
360        }
361        Op::Replace(value) => {
362            let feature = route_to_feature_pretty(&patch.route, document)?;
363
364            // Get the replacement content
365            let replacement = apply_value_replacement(&feature, document, value, true)?;
366
367            // Extract the current content to calculate spans
368            let current_content = document.extract(&feature);
369            let current_content_with_ws = document.extract_with_leading_whitespace(&feature);
370
371            // Find the span to replace - use the span with leading whitespace if it's a key-value pair
372            let (start_span, end_span) = if current_content_with_ws.contains(':') {
373                // Replace the entire key-value pair span
374                let ws_start = feature.location.byte_span.0
375                    - (current_content_with_ws.len() - current_content.len());
376                (ws_start, feature.location.byte_span.1)
377            } else {
378                // Replace just the value
379                (feature.location.byte_span.0, feature.location.byte_span.1)
380            };
381
382            // Replace the content
383            let mut result = content.to_string();
384            result.replace_range(start_span..end_span, &replacement);
385
386            result
387        }
388        Op::Add { key, value } => {
389            // Check to see whether `key` is already present within the route.
390            // NOTE: Safe unwrap, since `with_keys` ensures we always have at
391            // least one component.
392            let key_query = patch.route.with_key(key.as_str());
393
394            if document.query_exists(&key_query) {
395                return Err(Error::InvalidOperation(format!(
396                    "key '{key}' already exists at {route:?}",
397                    key = key,
398                    route = patch.route
399                )));
400            }
401
402            let feature = if patch.route.is_empty() {
403                document.top_feature()?
404            } else {
405                route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
406                    Error::InvalidOperation(format!(
407                        "no existing mapping at {route:?}",
408                        route = patch.route
409                    ))
410                })?
411            };
412
413            let style = Style::from_feature(&feature, document);
414            let feature_content = document.extract(&feature);
415
416            let updated_feature = match style {
417                Style::BlockMapping => {
418                    handle_block_mapping_addition(feature_content, document, &feature, key, value)
419                }
420                Style::FlowMapping => handle_flow_mapping_addition(feature_content, key, value),
421                // TODO: Remove this limitation.
422                Style::MultilineFlowMapping => Err(Error::InvalidOperation(format!(
423                    "add operation is not permitted against multiline flow mapping route: {:?}",
424                    patch.route
425                ))),
426                _ => Err(Error::InvalidOperation(format!(
427                    "add operation is not permitted against non-mapping route: {:?}",
428                    patch.route
429                ))),
430            }?;
431
432            // Replace the content in the document
433            let mut result = content.to_string();
434            result.replace_range(&feature, &updated_feature);
435
436            result
437        }
438        Op::MergeInto { key, updates } => {
439            let existing_key_route = patch.route.with_key(key.as_str());
440            match route_to_feature_exact(&existing_key_route, document) {
441                // The key already exists, and has a nonempty body.
442                Ok(Some(existing_feature)) => {
443                    // Sanity-check that we're on a mapping.
444                    let style = Style::from_feature(&existing_feature, document);
445                    if !matches!(style, Style::BlockMapping | Style::FlowMapping) {
446                        return Err(Error::InvalidOperation(format!(
447                            "can't perform merge against non-mapping at {existing_key_route:?}"
448                        )));
449                    }
450
451                    // NOTE: We need to extract the original mapping with
452                    // leading whitespace so that serde_yaml can parse it;
453                    // otherwise something like:
454                    //
455                    // ```yaml
456                    // env:
457                    //   FOO: bar
458                    //   ABC: def
459                    // ```
460                    //
461                    // would be extracted as:
462                    //
463                    // ```yaml
464                    // FOO: bar
465                    //   ABC: def
466                    // ```
467                    let existing_content =
468                        document.extract_with_leading_whitespace(&existing_feature);
469                    let existing_mapping = serde_yaml::from_str::<serde_yaml::Mapping>(
470                        existing_content,
471                    )
472                    .map_err(|e| {
473                        Error::InvalidOperation(format!(
474                            "MergeInto: failed to parse existing mapping at {existing_key_route:?}: {e}"
475                        ))
476                    })?;
477
478                    // Add or replace each key-value pair in the updates.
479                    let mut current_document = document.clone();
480                    for (k, v) in updates {
481                        if existing_mapping.contains_key(k) {
482                            current_document = apply_single_patch(
483                                &current_document,
484                                &Patch {
485                                    route: existing_key_route.with_key(k.as_str()),
486                                    operation: Op::Replace(v.clone()),
487                                },
488                            )?;
489                        } else {
490                            current_document = apply_single_patch(
491                                &current_document,
492                                &Patch {
493                                    route: existing_key_route.clone(),
494                                    operation: Op::Add {
495                                        key: k.into(),
496                                        value: v.clone(),
497                                    },
498                                },
499                            )?;
500                        }
501                    }
502
503                    return Ok(current_document);
504                }
505                // The key exists, but has an empty body.
506                // TODO: Support this.
507                Ok(None) => {
508                    return Err(Error::InvalidOperation(format!(
509                        "MergeInto: cannot merge into empty key at {existing_key_route:?}"
510                    )));
511                }
512                // The key does not exist.
513                Err(Error::Query(yamlpath::QueryError::ExhaustedMapping(_))) => {
514                    return apply_single_patch(
515                        document,
516                        &Patch {
517                            route: patch.route.clone(),
518                            operation: Op::Add {
519                                key: key.clone(),
520                                value: serde_yaml::to_value(updates.clone())?,
521                            },
522                        },
523                    );
524                }
525                Err(e) => return Err(e),
526            }
527        }
528        Op::Remove => {
529            if patch.route.is_empty() {
530                return Err(Error::InvalidOperation(
531                    "Cannot remove root document".to_string(),
532                ));
533            }
534
535            let feature = route_to_feature_pretty(&patch.route, document)?;
536
537            // For removal, we need to remove the entire line including leading whitespace
538            // TODO: This isn't sound, e.g. removing `b:` from `{a: a, b: b}` will
539            // remove the entire line.
540            let start_pos = {
541                let range = line_span(document, feature.location.byte_span.0);
542                range.start
543            };
544            let end_pos = {
545                let range = line_span(document, feature.location.byte_span.1);
546                range.end
547            };
548
549            let mut result = content.to_string();
550            result.replace_range(start_pos..end_pos, "");
551
552            result
553        }
554        Op::Append { value } => {
555            let feature = route_to_feature_exact(&patch.route, document)?.ok_or_else(|| {
556                Error::InvalidOperation(format!(
557                    "no existing sequence at {route:?}",
558                    route = patch.route
559                ))
560            })?;
561
562            let style = Style::from_feature(&feature, document);
563
564            match style {
565                Style::BlockSequence => {
566                    let updated_feature = handle_block_sequence_append(document, &feature, value)?;
567
568                    // Replace the content in the document
569                    let mut result = content.to_string();
570                    result.replace_range(&feature, &updated_feature);
571
572                    result
573                }
574                Style::FlowSequence => {
575                    return Err(Error::InvalidOperation(format!(
576                        "append operation is not permitted against flow sequence route: {:?}",
577                        patch.route
578                    )));
579                }
580                _ => {
581                    return Err(Error::InvalidOperation(format!(
582                        "append operation is only permitted against sequence routes: {:?}",
583                        patch.route
584                    )));
585                }
586            }
587        }
588    };
589
590    if !patched_content.ends_with('\n') {
591        patched_content.push('\n');
592    }
593
594    yamlpath::Document::new(patched_content).map_err(Error::from)
595}
596
597pub fn route_to_feature_pretty<'a>(
598    route: &yamlpath::Route<'_>,
599    doc: &'a yamlpath::Document,
600) -> Result<yamlpath::Feature<'a>, Error> {
601    doc.query_pretty(route).map_err(Error::from)
602}
603
604pub fn route_to_feature_exact<'a>(
605    route: &yamlpath::Route<'_>,
606    doc: &'a yamlpath::Document,
607) -> Result<Option<yamlpath::Feature<'a>>, Error> {
608    doc.query_exact(route).map_err(Error::from)
609}
610
611/// Serialize a serde_yaml::Value to a YAML string, handling different types appropriately
612fn serialize_yaml_value(value: &serde_yaml::Value) -> Result<String, Error> {
613    let yaml_str = serde_yaml::to_string(value)?;
614    Ok(yaml_str.trim_end().to_string()) // Remove trailing newline
615}
616
617/// Serialize a [`serde_yaml::Value`] to a YAML string in flow layout.
618///
619/// This serializes only a restricted subset of YAML: tags are not
620/// supported, and mapping keys must be strings.
621pub fn serialize_flow(value: &serde_yaml::Value) -> Result<String, Error> {
622    let mut buf = String::new();
623    fn serialize_inner(value: &serde_yaml::Value, buf: &mut String) -> Result<(), Error> {
624        match value {
625            serde_yaml::Value::Null => {
626                // serde_yaml puts a trailing newline on this for some reasons
627                // so we do it manually.
628                buf.push_str("null");
629                Ok(())
630            }
631            serde_yaml::Value::Bool(b) => {
632                buf.push_str(if *b { "true" } else { "false" });
633                Ok(())
634            }
635            serde_yaml::Value::Number(n) => {
636                buf.push_str(&n.to_string());
637                Ok(())
638            }
639            serde_yaml::Value::String(s) => {
640                // Note: there are other plain-scalar-safe chars, but this is fine
641                // for a first approximation.
642                if s.chars()
643                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
644                {
645                    buf.push_str(s);
646                } else {
647                    // Dumb hack: serde_yaml will always produce a reasonable-enough
648                    // single-line string scalar for us.
649                    buf.push_str(
650                        &serde_json::to_string(s)
651                            .map_err(|e| Error::InvalidOperation(e.to_string()))?,
652                    );
653                }
654
655                Ok(())
656            }
657            serde_yaml::Value::Sequence(values) => {
658                // Serialize sequence in flow style: [item1, item2, item3]
659                buf.push('[');
660                for (i, item) in values.iter().enumerate() {
661                    if i > 0 {
662                        buf.push_str(", ");
663                    }
664                    serialize_inner(item, buf)?;
665                }
666                buf.push(']');
667                Ok(())
668            }
669            serde_yaml::Value::Mapping(mapping) => {
670                // Serialize mapping in flow style: { key1: value1, key2: value2 }
671                buf.push_str("{ ");
672                for (i, (key, value)) in mapping.iter().enumerate() {
673                    if i > 0 {
674                        buf.push_str(", ");
675                    }
676                    if !matches!(key, serde_yaml::Value::String(_)) {
677                        return Err(Error::InvalidOperation(format!(
678                            "mapping keys must be strings, found: {key:?}"
679                        )));
680                    }
681                    serialize_inner(key, buf)?;
682
683                    buf.push_str(": ");
684                    if !matches!(value, serde_yaml::Value::Null) {
685                        // Skip the null part of `key: null`, since `key: `
686                        // is more idiomatic.
687                        serialize_inner(value, buf)?;
688                    }
689                }
690                buf.push_str(" }");
691                Ok(())
692            }
693            serde_yaml::Value::Tagged(tagged_value) => Err(Error::InvalidOperation(format!(
694                "cannot serialize tagged value: {tagged_value:?}"
695            ))),
696        }
697    }
698
699    serialize_inner(value, &mut buf)?;
700    Ok(buf)
701}
702
703/// Given a document and a position, return the span of the line containing that position.
704///
705/// Panics if the position is invalid.
706fn line_span(doc: &yamlpath::Document, pos: usize) -> core::ops::Range<usize> {
707    let pos = TextSize::new(pos as u32);
708    let LineCol { line, .. } = doc.line_index().line_col(pos);
709    doc.line_index()
710        .line(line)
711        .expect("impossible: line index gave us an invalid line")
712        .into()
713}
714
715/// Extract the number of leading spaces need to align a block item with
716/// its surrounding context.
717///
718/// This takes into account block sequences, e.g. where the mapping is
719/// a child of a list item and needs to be properly aligned with the list
720/// item's other content.
721pub fn extract_leading_indentation_for_block_item(
722    doc: &yamlpath::Document,
723    feature: &yamlpath::Feature,
724) -> usize {
725    let line_range = line_span(doc, feature.location.byte_span.0);
726
727    // NOTE: We trim the end since trailing whitespace doesn't count,
728    // and we don't watch to match on the line's newline.
729    let line_content = &doc.source()[line_range].trim_end();
730
731    let mut accept_dash = true;
732    for (idx, b) in line_content.bytes().enumerate() {
733        match b {
734            b' ' => {
735                accept_dash = true;
736            }
737            b'-' => {
738                if accept_dash {
739                    accept_dash = false;
740                } else {
741                    return idx - 1;
742                }
743            }
744            _ => {
745                // If we accepted a dash last and we're on a non-dash/non-space,
746                // then the last dash was part of a scalar.
747                if !accept_dash {
748                    return idx - 1;
749                } else {
750                    return idx;
751                }
752            }
753        }
754    }
755
756    // If we've reached the end of the line without hitting a non-space
757    // or non-dash, then we have a funky line item like:
758    //
759    // ```yaml
760    //   -
761    //     foo: bar
762    // ```
763    //
764    // In which case our expected leading indentation the length plus one.
765    //
766    // This is reliable in practice but not technically sound, since the
767    // user might have written:
768    //
769    // ```yaml
770    //   -
771    //       foo: bar
772    // ```
773    //
774    // In which case we'll attempt to insert at the wrong indentation, and
775    // probably produce invalid YAML.
776    //
777    // The trick there would probably be to walk forward on the feature's
778    // lines and grab the first non-empty, non-comment line's leading whitespace.
779    line_content.len() + 1
780}
781
782/// Extract leading whitespace from the beginning of the line containing
783/// the given feature.
784pub fn extract_leading_whitespace<'doc>(
785    doc: &'doc yamlpath::Document,
786    feature: &yamlpath::Feature,
787) -> &'doc str {
788    let line_range = line_span(doc, feature.location.byte_span.0);
789    let line_content = &doc.source()[line_range];
790
791    let end = line_content
792        .bytes()
793        .position(|b| b != b' ')
794        .unwrap_or(line_content.len());
795
796    &line_content[..end]
797}
798
799/// Indent multi-line YAML content to match the target indentation
800fn indent_multiline_yaml(content: &str, base_indent: &str) -> String {
801    let lines: Vec<&str> = content.lines().collect();
802    if lines.len() <= 1 {
803        return content.to_string();
804    }
805
806    let mut result = String::new();
807    for (i, line) in lines.iter().enumerate() {
808        if i == 0 {
809            result.push_str(line);
810        } else {
811            result.push('\n');
812            result.push_str(base_indent);
813            if !line.trim().is_empty() {
814                result.push_str("  "); // Additional indentation for continuation
815                result.push_str(line.trim_start());
816            }
817        }
818    }
819    result
820}
821
822fn handle_block_mapping_addition(
823    feature_content: &str,
824    doc: &yamlpath::Document,
825    feature: &yamlpath::Feature,
826    key: &str,
827    value: &serde_yaml::Value,
828) -> Result<String, Error> {
829    // Convert the new value to YAML string for block style handling
830    let new_value_str = if matches!(value, serde_yaml::Value::Sequence(_)) {
831        // For sequences, use flow-aware serialization to maintain consistency
832        serialize_flow(value)?
833    } else {
834        serialize_yaml_value(value)?
835    };
836    let new_value_str = new_value_str.trim_end(); // Remove trailing newline
837
838    // Determine the appropriate indentation
839    let indent = " ".repeat(extract_leading_indentation_for_block_item(doc, feature));
840
841    // Format the new entry
842    let mut final_entry = if let serde_yaml::Value::Mapping(mapping) = &value {
843        if mapping.is_empty() {
844            // For empty mappings, format inline
845            format!("\n{indent}{key}: {new_value_str}")
846        } else {
847            // For non-empty mappings, format as a nested structure
848            let value_lines = new_value_str.lines();
849            let mut result = format!("\n{indent}{key}:");
850            for line in value_lines {
851                if !line.trim().is_empty() {
852                    result.push('\n');
853                    result.push_str(&indent);
854                    result.push_str("  "); // 2 spaces for nested content
855                    result.push_str(line.trim_start());
856                }
857            }
858            result
859        }
860    } else if new_value_str.contains('\n') {
861        // Handle multiline values
862        let indented_value = indent_multiline_yaml(new_value_str, &indent);
863        format!("\n{indent}{key}: {indented_value}")
864    } else {
865        format!("\n{indent}{key}: {new_value_str}")
866    };
867
868    // Figure out the insertion point.
869    // To do this, we find the end of the feature's content, i.e.
870    // the last non-empty, non-comment line in the feature.
871    let insertion_point = find_content_end(feature, doc);
872
873    // If our insertion point is before the end of the feature,
874    // we need to insert a newline to preserve the flow of any
875    // trailing comments.
876    if insertion_point < feature.location.byte_span.1 {
877        final_entry.push('\n');
878    }
879
880    // Check if we need to add a newline before the entry
881    // If the content at insertion point already ends with a newline, don't add another
882    let needs_leading_newline = if insertion_point > 0 {
883        doc.source().as_bytes().get(insertion_point - 1) != Some(&b'\n')
884    } else {
885        true
886    };
887
888    let final_entry_to_insert = if needs_leading_newline {
889        final_entry
890    } else {
891        // Remove the leading newline since there's already one
892        final_entry
893            .strip_prefix('\n')
894            .unwrap_or(&final_entry)
895            .to_string()
896    };
897
898    // Insert the final entry into the feature's content.
899    // To do this, we need to readjust the insertion point using
900    // the feature's start as the bias.
901    let bias = feature.location.byte_span.0;
902    let relative_insertion_point = insertion_point - bias;
903
904    let mut updated_feature = feature_content.to_string();
905    updated_feature.insert_str(relative_insertion_point, &final_entry_to_insert);
906
907    Ok(updated_feature)
908}
909
910fn handle_block_sequence_append(
911    doc: &yamlpath::Document,
912    feature: &yamlpath::Feature,
913    value: &serde_yaml::Value,
914) -> Result<String, Error> {
915    let feature_content = doc.extract(feature);
916    let indent = extract_leading_whitespace(doc, feature);
917
918    // Use flow-style for nested sequences to produce more idiomatic YAML
919    let value_str = if matches!(value, serde_yaml::Value::Sequence(_)) {
920        serialize_flow(value)?
921    } else {
922        serialize_yaml_value(value)?
923    };
924    let insertion_point = find_content_end(feature, doc);
925    let bias = feature.location.byte_span.0;
926    let relative_insertion_point = insertion_point - bias;
927
928    // Check if a newline is needed before adding the new item.
929    let needs_leading_newline = if relative_insertion_point > 0 {
930        feature_content.chars().nth(relative_insertion_point - 1) != Some('\n')
931    } else {
932        !feature_content.is_empty()
933    };
934
935    let mut new_item = String::new();
936    if needs_leading_newline {
937        new_item.push('\n');
938    }
939
940    let mut lines = value_str.lines();
941    if let Some(first_line) = lines.next() {
942        // The first line of the item is placed next to the dash.
943        new_item.push_str(&format!("{}- {}", indent, first_line));
944
945        // Subsequent lines are indented two spaces deeper than the dash.
946        let item_content_indent = format!("{}  ", indent);
947        for line in lines {
948            new_item.push('\n');
949            new_item.push_str(&item_content_indent);
950            new_item.push_str(line);
951        }
952    } else {
953        // This handles cases like an empty string value.
954        new_item.push_str(&format!("{}- {}", indent, value_str));
955    }
956
957    let mut updated_feature = feature_content.to_string();
958    updated_feature.insert_str(relative_insertion_point, &new_item);
959
960    Ok(updated_feature)
961}
962
963/// Handle adding a key-value pair to a flow mapping while preserving flow style
964fn handle_flow_mapping_addition(
965    feature_content: &str,
966    key: &str,
967    value: &serde_yaml::Value,
968) -> Result<String, Error> {
969    // Our strategy for flow mappings is to deserialize the existing feature,
970    // add the new key-value pair, and then serialize it back.
971    // This is probably slightly slower than just string manipulation,
972    // but it saves us a lot of special-casing around trailing commas,
973    // empty mapping forms, etc.
974    //
975    // We can get away with this because, unlike block mappings, single
976    // line flow mappings can't contain comments or (much) other user
977    // significant formatting.
978
979    let mut existing_mapping = serde_yaml::from_str::<serde_yaml::Mapping>(feature_content)
980        .map_err(Error::Serialization)?;
981
982    existing_mapping.insert(key.into(), value.clone());
983
984    let updated_content = serialize_flow(&serde_yaml::Value::Mapping(existing_mapping))?;
985
986    Ok(updated_content)
987}
988
989/// Find the end of actual step content, excluding trailing comments
990pub fn find_content_end(feature: &yamlpath::Feature, doc: &yamlpath::Document) -> usize {
991    let lines: Vec<_> = doc
992        .line_index()
993        .lines(TextRange::new(
994            (feature.location.byte_span.0 as u32).into(),
995            (feature.location.byte_span.1 as u32).into(),
996        ))
997        .collect();
998
999    // Walk over the feature's lines in reverse, and return the absolute
1000    // position of the end of the last non-empty, non-comment line
1001    for line in lines.into_iter().rev() {
1002        let line_content = &doc.source()[line];
1003        let trimmed = line_content.trim();
1004
1005        if !trimmed.is_empty() && !trimmed.starts_with('#') {
1006            return line.end().into();
1007        }
1008    }
1009
1010    feature.location.byte_span.1 // Fallback to original end if no content found
1011}
1012
1013/// Apply a value replacement at the given feature location, preserving key structure and formatting
1014fn apply_value_replacement(
1015    feature: &yamlpath::Feature,
1016    doc: &yamlpath::Document,
1017    value: &serde_yaml::Value,
1018    support_multiline_literals: bool,
1019) -> Result<String, Error> {
1020    // Extract the current content to see what we're replacing
1021    let current_content_with_ws = doc.extract_with_leading_whitespace(feature);
1022
1023    // Get the byte span for precise replacement
1024    let start_byte = feature.location.byte_span.0;
1025    let end_byte = feature.location.byte_span.1;
1026
1027    // Check if we're in a flow mapping context by examining the extracted content
1028    // For true flow mappings, the entire content should be a single-line flow mapping
1029    let trimmed_content = current_content_with_ws.trim();
1030    let is_flow_mapping = trimmed_content.starts_with('{')
1031        && trimmed_content.ends_with('}')
1032        && !trimmed_content.contains('\n');
1033
1034    if is_flow_mapping {
1035        // Handle flow mapping replacement - we need to be more surgical
1036        return handle_flow_mapping_value_replacement(
1037            doc.source(),
1038            start_byte,
1039            end_byte,
1040            current_content_with_ws,
1041            value,
1042        );
1043    }
1044
1045    // For mapping values, we need to preserve the key part
1046    let replacement = if let Some(colon_pos) = current_content_with_ws.find(':') {
1047        // This is a key-value pair, preserve the key and whitespace
1048        let key_part = &current_content_with_ws[..colon_pos + 1];
1049        let value_part = &current_content_with_ws[colon_pos + 1..];
1050
1051        if support_multiline_literals {
1052            // Check if this is a multiline YAML string (contains |)
1053            let is_multiline_literal = value_part.trim_start().starts_with('|');
1054
1055            if is_multiline_literal {
1056                // Check if this is a multiline string value
1057                if let serde_yaml::Value::String(string_content) = value
1058                    && string_content.contains('\n')
1059                {
1060                    // For multiline literal blocks, use the raw string content
1061                    let leading_whitespace = extract_leading_whitespace(doc, feature);
1062                    let content_indent = format!("{leading_whitespace}  "); // Key indent + 2 spaces for content
1063
1064                    // Format as: key: |\n  content\n  more content
1065                    let indented_content = string_content
1066                        .lines()
1067                        .map(|line| {
1068                            if line.trim().is_empty() {
1069                                String::new()
1070                            } else {
1071                                format!("{}{}", content_indent, line.trim_start())
1072                            }
1073                        })
1074                        .collect::<Vec<_>>()
1075                        .join("\n");
1076
1077                    // Find the position of | in the original content and include it
1078                    let pipe_pos = value_part.find('|').expect("impossible");
1079                    let key_with_pipe = &current_content_with_ws
1080                        [..colon_pos + 1 + value_part[..pipe_pos].len() + 1];
1081                    return Ok(format!(
1082                        "{}\n{}",
1083                        key_with_pipe.trim_end(),
1084                        indented_content
1085                    ));
1086                }
1087            }
1088        }
1089
1090        // Regular block style - use standard formatting
1091        let val_str = serialize_yaml_value(value)?;
1092        format!("{} {}", key_part, val_str.trim())
1093    } else {
1094        // This is just a value, replace it directly
1095
1096        serialize_yaml_value(value)?
1097    };
1098
1099    Ok(replacement)
1100}
1101
1102/// Handle value replacement within flow mappings more precisely
1103fn handle_flow_mapping_value_replacement(
1104    _content: &str,
1105    _start_byte: usize,
1106    _end_byte: usize,
1107    current_content: &str,
1108    value: &serde_yaml::Value,
1109) -> Result<String, Error> {
1110    let val_str = serialize_yaml_value(value)?;
1111    let val_str = val_str.trim();
1112
1113    // Parse the flow mapping content to understand the structure
1114    let trimmed = current_content.trim();
1115
1116    // Case 1: { key: } - has colon, empty value
1117    if let Some(colon_pos) = trimmed.find(':') {
1118        let before_colon = &trimmed[..colon_pos];
1119        let after_colon = &trimmed[colon_pos + 1..];
1120
1121        // Check if there's already a value after the colon (excluding the closing brace)
1122        let value_part = after_colon.trim().trim_end_matches('}').trim();
1123
1124        if value_part.is_empty() {
1125            // Case: { key: } -> { key: value }
1126            let key_part = before_colon.trim_start_matches('{').trim();
1127            Ok(format!("{{ {key_part}: {val_str} }}"))
1128        } else {
1129            // Case: { key: oldvalue } -> { key: newvalue }
1130            let key_part = before_colon.trim_start_matches('{').trim();
1131            Ok(format!("{{ {key_part}: {val_str} }}"))
1132        }
1133    } else {
1134        // Case 2: { key } - no colon, bare key -> { key: value }
1135        let key_part = trimmed.trim_start_matches('{').trim_end_matches('}').trim();
1136        Ok(format!("{{ {key_part}: {val_str} }}"))
1137    }
1138}