use std::path::{Path, PathBuf};
use harn_hostlib::ast::Language;
use crate::engine::CompiledRule;
use crate::error::RulesError;
#[derive(Debug, Clone)]
pub struct SourceFile {
pub path: PathBuf,
pub language: Language,
pub source: String,
}
impl SourceFile {
pub fn detect(path: impl Into<PathBuf>, source: impl Into<String>) -> Option<Self> {
let path = path.into();
let language = Language::detect(&path, None)?;
Some(SourceFile {
path,
language,
source: source.into(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChange {
Edit {
path: PathBuf,
contents: String,
},
Create {
path: PathBuf,
contents: String,
},
Delete {
path: PathBuf,
},
}
impl FileChange {
pub fn path(&self) -> &Path {
match self {
FileChange::Edit { path, .. }
| FileChange::Create { path, .. }
| FileChange::Delete { path } => path,
}
}
}
pub trait ScanningRecipe {
type Acc: Default;
fn scan(&self, file: &SourceFile, acc: &mut Self::Acc) -> Result<(), RulesError>;
fn generate(
&self,
files: &[SourceFile],
acc: &Self::Acc,
) -> Result<Vec<FileChange>, RulesError>;
}
#[derive(Debug, Clone)]
pub struct RecipeRun {
pub changes: Vec<FileChange>,
}
impl RecipeRun {
pub fn edits(&self) -> impl Iterator<Item = &FileChange> {
self.changes
.iter()
.filter(|c| matches!(c, FileChange::Edit { .. }))
}
pub fn creations(&self) -> impl Iterator<Item = &FileChange> {
self.changes
.iter()
.filter(|c| matches!(c, FileChange::Create { .. }))
}
}
pub fn run_recipe<R: ScanningRecipe>(
recipe: &R,
mut files: Vec<SourceFile>,
) -> Result<RecipeRun, RulesError> {
files.sort_by(|a, b| a.path.cmp(&b.path));
let mut acc = R::Acc::default();
for file in &files {
recipe.scan(file, &mut acc)?;
}
let mut changes = recipe.generate(&files, &acc)?;
changes.sort_by(|a, b| a.path().cmp(b.path()));
Ok(RecipeRun { changes })
}
pub struct RuleRecipe<'a> {
pub rule: &'a CompiledRule,
}
impl ScanningRecipe for RuleRecipe<'_> {
type Acc = ();
fn scan(&self, _file: &SourceFile, _acc: &mut ()) -> Result<(), RulesError> {
Ok(())
}
fn generate(&self, files: &[SourceFile], _acc: &()) -> Result<Vec<FileChange>, RulesError> {
let mut changes = Vec::new();
for file in files {
if file.language != self.rule.language() {
continue;
}
let result = self.rule.apply(&file.source)?;
if result.changed {
changes.push(FileChange::Edit {
path: file.path.clone(),
contents: result.rewritten,
});
}
}
Ok(changes)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Rule;
fn ts(path: &str, source: &str) -> SourceFile {
SourceFile {
path: PathBuf::from(path),
language: Language::TypeScript,
source: source.to_string(),
}
}
struct CallCounter;
impl ScanningRecipe for CallCounter {
type Acc = usize;
fn scan(&self, file: &SourceFile, acc: &mut usize) -> Result<(), RulesError> {
*acc += file.source.matches("()").count();
Ok(())
}
fn generate(
&self,
_files: &[SourceFile],
acc: &usize,
) -> Result<Vec<FileChange>, RulesError> {
if *acc == 0 {
return Ok(vec![]);
}
Ok(vec![FileChange::Create {
path: PathBuf::from("report.txt"),
contents: format!("calls: {acc}\n"),
}])
}
}
#[test]
fn recipe_accumulates_then_generates_a_new_file() {
let files = vec![ts("a.ts", "foo();\n"), ts("b.ts", "bar(); baz();\n")];
let run = run_recipe(&CallCounter, files).unwrap();
assert_eq!(run.changes.len(), 1);
assert_eq!(
run.changes[0],
FileChange::Create {
path: PathBuf::from("report.txt"),
contents: "calls: 3\n".into(),
}
);
}
#[test]
fn scan_runs_in_path_sorted_order() {
struct OrderRecorder;
impl ScanningRecipe for OrderRecorder {
type Acc = Vec<String>;
fn scan(&self, file: &SourceFile, acc: &mut Vec<String>) -> Result<(), RulesError> {
acc.push(file.path.to_string_lossy().to_string());
Ok(())
}
fn generate(
&self,
_f: &[SourceFile],
acc: &Vec<String>,
) -> Result<Vec<FileChange>, RulesError> {
Ok(vec![FileChange::Create {
path: PathBuf::from("order.txt"),
contents: acc.join(","),
}])
}
}
let files = vec![ts("z.ts", ""), ts("a.ts", ""), ts("m.ts", "")];
let run = run_recipe(&OrderRecorder, files).unwrap();
match &run.changes[0] {
FileChange::Create { contents, .. } => assert_eq!(contents, "a.ts,m.ts,z.ts"),
other => panic!("expected create, got {other:?}"),
}
}
#[test]
fn rule_recipe_edits_matching_files_only() {
let rule = crate::engine::CompiledRule::compile(
&Rule::from_toml_str(
r#"
id = "rename-foo"
language = "typescript"
safety = "behavior-preserving"
fix = "bar()"
[rule]
pattern = "foo()"
"#,
)
.unwrap(),
)
.unwrap();
let files = vec![
ts("a.ts", "foo();\n"),
ts("b.ts", "noMatch();\n"),
SourceFile {
path: PathBuf::from("c.rs"),
language: Language::Rust,
source: "fn f() { foo(); }".into(),
},
];
let run = run_recipe(&RuleRecipe { rule: &rule }, files).unwrap();
assert_eq!(run.changes.len(), 1);
assert_eq!(run.changes[0].path(), Path::new("a.ts"));
}
}