espanso/espanso-config/src/matches/store/default.rs
2021-10-05 22:05:53 +02:00

739 lines
17 KiB
Rust

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<String, MatchGroup>,
}
impl DefaultMatchStore {
pub fn load(paths: &[String]) -> (Self, Vec<NonFatalErrorSet>) {
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<String> {
self.groups.keys().cloned().collect()
}
}
fn load_match_groups_recursively(
groups: &mut HashMap<String, MatchGroup>,
paths: &[String],
non_fatal_error_sets: &mut Vec<NonFatalErrorSet>,
) {
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<String, MatchGroup>,
visited_paths: &mut HashSet<String>,
visited_matches: &mut HashSet<StructId>,
visited_global_vars: &mut HashSet<StructId>,
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<Match> {
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<Variable> {
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<Match> = 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<Match> = 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<Match> = 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::<Vec<Match>>(),
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::<Vec<Variable>>(),
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::<Vec<Match>>(),
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::<Vec<Variable>>(),
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::<Vec<Match>>(),
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::<Vec<Variable>>(),
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::<Vec<Match>>(),
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::<Vec<Variable>>(),
create_vars(&["var2", "var1"])
);
});
}
// TODO: add fatal and non-fatal error cases
}