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