Skip to main content

coding_tools/
edit.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The per-file replacement engine behind `ct-edit`: a line-scoped find/replace
5//! that preserves every untouched byte (line terminators, indentation, and
6//! surrounding text) and records the changed lines.
7
8use regex::{NoExpand, Regex};
9
10/// One line that an edit changed, captured before/after for preview.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Site {
13    pub path: String,
14    pub line: usize,
15    pub before: String,
16    pub after: String,
17}
18
19/// Apply `re`/`replacement` to one file's `content`, per line, preserving line
20/// terminators and every untouched byte. Returns the new content, the number of
21/// occurrences replaced, and the changed lines. `literal` selects literal
22/// replacement (no `$` capture expansion), used for literal/glob finds.
23///
24/// # Examples
25///
26/// ```
27/// use coding_tools::edit::edit_content;
28/// use coding_tools::pattern::compile;
29///
30/// let re = compile("foo").unwrap();
31/// let (out, n, sites) = edit_content("f.rs", "a\nfoo bar\n  foo\n", &re, "X", true);
32/// assert_eq!(n, 2);
33/// // Untouched lines and the indentation on the changed line are preserved.
34/// assert_eq!(out, "a\nX bar\n  X\n");
35/// assert_eq!(sites.len(), 2);
36/// assert_eq!(sites[1].after, "  X");
37///
38/// // A literal find does not expand `$` in the replacement.
39/// let key = compile("KEY").unwrap();
40/// assert_eq!(edit_content("f", "KEY\n", &key, "$1 cost", true).0, "$1 cost\n");
41/// ```
42pub fn edit_content(
43    path: &str,
44    content: &str,
45    re: &Regex,
46    replacement: &str,
47    literal: bool,
48) -> (String, usize, Vec<Site>) {
49    let mut out = String::with_capacity(content.len());
50    let mut count = 0usize;
51    let mut sites = Vec::new();
52
53    for (idx, segment) in content.split_inclusive('\n').enumerate() {
54        let (body, nl) = match segment.strip_suffix('\n') {
55            Some(b) => (b, "\n"),
56            None => (segment, ""),
57        };
58        let hits = re.find_iter(body).count();
59        if hits == 0 {
60            out.push_str(segment);
61            continue;
62        }
63        count += hits;
64        let new_body = if literal {
65            re.replace_all(body, NoExpand(replacement))
66        } else {
67            re.replace_all(body, replacement)
68        };
69        if new_body.as_ref() != body {
70            sites.push(Site {
71                path: path.to_string(),
72                line: idx + 1,
73                before: body.to_string(),
74                after: new_body.to_string(),
75            });
76        }
77        out.push_str(&new_body);
78        out.push_str(nl);
79    }
80
81    (out, count, sites)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::pattern::{self, PatternKind};
88
89    fn re(p: &str) -> (Regex, bool) {
90        (
91            pattern::compile(p).unwrap(),
92            !matches!(pattern::classify(p), PatternKind::Regex),
93        )
94    }
95
96    #[test]
97    fn preserves_untouched_lines_and_terminators() {
98        let (r, lit) = re("foo");
99        let (out, n, sites) = edit_content("f", "a\nfoo bar\n  foo\n", &r, "X", lit);
100        assert_eq!(n, 2);
101        assert_eq!(out, "a\nX bar\n  X\n");
102        assert_eq!(sites.len(), 2);
103        assert_eq!(sites[1].after, "  X");
104    }
105
106    #[test]
107    fn missing_final_newline_is_preserved() {
108        let (r, lit) = re("a");
109        let (out, n, _) = edit_content("f", "a", &r, "b", lit);
110        assert_eq!((out.as_str(), n), ("b", 1));
111    }
112
113    #[test]
114    fn literal_find_does_not_expand_dollar_in_replacement() {
115        let (r, lit) = re("KEY");
116        let (out, _, _) = edit_content("f", "KEY\n", &r, "$1 cost", lit);
117        assert_eq!(out, "$1 cost\n");
118    }
119
120    #[test]
121    fn regex_find_expands_captures() {
122        let (r, lit) = re(r"v(\d+)");
123        let (out, n, _) = edit_content("f", "v12\n", &r, "ver${1}", lit);
124        assert_eq!((out.as_str(), n), ("ver12\n", 1));
125    }
126}