Skip to main content

nmbrs_workload/edit/
splice.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Byte-range splicer for the workload edit primitive.
5//!
6//! Once [`super::locate`] tells us *where* to make a
7//! change, this module emits the new bytes that go in.
8//! Three operations:
9//!
10//! - [`replace_range`] — overwrite an existing value's
11//!   byte range (replace-style edits, e.g.
12//!   `--add --replace`).
13//! - [`insert_at`] — insert new content at an offset
14//!   (add-new-key edits, e.g. `--add` with a missing
15//!   anchor).
16//! - [`indent_block`] — re-indent a multi-line block of
17//!   text to a given column. Used to align emitted YAML
18//!   to the locator-reported indent.
19//!
20//! Each function returns the post-splice source string;
21//! it doesn't write to disk. The transactional driver in
22//! [`super`] does the on-disk rename via
23//! [`super::backup::commit_temp`].
24
25/// Replace `original[range]` with `replacement`. The
26/// ranges either side are preserved byte-for-byte.
27pub fn replace_range(original: &str, range: std::ops::Range<usize>, replacement: &str) -> String {
28    let mut out =
29        String::with_capacity(original.len() + replacement.len() - (range.end - range.start));
30    out.push_str(&original[..range.start]);
31    out.push_str(replacement);
32    out.push_str(&original[range.end..]);
33    out
34}
35
36/// Insert `new_content` at `offset`. Equivalent to
37/// `replace_range(original, offset..offset, new_content)`,
38/// expressed plainly because insertion is the common case
39/// for adding new keys.
40pub fn insert_at(original: &str, offset: usize, new_content: &str) -> String {
41    let mut out = String::with_capacity(original.len() + new_content.len());
42    out.push_str(&original[..offset]);
43    out.push_str(new_content);
44    out.push_str(&original[offset..]);
45    out
46}
47
48/// Re-indent `block` to start every non-empty line at
49/// column `column`. Existing leading whitespace on each
50/// line is replaced by exactly `column` spaces.
51///
52/// Empty lines are preserved as empty (no trailing
53/// whitespace), so blank lines inside the block don't
54/// gain spurious indent.
55///
56/// Used to format a generated multi-line YAML block to
57/// the indent level the locator reported.
58pub fn indent_block(block: &str, column: usize) -> String {
59    let pad = " ".repeat(column);
60    let mut out = String::with_capacity(block.len() + column * 4);
61    for (i, line) in block.split_inclusive('\n').enumerate() {
62        let trimmed = line.trim_start_matches([' ', '\t']);
63        if trimmed.is_empty() || trimmed == "\n" {
64            out.push_str(trimmed);
65            continue;
66        }
67        // Don't indent the very first line — the caller
68        // typically supplies content that starts at the
69        // anchor's own column, and the surrounding source
70        // already has the right column up to that point.
71        // (Or supplies a leading `\n` when it wants the
72        // first line indented.)
73        if i == 0 {
74            out.push_str(trimmed);
75        } else {
76            out.push_str(&pad);
77            out.push_str(trimmed);
78        }
79    }
80    out
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn replace_range_replaces_inner_bytes() {
89        let s = "abcXYZdef";
90        let out = replace_range(s, 3..6, "...");
91        assert_eq!(out, "abc...def");
92    }
93
94    #[test]
95    fn replace_range_at_start() {
96        let s = "abcdef";
97        let out = replace_range(s, 0..3, "ZZZ");
98        assert_eq!(out, "ZZZdef");
99    }
100
101    #[test]
102    fn replace_range_at_end() {
103        let s = "abcdef";
104        let out = replace_range(s, 3..6, "ZZZ");
105        assert_eq!(out, "abcZZZ");
106    }
107
108    #[test]
109    fn replace_range_with_longer_replacement() {
110        let s = "abc.def";
111        let out = replace_range(s, 3..4, "XYZ");
112        assert_eq!(out, "abcXYZdef");
113    }
114
115    #[test]
116    fn replace_range_with_shorter_replacement() {
117        let s = "abcXYZdef";
118        let out = replace_range(s, 3..6, "_");
119        assert_eq!(out, "abc_def");
120    }
121
122    #[test]
123    fn insert_at_pushes_offset_content_right() {
124        let s = "abdef";
125        let out = insert_at(s, 2, "c");
126        assert_eq!(out, "abcdef");
127    }
128
129    #[test]
130    fn insert_at_start_and_end() {
131        assert_eq!(insert_at("def", 0, "abc"), "abcdef");
132        assert_eq!(insert_at("abc", 3, "def"), "abcdef");
133    }
134
135    #[test]
136    fn indent_block_pads_subsequent_lines_only() {
137        let block = "key:\n  child: value\n";
138        let out = indent_block(block, 4);
139        // First line not re-indented; subsequent ones get
140        // pad applied to their content.
141        assert_eq!(out, "key:\n    child: value\n");
142    }
143
144    #[test]
145    fn indent_block_preserves_blank_lines() {
146        let block = "a:\n\nb:\n";
147        let out = indent_block(block, 2);
148        assert_eq!(out, "a:\n\n  b:\n");
149    }
150
151    #[test]
152    fn indent_block_replaces_existing_leading_whitespace() {
153        let block = "key:\n      already_indented: x\n";
154        let out = indent_block(block, 2);
155        assert_eq!(
156            out, "key:\n  already_indented: x\n",
157            "existing leading ws should be replaced, not appended"
158        );
159    }
160}