Skip to main content

harn_rules/
transform.rs

1//! The `transform` pipeline: synthesize new metavars from captured ones
2//! before `fix` interpolation (ast-grep `transform:`).
3//!
4//! Each transform reads one `source` metavar and applies exactly one
5//! operation — regex `replace`, a `substring` slice, or a case `convert` —
6//! binding the result under a new metavar name. In v1 transforms read only
7//! the originally-captured metavars (not each other's output).
8
9use regex::Regex;
10
11use crate::error::RulesError;
12use crate::model::{ConvertOp, Transform};
13
14/// A compiled transform: a source metavar plus one operation.
15pub struct CompiledTransform {
16    /// The source metavar name (without `$`).
17    pub source: String,
18    op: Op,
19}
20
21enum Op {
22    Replace {
23        regex: Regex,
24        by: String,
25    },
26    Substring {
27        start: Option<i64>,
28        end: Option<i64>,
29    },
30    Convert(ConvertOp),
31}
32
33impl CompiledTransform {
34    /// Compile a transform, enforcing the exactly-one-operation rule.
35    pub fn compile(rule_id: &str, name: &str, t: &Transform) -> Result<Self, RulesError> {
36        let set = [
37            t.replace.is_some(),
38            t.substring.is_some(),
39            t.convert.is_some(),
40        ]
41        .into_iter()
42        .filter(|b| *b)
43        .count();
44        if set != 1 {
45            return Err(RulesError::PatternCompile {
46                rule: rule_id.to_string(),
47                message: format!(
48                    "transform `{name}` must set exactly one of `replace` / `substring` / `convert`"
49                ),
50            });
51        }
52
53        let op = if let Some(r) = &t.replace {
54            Op::Replace {
55                regex: Regex::new(&r.regex).map_err(|err| RulesError::PatternCompile {
56                    rule: rule_id.to_string(),
57                    message: format!("transform `{name}`: invalid regex `{}`: {err}", r.regex),
58                })?,
59                by: r.by.clone(),
60            }
61        } else if let Some(s) = &t.substring {
62            Op::Substring {
63                start: s.start,
64                end: s.end,
65            }
66        } else {
67            Op::Convert(t.convert.expect("convert is set"))
68        };
69
70        Ok(CompiledTransform {
71            source: t.source.clone(),
72            op,
73        })
74    }
75
76    /// Apply the transform to the source metavar's text.
77    pub fn apply(&self, input: &str) -> String {
78        match &self.op {
79            Op::Replace { regex, by } => regex.replace_all(input, by.as_str()).into_owned(),
80            Op::Substring { start, end } => slice_chars(input, *start, *end),
81            Op::Convert(convert) => convert_case(input, *convert),
82        }
83    }
84}
85
86/// Slice `input` by 0-based char indices. A negative index counts from the
87/// end; out-of-range bounds clamp.
88fn slice_chars(input: &str, start: Option<i64>, end: Option<i64>) -> String {
89    let chars: Vec<char> = input.chars().collect();
90    let len = chars.len() as i64;
91    let resolve = |idx: i64| -> usize {
92        let resolved = if idx < 0 { len + idx } else { idx };
93        resolved.clamp(0, len) as usize
94    };
95    let s = resolve(start.unwrap_or(0));
96    let e = resolve(end.unwrap_or(len));
97    if s >= e {
98        return String::new();
99    }
100    chars[s..e].iter().collect()
101}
102
103/// Split an identifier into lowercase words, honoring `_` / `-` / space
104/// separators and camelCase / digit boundaries.
105fn split_words(input: &str) -> Vec<String> {
106    let mut words = Vec::new();
107    let mut current = String::new();
108    let mut prev: Option<char> = None;
109    for ch in input.chars() {
110        if ch == '_' || ch == '-' || ch == ' ' || ch == '/' || ch == '.' {
111            if !current.is_empty() {
112                words.push(std::mem::take(&mut current));
113            }
114            prev = None;
115            continue;
116        }
117        // A lower→upper transition (`fooBar`) or letter→digit starts a word.
118        if let Some(p) = prev {
119            let boundary = (p.is_lowercase() && ch.is_uppercase())
120                || (p.is_alphabetic() && ch.is_ascii_digit())
121                || (p.is_ascii_digit() && ch.is_alphabetic());
122            if boundary && !current.is_empty() {
123                words.push(std::mem::take(&mut current));
124            }
125        }
126        current.push(ch.to_ascii_lowercase());
127        prev = Some(ch);
128    }
129    if !current.is_empty() {
130        words.push(current);
131    }
132    words
133}
134
135fn capitalize(word: &str) -> String {
136    let mut chars = word.chars();
137    match chars.next() {
138        Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
139        None => String::new(),
140    }
141}
142
143fn convert_case(input: &str, convert: ConvertOp) -> String {
144    match convert {
145        ConvertOp::Lower => input.to_lowercase(),
146        ConvertOp::Upper => input.to_uppercase(),
147        ConvertOp::Snake => split_words(input).join("_"),
148        ConvertOp::ScreamingSnake => split_words(input)
149            .iter()
150            .map(|w| w.to_uppercase())
151            .collect::<Vec<_>>()
152            .join("_"),
153        ConvertOp::Kebab => split_words(input).join("-"),
154        ConvertOp::UpperCamel => split_words(input).iter().map(|w| capitalize(w)).collect(),
155        ConvertOp::LowerCamel => {
156            let words = split_words(input);
157            let mut out = String::new();
158            for (i, w) in words.iter().enumerate() {
159                if i == 0 {
160                    out.push_str(w);
161                } else {
162                    out.push_str(&capitalize(w));
163                }
164            }
165            out
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use crate::model::{ReplaceOp, SubstringOp};
174
175    fn convert(input: &str, op: ConvertOp) -> String {
176        let t = Transform {
177            source: "X".into(),
178            replace: None,
179            substring: None,
180            convert: Some(op),
181        };
182        CompiledTransform::compile("r", "out", &t)
183            .unwrap()
184            .apply(input)
185    }
186
187    #[test]
188    fn case_conversions() {
189        assert_eq!(convert("user_id", ConvertOp::LowerCamel), "userId");
190        assert_eq!(convert("userId", ConvertOp::Snake), "user_id");
191        assert_eq!(convert("user-id", ConvertOp::UpperCamel), "UserId");
192        assert_eq!(convert("userId", ConvertOp::ScreamingSnake), "USER_ID");
193        assert_eq!(convert("userId", ConvertOp::Kebab), "user-id");
194        assert_eq!(convert("FooBar", ConvertOp::Lower), "foobar");
195    }
196
197    #[test]
198    fn regex_replace() {
199        let t = Transform {
200            source: "X".into(),
201            replace: Some(ReplaceOp {
202                regex: "Controller$".into(),
203                by: String::new(),
204            }),
205            substring: None,
206            convert: None,
207        };
208        let c = CompiledTransform::compile("r", "out", &t).unwrap();
209        assert_eq!(c.apply("UserController"), "User");
210    }
211
212    #[test]
213    fn substring_slice() {
214        let t = Transform {
215            source: "X".into(),
216            replace: None,
217            substring: Some(SubstringOp {
218                start: Some(0),
219                end: Some(3),
220            }),
221            convert: None,
222        };
223        let c = CompiledTransform::compile("r", "out", &t).unwrap();
224        assert_eq!(c.apply("abcdef"), "abc");
225    }
226
227    #[test]
228    fn substring_negative_end() {
229        let t = Transform {
230            source: "X".into(),
231            replace: None,
232            substring: Some(SubstringOp {
233                start: None,
234                end: Some(-1),
235            }),
236            convert: None,
237        };
238        let c = CompiledTransform::compile("r", "out", &t).unwrap();
239        assert_eq!(c.apply("hello"), "hell");
240    }
241
242    #[test]
243    fn rejects_zero_or_multiple_ops() {
244        let none = Transform {
245            source: "X".into(),
246            replace: None,
247            substring: None,
248            convert: None,
249        };
250        assert!(CompiledTransform::compile("r", "out", &none).is_err());
251        let two = Transform {
252            source: "X".into(),
253            replace: Some(ReplaceOp {
254                regex: "a".into(),
255                by: "b".into(),
256            }),
257            substring: None,
258            convert: Some(ConvertOp::Lower),
259        };
260        assert!(CompiledTransform::compile("r", "out", &two).is_err());
261    }
262}