mod git; use std::{ collections::BTreeMap, path::{Path, PathBuf}, }; pub use git::GitEngine; use super::parser::Parser; pub trait Engine { /// Iterate over changed files that match the given patterns and patterns that don't match any file. /// /// If patterns is empty, all changed files are returned. fn matches( &self, patterns: impl IntoIterator>, ) -> impl Iterator>; /// Resolve a path to an absolute path. fn resolve(&self, path: impl AsRef) -> PathBuf; /// Check if a file has been ignored. fn is_ignored(&self, path: impl AsRef) -> bool; /// Check if a range of lines in a file has been modified. fn is_range_modified(&self, path: impl AsRef, range: (usize, usize)) -> bool; /// Check a file for dependent changes. fn check(&self, path: impl AsRef) -> Result<(), Vec> { let path = path.as_ref(); let parser = match Parser::new(path, self.resolve(path)) { Ok(parser) => parser, Err(error) => return Err(vec![format!("Could not open {path:?}: {error}")]), }; let mut errors = Vec::new(); for block in parser { let block = match block { Ok(block) => block, Err(error) => { errors.extend(error); continue; } }; if !self.is_range_modified(path, block.range) { continue; } // Resolve patterns based on the current file. let resolved_patterns = block .patterns .into_iter() .map(|mut pattern| { // Empty pattern means current file. pattern.value = if pattern.value == Path::new("") { path.to_owned() } else { path.parent().unwrap().join(&pattern.value) }; pattern }) .collect::>(); let mut named_patterns = BTreeMap::new(); let mut unnamed_patterns = BTreeMap::new(); for pattern in &resolved_patterns { let Some(name) = &pattern.name else { unnamed_patterns.insert(&*pattern.value, pattern.line); continue; }; named_patterns.insert(&*pattern.value, (&**name, pattern.line)); } for pattern in self.matches(unnamed_patterns.keys()).flat_map(Result::err) { let line = unnamed_patterns.get(&*pattern).unwrap(); errors.push(format!( "Expected {pattern:?} to be modified because of \"then-change\" in {path:?} at line {line}." )); } for (pattern, (name, line)) in named_patterns { for result in self.matches([pattern]) { let dependent = match result { Ok(path) => path, Err(pattern) => { errors.push(format!( "Expected {pattern:?} to be modified because of \"then-change\" in {path:?} at line {line}." )); continue; } }; // Try to open the file in search of the named block. let mut parser = match Parser::new(&dependent, self.resolve(&dependent)) { Ok(parser) => parser, Err(error) => { errors.push(format!( "Could not open {dependent:?} for \"then-change\" in {path:?} at line {line}: {error:?}" )); continue; } }; // Search for the named block, accumulating errors along the way. let Some(block) = parser.find_map(|block| match block { Ok(block) if block.name.as_deref() == Some(name) => Some(Ok(block)), Err(error) => Some(Err(error)), _ => None, }) else { errors.push(format!( "Could not find \"if-changed\" with name \"{name}\" in {dependent:?} for \"then-change\" in {path:?} at line {line}." )); continue; }; match block { Ok(block) => { if !self.is_range_modified(&dependent, block.range) { errors.push(format!( "Expected {dependent:?} to be modified because of \"then-change\" in {path:?} at line {line}." )); } } Err(error) => errors.extend(error), } } } } if errors.is_empty() { Ok(()) } else { Err(errors) } } } #[cfg(test)] mod tests { use std::path::Path; use indoc::indoc; use crate::{engine::GitEngine, testing::git_test, Engine as _}; #[test] fn test_check() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js) "}, "src/b.js" => "" ] working: [ "src/a.js" => indoc!{" // if-changed foobar // then-change(b.js) "}, "src/b.js" => "bar" ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###); } #[test] fn test_check_fail() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js) "}, "src/b.js" => "" ] working: [ "src/a.js" => indoc!{" // if-changed foobar // then-change(b.js) "} ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::>(), @r###"[{"Ok": "src/a.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Err": ["Expected \"src/b.js\" to be modified because of \"then-change\" in \"src/a.js\" at line 3."]}"###); } #[test] fn test_check_unrelated() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js) "}, "src/b.js" => "" ] working: [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js) this "} ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches(["";0]).collect::>(), @r###"[{"Ok": "src/a.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###); } #[test] fn test_check_missing_file() { let (tempdir, repo) = git_test! {}; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); assert!(engine .check(Path::new("a.js")) .unwrap_err() .first() .unwrap() .contains("Could not open \"a.js\"")); } #[test] fn test_check_named() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js:bar) "}, "src/b.js" => indoc!{" // if-changed(bar) foo // then-change(a.js) "} ] working: [ "src/a.js" => indoc!{" // if-changed foobar // then-change(b.js:bar) "}, "src/b.js" => indoc!{" // if-changed(bar) foobar // then-change(a.js) "} ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Ok": null}"###); } #[test] fn test_check_named_fail() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js:bar) "}, "src/b.js" => indoc!{" // if-changed(bar) foo // then-change(a.js) "} ] working: [ "src/a.js" => indoc!{" // if-changed foobar // then-change(b.js:bar) "}, "src/b.js" => indoc!{" // if-changed(bar) foo // then-change(a.js) bar "} ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###"{"Err": ["Expected \"src/b.js\" to be modified because of \"then-change\" in \"src/a.js\" at line 3."]}"###); } #[test] fn test_check_named_missing() { let (tempdir, repo) = git_test! { "initial commit": [ "src/a.js" => indoc!{" // if-changed foo // then-change(b.js:bar) "}, "src/b.js" => "" ] working: [ "src/a.js" => indoc!{" // if-changed foobar // then-change(b.js:bar) "}, "src/b.js" => "foo" ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::>(), @r###"[{"Ok": "src/a.js"}, {"Ok": "src/b.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("src/a.js")), @r###" { "Err": [ "Could not find \"if-changed\" with name \"bar\" in \"src/b.js\" for \"then-change\" in \"src/a.js\" at line 3." ] } "###); } #[test] fn test_check_empty_then_change() { let (tempdir, repo) = git_test! { working: [ "a.js" => indoc!{" // if-changed foo // then-change( "} ] }; let engine = GitEngine::new(&repo, None, None); assert_eq!(engine.resolve(""), tempdir.path().canonicalize().unwrap()); insta::assert_compact_json_snapshot!(engine.matches([""; 0]).collect::>(), @r###"[{"Ok": "a.js"}]"###); insta::assert_compact_json_snapshot!(engine.check(Path::new("a.js")), @r###"{"Err": ["Could not find ')' for \"then-change\" at line 3 for \"a.js\"."]}"###); } }