/* * This file is part of espanso. * * Copyright (C) 2019-2021 Federico Terzi * * espanso is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * espanso is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with espanso. If not, see . */ use super::{MatchSet, MatchStore}; use crate::{ counter::StructId, error::NonFatalErrorSet, matches::{group::MatchGroup, Match, Variable}, }; use anyhow::Context; use std::{ collections::{HashMap, HashSet}, path::PathBuf, }; pub(crate) struct DefaultMatchStore { pub groups: HashMap, } impl DefaultMatchStore { pub fn load(paths: &[String]) -> (Self, Vec) { let mut groups = HashMap::new(); let mut non_fatal_error_sets = Vec::new(); // Because match groups can imports other match groups, // we have to load them recursively starting from the // top-level ones. load_match_groups_recursively(&mut groups, paths, &mut non_fatal_error_sets); (Self { groups }, non_fatal_error_sets) } } impl MatchStore for DefaultMatchStore { fn query(&self, paths: &[String]) -> MatchSet { let mut matches: Vec<&Match> = Vec::new(); let mut global_vars: Vec<&Variable> = Vec::new(); let mut visited_paths = HashSet::new(); let mut visited_matches = HashSet::new(); let mut visited_global_vars = HashSet::new(); query_matches_for_paths( &self.groups, &mut visited_paths, &mut visited_matches, &mut visited_global_vars, &mut matches, &mut global_vars, paths, ); MatchSet { matches, global_vars, } } fn loaded_paths(&self) -> Vec { self.groups.keys().cloned().collect() } } fn load_match_groups_recursively( groups: &mut HashMap, paths: &[String], non_fatal_error_sets: &mut Vec, ) { for path in paths.iter() { if !groups.contains_key(path) { let group_path = PathBuf::from(path); match MatchGroup::load(&group_path) .with_context(|| format!("unable to load match group {:?}", group_path)) { Ok((group, non_fatal_error_set)) => { let imports = group.imports.clone(); groups.insert(path.clone(), group); if let Some(non_fatal_error_set) = non_fatal_error_set { non_fatal_error_sets.push(non_fatal_error_set); } load_match_groups_recursively(groups, &imports, non_fatal_error_sets); } Err(err) => { non_fatal_error_sets.push(NonFatalErrorSet::single_error(&group_path, err)); } } } } } fn query_matches_for_paths<'a>( groups: &'a HashMap, visited_paths: &mut HashSet, visited_matches: &mut HashSet, visited_global_vars: &mut HashSet, matches: &mut Vec<&'a Match>, global_vars: &mut Vec<&'a Variable>, paths: &[String], ) { for path in paths.iter() { if !visited_paths.contains(path) { visited_paths.insert(path.clone()); if let Some(group) = groups.get(path) { query_matches_for_paths( groups, visited_paths, visited_matches, visited_global_vars, matches, global_vars, &group.imports, ); for m in group.matches.iter() { if !visited_matches.contains(&m.id) { matches.push(m); visited_matches.insert(m.id); } } for var in group.global_vars.iter() { if !visited_global_vars.contains(&var.id) { global_vars.push(var); visited_global_vars.insert(var.id); } } } } } } #[cfg(test)] mod tests { use super::*; use crate::{ matches::{MatchCause, MatchEffect, TextEffect, TriggerCause}, util::tests::use_test_directory, }; use std::fs::create_dir_all; fn create_match(trigger: &str, replace: &str) -> Match { Match { cause: MatchCause::Trigger(TriggerCause { triggers: vec![trigger.to_string()], ..Default::default() }), effect: MatchEffect::Text(TextEffect { replace: replace.to_string(), ..Default::default() }), ..Default::default() } } fn create_matches(matches: &[(&str, &str)]) -> Vec { matches .iter() .map(|(trigger, replace)| create_match(trigger, replace)) .collect() } fn create_test_var(name: &str) -> Variable { Variable { name: name.to_string(), var_type: "test".to_string(), ..Default::default() } } fn create_vars(vars: &[&str]) -> Vec { vars.iter().map(|var| create_test_var(var)).collect() } #[test] fn match_store_loads_correctly() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" imports: - "sub/sub.yml" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]); assert_eq!(non_fatal_error_sets.len(), 0); assert_eq!(match_store.groups.len(), 3); let base_group = &match_store .groups .get(&base_file.to_string_lossy().to_string()) .unwrap() .matches; let base_group: Vec = base_group .iter() .map(|m| { let mut copy = m.clone(); copy.id = 0; copy }) .collect(); assert_eq!(base_group, create_matches(&[("hello", "world")])); let another_group = &match_store .groups .get(&another_file.to_string_lossy().to_string()) .unwrap() .matches; let another_group: Vec = another_group .iter() .map(|m| { let mut copy = m.clone(); copy.id = 0; copy }) .collect(); assert_eq!( another_group, create_matches(&[("hello", "world2"), ("foo", "bar")]) ); let sub_group = &match_store .groups .get(&sub_file.to_string_lossy().to_string()) .unwrap() .matches; let sub_group: Vec = sub_group .iter() .map(|m| { let mut copy = m.clone(); copy.id = 0; copy }) .collect(); assert_eq!(sub_group, create_matches(&[("hello", "world3")])); }); } #[test] fn match_store_handles_circular_dependency() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" imports: - "sub/sub.yml" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" imports: - "../_another.yml" matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]); assert_eq!(match_store.groups.len(), 3); assert_eq!(non_fatal_error_sets.len(), 0); }); } #[test] fn match_store_query_single_path_with_imports() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" global_vars: - name: var1 type: test matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" imports: - "sub/sub.yml" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" global_vars: - name: var2 type: test matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]); assert_eq!(non_fatal_error_sets.len(), 0); let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]); assert_eq!( match_set .matches .into_iter() .cloned() .map(|mut m| { m.id = 0; m }) .collect::>(), create_matches(&[ ("hello", "world3"), ("hello", "world2"), ("foo", "bar"), ("hello", "world"), ]) ); assert_eq!( match_set .global_vars .into_iter() .cloned() .map(|mut v| { v.id = 0; v }) .collect::>(), create_vars(&["var2", "var1"]) ); }); } #[test] fn match_store_query_handles_circular_depencencies() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" global_vars: - name: var1 type: test matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" imports: - "sub/sub.yml" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" imports: - "../_another.yml" # Circular import global_vars: - name: var2 type: test matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]); assert_eq!(non_fatal_error_sets.len(), 0); let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]); assert_eq!( match_set .matches .into_iter() .cloned() .map(|mut m| { m.id = 0; m }) .collect::>(), create_matches(&[ ("hello", "world3"), ("hello", "world2"), ("foo", "bar"), ("hello", "world"), ]) ); assert_eq!( match_set .global_vars .into_iter() .cloned() .map(|mut v| { v.id = 0; v }) .collect::>(), create_vars(&["var2", "var1"]) ); }); } #[test] fn match_store_query_multiple_paths() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" global_vars: - name: var1 type: test matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" global_vars: - name: var2 type: test matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[ base_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string(), ]); assert_eq!(non_fatal_error_sets.len(), 0); let match_set = match_store.query(&[ base_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string(), ]); assert_eq!( match_set .matches .into_iter() .cloned() .map(|mut m| { m.id = 0; m }) .collect::>(), create_matches(&[ ("hello", "world2"), ("foo", "bar"), ("hello", "world"), ("hello", "world3"), ]) ); assert_eq!( match_set .global_vars .into_iter() .cloned() .map(|mut v| { v.id = 0; v }) .collect::>(), create_vars(&["var1", "var2"]) ); }); } #[test] fn match_store_query_handle_duplicates_when_imports_and_paths_overlap() { use_test_directory(|_, match_dir, _| { let sub_dir = match_dir.join("sub"); create_dir_all(&sub_dir).unwrap(); let base_file = match_dir.join("base.yml"); std::fs::write( &base_file, r#" imports: - "_another.yml" global_vars: - name: var1 type: test matches: - trigger: "hello" replace: "world" "#, ) .unwrap(); let another_file = match_dir.join("_another.yml"); std::fs::write( &another_file, r#" imports: - "sub/sub.yml" matches: - trigger: "hello" replace: "world2" - trigger: "foo" replace: "bar" "#, ) .unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write( &sub_file, r#" global_vars: - name: var2 type: test matches: - trigger: "hello" replace: "world3" "#, ) .unwrap(); let (match_store, non_fatal_error_sets) = DefaultMatchStore::load(&[base_file.to_string_lossy().to_string()]); assert_eq!(non_fatal_error_sets.len(), 0); let match_set = match_store.query(&[ base_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string(), ]); assert_eq!( match_set .matches .into_iter() .cloned() .map(|mut m| { m.id = 0; m }) .collect::>(), create_matches(&[ ("hello", "world3"), // This appears only once, though it appears 2 times ("hello", "world2"), ("foo", "bar"), ("hello", "world"), ]) ); assert_eq!( match_set .global_vars .into_iter() .cloned() .map(|mut v| { v.id = 0; v }) .collect::>(), create_vars(&["var2", "var1"]) ); }); } // TODO: add fatal and non-fatal error cases }