Skip to main content

harn_rules/
recipe.rs

1//! Whole-project scan → accumulate → edit lifecycle (#2836).
2//!
3//! Adapted from OpenRewrite's `ScanningRecipe`: a rule can read the *whole*
4//! fileset into a typed accumulator before it edits, and can emit new files
5//! or delete existing ones — not just edit in place. That whole-project view
6//! is what import insertion, codegen, and cross-file dead-code removal need.
7//!
8//! A run is two deterministic passes over the (path-sorted) files:
9//!
10//! 1. **scan** — each file updates a typed accumulator; no edits.
11//! 2. **generate** — the accumulator + the files produce a set of
12//!    [`FileChange`]s (edit / create / delete).
13//!
14//! Per-file declarative codemods plug in via [`RuleRecipe`], which needs no
15//! scan state; richer recipes implement [`ScanningRecipe`] directly.
16
17use std::path::{Path, PathBuf};
18
19use harn_hostlib::ast::Language;
20
21use crate::engine::CompiledRule;
22use crate::error::RulesError;
23
24/// One source file handed to a recipe.
25#[derive(Debug, Clone)]
26pub struct SourceFile {
27    /// The file's path (used for ordering and change attribution).
28    pub path: PathBuf,
29    /// The file's language.
30    pub language: Language,
31    /// The file's contents.
32    pub source: String,
33}
34
35impl SourceFile {
36    /// Construct a source file, detecting the language from the path's
37    /// extension. Returns `None` if no grammar matches.
38    pub fn detect(path: impl Into<PathBuf>, source: impl Into<String>) -> Option<Self> {
39        let path = path.into();
40        let language = Language::detect(&path, None)?;
41        Some(SourceFile {
42            path,
43            language,
44            source: source.into(),
45        })
46    }
47}
48
49/// A change a recipe wants to make to the project. The runner returns these;
50/// the caller (a CLI / the staged filesystem) decides whether to write them.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FileChange {
53    /// Replace an existing file's contents.
54    Edit {
55        /// The file to rewrite.
56        path: PathBuf,
57        /// The new contents.
58        contents: String,
59    },
60    /// Create a new file.
61    Create {
62        /// The path to create.
63        path: PathBuf,
64        /// The new file's contents.
65        contents: String,
66    },
67    /// Delete an existing file.
68    Delete {
69        /// The path to delete.
70        path: PathBuf,
71    },
72}
73
74impl FileChange {
75    /// The path this change targets.
76    pub fn path(&self) -> &Path {
77        match self {
78            FileChange::Edit { path, .. }
79            | FileChange::Create { path, .. }
80            | FileChange::Delete { path } => path,
81        }
82    }
83}
84
85/// A two-phase, whole-project rule.
86pub trait ScanningRecipe {
87    /// The typed accumulator threaded from `scan` into `generate`.
88    type Acc: Default;
89
90    /// Read one file and update the accumulator. Called once per file, in
91    /// path-sorted order, before any `generate`.
92    fn scan(&self, file: &SourceFile, acc: &mut Self::Acc) -> Result<(), RulesError>;
93
94    /// Produce the project's changes from the accumulated state and the
95    /// (path-sorted) files.
96    fn generate(
97        &self,
98        files: &[SourceFile],
99        acc: &Self::Acc,
100    ) -> Result<Vec<FileChange>, RulesError>;
101}
102
103/// The result of running a recipe over a project.
104#[derive(Debug, Clone)]
105pub struct RecipeRun {
106    /// The changes the recipe produced, sorted by path for determinism.
107    pub changes: Vec<FileChange>,
108}
109
110impl RecipeRun {
111    /// Files this run edits.
112    pub fn edits(&self) -> impl Iterator<Item = &FileChange> {
113        self.changes
114            .iter()
115            .filter(|c| matches!(c, FileChange::Edit { .. }))
116    }
117
118    /// Files this run creates.
119    pub fn creations(&self) -> impl Iterator<Item = &FileChange> {
120        self.changes
121            .iter()
122            .filter(|c| matches!(c, FileChange::Create { .. }))
123    }
124}
125
126/// Run `recipe` over `files`: a `scan` pass (path-sorted) followed by a
127/// `generate` pass. The returned changes are path-sorted for a stable,
128/// reproducible result.
129pub fn run_recipe<R: ScanningRecipe>(
130    recipe: &R,
131    mut files: Vec<SourceFile>,
132) -> Result<RecipeRun, RulesError> {
133    files.sort_by(|a, b| a.path.cmp(&b.path));
134
135    let mut acc = R::Acc::default();
136    for file in &files {
137        recipe.scan(file, &mut acc)?;
138    }
139
140    let mut changes = recipe.generate(&files, &acc)?;
141    changes.sort_by(|a, b| a.path().cmp(b.path()));
142    Ok(RecipeRun { changes })
143}
144
145/// Adapter that runs a declarative [`CompiledRule`] as a recipe: a per-file
146/// codemod with no scan state. Each file matching the rule's language is
147/// rewritten via [`CompiledRule::apply`]; only changed files yield an edit.
148pub struct RuleRecipe<'a> {
149    /// The compiled codemod rule.
150    pub rule: &'a CompiledRule,
151}
152
153impl ScanningRecipe for RuleRecipe<'_> {
154    type Acc = ();
155
156    fn scan(&self, _file: &SourceFile, _acc: &mut ()) -> Result<(), RulesError> {
157        Ok(())
158    }
159
160    fn generate(&self, files: &[SourceFile], _acc: &()) -> Result<Vec<FileChange>, RulesError> {
161        let mut changes = Vec::new();
162        for file in files {
163            if file.language != self.rule.language() {
164                continue;
165            }
166            let result = self.rule.apply(&file.source)?;
167            if result.changed {
168                changes.push(FileChange::Edit {
169                    path: file.path.clone(),
170                    contents: result.rewritten,
171                });
172            }
173        }
174        Ok(changes)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::model::Rule;
182
183    fn ts(path: &str, source: &str) -> SourceFile {
184        SourceFile {
185            path: PathBuf::from(path),
186            language: Language::TypeScript,
187            source: source.to_string(),
188        }
189    }
190
191    /// A recipe that counts call expressions across the project and, if any
192    /// exist, emits a single generated report file — exercising both the
193    /// accumulator and `FileChange::Create`.
194    struct CallCounter;
195    impl ScanningRecipe for CallCounter {
196        type Acc = usize;
197        fn scan(&self, file: &SourceFile, acc: &mut usize) -> Result<(), RulesError> {
198            *acc += file.source.matches("()").count();
199            Ok(())
200        }
201        fn generate(
202            &self,
203            _files: &[SourceFile],
204            acc: &usize,
205        ) -> Result<Vec<FileChange>, RulesError> {
206            if *acc == 0 {
207                return Ok(vec![]);
208            }
209            Ok(vec![FileChange::Create {
210                path: PathBuf::from("report.txt"),
211                contents: format!("calls: {acc}\n"),
212            }])
213        }
214    }
215
216    #[test]
217    fn recipe_accumulates_then_generates_a_new_file() {
218        let files = vec![ts("a.ts", "foo();\n"), ts("b.ts", "bar(); baz();\n")];
219        let run = run_recipe(&CallCounter, files).unwrap();
220        assert_eq!(run.changes.len(), 1);
221        assert_eq!(
222            run.changes[0],
223            FileChange::Create {
224                path: PathBuf::from("report.txt"),
225                contents: "calls: 3\n".into(),
226            }
227        );
228    }
229
230    #[test]
231    fn scan_runs_in_path_sorted_order() {
232        // The accumulator records the order files are scanned in; the runner
233        // must sort by path regardless of input order.
234        struct OrderRecorder;
235        impl ScanningRecipe for OrderRecorder {
236            type Acc = Vec<String>;
237            fn scan(&self, file: &SourceFile, acc: &mut Vec<String>) -> Result<(), RulesError> {
238                acc.push(file.path.to_string_lossy().to_string());
239                Ok(())
240            }
241            fn generate(
242                &self,
243                _f: &[SourceFile],
244                acc: &Vec<String>,
245            ) -> Result<Vec<FileChange>, RulesError> {
246                Ok(vec![FileChange::Create {
247                    path: PathBuf::from("order.txt"),
248                    contents: acc.join(","),
249                }])
250            }
251        }
252        let files = vec![ts("z.ts", ""), ts("a.ts", ""), ts("m.ts", "")];
253        let run = run_recipe(&OrderRecorder, files).unwrap();
254        match &run.changes[0] {
255            FileChange::Create { contents, .. } => assert_eq!(contents, "a.ts,m.ts,z.ts"),
256            other => panic!("expected create, got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn rule_recipe_edits_matching_files_only() {
262        let rule = crate::engine::CompiledRule::compile(
263            &Rule::from_toml_str(
264                r#"
265                id = "rename-foo"
266                language = "typescript"
267                safety = "behavior-preserving"
268                fix = "bar()"
269                [rule]
270                pattern = "foo()"
271                "#,
272            )
273            .unwrap(),
274        )
275        .unwrap();
276        let files = vec![
277            ts("a.ts", "foo();\n"),
278            ts("b.ts", "noMatch();\n"),
279            SourceFile {
280                path: PathBuf::from("c.rs"),
281                language: Language::Rust,
282                source: "fn f() { foo(); }".into(),
283            },
284        ];
285        let run = run_recipe(&RuleRecipe { rule: &rule }, files).unwrap();
286        // Only a.ts changes: b.ts has no match, c.rs is a different language.
287        assert_eq!(run.changes.len(), 1);
288        assert_eq!(run.changes[0].path(), Path::new("a.ts"));
289    }
290}