From 174ba55c957726d0bc924f976f4d0ef017534050 Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sat, 15 Feb 2025 22:38:18 +0000 Subject: [PATCH] Add description_contains() condition function It evaluates to true if the plugin description contains text that matches the given regex. --- src/function/eval.rs | 78 ++++++++++++++++++++++++++++++++++--- src/function/mod.rs | 89 +++++++++++++++++++++++++++++++++++++++++++ src/function/parse.rs | 73 ++++++++++++++++++++++++++++++++--- 3 files changed, 228 insertions(+), 12 deletions(-) diff --git a/src/function/eval.rs b/src/function/eval.rs index 7440226..28cce8a 100644 --- a/src/function/eval.rs +++ b/src/function/eval.rs @@ -106,11 +106,11 @@ fn evaluate_active_regex(state: &State, regex: &Regex) -> Result { Ok(state.active_plugins.iter().any(|p| regex.is_match(p))) } -fn evaluate_is_master(state: &State, file_path: &Path) -> Result { +fn parse_plugin(state: &State, file_path: &Path) -> Option { use esplugin::GameId; let game_id = match state.game_type { - GameType::OpenMW => return Ok(false), + GameType::OpenMW => return None, GameType::Morrowind => GameId::Morrowind, GameType::Oblivion => GameId::Oblivion, GameType::Skyrim => GameId::Skyrim, @@ -125,10 +125,17 @@ fn evaluate_is_master(state: &State, file_path: &Path) -> Result { let mut plugin = esplugin::Plugin::new(game_id, &path); - plugin - .parse_file(ParseOptions::header_only()) - .map(|_| plugin.is_master_file()) - .or(Ok(false)) + if plugin.parse_file(ParseOptions::header_only()).is_ok() { + Some(plugin) + } else { + None + } +} + +fn evaluate_is_master(state: &State, file_path: &Path) -> Result { + Ok(parse_plugin(state, file_path) + .map(|plugin| plugin.is_master_file()) + .unwrap_or(false)) } fn evaluate_many_active(state: &State, regex: &Regex) -> Result { @@ -281,6 +288,17 @@ fn evaluate_filename_version( evaluate_dir_entries(state, parent_path, evaluator) } +fn evaluate_description_contains( + state: &State, + file_path: &Path, + regex: &Regex, +) -> Result { + Ok(parse_plugin(state, file_path) + .and_then(|plugin| plugin.description().unwrap_or(None)) + .map(|description| regex.is_match(&description)) + .unwrap_or(false)) +} + impl Function { pub fn eval(&self, state: &State) -> Result { if self.is_slow() { @@ -308,6 +326,7 @@ impl Function { evaluate_version(state, p, v, *c, |_, p| get_product_version(p)) } Function::FilenameVersion(p, r, v, c) => evaluate_filename_version(state, p, r, v, *c), + Function::DescriptionContains(p, r) => evaluate_description_contains(state, p, r), }; if self.is_slow() { @@ -1580,4 +1599,51 @@ mod tests { assert!(function.eval(&state).unwrap()); } + + #[test] + fn function_description_contains_eval_should_return_false_if_the_file_does_not_exist() { + let state = state_with_versions("tests/testing-plugins/Oblivion/Data", &[]); + + let function = Function::DescriptionContains("missing.esp".into(), regex("€ƒ.")); + + assert!(!function.eval(&state).unwrap()); + } + + #[test] + fn function_description_contains_eval_should_return_false_if_the_file_is_not_a_plugin() { + let state = state_with_versions("tests/testing-plugins/Oblivion/Data", &[]); + + let function = Function::DescriptionContains("Blank.bsa".into(), regex("€ƒ.")); + + assert!(!function.eval(&state).unwrap()); + } + + #[test] + fn function_description_contains_eval_should_return_false_if_the_plugin_has_no_description() { + let state = state_with_versions("tests/testing-plugins/Oblivion/Data", &[]); + + let function = Function::DescriptionContains("Blank - Different.esm".into(), regex("€ƒ.")); + + assert!(!function.eval(&state).unwrap()); + } + + #[test] + fn function_description_contains_eval_should_return_false_if_the_plugin_description_does_not_match( + ) { + let state = state_with_versions("tests/testing-plugins/Oblivion/Data", &[]); + + let function = Function::DescriptionContains("Blank.esm".into(), regex("€ƒ.")); + + assert!(!function.eval(&state).unwrap()); + } + + #[test] + fn function_description_contains_eval_should_return_true_if_the_plugin_description_contains_a_match( + ) { + let state = state_with_versions("tests/testing-plugins/Oblivion/Data", &[]); + + let function = Function::DescriptionContains("Blank.esp".into(), regex("ƒ")); + + assert!(function.eval(&state).unwrap()); + } } diff --git a/src/function/mod.rs b/src/function/mod.rs index c4ff7d0..0322280 100644 --- a/src/function/mod.rs +++ b/src/function/mod.rs @@ -51,6 +51,7 @@ pub enum Function { Version(PathBuf, String, ComparisonOperator), ProductVersion(PathBuf, String, ComparisonOperator), FilenameVersion(PathBuf, Regex, String, ComparisonOperator), + DescriptionContains(PathBuf, Regex), } impl fmt::Display for Function { @@ -82,6 +83,9 @@ impl fmt::Display for Function { c ) } + DescriptionContains(p, r) => { + write!(f, "description_contains(\"{}\", \"{}\")", p.display(), r) + } } } } @@ -123,6 +127,9 @@ impl PartialEq for Function { && eq(r1.as_str(), r2.as_str()) && eq(&p1.to_string_lossy(), &p2.to_string_lossy()) } + (DescriptionContains(p1, r1), DescriptionContains(p2, r2)) => { + eq(r1.as_str(), r2.as_str()) && eq(&p1.to_string_lossy(), &p2.to_string_lossy()) + } _ => false, } } @@ -187,6 +194,10 @@ impl Hash for Function { v.to_lowercase().hash(state); c.hash(state); } + DescriptionContains(p, r) => { + p.to_string_lossy().to_lowercase().hash(state); + r.as_str().to_lowercase().hash(state); + } } discriminant(self).hash(state); @@ -332,6 +343,16 @@ mod tests { &format!("{}", function) ); } + + #[test] + fn function_fmt_for_description_contains_should_format_correctly() { + let function = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + + assert_eq!( + "description_contains(\"Blank.esp\", \"€ƒ.\")", + &format!("{}", function) + ); + } } mod eq { @@ -815,6 +836,40 @@ mod tests { ) ); } + + #[test] + fn function_eq_for_description_contains_should_check_pathbuf_and_regex() { + assert_eq!( + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")), + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")) + ); + + assert_ne!( + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")), + Function::DescriptionContains("Blank.esp".into(), regex(".*")) + ); + assert_ne!( + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")), + Function::DescriptionContains("other".into(), regex("€ƒ.")) + ); + } + + #[test] + fn function_eq_for_description_contains_should_be_case_insensitive_on_pathbuf_and_regex() { + assert_eq!( + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")), + Function::DescriptionContains("blank.esp".into(), regex("€Ƒ.")) + ); + } + + #[test] + fn function_eq_description_contains_should_not_be_equal_to_file_regex_with_same_pathbuf_and_regex( + ) { + assert_ne!( + Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")), + Function::FileRegex("Blank.esp".into(), regex("€ƒ.")) + ); + } } mod hash { @@ -1346,5 +1401,39 @@ mod tests { assert_eq!(hash(function1), hash(function2)); } + + #[test] + fn function_hash_description_contains_should_hash_pathbuf_and_regex() { + let function1 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + let function2 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + + assert_eq!(hash(function1), hash(function2)); + + let function1 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + let function2 = Function::DescriptionContains("other".into(), regex("€ƒ.")); + + assert_ne!(hash(function1), hash(function2)); + + let function1 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + let function2 = Function::DescriptionContains("Blank.esp".into(), regex(".*")); + + assert_ne!(hash(function1), hash(function2)); + } + + #[test] + fn function_hash_description_contains_should_be_case_insensitive() { + let function1 = Function::DescriptionContains("blank.esp".into(), regex("€Ƒ.")); + let function2 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + + assert_eq!(hash(function1), hash(function2)); + } + + #[test] + fn function_hash_file_regex_and_description_contains_should_not_have_equal_hashes() { + let function1 = Function::FileRegex("Blank.esp".into(), regex("€ƒ.")); + let function2 = Function::DescriptionContains("Blank.esp".into(), regex("€ƒ.")); + + assert_ne!(hash(function1), hash(function2)); + } } } diff --git a/src/function/parse.rs b/src/function/parse.rs index 72c15c5..ab1dd37 100644 --- a/src/function/parse.rs +++ b/src/function/parse.rs @@ -46,11 +46,20 @@ fn is_in_game_path(path: &Path) -> bool { true } -fn parse_regex(input: &str) -> ParsingResult { - RegexBuilder::new(&format!("^{}$", input)) + +fn build_regex(input: &str) -> Result<(&'static str, Regex), regex::Error> { + RegexBuilder::new(input) .case_insensitive(true) .build() .map(|r| ("", r)) +} + +fn parse_regex(input: &str) -> ParsingResult { + build_regex(input).map_err(|e| Err::Failure(ParsingErrorKind::from(e).at(input))) +} + +fn parse_anchored_regex(input: &str) -> ParsingResult { + build_regex(&format!("^{}$", input)) .map_err(|e| Err::Failure(ParsingErrorKind::from(e).at(input))) } @@ -136,6 +145,22 @@ fn parse_filename_version_args( Ok((remaining_input, (path, regex, version, comparator))) } +fn parse_description_contains_args(input: &str) -> ParsingResult<(PathBuf, Regex)> { + let mut parser = ( + map_err(parse_path), + map_err(whitespace(tag(","))), + delimited( + map_err(tag("\"")), + map_parser(is_not("\""), parse_regex), + map_err(tag("\"")), + ), + ); + + let (remaining_input, (path, _, regex)) = parser.parse(input)?; + + Ok((remaining_input, (path, regex))) +} + fn parse_crc(input: &str) -> ParsingResult { u32::from_str_radix(input, 16) .map(|c| ("", c)) @@ -193,13 +218,13 @@ fn parse_regex_path(input: &str) -> ParsingResult<(PathBuf, Regex)> { return Err(not_in_game_directory(input, parent_path)); } - let regex = parse_regex(regex_slice)?.1; + let regex = parse_anchored_regex(regex_slice)?.1; Ok((remaining_input, (parent_path, regex))) } fn parse_regex_filename(input: &str) -> ParsingResult { - map_parser(is_not(INVALID_REGEX_PATH_CHARS), parse_regex).parse(input) + map_parser(is_not(INVALID_REGEX_PATH_CHARS), parse_anchored_regex).parse(input) } impl Function { @@ -319,6 +344,14 @@ impl Function { ), |(path, crc)| Function::Checksum(path, crc), ), + map( + delimited( + map_err(tag("description_contains(")), + parse_description_contains_args, + map_err(tag(")")), + ), + |(path, regex)| Function::DescriptionContains(path, regex), + ), )) .parse(input) } @@ -338,8 +371,22 @@ mod tests { } #[test] - fn parse_regex_should_produce_a_regex_that_does_not_partially_match() { - let (_, regex) = parse_regex("cargo.").unwrap(); + fn parse_regex_should_produce_a_regex_that_does_partially_match() { + let (_, regex) = parse_regex("argo.").unwrap(); + + assert!(regex.is_match("Cargo.toml")); + } + + #[test] + fn parse_anchored_regex_should_produce_case_insensitive_regex() { + let (_, regex) = parse_anchored_regex("cargo.*").unwrap(); + + assert!(regex.is_match("Cargo.toml")); + } + + #[test] + fn parse_anchored_regex_should_produce_a_regex_that_does_not_partially_match() { + let (_, regex) = parse_anchored_regex("cargo.").unwrap(); assert!(!regex.is_match("Cargo.toml")); } @@ -725,4 +772,18 @@ mod tests { Function::parse("filename_version(\"subdir/Cargo .+.toml\", \"1.2\", ==)").is_err() ); } + + #[test] + fn function_parse_should_parse_a_description_contains_function() { + let output = Function::parse("description_contains(\"Blank.esp\", \"€ƒ.\")").unwrap(); + + assert!(output.0.is_empty()); + match output.1 { + Function::DescriptionContains(p, r) => { + assert_eq!(PathBuf::from("Blank.esp"), p); + assert_eq!(Regex::new("€ƒ.").unwrap().as_str(), r.as_str()); + } + _ => panic!("Expected a description_contains function"), + } + } }