Skip to main content

harn_rules/
fold.rs

1//! Fold consecutive destructure-with-defaults runs (#2824).
2//!
3//! Collapses a run of consecutive `let <name> = <src>?.<key> ?? <default>`
4//! statements sharing the same `<src>` into a single destructuring bind:
5//!
6//! ```text
7//!   let timeout = cfg?.timeout ?? 30
8//!   let retries = cfg?.retries ?? 3
9//! ```
10//! becomes
11//! ```text
12//!   let { timeout = 30, retries = 3 } = cfg ?? {}
13//! ```
14//!
15//! **Behavior-preserving:** `cfg ?? {}` guards a nil source — bare
16//! `let { x = d } = nil` throws, whereas `cfg?.x ?? d` yields `d`. Coalescing
17//! the source to `{}` first reproduces the `?.`/`??` semantics exactly.
18//!
19//! Aliased sites (`let t = cfg?.timeout ?? d`) are folded with the Harn dict
20//! pattern alias form (`{ timeout: t = d }`). Only consecutive statements
21//! sharing one source are merged; a blank line, comment, or other statement
22//! between two `let`s breaks the run.
23
24use crate::engine::{CompiledRule, RuleMatch};
25use crate::error::RulesError;
26use crate::model::Rule;
27
28/// The matcher for a single migratable site.
29fn site_rule(language: &str) -> CompiledRule {
30    let toml = format!(
31        "id = \"destructure-fold\"\nlanguage = \"{language}\"\n[rule]\npattern = \"let $N:identifier = $X?.$K:identifier ?? $D\"\n"
32    );
33    let rule = Rule::from_toml_str(&toml).expect("internal fold rule parses");
34    CompiledRule::compile(&rule).expect("internal fold rule compiles")
35}
36
37/// One captured site: the binding name, property key, default, and source.
38struct Site {
39    binding: String,
40    key: String,
41    default: String,
42    source: String,
43    start_byte: usize,
44    end_byte: usize,
45    start_row: usize,
46    end_row: usize,
47}
48
49impl Site {
50    fn from_match(m: &RuleMatch) -> Option<Self> {
51        Some(Self {
52            binding: m.bindings.get("N")?.text.clone(),
53            key: m.bindings.get("K")?.text.clone(),
54            default: m.bindings.get("D")?.text.clone(),
55            source: m.bindings.get("X")?.text.clone(),
56            start_byte: m.span.start_byte,
57            end_byte: m.span.end_byte,
58            start_row: m.span.start_row,
59            end_row: m.span.end_row,
60        })
61    }
62
63    fn field(&self) -> String {
64        if self.binding == self.key {
65            format!("{} = {}", self.key, self.default)
66        } else {
67            format!("{}: {} = {}", self.key, self.binding, self.default)
68        }
69    }
70}
71
72/// Fold a source string's consecutive same-source `let x = src?.x ?? d` runs of
73/// length ≥ 2 into merged destructures. Returns the rewritten source (identical
74/// when nothing folds). `language` must name a tree-sitter grammar (e.g.
75/// `"harn"`, `"typescript"`).
76pub fn fold_destructure_defaults(source: &str, language: &str) -> Result<String, RulesError> {
77    let rule = site_rule(language);
78    let matches = rule.run(source)?;
79
80    let mut sites: Vec<Site> = matches.iter().filter_map(Site::from_match).collect();
81    sites.sort_by_key(|s| s.start_byte);
82
83    // Group consecutive sites: same source, and starting on the line after the
84    // previous statement ends. This supports wrapped defaults/source
85    // expressions without merging across blank lines or comments.
86    let mut groups: Vec<Vec<Site>> = Vec::new();
87    for site in sites {
88        match groups.last_mut() {
89            Some(group)
90                if group.last().is_some_and(|prev| {
91                    prev.source == site.source && site.start_row == prev.end_row + 1
92                }) =>
93            {
94                group.push(site);
95            }
96            _ => groups.push(vec![site]),
97        }
98    }
99
100    // Build replacement edits for runs of length ≥ 2, applied back-to-front so
101    // earlier byte offsets stay valid.
102    let mut edits: Vec<(usize, usize, String)> = groups
103        .into_iter()
104        .filter(|group| group.len() >= 2)
105        .filter(|group| has_unique_keys(group))
106        .map(|group| {
107            let fields = group.iter().map(Site::field).collect::<Vec<_>>().join(", ");
108            let replacement = format!("let {{ {fields} }} = {} ?? {{}}", group[0].source);
109            (
110                group[0].start_byte,
111                group[group.len() - 1].end_byte,
112                replacement,
113            )
114        })
115        .collect();
116    edits.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
117
118    let mut out = source.to_string();
119    for (start, end, replacement) in edits {
120        out.replace_range(start..end, &replacement);
121    }
122    Ok(out)
123}
124
125fn has_unique_keys(group: &[Site]) -> bool {
126    let mut seen = std::collections::BTreeSet::new();
127    group.iter().all(|site| seen.insert(&site.key))
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn fold(src: &str) -> String {
135        fold_destructure_defaults(src, "harn").unwrap()
136    }
137
138    #[test]
139    fn folds_a_consecutive_run() {
140        let src =
141            "fn f() {\n  let timeout = cfg?.timeout ?? 30\n  let retries = cfg?.retries ?? 3\n}\n";
142        let out = fold(src);
143        assert_eq!(
144            out,
145            "fn f() {\n  let { timeout = 30, retries = 3 } = cfg ?? {}\n}\n"
146        );
147    }
148
149    #[test]
150    fn leaves_a_single_site_untouched() {
151        // A lone site is not a "run"; folding it would be a lateral change.
152        let src = "fn f() {\n  let timeout = cfg?.timeout ?? 30\n}\n";
153        assert_eq!(fold(src), src);
154    }
155
156    #[test]
157    fn does_not_merge_across_different_sources() {
158        let src = "fn f() {\n  let a = x?.a ?? 1\n  let b = y?.b ?? 2\n}\n";
159        // Two different sources, each a lone site → no fold.
160        assert_eq!(fold(src), src);
161    }
162
163    #[test]
164    fn does_not_merge_across_a_blank_line() {
165        let src = "fn f() {\n  let a = x?.a ?? 1\n\n  let b = x?.b ?? 2\n}\n";
166        assert_eq!(fold(src), src);
167    }
168
169    #[test]
170    fn folds_three_and_preserves_surrounding_code() {
171        let src = "fn f() {\n  before()\n  let a = s?.a ?? 1\n  let b = s?.b ?? 2\n  let c = s?.c ?? 3\n  after()\n}\n";
172        let out = fold(src);
173        assert_eq!(
174            out,
175            "fn f() {\n  before()\n  let { a = 1, b = 2, c = 3 } = s ?? {}\n  after()\n}\n"
176        );
177    }
178
179    #[test]
180    fn folds_aliased_sites() {
181        let src = "fn f() {\n  let t = cfg?.timeout ?? 30\n  let retries = cfg?.retries ?? 3\n  let label = cfg?.name ?? \"anon\"\n}\n";
182        let out = fold(src);
183        assert_eq!(
184            out,
185            "fn f() {\n  let { timeout: t = 30, retries = 3, name: label = \"anon\" } = cfg ?? {}\n}\n"
186        );
187    }
188
189    #[test]
190    fn folds_after_a_wrapped_previous_site() {
191        let src = "fn f() {\n  let path = parse({name: \"x\"}, argv).ok?.path\n    ?? \"\"\n  let verbose = parse({name: \"x\"}, argv).ok?.verbose ?? false\n}\n";
192        let out = fold(src);
193        assert_eq!(
194            out,
195            "fn f() {\n  let { path = \"\", verbose = false } = parse({name: \"x\"}, argv).ok ?? {}\n}\n"
196        );
197    }
198
199    #[test]
200    fn leaves_duplicate_property_keys_untouched() {
201        let src = "fn f() {\n  let first = cfg?.value ?? 1\n  let second = cfg?.value ?? 2\n}\n";
202        assert_eq!(fold(src), src);
203    }
204}