Skip to main content

harn_rules/
fix.rs

1//! `fix` template interpolation and application.
2//!
3//! A `fix` is a replacement template that interpolates the match's
4//! metavars — both the captured `$VAR`s and any `transform`-synthesized
5//! ones — into replacement text. The engine computes one replacement per
6//! match and splices them into the source (format-preserving byte-splice,
7//! the same guarantee as `ast.batch_apply`).
8
9use std::collections::BTreeMap;
10
11use crate::engine::Span;
12
13/// Interpolate `$VAR` / `${VAR}` references in `template` from `vars`.
14/// An unknown reference is left verbatim (so a literal `$` survives), and
15/// `$$` is an escaped literal dollar sign.
16#[expect(
17    clippy::string_slice,
18    reason = "cursor offsets advance by len_utf8 or across ASCII bytes and find results, \
19              so every offset is a char boundary"
20)]
21pub fn interpolate(template: &str, vars: &BTreeMap<String, String>) -> String {
22    let bytes = template.as_bytes();
23    let mut out = String::with_capacity(template.len());
24    let mut i = 0;
25    while i < bytes.len() {
26        if bytes[i] != b'$' {
27            let ch = template[i..].chars().next().unwrap();
28            out.push(ch);
29            i += ch.len_utf8();
30            continue;
31        }
32        // `$$` -> literal `$`.
33        if template[i..].starts_with("$$") {
34            out.push('$');
35            i += 2;
36            continue;
37        }
38        // `${NAME}` braced form.
39        let (name, consumed) = if template[i..].starts_with("${") {
40            match template[i + 2..].find('}') {
41                Some(rel) => (&template[i + 2..i + 2 + rel], 2 + rel + 1),
42                None => {
43                    out.push('$');
44                    i += 1;
45                    continue;
46                }
47            }
48        } else {
49            // `$NAME` bare form.
50            let name_start = i + 1;
51            let mut j = name_start;
52            if j < bytes.len() && is_ident_start(bytes[j]) {
53                j += 1;
54                while j < bytes.len() && is_ident_continue(bytes[j]) {
55                    j += 1;
56                }
57            }
58            (&template[name_start..j], j - i)
59        };
60
61        if name.is_empty() {
62            out.push('$');
63            i += 1;
64            continue;
65        }
66        match vars.get(name) {
67            Some(value) => out.push_str(value),
68            // Unknown metavar: keep the reference literal.
69            None => out.push_str(&template[i..i + consumed]),
70        }
71        i += consumed;
72    }
73    out
74}
75
76fn is_ident_start(b: u8) -> bool {
77    b.is_ascii_alphabetic() || b == b'_'
78}
79
80fn is_ident_continue(b: u8) -> bool {
81    b.is_ascii_alphanumeric() || b == b'_'
82}
83
84/// One concrete edit: replace `span`'s bytes (`before`) with `replacement`.
85#[derive(Debug, Clone)]
86pub struct AppliedEdit {
87    /// The replaced span.
88    pub span: Span,
89    /// The original text at the span.
90    pub before: String,
91    /// The interpolated replacement text.
92    pub replacement: String,
93}
94
95/// Apply `edits` to `source` by byte-splice. Edits are spliced in reverse
96/// start order so earlier offsets stay valid; whitespace outside each span
97/// is preserved verbatim.
98///
99/// Edits MUST be non-overlapping — the engine resolves overlaps up front (see
100/// `engine::dedupe_overlapping`) by keeping the outermost match. This function
101/// is a defensive backstop: an out-of-range, mis-aligned, or overlapping span
102/// is skipped rather than allowed to panic `replace_range`, so a buggy rule can
103/// never corrupt a file by splicing a stale offset.
104pub fn splice(source: &str, edits: &[AppliedEdit]) -> String {
105    let mut ordered: Vec<&AppliedEdit> = edits.iter().collect();
106    ordered.sort_by_key(|e| std::cmp::Reverse(e.span.start_byte));
107    let mut out = source.to_string();
108    // Lowest start byte applied so far (we walk highest-start first). An edit
109    // overlaps an already-applied one when its end runs past this boundary.
110    let mut applied_low = usize::MAX;
111    for edit in ordered {
112        let (start, end) = (edit.span.start_byte, edit.span.end_byte);
113        let in_range = start <= end
114            && end <= out.len()
115            && out.is_char_boundary(start)
116            && out.is_char_boundary(end);
117        let overlaps = end > applied_low;
118        debug_assert!(
119            !overlaps,
120            "splice received overlapping edits; engine should have deduped"
121        );
122        if !in_range || overlaps {
123            continue;
124        }
125        out.replace_range(start..end, &edit.replacement);
126        applied_low = start;
127    }
128    out
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn vars(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
136        pairs
137            .iter()
138            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
139            .collect()
140    }
141
142    #[test]
143    fn interpolates_bare_and_braced() {
144        let v = vars(&[("KEY", "userId"), ("NAME", "id")]);
145        assert_eq!(interpolate("{ $KEY: $NAME }", &v), "{ userId: id }");
146        assert_eq!(interpolate("${KEY}_${NAME}", &v), "userId_id");
147    }
148
149    #[test]
150    fn unknown_metavar_left_literal() {
151        let v = vars(&[("KEY", "x")]);
152        assert_eq!(interpolate("$KEY $UNKNOWN", &v), "x $UNKNOWN");
153    }
154
155    #[test]
156    fn escaped_dollar() {
157        let v = vars(&[]);
158        assert_eq!(interpolate("price is $$5", &v), "price is $5");
159    }
160
161    #[test]
162    fn splices_in_reverse_order() {
163        let source = "aaa bbb ccc";
164        let edits = vec![
165            AppliedEdit {
166                span: Span {
167                    start_byte: 0,
168                    end_byte: 3,
169                    start_row: 0,
170                    start_col: 0,
171                    end_row: 0,
172                    end_col: 3,
173                },
174                before: "aaa".into(),
175                replacement: "X".into(),
176            },
177            AppliedEdit {
178                span: Span {
179                    start_byte: 8,
180                    end_byte: 11,
181                    start_row: 0,
182                    start_col: 8,
183                    end_row: 0,
184                    end_col: 11,
185                },
186                before: "ccc".into(),
187                replacement: "ZZZZ".into(),
188            },
189        ];
190        assert_eq!(splice(source, &edits), "X bbb ZZZZ");
191    }
192}