mod git;
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
pub use git::GitEngine;
use super::parser::Parser;
pub trait Engine {
fn matches(
&self,
patterns: impl IntoIterator<Item = impl AsRef<Path>>,
) -> impl Iterator<Item = Result<PathBuf, PathBuf>>;
fn resolve(&self, path: impl AsRef<Path>) -> PathBuf;
fn is_ignored(&self, path: impl AsRef<Path>) -> bool;
fn is_range_modified(&self, path: impl AsRef<Path>, range: (usize, usize)) -> bool;
fn check(&self, path: impl AsRef<Path>) -> Result<(), Vec<String>> {
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;
}
let resolved_patterns = block
.patterns
.into_iter()
.map(|mut pattern| {
pattern.value = if pattern.value == Path::new("") {
path.to_owned()
} else {
path.parent().unwrap().join(&pattern.value)
};
pattern
})
.collect::<Vec<_>>();
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;
}
};
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;
}
};
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::<Vec<_>>(), @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::<Vec<_>>(), @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::<Vec<_>>(), @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::<Vec<_>>(), @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::<Vec<_>>(), @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::<Vec<_>>(), @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::<Vec<_>>(), @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\"."]}"###);
}
}