/* * 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::{ default::{ DEFAULT_CLIPBOARD_THRESHOLD, DEFAULT_PRE_PASTE_DELAY, DEFAULT_RESTORE_CLIPBOARD_DELAY, DEFAULT_SHORTCUT_EVENT_DELAY, }, parse::ParsedConfig, path::calculate_paths, util::os_matches, AppProperties, Backend, Config, ToggleKey, }; use crate::{counter::next_id, merge}; use anyhow::Result; use log::error; use regex::Regex; use std::iter::FromIterator; use std::{collections::HashSet, path::Path}; use thiserror::Error; const STANDARD_INCLUDES: &[&str] = &["../match/**/*.yml"]; const STANDARD_EXCLUDES: &[&str] = &["../match/**/_*.yml"]; #[derive(Debug, Clone)] pub(crate) struct ResolvedConfig { parsed: ParsedConfig, // Generated properties id: i32, match_paths: Vec, filter_title: Option, filter_class: Option, filter_exec: Option, } impl Default for ResolvedConfig { fn default() -> Self { Self { parsed: Default::default(), id: 0, match_paths: Vec::new(), filter_title: None, filter_class: None, filter_exec: None, } } } impl Config for ResolvedConfig { fn id(&self) -> i32 { self.id } fn label(&self) -> &str { self.parsed.label.as_deref().unwrap_or("none") } fn match_paths(&self) -> &[String] { &self.match_paths } fn is_match(&self, app: &AppProperties) -> bool { if self.parsed.filter_os.is_none() && self.parsed.filter_title.is_none() && self.parsed.filter_exec.is_none() && self.parsed.filter_class.is_none() { return false; } let is_os_match = if let Some(filter_os) = self.parsed.filter_os.as_deref() { os_matches(filter_os) } else { true }; let is_title_match = if let Some(title_regex) = self.filter_title.as_ref() { if let Some(title) = app.title { title_regex.is_match(title) } else { false } } else { true }; let is_exec_match = if let Some(exec_regex) = self.filter_exec.as_ref() { if let Some(exec) = app.exec { exec_regex.is_match(exec) } else { false } } else { true }; let is_class_match = if let Some(class_regex) = self.filter_class.as_ref() { if let Some(class) = app.class { class_regex.is_match(class) } else { false } } else { true }; // All the filters that have been specified must be true to define a match is_os_match && is_exec_match && is_title_match && is_class_match } fn backend(&self) -> Backend { // TODO: test match self .parsed .backend .as_deref() .map(|b| b.to_lowercase()) .as_deref() { Some("clipboard") => Backend::Clipboard, Some("inject") => Backend::Inject, Some("auto") => Backend::Auto, None => Backend::Auto, err => { error!("invalid backend specified {:?}, falling back to Auto", err); Backend::Auto } } } fn clipboard_threshold(&self) -> usize { self .parsed .clipboard_threshold .unwrap_or(DEFAULT_CLIPBOARD_THRESHOLD) } fn auto_restart(&self) -> bool { self.parsed.auto_restart.unwrap_or(true) } fn pre_paste_delay(&self) -> usize { self .parsed .pre_paste_delay .unwrap_or(DEFAULT_PRE_PASTE_DELAY) } fn toggle_key(&self) -> Option { // TODO: test match self .parsed .toggle_key .as_deref() .map(|key| key.to_lowercase()) .as_deref() { Some("ctrl") => Some(ToggleKey::Ctrl), Some("alt") => Some(ToggleKey::Alt), Some("shift") => Some(ToggleKey::Shift), Some("meta") | Some("cmd") => Some(ToggleKey::Meta), Some("right_ctrl") => Some(ToggleKey::RightCtrl), Some("right_alt") => Some(ToggleKey::RightAlt), Some("right_shift") => Some(ToggleKey::RightShift), Some("right_meta") | Some("right_cmd") => Some(ToggleKey::RightMeta), Some("left_ctrl") => Some(ToggleKey::LeftCtrl), Some("left_alt") => Some(ToggleKey::LeftAlt), Some("left_shift") => Some(ToggleKey::LeftShift), Some("left_meta") | Some("left_cmd") => Some(ToggleKey::LeftMeta), Some("off") => None, None => Some(ToggleKey::Alt), err => { error!( "invalid toggle_key specified {:?}, falling back to ALT", err ); Some(ToggleKey::Alt) } } } fn preserve_clipboard(&self) -> bool { self.parsed.preserve_clipboard.unwrap_or(true) } fn restore_clipboard_delay(&self) -> usize { self .parsed .restore_clipboard_delay .unwrap_or(DEFAULT_RESTORE_CLIPBOARD_DELAY) } fn paste_shortcut_event_delay(&self) -> usize { self .parsed .paste_shortcut_event_delay .unwrap_or(DEFAULT_SHORTCUT_EVENT_DELAY) } fn paste_shortcut(&self) -> Option { self.parsed.paste_shortcut.clone() } fn disable_x11_fast_inject(&self) -> bool { self.parsed.disable_x11_fast_inject.unwrap_or(false) } fn inject_delay(&self) -> Option { self.parsed.inject_delay } fn key_delay(&self) -> Option { self.parsed.key_delay } fn word_separators(&self) -> Vec { self.parsed.word_separators.clone().unwrap_or(vec![ " ".to_string(), ",".to_string(), ".".to_string(), "?".to_string(), "!".to_string(), "\r".to_string(), "\n".to_string(), (22u8 as char).to_string(), ]) } fn backspace_limit(&self) -> usize { self.parsed.backspace_limit.unwrap_or(5) } } impl ResolvedConfig { pub fn load(path: &Path, parent: Option<&Self>) -> Result { let mut config = ParsedConfig::load(path)?; // Merge with parent config if present if let Some(parent) = parent { Self::merge_parsed(&mut config, &parent.parsed); } // Extract the base directory let base_dir = path .parent() .ok_or_else(ResolveError::ParentResolveFailed)?; let match_paths = Self::generate_match_paths(&config, base_dir) .into_iter() .collect(); let filter_title = if let Some(filter_title) = config.filter_title.as_deref() { Some(Regex::new(filter_title)?) } else { None }; let filter_class = if let Some(filter_class) = config.filter_class.as_deref() { Some(Regex::new(filter_class)?) } else { None }; let filter_exec = if let Some(filter_exec) = config.filter_exec.as_deref() { Some(Regex::new(filter_exec)?) } else { None }; Ok(Self { parsed: config, id: next_id(), match_paths, filter_title, filter_class, filter_exec, }) } fn merge_parsed(child: &mut ParsedConfig, parent: &ParsedConfig) { // Override the None fields with the parent's value merge!( ParsedConfig, child, parent, // Fields label, backend, clipboard_threshold, auto_restart, pre_paste_delay, preserve_clipboard, restore_clipboard_delay, paste_shortcut, paste_shortcut_event_delay, disable_x11_fast_inject, toggle_key, inject_delay, key_delay, word_separators, backspace_limit, includes, excludes, extra_includes, extra_excludes, use_standard_includes, filter_title, filter_class, filter_exec, filter_os ); } fn aggregate_includes(config: &ParsedConfig) -> HashSet { let mut includes = HashSet::new(); if config.use_standard_includes.is_none() || config.use_standard_includes.unwrap() { STANDARD_INCLUDES.iter().for_each(|include| { includes.insert(include.to_string()); }) } if let Some(yaml_includes) = config.includes.as_ref() { yaml_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) } if let Some(extra_includes) = config.extra_includes.as_ref() { extra_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) } includes } fn aggregate_excludes(config: &ParsedConfig) -> HashSet { let mut excludes = HashSet::new(); if config.use_standard_includes.is_none() || config.use_standard_includes.unwrap() { STANDARD_EXCLUDES.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) } if let Some(yaml_excludes) = config.excludes.as_ref() { yaml_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) } if let Some(extra_excludes) = config.extra_excludes.as_ref() { extra_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) } excludes } fn generate_match_paths(config: &ParsedConfig, base_dir: &Path) -> HashSet { let includes = Self::aggregate_includes(config); let excludes = Self::aggregate_excludes(config); // Extract the paths let exclude_paths = calculate_paths(base_dir, excludes.iter()); let include_paths = calculate_paths(base_dir, includes.iter()); HashSet::from_iter(include_paths.difference(&exclude_paths).cloned()) } } #[derive(Error, Debug)] pub enum ResolveError { #[error("unable to resolve parent path")] ParentResolveFailed(), } #[cfg(test)] mod tests { use super::*; use crate::util::tests::use_test_directory; use std::fs::create_dir_all; use std::iter::FromIterator; #[test] fn aggregate_includes_empty_config() { assert_eq!( ResolvedConfig::aggregate_includes(&ParsedConfig { ..Default::default() }), HashSet::from_iter(vec!["../match/**/*.yml".to_string(),].iter().cloned()) ); } #[test] fn aggregate_includes_no_standard() { assert_eq!( ResolvedConfig::aggregate_includes(&ParsedConfig { use_standard_includes: Some(false), ..Default::default() }), HashSet::new() ); } #[test] fn aggregate_includes_custom_includes() { assert_eq!( ResolvedConfig::aggregate_includes(&ParsedConfig { includes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec!["../match/**/*.yml".to_string(), "custom/*.yml".to_string()] .iter() .cloned() ) ); } #[test] fn aggregate_includes_extra_includes() { assert_eq!( ResolvedConfig::aggregate_includes(&ParsedConfig { extra_includes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec!["../match/**/*.yml".to_string(), "custom/*.yml".to_string()] .iter() .cloned() ) ); } #[test] fn aggregate_includes_includes_and_extra_includes() { assert_eq!( ResolvedConfig::aggregate_includes(&ParsedConfig { includes: Some(vec!["sub/*.yml".to_string()]), extra_includes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec![ "../match/**/*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string() ] .iter() .cloned() ) ); } #[test] fn aggregate_excludes_empty_config() { assert_eq!( ResolvedConfig::aggregate_excludes(&ParsedConfig { ..Default::default() }), HashSet::from_iter(vec!["../match/**/_*.yml".to_string(),].iter().cloned()) ); } #[test] fn aggregate_excludes_no_standard() { assert_eq!( ResolvedConfig::aggregate_excludes(&ParsedConfig { use_standard_includes: Some(false), ..Default::default() }), HashSet::new() ); } #[test] fn aggregate_excludes_custom_excludes() { assert_eq!( ResolvedConfig::aggregate_excludes(&ParsedConfig { excludes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec!["../match/**/_*.yml".to_string(), "custom/*.yml".to_string()] .iter() .cloned() ) ); } #[test] fn aggregate_excludes_extra_excludes() { assert_eq!( ResolvedConfig::aggregate_excludes(&ParsedConfig { extra_excludes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec!["../match/**/_*.yml".to_string(), "custom/*.yml".to_string()] .iter() .cloned() ) ); } #[test] fn aggregate_excludes_excludes_and_extra_excludes() { assert_eq!( ResolvedConfig::aggregate_excludes(&ParsedConfig { excludes: Some(vec!["sub/*.yml".to_string()]), extra_excludes: Some(vec!["custom/*.yml".to_string()]), ..Default::default() }), HashSet::from_iter( vec![ "../match/**/_*.yml".to_string(), "custom/*.yml".to_string(), "sub/*.yml".to_string() ] .iter() .cloned() ) ); } #[test] fn merge_parent_field_parent_fallback() { let parent = ParsedConfig { use_standard_includes: Some(false), ..Default::default() }; let mut child = ParsedConfig { ..Default::default() }; assert_eq!(child.use_standard_includes, None); ResolvedConfig::merge_parsed(&mut child, &parent); assert_eq!(child.use_standard_includes, Some(false)); } #[test] fn merge_parent_field_child_overwrite_parent() { let parent = ParsedConfig { use_standard_includes: Some(true), ..Default::default() }; let mut child = ParsedConfig { use_standard_includes: Some(false), ..Default::default() }; assert_eq!(child.use_standard_includes, Some(false)); ResolvedConfig::merge_parsed(&mut child, &parent); assert_eq!(child.use_standard_includes, Some(false)); } #[test] fn match_paths_generated_correctly() { use_test_directory(|_, match_dir, config_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, "test").unwrap(); let another_file = match_dir.join("another.yml"); std::fs::write(&another_file, "test").unwrap(); let under_file = match_dir.join("_sub.yml"); std::fs::write(&under_file, "test").unwrap(); let sub_file = sub_dir.join("sub.yml"); std::fs::write(&sub_file, "test").unwrap(); let config_file = config_dir.join("default.yml"); std::fs::write(&config_file, "").unwrap(); let config = ResolvedConfig::load(&config_file, None).unwrap(); let mut expected = vec![ base_file.to_string_lossy().to_string(), another_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string(), ]; expected.sort(); let mut result = config.match_paths().to_vec(); result.sort(); assert_eq!(result, expected.as_slice()); }); } #[test] fn match_paths_generated_correctly_with_child_config() { use_test_directory(|_, match_dir, config_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, "test").unwrap(); let another_file = match_dir.join("another.yml"); std::fs::write(&another_file, "test").unwrap(); let under_file = match_dir.join("_sub.yml"); std::fs::write(&under_file, "test").unwrap(); let sub_file = sub_dir.join("another.yml"); std::fs::write(&sub_file, "test").unwrap(); let sub_under_file = sub_dir.join("_sub.yml"); std::fs::write(&sub_under_file, "test").unwrap(); // Configs let parent_file = config_dir.join("parent.yml"); std::fs::write( &parent_file, r#" excludes: ['../**/another.yml'] "#, ) .unwrap(); let config_file = config_dir.join("default.yml"); std::fs::write( &config_file, r#" use_standard_includes: false excludes: [] includes: ["../match/sub/*.yml"] "#, ) .unwrap(); let parent = ResolvedConfig::load(&parent_file, None).unwrap(); let child = ResolvedConfig::load(&config_file, Some(&parent)).unwrap(); let mut expected = vec![ sub_file.to_string_lossy().to_string(), sub_under_file.to_string_lossy().to_string(), ]; expected.sort(); let mut result = child.match_paths().to_vec(); result.sort(); assert_eq!(result, expected.as_slice()); let expected = vec![base_file.to_string_lossy().to_string()]; assert_eq!(parent.match_paths(), expected.as_slice()); }); } fn test_filter_is_match(config: &str, app: &AppProperties) -> bool { let mut result = false; let result_ref = &mut result; use_test_directory(move |_, _, config_dir| { let config_file = config_dir.join("default.yml"); std::fs::write(&config_file, config).unwrap(); let config = ResolvedConfig::load(&config_file, None).unwrap(); *result_ref = config.is_match(app) }); result } #[test] fn is_match_no_filters() { assert!(!test_filter_is_match( "", &AppProperties { title: Some("Google"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); } #[test] fn is_match_filter_title() { assert!(test_filter_is_match( "filter_title: Google", &AppProperties { title: Some("Google Mail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( "filter_title: Google", &AppProperties { title: Some("Yahoo"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( "filter_title: Google", &AppProperties { title: None, class: Some("Chrome"), exec: Some("chrome.exe"), }, )); } #[test] fn is_match_filter_class() { assert!(test_filter_is_match( "filter_class: Chrome", &AppProperties { title: Some("Google Mail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( "filter_class: Chrome", &AppProperties { title: Some("Yahoo"), class: Some("Another"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( "filter_class: Chrome", &AppProperties { title: Some("google"), class: None, exec: Some("chrome.exe"), }, )); } #[test] fn is_match_filter_exec() { assert!(test_filter_is_match( "filter_exec: chrome.exe", &AppProperties { title: Some("Google Mail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( "filter_exec: chrome.exe", &AppProperties { title: Some("Yahoo"), class: Some("Another"), exec: Some("zoom.exe"), }, )); assert!(!test_filter_is_match( "filter_exec: chrome.exe", &AppProperties { title: Some("google"), class: Some("Chrome"), exec: None, }, )); } #[test] fn is_match_filter_os() { let (current, another) = if cfg!(target_os = "windows") { ("windows", "macos") } else if cfg!(target_os = "macos") { ("macos", "windows") } else if cfg!(target_os = "linux") { ("linux", "macos") } else { ("invalid", "invalid") }; assert!(test_filter_is_match( &format!("filter_os: {}", current), &AppProperties { title: Some("Google Mail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( &format!("filter_os: {}", another), &AppProperties { title: Some("Google Mail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); } #[test] fn is_match_multiple_filters() { assert!(test_filter_is_match( r#" filter_exec: chrome.exe filter_title: "Youtube" "#, &AppProperties { title: Some("Youtube - Broadcast Yourself"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); assert!(!test_filter_is_match( r#" filter_exec: chrome.exe filter_title: "Youtube" "#, &AppProperties { title: Some("Gmail"), class: Some("Chrome"), exec: Some("chrome.exe"), }, )); } }