diff --git a/Cargo.lock b/Cargo.lock index 9352572..63b5c69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "dtoa" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" + [[package]] name = "either" version = "1.6.1" @@ -212,6 +218,7 @@ dependencies = [ name = "espanso" version = "1.0.0" dependencies = [ + "espanso-config", "espanso-detect", "espanso-inject", "espanso-ui", @@ -219,6 +226,18 @@ dependencies = [ "simplelog", ] +[[package]] +name = "espanso-config" +version = "0.1.0" +dependencies = [ + "anyhow", + "glob", + "log", + "serde", + "serde_yaml", + "thiserror", +] + [[package]] name = "espanso-detect" version = "0.1.0" @@ -292,6 +311,12 @@ dependencies = [ "wasi 0.9.0+wasi-snapshot-preview1", ] +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + [[package]] name = "heck" version = "0.3.2" @@ -343,6 +368,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + [[package]] name = "log" version = "0.4.14" @@ -588,6 +619,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23" +dependencies = [ + "dtoa", + "linked-hash-map", + "serde", + "yaml-rust", +] + [[package]] name = "simplelog" version = "0.9.0" @@ -792,3 +835,12 @@ checksum = "e1945e12e16b951721d7976520b0832496ef79c31602c7a29d950de79ba74621" dependencies = [ "bitflags", ] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] diff --git a/Cargo.toml b/Cargo.toml index 8484773..abb5918 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "espanso-ui", "espanso-inject", "espanso-ipc", + "espanso-config", ] \ No newline at end of file diff --git a/espanso-config/Cargo.toml b/espanso-config/Cargo.toml new file mode 100644 index 0000000..1179b83 --- /dev/null +++ b/espanso-config/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "espanso-config" +version = "0.1.0" +authors = ["Federico Terzi "] +edition = "2018" + +[dependencies] +log = "0.4.14" +anyhow = "1.0.38" +thiserror = "1.0.23" +serde = { version = "1.0.123", features = ["derive"] } +serde_yaml = "0.8.17" +glob = "0.3.0" \ No newline at end of file diff --git a/espanso-config/src/config/mod.rs b/espanso-config/src/config/mod.rs new file mode 100644 index 0000000..2f4f1b0 --- /dev/null +++ b/espanso-config/src/config/mod.rs @@ -0,0 +1,15 @@ +use std::collections::HashSet; +use anyhow::Result; + + +mod yaml; +mod path; + +pub struct Config { + pub label: Option, + //pub backend: + pub match_files: Vec, +} + +impl Config { +} diff --git a/espanso-config/src/config/path.rs b/espanso-config/src/config/path.rs new file mode 100644 index 0000000..ecdfdb1 --- /dev/null +++ b/espanso-config/src/config/path.rs @@ -0,0 +1,34 @@ +use std::collections::HashSet; + +use glob::glob; +use log::error; + +// TODO: test +pub fn calculate_paths<'a>(glob_patterns: impl Iterator) -> HashSet { + let mut path_set = HashSet::new(); + for glob_pattern in glob_patterns { + let entries = glob(glob_pattern); + match entries { + Ok(paths) => { + for path in paths { + match path { + Ok(path) => { + path_set.insert(path.to_string_lossy().to_string()); + } + Err(err) => { + error!("glob error when processing pattern: {}, with error: {}", glob_pattern, err) + } + } + } + } + Err(err) => { + error!( + "unable to calculate glob from pattern: {}, with error: {}", + glob_pattern, err + ); + } + } + } + + path_set +} diff --git a/espanso-config/src/config/yaml.rs b/espanso-config/src/config/yaml.rs new file mode 100644 index 0000000..8fad014 --- /dev/null +++ b/espanso-config/src/config/yaml.rs @@ -0,0 +1,89 @@ +use std::collections::HashSet; +use std::iter::FromIterator; + +use super::path::calculate_paths; + +const STANDARD_INCLUDES: &[&str] = &["match/**/*.yml"]; +const STANDARD_EXCLUDES: &[&str] = &["match/**/_*.yml"]; + +#[derive(Debug, Clone)] +pub struct YAMLConfig { + pub label: Option, + + pub includes: Option>, + + pub excludes: Option>, + + pub extra_includes: Option>, + + pub extra_excludes: Option>, + + pub use_standard_includes: bool, + + // Filters + + pub filter_title: Option, + pub filter_class: Option, + pub filter_exec: Option, + pub filter_os: Option, +} + +impl YAMLConfig { + // TODO test + pub fn aggregate_includes(&self) -> HashSet { + let mut includes = HashSet::new(); + + if self.use_standard_includes { + STANDARD_INCLUDES.iter().for_each(|include| { includes.insert(include.to_string()); }) + } + + if let Some(yaml_includes) = self.includes.as_ref() { + yaml_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) + } + + if let Some(extra_includes) = self.extra_includes.as_ref() { + extra_includes.iter().for_each(|include| { includes.insert(include.to_string()); }) + } + + includes + } + + // TODO test + pub fn aggregate_excludes(&self) -> HashSet { + let mut excludes = HashSet::new(); + + if self.use_standard_includes { + STANDARD_EXCLUDES.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + } + + if let Some(yaml_excludes) = self.excludes.as_ref() { + yaml_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + } + + if let Some(extra_excludes) = self.extra_excludes.as_ref() { + extra_excludes.iter().for_each(|exclude| { excludes.insert(exclude.to_string()); }) + } + + excludes + } +} + +// TODO: convert to TryFrom (check the matches section for an example) +impl From for super::Config { + // TODO: test + fn from(yaml_config: YAMLConfig) -> Self { + let includes = yaml_config.aggregate_includes(); + let excludes = yaml_config.aggregate_excludes(); + + // Extract the paths + let exclude_paths = calculate_paths(excludes.iter()); + let include_paths = calculate_paths(includes.iter()); + + let match_files: Vec = Vec::from_iter(include_paths.difference(&exclude_paths).cloned()); + + Self { + label: yaml_config.label, + match_files, + } + } +} diff --git a/espanso-config/src/lib.rs b/espanso-config/src/lib.rs new file mode 100644 index 0000000..f8037fc --- /dev/null +++ b/espanso-config/src/lib.rs @@ -0,0 +1,58 @@ +/* + * 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 . + */ + +mod config; +mod matches; + +use std::path::Path; +use anyhow::Result; +use serde::{Serialize, de::DeserializeOwned}; +use thiserror::Error; +use config::Config; + + +pub struct ConfigSet { + +} + +impl ConfigSet { + //fn active(&self, app: AppProperties) -> &'a Config { + // TODO: using the app properties, check if any of the sub configs match or not. If not, return the default + // Here a RegexSet might be very useful to efficiently match them. + //} + + //fn default(&self) -> &'a Config {} +} + +pub struct AppProperties<'a> { + pub title: Option<&'a str>, + pub class: Option<&'a str>, + pub exec: Option<&'a str>, +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn todo() { + + } +} \ No newline at end of file diff --git a/espanso-config/src/matches/mod.rs b/espanso-config/src/matches/mod.rs new file mode 100644 index 0000000..e92c295 --- /dev/null +++ b/espanso-config/src/matches/mod.rs @@ -0,0 +1,85 @@ +use serde_yaml::Mapping; + +mod yaml; + +#[derive(Debug, Clone, PartialEq)] +pub struct Match { + cause: MatchCause, + effect: MatchEffect, + + // Metadata + label: Option, +} + +impl Default for Match { + fn default() -> Self { + Self { + cause: MatchCause::None, + effect: MatchEffect::None, + label: None, + } + } +} + +// Causes + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchCause { + None, + Trigger(TriggerCause), + // TODO: regex + // TODO: shortcut +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TriggerCause { + pub triggers: Vec, + + pub left_word: bool, + pub right_word: bool, + + pub propagate_case: bool, +} + +impl Default for TriggerCause { + fn default() -> Self { + Self { + triggers: Vec::new(), + left_word: false, + right_word: false, + propagate_case: false, + } + } +} + +// Effects + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchEffect { + None, + Text(TextEffect), + // TODO: image + // TODO: rich text +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TextEffect { + pub replace: String, + pub vars: Vec, +} + +impl Default for TextEffect { + fn default() -> Self { + Self { + replace: String::new(), + vars: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Variable { + pub name: String, + pub var_type: String, + pub params: Mapping, +} diff --git a/espanso-config/src/matches/yaml.rs b/espanso-config/src/matches/yaml.rs new file mode 100644 index 0000000..ecad598 --- /dev/null +++ b/espanso-config/src/matches/yaml.rs @@ -0,0 +1,384 @@ +use std::{ + collections::HashMap, + convert::{TryFrom, TryInto}, +}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_yaml::{Mapping, Value}; +use thiserror::Error; + +use super::{MatchCause, MatchEffect, TextEffect, TriggerCause, Variable}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct YAMLMatch { + #[serde(default)] + pub trigger: Option, + + #[serde(default)] + pub triggers: Option>, + + #[serde(default)] + pub replace: Option, + + #[serde(default)] + pub image_path: Option, // TODO: map + + #[serde(default)] + pub form: Option, // TODO: map + + #[serde(default)] + pub form_fields: Option>, // TODO: map + + #[serde(default)] + pub vars: Option>, + + #[serde(default)] + pub word: Option, + + #[serde(default)] + pub left_word: Option, + + #[serde(default)] + pub right_word: Option, + + #[serde(default)] + pub propagate_case: Option, + + #[serde(default)] + pub force_clipboard: Option, + + #[serde(default)] + pub markdown: Option, // TODO: map + + #[serde(default)] + pub paragraph: Option, // TODO: map + + #[serde(default)] + pub html: Option, // TODO: map +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct YAMLVariable { + pub name: String, + + #[serde(rename = "type")] + pub var_type: String, + + #[serde(default = "default_params")] + pub params: Mapping, +} + +fn default_params() -> Mapping { + Mapping::new() +} + +impl TryFrom for super::Match { + type Error = anyhow::Error; + + // TODO: test + fn try_from(yaml_match: YAMLMatch) -> Result { + let triggers = if let Some(trigger) = yaml_match.trigger { + Some(vec![trigger]) + } else if let Some(triggers) = yaml_match.triggers { + Some(triggers) + } else { + None + }; + + let cause = if let Some(triggers) = triggers { + MatchCause::Trigger(TriggerCause { + triggers, + left_word: yaml_match + .left_word + .or(yaml_match.word) + .unwrap_or(TriggerCause::default().left_word), + right_word: yaml_match + .right_word + .or(yaml_match.word) + .unwrap_or(TriggerCause::default().right_word), + propagate_case: yaml_match + .propagate_case + .unwrap_or(TriggerCause::default().propagate_case), + }) + } else { + MatchCause::None + }; + + let effect = if let Some(replace) = yaml_match.replace { + let vars: Result> = yaml_match + .vars + .unwrap_or_default() + .into_iter() + .map(|var| var.try_into()) + .collect(); + MatchEffect::Text(TextEffect { + replace, + vars: vars?, + }) + } else { + MatchEffect::None + }; + + // TODO: log none match effect + + Ok(Self { + cause, + effect, + label: None, + }) + } +} + +impl TryFrom for super::Variable { + type Error = anyhow::Error; + + // TODO: test + fn try_from(yaml_var: YAMLVariable) -> Result { + Ok(Self { + name: yaml_var.name, + var_type: yaml_var.var_type, + params: yaml_var.params, + }) + } +} + +#[derive(Error, Debug)] +pub enum YAMLConversionError { + // TODO +//#[error("unknown data store error")] +//Unknown, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::matches::Match; + + fn create_match(yaml: &str) -> Result { + let yaml_match: YAMLMatch = serde_yaml::from_str(yaml)?; + let m: Match = yaml_match.try_into()?; + Ok(m) + } + + #[test] + fn basic_match_maps_correctly() { + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn multiple_triggers_maps_correctly() { + assert_eq!( + create_match( + r#" + triggers: ["Hello", "john"] + replace: "world" + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string(), "john".to_string()], + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn word_maps_correctly() { + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + word: true + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + left_word: true, + right_word: true, + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn left_word_maps_correctly() { + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + left_word: true + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + left_word: true, + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn right_word_maps_correctly() { + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + right_word: true + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + right_word: true, + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn propagate_case_maps_correctly() { + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + propagate_case: true + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + propagate_case: true, + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + ..Default::default() + }), + ..Default::default() + } + ) + } + + #[test] + fn vars_maps_correctly() { + let mut params = Mapping::new(); + params.insert(Value::String("param1".to_string()), Value::Bool(true)); + let vars = vec![Variable { + name: "var1".to_string(), + var_type: "test".to_string(), + params, + }]; + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + vars: + - name: var1 + type: test + params: + param1: true + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + vars, + }), + ..Default::default() + } + ) + } + + #[test] + fn vars_no_params_maps_correctly() { + let vars = vec![Variable { + name: "var1".to_string(), + var_type: "test".to_string(), + params: Mapping::new(), + }]; + assert_eq!( + create_match( + r#" + trigger: "Hello" + replace: "world" + vars: + - name: var1 + type: test + "# + ) + .unwrap(), + Match { + cause: MatchCause::Trigger(TriggerCause { + triggers: vec!["Hello".to_string()], + ..Default::default() + }), + effect: MatchEffect::Text(TextEffect { + replace: "world".to_string(), + vars, + }), + ..Default::default() + } + ) + } +} diff --git a/espanso-ipc/src/lib.rs b/espanso-ipc/src/lib.rs index 2d8099a..551709e 100644 --- a/espanso-ipc/src/lib.rs +++ b/espanso-ipc/src/lib.rs @@ -91,7 +91,10 @@ mod tests { server.accept_one().unwrap(); }); - std::thread::sleep(std::time::Duration::from_secs(1)); + if cfg!(target_os = "windows") { + std::thread::sleep(std::time::Duration::from_secs(1)); + } + let client = client::("testespansoipc", &std::env::temp_dir()).unwrap(); client.send(Event::Foo("hello".to_string())).unwrap(); diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml index ffb32cd..d4ccd10 100644 --- a/espanso/Cargo.toml +++ b/espanso/Cargo.toml @@ -17,5 +17,6 @@ wayland = ["espanso-detect/wayland", "espanso-inject/wayland"] espanso-detect = { path = "../espanso-detect" } espanso-ui = { path = "../espanso-ui" } espanso-inject = { path = "../espanso-inject" } +espanso-config = { path = "../espanso-config" } maplit = "1.0.2" simplelog = "0.9.0" \ No newline at end of file