diff --git a/Cargo.lock b/Cargo.lock
index 13074cc..14617b0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -45,6 +45,12 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5"
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
[[package]]
name = "blake2b_simd"
version = "0.5.11"
@@ -261,7 +267,9 @@ dependencies = [
"serde",
"serde_yaml",
"tempdir",
+ "tempfile",
"thiserror",
+ "walkdir",
]
[[package]]
@@ -681,6 +689,15 @@ version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
+[[package]]
+name = "redox_syscall"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9"
+dependencies = [
+ "bitflags 1.2.1",
+]
+
[[package]]
name = "redox_users"
version = "0.3.5"
@@ -688,7 +705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
dependencies = [
"getrandom 0.1.16",
- "redox_syscall",
+ "redox_syscall 0.1.57",
"rust-argon2",
]
@@ -736,6 +753,15 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
[[package]]
name = "scopeguard"
version = "1.1.0"
@@ -853,6 +879,20 @@ dependencies = [
"remove_dir_all",
]
+[[package]]
+name = "tempfile"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "rand 0.8.3",
+ "redox_syscall 0.2.5",
+ "remove_dir_all",
+ "winapi",
+]
+
[[package]]
name = "termcolor"
version = "1.1.2"
@@ -935,6 +975,17 @@ dependencies = [
"libc",
]
+[[package]]
+name = "walkdir"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d"
+dependencies = [
+ "same-file",
+ "winapi",
+ "winapi-util",
+]
+
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
@@ -1012,7 +1063,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1945e12e16b951721d7976520b0832496ef79c31602c7a29d950de79ba74621"
dependencies = [
- "bitflags",
+ "bitflags 0.9.1",
]
[[package]]
diff --git a/espanso-config/Cargo.toml b/espanso-config/Cargo.toml
index c086296..e0d5e5f 100644
--- a/espanso-config/Cargo.toml
+++ b/espanso-config/Cargo.toml
@@ -14,6 +14,8 @@ glob = "0.3.0"
regex = "1.4.3"
lazy_static = "1.4.0"
dunce = "1.0.1"
+walkdir = "2.3.1"
[dev-dependencies]
-tempdir = "0.3.7"
\ No newline at end of file
+tempdir = "0.3.7"
+tempfile = "3.2.0"
\ No newline at end of file
diff --git a/espanso-config/src/legacy/config.rs b/espanso-config/src/legacy/config.rs
new file mode 100644
index 0000000..cc8a6f3
--- /dev/null
+++ b/espanso-config/src/legacy/config.rs
@@ -0,0 +1,1890 @@
+// This file is taken from the old version of espanso, and used to load
+// the "legacy" config format
+
+/*
+ * This file is part of espanso.
+ *
+ * Copyright (C) 2019 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::model::{KeyModifier, PasteShortcut};
+use log::error;
+use serde::{Deserialize, Serialize};
+use serde_yaml::Value;
+use std::collections::{HashMap, HashSet};
+use std::error::Error;
+use std::fmt;
+use std::fs::File;
+use std::io::Read;
+use std::path::{Path, PathBuf};
+use walkdir::{DirEntry, WalkDir};
+
+pub const DEFAULT_CONFIG_FILE_NAME: &str = "default.yml";
+pub const USER_CONFIGS_FOLDER_NAME: &str = "user";
+
+// Default values for primitives
+fn default_name() -> String {
+ "default".to_owned()
+}
+fn default_parent() -> String {
+ "self".to_owned()
+}
+fn default_filter_title() -> String {
+ "".to_owned()
+}
+fn default_filter_class() -> String {
+ "".to_owned()
+}
+fn default_filter_exec() -> String {
+ "".to_owned()
+}
+fn default_log_level() -> i32 {
+ 0
+}
+fn default_conflict_check() -> bool {
+ false
+}
+fn default_ipc_server_port() -> i32 {
+ 34982
+}
+fn default_worker_ipc_server_port() -> i32 {
+ 34983
+}
+fn default_use_system_agent() -> bool {
+ true
+}
+fn default_config_caching_interval() -> i32 {
+ 800
+}
+fn default_word_separators() -> Vec {
+ vec![' ', ',', '.', '?', '!', '\r', '\n', 22u8 as char]
+}
+fn default_toggle_interval() -> u32 {
+ 230
+}
+fn default_toggle_key() -> KeyModifier {
+ KeyModifier::ALT
+}
+fn default_preserve_clipboard() -> bool {
+ true
+}
+fn default_passive_match_regex() -> String {
+ "(?P:\\p{L}+)(/(?P.*)/)?".to_owned()
+}
+fn default_passive_arg_delimiter() -> char {
+ '/'
+}
+fn default_passive_arg_escape() -> char {
+ '\\'
+}
+fn default_passive_delay() -> u64 {
+ 100
+}
+fn default_passive_key() -> KeyModifier {
+ KeyModifier::OFF
+}
+fn default_enable_passive() -> bool {
+ false
+}
+fn default_enable_active() -> bool {
+ true
+}
+fn default_backspace_limit() -> i32 {
+ 3
+}
+fn default_backspace_delay() -> i32 {
+ 0
+}
+fn default_inject_delay() -> i32 {
+ 0
+}
+fn default_restore_clipboard_delay() -> i32 {
+ 300
+}
+fn default_exclude_default_entries() -> bool {
+ false
+}
+fn default_secure_input_watcher_enabled() -> bool {
+ true
+}
+fn default_secure_input_notification() -> bool {
+ true
+}
+fn default_show_notifications() -> bool {
+ true
+}
+fn default_auto_restart() -> bool {
+ true
+}
+fn default_undo_backspace() -> bool {
+ true
+}
+fn default_show_icon() -> bool {
+ true
+}
+fn default_fast_inject() -> bool {
+ true
+}
+fn default_secure_input_watcher_interval() -> i32 {
+ 5000
+}
+fn default_matches() -> Vec {
+ Vec::new()
+}
+fn default_global_vars() -> Vec {
+ Vec::new()
+}
+fn default_modulo_path() -> Option {
+ None
+}
+fn default_post_inject_delay() -> u64 {
+ 100
+}
+
+fn default_wait_for_modifiers_release() -> bool {
+ false
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Configs {
+ #[serde(default = "default_name")]
+ pub name: String,
+
+ #[serde(default = "default_parent")]
+ pub parent: String,
+
+ #[serde(default = "default_filter_title")]
+ pub filter_title: String,
+
+ #[serde(default = "default_filter_class")]
+ pub filter_class: String,
+
+ #[serde(default = "default_filter_exec")]
+ pub filter_exec: String,
+
+ #[serde(default = "default_log_level")]
+ pub log_level: i32,
+
+ #[serde(default = "default_conflict_check")]
+ pub conflict_check: bool,
+
+ #[serde(default = "default_ipc_server_port")]
+ pub ipc_server_port: i32,
+
+ #[serde(default = "default_worker_ipc_server_port")]
+ pub worker_ipc_server_port: i32,
+
+ #[serde(default = "default_use_system_agent")]
+ pub use_system_agent: bool,
+
+ #[serde(default = "default_config_caching_interval")]
+ pub config_caching_interval: i32,
+
+ #[serde(default = "default_word_separators")]
+ pub word_separators: Vec, // TODO: add parsing test
+
+ #[serde(default = "default_toggle_key")]
+ pub toggle_key: KeyModifier,
+
+ #[serde(default = "default_toggle_interval")]
+ pub toggle_interval: u32,
+
+ #[serde(default = "default_preserve_clipboard")]
+ pub preserve_clipboard: bool,
+
+ #[serde(default = "default_passive_match_regex")]
+ pub passive_match_regex: String,
+
+ #[serde(default = "default_passive_arg_delimiter")]
+ pub passive_arg_delimiter: char,
+
+ #[serde(default = "default_passive_arg_escape")]
+ pub passive_arg_escape: char,
+
+ #[serde(default = "default_passive_key")]
+ pub passive_key: KeyModifier,
+
+ #[serde(default = "default_passive_delay")]
+ pub passive_delay: u64,
+
+ #[serde(default = "default_enable_passive")]
+ pub enable_passive: bool,
+
+ #[serde(default = "default_enable_active")]
+ pub enable_active: bool,
+
+ #[serde(default = "default_undo_backspace")]
+ pub undo_backspace: bool,
+
+ #[serde(default)]
+ pub paste_shortcut: PasteShortcut,
+
+ #[serde(default = "default_backspace_limit")]
+ pub backspace_limit: i32,
+
+ #[serde(default = "default_restore_clipboard_delay")]
+ pub restore_clipboard_delay: i32,
+
+ #[serde(default = "default_secure_input_watcher_enabled")]
+ pub secure_input_watcher_enabled: bool,
+
+ #[serde(default = "default_secure_input_watcher_interval")]
+ pub secure_input_watcher_interval: i32,
+
+ #[serde(default = "default_post_inject_delay")]
+ pub post_inject_delay: u64,
+
+ #[serde(default = "default_secure_input_notification")]
+ pub secure_input_notification: bool,
+
+ #[serde(default)]
+ pub backend: BackendType,
+
+ #[serde(default = "default_exclude_default_entries")]
+ pub exclude_default_entries: bool,
+
+ #[serde(default = "default_show_notifications")]
+ pub show_notifications: bool,
+
+ #[serde(default = "default_show_icon")]
+ pub show_icon: bool,
+
+ #[serde(default = "default_fast_inject")]
+ pub fast_inject: bool,
+
+ #[serde(default = "default_backspace_delay")]
+ pub backspace_delay: i32,
+
+ #[serde(default = "default_inject_delay")]
+ pub inject_delay: i32,
+
+ #[serde(default = "default_auto_restart")]
+ pub auto_restart: bool,
+
+ #[serde(default = "default_matches")]
+ pub matches: Vec,
+
+ #[serde(default = "default_global_vars")]
+ pub global_vars: Vec,
+
+ #[serde(default = "default_modulo_path")]
+ pub modulo_path: Option,
+
+ #[serde(default = "default_wait_for_modifiers_release")]
+ pub wait_for_modifiers_release: bool,
+}
+
+// Macro used to validate config fields
+#[macro_export]
+macro_rules! validate_field {
+ ($result:expr, $field:expr, $def_value:expr) => {
+ if $field != $def_value {
+ let mut field_name = stringify!($field);
+ if field_name.starts_with("self.") {
+ field_name = &field_name[5..]; // Remove the 'self.' prefix
+ }
+ error!("Validation error, parameter '{}' is reserved and can be only used in the default.yml config file", field_name);
+ $result = false;
+ }
+ };
+}
+
+impl Configs {
+ /*
+ * Validate the Config instance.
+ * It makes sure that user defined config instances do not define
+ * attributes reserved to the default config.
+ */
+ fn validate_user_defined_config(&self) -> bool {
+ let mut result = true;
+
+ validate_field!(
+ result,
+ self.config_caching_interval,
+ default_config_caching_interval()
+ );
+ validate_field!(result, self.log_level, default_log_level());
+ validate_field!(result, self.conflict_check, default_conflict_check());
+ validate_field!(result, self.toggle_key, default_toggle_key());
+ validate_field!(result, self.toggle_interval, default_toggle_interval());
+ validate_field!(result, self.backspace_limit, default_backspace_limit());
+ validate_field!(result, self.ipc_server_port, default_ipc_server_port());
+ validate_field!(result, self.use_system_agent, default_use_system_agent());
+ validate_field!(
+ result,
+ self.preserve_clipboard,
+ default_preserve_clipboard()
+ );
+ validate_field!(
+ result,
+ self.passive_match_regex,
+ default_passive_match_regex()
+ );
+ validate_field!(
+ result,
+ self.passive_arg_delimiter,
+ default_passive_arg_delimiter()
+ );
+ validate_field!(
+ result,
+ self.passive_arg_escape,
+ default_passive_arg_escape()
+ );
+ validate_field!(result, self.passive_key, default_passive_key());
+ validate_field!(
+ result,
+ self.restore_clipboard_delay,
+ default_restore_clipboard_delay()
+ );
+ validate_field!(
+ result,
+ self.secure_input_watcher_enabled,
+ default_secure_input_watcher_enabled()
+ );
+ validate_field!(
+ result,
+ self.secure_input_watcher_interval,
+ default_secure_input_watcher_interval()
+ );
+ validate_field!(
+ result,
+ self.secure_input_notification,
+ default_secure_input_notification()
+ );
+ validate_field!(
+ result,
+ self.show_notifications,
+ default_show_notifications()
+ );
+ validate_field!(result, self.show_icon, default_show_icon());
+
+ result
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum BackendType {
+ Inject,
+ Clipboard,
+
+ // On Linux systems there is a long standing issue with text injection (which
+ // in general is better than Clipboard copy/pasting) that prevents certain
+ // apps from correctly handling special characters (such as emojis or accented letters)
+ // when injected. For this reason, espanso initially defaulted on the Clipboard
+ // backend on Linux, as it was the most reliable (working in 99% of cases),
+ // even though it was less efficient and with a few inconveniences (for example, the
+ // previous clipboard content being overwritten).
+ // The Auto backend tries to take it a step further, by automatically determining
+ // when an injection is possible (only ascii characters in the replacement), and falling
+ // back to the Clipboard backend otherwise.
+ // Should only be used on Linux systems.
+ Auto,
+}
+impl Default for BackendType {
+ // The default backend varies based on the operating system.
+ // On Windows and macOS, the Inject backend is working great and should
+ // be preferred as it doesn't override the clipboard.
+ // On the other hand, on linux it has many problems due to the bugs
+ // of the libxdo used. For this reason, Clipboard will be the default
+ // backend on Linux from version v0.3.0
+
+ #[cfg(not(target_os = "linux"))]
+ fn default() -> Self {
+ BackendType::Inject
+ }
+
+ #[cfg(target_os = "linux")]
+ fn default() -> Self {
+ BackendType::Auto
+ }
+}
+
+impl Configs {
+ fn load_config(path: &Path) -> Result {
+ let file_res = File::open(path);
+ if let Ok(mut file) = file_res {
+ let mut contents = String::new();
+ let res = file.read_to_string(&mut contents);
+
+ if res.is_err() {
+ return Err(ConfigLoadError::UnableToReadFile);
+ }
+
+ let config_res = serde_yaml::from_str(&contents);
+
+ match config_res {
+ Ok(config) => Ok(config),
+ Err(e) => Err(ConfigLoadError::InvalidYAML(path.to_owned(), e.to_string())),
+ }
+ } else {
+ eprintln!("Error: Cannot load file {:?}", path);
+ Err(ConfigLoadError::FileNotFound)
+ }
+ }
+
+ fn merge_overwrite(&mut self, new_config: Configs) {
+ // Merge matches
+ let mut merged_matches = new_config.matches;
+ let mut match_trigger_set = HashSet::new();
+ merged_matches.iter().for_each(|m| {
+ match_trigger_set.extend(triggers_for_match(m));
+ });
+ let parent_matches: Vec = self
+ .matches
+ .iter()
+ .filter(|&m| {
+ !triggers_for_match(m)
+ .iter()
+ .any(|trigger| match_trigger_set.contains(trigger))
+ })
+ .cloned()
+ .collect();
+
+ merged_matches.extend(parent_matches);
+ self.matches = merged_matches;
+
+ // Merge global variables
+ let mut merged_global_vars = new_config.global_vars;
+ let mut vars_name_set = HashSet::new();
+ merged_global_vars.iter().for_each(|m| {
+ vars_name_set.insert(name_for_global_var(m));
+ });
+ let parent_vars: Vec = self
+ .global_vars
+ .iter()
+ .filter(|&m| !vars_name_set.contains(&name_for_global_var(m)))
+ .cloned()
+ .collect();
+
+ merged_global_vars.extend(parent_vars);
+ self.global_vars = merged_global_vars;
+ }
+
+ fn merge_no_overwrite(&mut self, default: &Configs) {
+ // Merge matches
+ let mut match_trigger_set = HashSet::new();
+ self.matches.iter().for_each(|m| {
+ match_trigger_set.extend(triggers_for_match(m));
+ });
+ let default_matches: Vec = default
+ .matches
+ .iter()
+ .filter(|&m| {
+ !triggers_for_match(m)
+ .iter()
+ .any(|trigger| match_trigger_set.contains(trigger))
+ })
+ .cloned()
+ .collect();
+
+ self.matches.extend(default_matches);
+
+ // Merge global variables
+ let mut vars_name_set = HashSet::new();
+ self.global_vars.iter().for_each(|m| {
+ vars_name_set.insert(name_for_global_var(m));
+ });
+ let default_vars: Vec = default
+ .global_vars
+ .iter()
+ .filter(|&m| !vars_name_set.contains(&name_for_global_var(m)))
+ .cloned()
+ .collect();
+
+ self.global_vars.extend(default_vars);
+ }
+}
+
+fn triggers_for_match(m: &Value) -> Vec {
+ if let Some(triggers) = m.get("triggers").and_then(|v| v.as_sequence()) {
+ triggers.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()
+ } else if let Some(trigger) = m.get("trigger").and_then(|v| v.as_str()) {
+ vec![trigger.to_string()]
+ } else {
+ panic!("Match does not have any trigger defined: {:?}", m)
+ }
+}
+
+fn replace_for_match(m: &Value) -> String {
+ m.get("replace").and_then(|v| v.as_str()).expect("match is missing replace field").to_string()
+}
+
+fn name_for_global_var(v: &Value) -> String {
+ v.get("name").and_then(|v| v.as_str()).expect("global var is missing name field").to_string()
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct ConfigSet {
+ pub default: Configs,
+ pub specific: Vec,
+}
+
+impl ConfigSet {
+ pub fn load(config_dir: &Path, package_dir: &Path) -> Result {
+ if !config_dir.is_dir() {
+ return Err(ConfigLoadError::InvalidConfigDirectory);
+ }
+
+ // Load default configuration
+ let default_file = config_dir.join(DEFAULT_CONFIG_FILE_NAME);
+ let default = Configs::load_config(default_file.as_path())?;
+
+ // Check that a compatible backend is used, otherwise warn the user
+ if cfg!(not(target_os = "linux")) && default.backend == BackendType::Auto {
+ eprintln!(
+ "Warning: Using Auto backend is only supported on Linux, falling back to Inject backend."
+ );
+ }
+
+ // Analyze which config files have to be loaded
+
+ let mut target_files = Vec::new();
+
+ let specific_dir = config_dir.join(USER_CONFIGS_FOLDER_NAME);
+ if specific_dir.exists() {
+ let dir_entry = WalkDir::new(specific_dir);
+ target_files.extend(dir_entry);
+ }
+
+ let package_files = if package_dir.exists() {
+ let dir_entry = WalkDir::new(package_dir);
+ dir_entry.into_iter().collect()
+ } else {
+ vec![]
+ };
+
+ // Load the user defined config files
+
+ let mut name_set = HashSet::new();
+ let mut children_map: HashMap> = HashMap::new();
+ let mut package_map: HashMap> = HashMap::new();
+ let mut root_configs = Vec::new();
+ root_configs.push(default);
+
+ let mut file_loader = |entry: walkdir::Result,
+ dest_map: &mut HashMap>|
+ -> Result<(), ConfigLoadError> {
+ match entry {
+ Ok(entry) => {
+ let path = entry.path();
+
+ // Skip non-yaml config files
+ if path
+ .extension()
+ .unwrap_or_default()
+ .to_str()
+ .unwrap_or_default()
+ != "yml"
+ {
+ return Ok(());
+ }
+
+ // Skip hidden files
+ if path
+ .file_name()
+ .unwrap_or_default()
+ .to_str()
+ .unwrap_or_default()
+ .starts_with(".")
+ {
+ return Ok(());
+ }
+
+ let mut config = Configs::load_config(&path)?;
+
+ // Make sure the config does not contain reserved fields
+ if !config.validate_user_defined_config() {
+ return Err(ConfigLoadError::InvalidParameter(path.to_owned()));
+ }
+
+ // No name specified, defaulting to the path name
+ if config.name == "default" {
+ config.name = path.to_str().unwrap_or_default().to_owned();
+ }
+
+ if name_set.contains(&config.name) {
+ return Err(ConfigLoadError::NameDuplicate(path.to_owned()));
+ }
+
+ name_set.insert(config.name.clone());
+
+ if config.parent == "self" {
+ // No parent, root config
+ root_configs.push(config);
+ } else {
+ // Children config
+ let children_vec = dest_map.entry(config.parent.clone()).or_default();
+ children_vec.push(config);
+ }
+ }
+ Err(e) => {
+ eprintln!("Warning: Unable to read config file: {}", e);
+ }
+ }
+
+ Ok(())
+ };
+
+ // Load the default and user specific configs
+ for entry in target_files {
+ file_loader(entry, &mut children_map)?;
+ }
+
+ // Load the package related configs
+ for entry in package_files {
+ file_loader(entry, &mut package_map)?;
+ }
+
+ // Merge the children config files
+ let mut configs_without_packages = Vec::new();
+ for root_config in root_configs {
+ let config = ConfigSet::reduce_configs(root_config, &children_map, true);
+ configs_without_packages.push(config);
+ }
+
+ // Merge package files
+ // Note: we need two different steps as the packages have a lower priority
+ // than configs.
+ let mut configs = Vec::new();
+ for root_config in configs_without_packages {
+ let config = ConfigSet::reduce_configs(root_config, &package_map, false);
+ configs.push(config);
+ }
+
+ // Separate default from specific
+ let default = configs.get(0).unwrap().clone();
+ let mut specific = (&configs[1..]).to_vec().clone();
+
+ // Add default entries to specific configs when needed
+ for config in specific.iter_mut() {
+ if !config.exclude_default_entries {
+ config.merge_no_overwrite(&default);
+ }
+ }
+
+ // Check if some triggers are conflicting with each other
+ // For more information, see: https://github.com/federico-terzi/espanso/issues/135
+ if default.conflict_check {
+ let has_conflicts = Self::has_conflicts(&default, &specific);
+ if has_conflicts {
+ eprintln!("Warning: some triggers had conflicts and may not behave as intended");
+ eprintln!("To turn off this check, add \"conflict_check: false\" in the configuration");
+ }
+ }
+
+ Ok(ConfigSet { default, specific })
+ }
+
+ fn reduce_configs(
+ target: Configs,
+ children_map: &HashMap>,
+ higher_priority: bool,
+ ) -> Configs {
+ if children_map.contains_key(&target.name) {
+ let mut target = target;
+ for children in children_map.get(&target.name).unwrap() {
+ let children = Self::reduce_configs(children.clone(), children_map, higher_priority);
+ if higher_priority {
+ target.merge_overwrite(children);
+ } else {
+ target.merge_no_overwrite(&children);
+ }
+ }
+ target
+ } else {
+ target
+ }
+ }
+
+ fn has_conflicts(default: &Configs, specific: &Vec) -> bool {
+ let mut sorted_triggers: Vec = default
+ .matches
+ .iter()
+ .flat_map(|t| triggers_for_match(t))
+ .collect();
+ sorted_triggers.sort();
+
+ let mut has_conflicts = Self::list_has_conflicts(&sorted_triggers);
+
+ for s in specific.iter() {
+ let mut specific_triggers: Vec =
+ s.matches.iter().flat_map(|t| triggers_for_match(t)).collect();
+ specific_triggers.sort();
+ has_conflicts |= Self::list_has_conflicts(&specific_triggers);
+ }
+
+ has_conflicts
+ }
+
+ fn list_has_conflicts(sorted_list: &Vec) -> bool {
+ if sorted_list.len() <= 1 {
+ return false;
+ }
+
+ let mut has_conflicts = false;
+
+ for (i, item) in sorted_list.iter().skip(1).enumerate() {
+ let previous = &sorted_list[i];
+ if item.starts_with(previous) {
+ has_conflicts = true;
+ eprintln!(
+ "Warning: trigger '{}' is conflicting with '{}' and may not behave as intended",
+ item, previous
+ );
+ }
+ }
+
+ has_conflicts
+ }
+}
+
+// Error handling
+#[derive(Debug, PartialEq)]
+pub enum ConfigLoadError {
+ FileNotFound,
+ UnableToReadFile,
+ InvalidYAML(PathBuf, String),
+ InvalidConfigDirectory,
+ InvalidParameter(PathBuf),
+ NameDuplicate(PathBuf),
+ UnableToCreateDefaultConfig,
+}
+
+impl fmt::Display for ConfigLoadError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ ConfigLoadError::FileNotFound => write!(f, "File not found"),
+ ConfigLoadError::UnableToReadFile => write!(f, "Unable to read config file"),
+ ConfigLoadError::InvalidYAML(path, e) => write!(f, "Error parsing YAML file '{}', invalid syntax: {}", path.to_str().unwrap_or_default(), e),
+ ConfigLoadError::InvalidConfigDirectory => write!(f, "Invalid config directory"),
+ ConfigLoadError::InvalidParameter(path) => write!(f, "Invalid parameter in '{}', use of reserved parameters in used defined configs is not permitted", path.to_str().unwrap_or_default()),
+ ConfigLoadError::NameDuplicate(path) => write!(f, "Found duplicate 'name' in '{}', please use different names", path.to_str().unwrap_or_default()),
+ ConfigLoadError::UnableToCreateDefaultConfig => write!(f, "Could not generate default config file"),
+ }
+ }
+}
+
+impl Error for ConfigLoadError {
+ fn description(&self) -> &str {
+ match self {
+ ConfigLoadError::FileNotFound => "File not found",
+ ConfigLoadError::UnableToReadFile => "Unable to read config file",
+ ConfigLoadError::InvalidYAML(_, _) => "Error parsing YAML file, invalid syntax",
+ ConfigLoadError::InvalidConfigDirectory => "Invalid config directory",
+ ConfigLoadError::InvalidParameter(_) => {
+ "Invalid parameter, use of reserved parameters in user defined configs is not permitted"
+ }
+ ConfigLoadError::NameDuplicate(_) => {
+ "Found duplicate 'name' in some configurations, please use different names"
+ }
+ ConfigLoadError::UnableToCreateDefaultConfig => "Could not generate default config file",
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use tempfile::{NamedTempFile, TempDir};
+ use std::fs;
+ use std::fs::{create_dir_all};
+
+ const DEFAULT_CONFIG_FILE_CONTENT: &str = include_str!("res/test/default.yml");
+ const TEST_WORKING_CONFIG_FILE: &str = include_str!("res/test/working_config.yml");
+ const TEST_CONFIG_FILE_WITH_BAD_YAML: &str = include_str!("res/test/config_with_bad_yaml.yml");
+
+ // Test Configs
+
+ fn create_tmp_file(string: &str) -> NamedTempFile {
+ let file = NamedTempFile::new().unwrap();
+ file.as_file().write_all(string.as_bytes()).unwrap();
+ file
+ }
+
+ #[test]
+ fn test_config_file_not_found() {
+ let config = Configs::load_config(Path::new("invalid/path"));
+ assert_eq!(config.is_err(), true);
+ assert_eq!(config.unwrap_err(), ConfigLoadError::FileNotFound);
+ }
+
+ #[test]
+ fn test_config_file_with_bad_yaml_syntax() {
+ let broken_config_file = create_tmp_file(TEST_CONFIG_FILE_WITH_BAD_YAML);
+ let config = Configs::load_config(broken_config_file.path());
+ match config {
+ Ok(_) => assert!(false),
+ Err(e) => {
+ match e {
+ ConfigLoadError::InvalidYAML(p, _) => assert_eq!(p, broken_config_file.path().to_owned()),
+ _ => assert!(false),
+ }
+ assert!(true);
+ }
+ }
+ }
+
+ #[test]
+ fn test_validate_field_macro() {
+ let mut result = true;
+
+ validate_field!(result, 3, 3);
+ assert_eq!(result, true);
+
+ validate_field!(result, 10, 3);
+ assert_eq!(result, false);
+
+ validate_field!(result, 3, 3);
+ assert_eq!(result, false);
+ }
+
+ #[test]
+ fn test_user_defined_config_does_not_have_reserved_fields() {
+ let working_config_file = create_tmp_file(
+ r###"
+
+ backend: Clipboard
+
+ "###,
+ );
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.unwrap().validate_user_defined_config(), true);
+ }
+
+ #[test]
+ fn test_user_defined_config_has_reserved_fields_config_caching_interval() {
+ let working_config_file = create_tmp_file(
+ r###"
+
+ # This should not happen in an app-specific config
+ config_caching_interval: 100
+
+ "###,
+ );
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.unwrap().validate_user_defined_config(), false);
+ }
+
+ #[test]
+ fn test_user_defined_config_has_reserved_fields_toggle_key() {
+ let working_config_file = create_tmp_file(
+ r###"
+
+ # This should not happen in an app-specific config
+ toggle_key: CTRL
+
+ "###,
+ );
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.unwrap().validate_user_defined_config(), false);
+ }
+
+ #[test]
+ fn test_user_defined_config_has_reserved_fields_toggle_interval() {
+ let working_config_file = create_tmp_file(
+ r###"
+
+ # This should not happen in an app-specific config
+ toggle_interval: 1000
+
+ "###,
+ );
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.unwrap().validate_user_defined_config(), false);
+ }
+
+ #[test]
+ fn test_user_defined_config_has_reserved_fields_backspace_limit() {
+ let working_config_file = create_tmp_file(
+ r###"
+
+ # This should not happen in an app-specific config
+ backspace_limit: 10
+
+ "###,
+ );
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.unwrap().validate_user_defined_config(), false);
+ }
+
+ #[test]
+ fn test_config_loaded_correctly() {
+ let working_config_file = create_tmp_file(TEST_WORKING_CONFIG_FILE);
+ let config = Configs::load_config(working_config_file.path());
+ assert_eq!(config.is_ok(), true);
+ }
+
+ // Test ConfigSet
+
+ pub fn create_temp_espanso_directories() -> (TempDir, TempDir) {
+ create_temp_espanso_directories_with_default_content(DEFAULT_CONFIG_FILE_CONTENT)
+ }
+
+ pub fn create_temp_espanso_directories_with_default_content(
+ default_content: &str,
+ ) -> (TempDir, TempDir) {
+ let data_dir = TempDir::new().expect("unable to create data directory");
+ let package_dir = TempDir::new().expect("unable to create package directory");
+
+ let default_path = data_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
+ fs::write(default_path, default_content).unwrap();
+
+ (data_dir, package_dir)
+ }
+
+ pub fn create_temp_file_in_dir(tmp_dir: &PathBuf, name: &str, content: &str) -> PathBuf {
+ let user_defined_path = tmp_dir.join(name);
+ let user_defined_path_copy = user_defined_path.clone();
+ fs::write(user_defined_path, content).unwrap();
+
+ user_defined_path_copy
+ }
+
+ pub fn create_user_config_file(tmp_dir: &Path, name: &str, content: &str) -> PathBuf {
+ let user_config_dir = tmp_dir.join(USER_CONFIGS_FOLDER_NAME);
+ if !user_config_dir.exists() {
+ create_dir_all(&user_config_dir).unwrap();
+ }
+
+ create_temp_file_in_dir(&user_config_dir, name, content)
+ }
+
+ pub fn create_package_file(
+ package_data_dir: &Path,
+ package_name: &str,
+ filename: &str,
+ content: &str,
+ ) -> PathBuf {
+ let package_dir = package_data_dir.join(package_name);
+ if !package_dir.exists() {
+ create_dir_all(&package_dir).unwrap();
+ }
+
+ create_temp_file_in_dir(&package_dir, filename, content)
+ }
+
+ #[test]
+ fn test_config_set_default_content_should_work_correctly() {
+ let (data_dir, package_dir) = create_temp_espanso_directories();
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ assert!(config_set.is_ok());
+ }
+
+ #[test]
+ fn test_config_set_load_fail_bad_directory() {
+ let config_set = ConfigSet::load(Path::new("invalid/path"), Path::new("invalid/path"));
+ assert_eq!(config_set.is_err(), true);
+ assert_eq!(
+ config_set.unwrap_err(),
+ ConfigLoadError::InvalidConfigDirectory
+ );
+ }
+
+ #[test]
+ fn test_config_set_missing_default_file() {
+ let data_dir = TempDir::new().expect("unable to create temp directory");
+ let package_dir = TempDir::new().expect("unable to create package directory");
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ assert_eq!(config_set.is_err(), true);
+ assert_eq!(config_set.unwrap_err(), ConfigLoadError::FileNotFound);
+ }
+
+ #[test]
+ fn test_config_set_invalid_yaml_syntax() {
+ let (data_dir, package_dir) =
+ create_temp_espanso_directories_with_default_content(TEST_CONFIG_FILE_WITH_BAD_YAML);
+ let default_path = data_dir.path().join(DEFAULT_CONFIG_FILE_NAME);
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ match config_set {
+ Ok(_) => assert!(false),
+ Err(e) => {
+ match e {
+ ConfigLoadError::InvalidYAML(p, _) => assert_eq!(p, default_path),
+ _ => assert!(false),
+ }
+ assert!(true);
+ }
+ }
+ }
+
+ #[test]
+ fn test_config_set_specific_file_with_reserved_fields() {
+ let (data_dir, package_dir) = create_temp_espanso_directories();
+
+ let user_defined_path = create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ config_caching_interval: 10000
+ "###,
+ );
+ let user_defined_path_copy = user_defined_path.clone();
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ assert!(config_set.is_err());
+ assert_eq!(
+ config_set.unwrap_err(),
+ ConfigLoadError::InvalidParameter(user_defined_path_copy)
+ )
+ }
+
+ #[test]
+ fn test_config_set_specific_file_missing_name_auto_generated() {
+ let (data_dir, package_dir) = create_temp_espanso_directories();
+
+ let user_defined_path = create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ backend: Clipboard
+ "###,
+ );
+ let user_defined_path_copy = user_defined_path.clone();
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ assert!(config_set.is_ok());
+ assert_eq!(
+ config_set.unwrap().specific[0].name,
+ user_defined_path_copy.to_str().unwrap_or_default()
+ )
+ }
+
+ #[test]
+ fn test_config_set_specific_file_duplicate_name() {
+ let (data_dir, package_dir) = create_temp_espanso_directories();
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ name: specific1
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path());
+ assert!(config_set.is_err());
+ assert!(matches!(
+ &config_set.unwrap_err(),
+ &ConfigLoadError::NameDuplicate(_)
+ ))
+ }
+
+ #[test]
+ fn test_user_defined_config_set_merge_with_parent_matches() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ":lol"
+ replace: "LOL"
+ - trigger: ":yess"
+ replace: "Bob"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific1.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: "hello"
+ replace: "newstring"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.default.matches.len(), 2);
+ assert_eq!(config_set.specific[0].matches.len(), 3);
+
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| triggers_for_match(x)[0] == "hello")
+ .is_some());
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| triggers_for_match(x)[0] == ":lol")
+ .is_some());
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| triggers_for_match(x)[0] == ":yess")
+ .is_some());
+ }
+
+ #[test]
+ fn test_user_defined_config_set_merge_with_parent_matches_child_priority() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ":lol"
+ replace: "LOL"
+ - trigger: ":yess"
+ replace: "Bob"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: ":lol"
+ replace: "newstring"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.default.matches.len(), 2);
+ assert_eq!(config_set.specific[0].matches.len(), 2);
+
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| {
+ triggers_for_match(x)[0] == ":lol" && replace_for_match(x) == "newstring"
+ })
+ .is_some());
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| triggers_for_match(x)[0] == ":yess")
+ .is_some());
+ }
+
+ #[test]
+ fn test_user_defined_config_set_exclude_merge_with_parent_matches() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ":lol"
+ replace: "LOL"
+ - trigger: ":yess"
+ replace: "Bob"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ name: specific1
+
+ exclude_default_entries: true
+
+ matches:
+ - trigger: "hello"
+ replace: "newstring"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.default.matches.len(), 2);
+ assert_eq!(config_set.specific[0].matches.len(), 1);
+
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .find(|x| {
+ triggers_for_match(x)[0] == "hello" && replace_for_match(x) == "newstring"
+ })
+ .is_some());
+ }
+
+ #[test]
+ fn test_only_yaml_files_are_loaded_from_config() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ":lol"
+ replace: "LOL"
+ - trigger: ":yess"
+ replace: "Bob"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.zzz",
+ r###"
+ name: specific1
+
+ exclude_default_entries: true
+
+ matches:
+ - trigger: "hello"
+ replace: "newstring"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ }
+
+ #[test]
+ fn test_hidden_files_are_ignored() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ":lol"
+ replace: "LOL"
+ - trigger: ":yess"
+ replace: "Bob"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ ".specific.yml",
+ r###"
+ name: specific1
+
+ exclude_default_entries: true
+
+ matches:
+ - trigger: "hello"
+ replace: "newstring"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ }
+
+ #[test]
+ fn test_config_set_no_parent_configs_works_correctly() {
+ let (data_dir, package_dir) = create_temp_espanso_directories();
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ name: specific2
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 2);
+ }
+
+ #[test]
+ fn test_config_set_default_parent_works_correctly() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ parent: default
+
+ matches:
+ - trigger: "hello"
+ replace: "world"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.matches.len(), 2);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hello"));
+ }
+
+ #[test]
+ fn test_config_set_no_parent_should_not_merge() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ matches:
+ - trigger: "hello"
+ replace: "world"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 1);
+ assert_eq!(config_set.default.matches.len(), 1);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(!config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hello"));
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hello"));
+ }
+
+ #[test]
+ fn test_config_set_default_nested_parent_works_correctly() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: custom1
+ parent: default
+
+ matches:
+ - trigger: "hello"
+ replace: "world"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ parent: custom1
+
+ matches:
+ - trigger: "super"
+ replace: "mario"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.matches.len(), 3);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hello"));
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "super"));
+ }
+
+ #[test]
+ fn test_config_set_parent_merge_children_priority_should_be_higher() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ parent: default
+
+ matches:
+ - trigger: "hasta"
+ replace: "world"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.matches.len(), 1);
+ assert!(config_set.default.matches.iter().any(|m| {
+ triggers_for_match(m)[0] == "hasta" && replace_for_match(m) == "world"
+ }));
+ }
+
+ #[test]
+ fn test_config_set_package_configs_default_merge() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_package_file(
+ package_dir.path(),
+ "package1",
+ "package.yml",
+ r###"
+ parent: default
+
+ matches:
+ - trigger: "harry"
+ replace: "potter"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.matches.len(), 2);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "harry"));
+ }
+
+ #[test]
+ fn test_config_set_package_configs_lower_priority_than_user() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_package_file(
+ package_dir.path(),
+ "package1",
+ "package.yml",
+ r###"
+ parent: default
+
+ matches:
+ - trigger: "hasta"
+ replace: "potter"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.matches.len(), 1);
+ assert_eq!(triggers_for_match(&config_set.default.matches[0])[0], "hasta");
+ assert_eq!(replace_for_match(&config_set.default.matches[0]), "Hasta la vista");
+ }
+
+ #[test]
+ fn test_config_set_package_configs_without_merge() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_package_file(
+ package_dir.path(),
+ "package1",
+ "package.yml",
+ r###"
+ matches:
+ - trigger: "harry"
+ replace: "potter"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 1);
+ assert_eq!(config_set.default.matches.len(), 1);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "harry"));
+ }
+
+ #[test]
+ fn test_config_set_package_configs_multiple_files() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: hasta
+ replace: Hasta la vista
+ "###,
+ );
+
+ create_package_file(
+ package_dir.path(),
+ "package1",
+ "package.yml",
+ r###"
+ name: package1
+
+ matches:
+ - trigger: "harry"
+ replace: "potter"
+ "###,
+ );
+
+ create_package_file(
+ package_dir.path(),
+ "package1",
+ "addon.yml",
+ r###"
+ parent: package1
+
+ matches:
+ - trigger: "ron"
+ replace: "weasley"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 1);
+ assert_eq!(config_set.default.matches.len(), 1);
+ assert!(config_set
+ .default
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "hasta"));
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "harry"));
+ assert!(config_set.specific[0]
+ .matches
+ .iter()
+ .any(|m| triggers_for_match(m)[0] == "ron"));
+ }
+
+ #[test]
+ fn test_list_has_conflict_no_conflict() {
+ assert_eq!(
+ ConfigSet::list_has_conflicts(&vec!(":ab".to_owned(), ":bc".to_owned())),
+ false
+ );
+ }
+
+ #[test]
+ fn test_list_has_conflict_conflict() {
+ let mut list = vec!["ac".to_owned(), "ab".to_owned(), "abc".to_owned()];
+ list.sort();
+ assert_eq!(ConfigSet::list_has_conflicts(&list), true);
+ }
+
+ #[test]
+ fn test_has_conflict_no_conflict() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ac
+ replace: Hasta la vista
+ - trigger: bc
+ replace: Jon
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: "hello"
+ replace: "world"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(
+ ConfigSet::has_conflicts(&config_set.default, &config_set.specific),
+ false
+ );
+ }
+
+ #[test]
+ fn test_has_conflict_conflict_in_default() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ac
+ replace: Hasta la vista
+ - trigger: bc
+ replace: Jon
+ - trigger: acb
+ replace: Error
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: "hello"
+ replace: "world"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(
+ ConfigSet::has_conflicts(&config_set.default, &config_set.specific),
+ true
+ );
+ }
+
+ #[test]
+ fn test_has_conflict_conflict_in_specific_and_default() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ac
+ replace: Hasta la vista
+ - trigger: bc
+ replace: Jon
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: "bcd"
+ replace: "Conflict"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(
+ ConfigSet::has_conflicts(&config_set.default, &config_set.specific),
+ true
+ );
+ }
+
+ #[test]
+ fn test_has_conflict_no_conflict_in_specific_and_specific() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ matches:
+ - trigger: ac
+ replace: Hasta la vista
+ - trigger: bc
+ replace: Jon
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ name: specific1
+
+ matches:
+ - trigger: "bad"
+ replace: "Conflict"
+ "###,
+ );
+ create_user_config_file(
+ data_dir.path(),
+ "specific2.yml",
+ r###"
+ name: specific2
+
+ matches:
+ - trigger: "badass"
+ replace: "Conflict"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(
+ ConfigSet::has_conflicts(&config_set.default, &config_set.specific),
+ false
+ );
+ }
+
+ #[test]
+ fn test_config_set_specific_inherits_default_global_vars() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ global_vars:
+ - name: testvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ global_vars:
+ - name: specificvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 1);
+ assert_eq!(config_set.default.global_vars.len(), 1);
+ assert_eq!(config_set.specific[0].global_vars.len(), 2);
+ assert!(config_set.specific[0]
+ .global_vars
+ .iter()
+ .any(|m| name_for_global_var(m) == "testvar"));
+ assert!(config_set.specific[0]
+ .global_vars
+ .iter()
+ .any(|m| name_for_global_var(m) == "specificvar"));
+ }
+
+ #[test]
+ fn test_config_set_default_get_variables_from_specific() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ global_vars:
+ - name: testvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ parent: default
+ global_vars:
+ - name: specificvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 0);
+ assert_eq!(config_set.default.global_vars.len(), 2);
+ assert!(config_set
+ .default
+ .global_vars
+ .iter()
+ .any(|m| name_for_global_var(m) == "testvar"));
+ assert!(config_set
+ .default
+ .global_vars
+ .iter()
+ .any(|m| name_for_global_var(m) == "specificvar"));
+ }
+
+ #[test]
+ fn test_config_set_specific_dont_inherits_default_global_vars_when_exclude_is_on() {
+ let (data_dir, package_dir) = create_temp_espanso_directories_with_default_content(
+ r###"
+ global_vars:
+ - name: testvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ create_user_config_file(
+ data_dir.path(),
+ "specific.yml",
+ r###"
+ exclude_default_entries: true
+
+ global_vars:
+ - name: specificvar
+ type: date
+ params:
+ format: "%m"
+ "###,
+ );
+
+ let config_set = ConfigSet::load(data_dir.path(), package_dir.path()).unwrap();
+ assert_eq!(config_set.specific.len(), 1);
+ assert_eq!(config_set.default.global_vars.len(), 1);
+ assert_eq!(config_set.specific[0].global_vars.len(), 1);
+ assert!(config_set.specific[0]
+ .global_vars
+ .iter()
+ .any(|m| name_for_global_var(m) == "specificvar"));
+ }
+}
diff --git a/espanso-config/src/legacy/mod.rs b/espanso-config/src/legacy/mod.rs
new file mode 100644
index 0000000..31db2f7
--- /dev/null
+++ b/espanso-config/src/legacy/mod.rs
@@ -0,0 +1,32 @@
+/*
+ * 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 std::path::Path;
+use anyhow::Result;
+
+use crate::{config::ConfigStore, matches::store::MatchStore};
+
+mod config;
+mod model;
+
+pub fn load(base_dir: &Path) -> Result<(Box, Box)> {
+ // TODO: load legacy config set and then convert it to the new format
+
+ todo!()
+}
\ No newline at end of file
diff --git a/espanso-config/src/legacy/model.rs b/espanso-config/src/legacy/model.rs
new file mode 100644
index 0000000..f95ed31
--- /dev/null
+++ b/espanso-config/src/legacy/model.rs
@@ -0,0 +1,61 @@
+/*
+ * 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 serde::{Deserialize, Serialize};
+
+#[allow(non_camel_case_types)]
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum KeyModifier {
+ CTRL,
+ SHIFT,
+ ALT,
+ META,
+ BACKSPACE,
+ OFF,
+
+ // These are specific variants of the ones above. See issue: #117
+ // https://github.com/federico-terzi/espanso/issues/117
+ LEFT_CTRL,
+ RIGHT_CTRL,
+ LEFT_ALT,
+ RIGHT_ALT,
+ LEFT_META,
+ RIGHT_META,
+ LEFT_SHIFT,
+ RIGHT_SHIFT,
+
+ // Special cases, should not be used in config
+ CAPS_LOCK,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub enum PasteShortcut {
+ Default, // Default one for the current system
+ CtrlV, // Classic Ctrl+V shortcut
+ CtrlShiftV, // Could be used to paste without formatting in many applications
+ ShiftInsert, // Often used in Linux systems
+ CtrlAltV, // Used in some Linux terminals (urxvt)
+ MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS
+}
+
+impl Default for PasteShortcut {
+ fn default() -> Self {
+ PasteShortcut::Default
+ }
+}
diff --git a/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml b/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml
new file mode 100644
index 0000000..b5d6822
--- /dev/null
+++ b/espanso-config/src/legacy/res/test/config_with_bad_yaml.yml
@@ -0,0 +1,12 @@
+backend: Clipboard
+
+definitely a bad yaml
+
+matches:
+ # Default
+ - trigger: ":espanso"
+ replace: "Hi there!"
+
+ # Emojis
+ - trigger: ":lol"
+ replace: "😂"
\ No newline at end of file
diff --git a/espanso-config/src/legacy/res/test/default.yml b/espanso-config/src/legacy/res/test/default.yml
new file mode 100644
index 0000000..7ad3e30
--- /dev/null
+++ b/espanso-config/src/legacy/res/test/default.yml
@@ -0,0 +1,30 @@
+# espanso configuration file
+
+# This is the default configuration file, change it as you like it
+# You can refer to the official documentation:
+# https://espanso.org/docs/
+
+# Matches are the substitution rules, when you type the "trigger" string
+# it gets replaced by the "replace" string.
+matches:
+ # Simple text replacement
+ - trigger: ":espanso"
+ replace: "Hi there!"
+
+ # Dates
+ - trigger: ":date"
+ replace: "{{mydate}}"
+ vars:
+ - name: mydate
+ type: date
+ params:
+ format: "%m/%d/%Y"
+
+ # Shell commands
+ - trigger: ":shell"
+ replace: "{{output}}"
+ vars:
+ - name: output
+ type: shell
+ params:
+ cmd: "echo Hello from your shell"
\ No newline at end of file
diff --git a/espanso-config/src/legacy/res/test/working_config.yml b/espanso-config/src/legacy/res/test/working_config.yml
new file mode 100644
index 0000000..bcf8bf6
--- /dev/null
+++ b/espanso-config/src/legacy/res/test/working_config.yml
@@ -0,0 +1,10 @@
+backend: Clipboard
+
+matches:
+ # Default
+ - trigger: ":espanso"
+ replace: "Hi there!"
+
+ # Emojis
+ - trigger: ":lol"
+ replace: "😂"
\ No newline at end of file
diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs
index 33e19de..e049134 100644
--- a/espanso-config/src/lib.rs
+++ b/espanso-config/src/lib.rs
@@ -28,6 +28,7 @@ extern crate lazy_static;
pub mod config;
mod counter;
+mod legacy;
pub mod matches;
mod util;
@@ -40,12 +41,15 @@ pub fn load(base_path: &Path) -> Result<(impl ConfigStore, impl MatchStore)> {
let config_store = config::load_store(&config_dir)?;
let root_paths = config_store.get_all_match_paths();
- let mut match_store = matches::store::new();
- match_store.load(&root_paths.into_iter().collect::>());
+ let match_store = matches::store::new(&root_paths.into_iter().collect::>());
Ok((config_store, match_store))
}
+pub fn is_legacy_config(base_dir: &Path) -> bool {
+ !base_dir.join("config").is_dir() && !base_dir.join("match").is_dir()
+}
+
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("missing config directory")]
diff --git a/espanso-config/src/matches/store/default.rs b/espanso-config/src/matches/store/default.rs
index 2be0eb1..8b3ea6a 100644
--- a/espanso-config/src/matches/store/default.rs
+++ b/espanso-config/src/matches/store/default.rs
@@ -34,21 +34,21 @@ pub(crate) struct DefaultMatchStore {
}
impl DefaultMatchStore {
- pub fn new() -> Self {
+ pub fn new(paths: &[String]) -> Self {
+ let mut groups = HashMap::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);
+
Self {
- groups: HashMap::new(),
+ groups,
}
}
}
impl MatchStore for DefaultMatchStore {
- fn load(&mut self, paths: &[String]) {
- // 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 self.groups, paths);
- }
-
fn query(&self, paths: &[String]) -> MatchSet {
let mut matches: Vec<&Match> = Vec::new();
let mut global_vars: Vec<&Variable> = Vec::new();
@@ -223,9 +223,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[base_file.to_string_lossy().to_string()]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]);
assert_eq!(match_store.groups.len(), 3);
@@ -305,9 +303,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[base_file.to_string_lossy().to_string()]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]);
assert_eq!(match_store.groups.len(), 3);
});
@@ -368,9 +364,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[base_file.to_string_lossy().to_string()]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]);
let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]);
@@ -457,9 +451,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[base_file.to_string_lossy().to_string()]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]);
let match_set = match_store.query(&[base_file.to_string_lossy().to_string()]);
@@ -540,12 +532,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[
- base_file.to_string_lossy().to_string(),
- sub_file.to_string_lossy().to_string(),
- ]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string(), sub_file.to_string_lossy().to_string()]);
let match_set = match_store.query(&[
base_file.to_string_lossy().to_string(),
@@ -632,9 +619,7 @@ mod tests {
)
.unwrap();
- let mut match_store = DefaultMatchStore::new();
-
- match_store.load(&[base_file.to_string_lossy().to_string()]);
+ let match_store = DefaultMatchStore::new(&[base_file.to_string_lossy().to_string()]);
let match_set = match_store.query(&[
base_file.to_string_lossy().to_string(),
diff --git a/espanso-config/src/matches/store/mod.rs b/espanso-config/src/matches/store/mod.rs
index 2872160..edeee9e 100644
--- a/espanso-config/src/matches/store/mod.rs
+++ b/espanso-config/src/matches/store/mod.rs
@@ -22,7 +22,6 @@ use super::{Match, Variable};
mod default;
pub trait MatchStore {
- fn load(&mut self, paths: &[String]);
fn query(&self, paths: &[String]) -> MatchSet;
}
@@ -32,8 +31,8 @@ pub struct MatchSet<'a> {
pub global_vars: Vec<&'a Variable>,
}
-pub fn new() -> impl MatchStore {
+pub fn new(paths: &[String]) -> impl MatchStore {
// TODO: here we can replace the DefaultMatchStore with a caching wrapper
// that returns the same response for the given "paths" query
- default::DefaultMatchStore::new()
+ default::DefaultMatchStore::new(paths)
}
diff --git a/espanso-config/src/util.rs b/espanso-config/src/util.rs
index 2c1bf80..2335274 100644
--- a/espanso-config/src/util.rs
+++ b/espanso-config/src/util.rs
@@ -23,7 +23,7 @@
pub fn is_yaml_empty(yaml: &str) -> bool {
for line in yaml.lines() {
let trimmed_line = line.trim();
- if !trimmed_line.starts_with("#") && !trimmed_line.is_empty() {
+ if !trimmed_line.starts_with('#') && !trimmed_line.is_empty() {
return false;
}
}