Initial draft of config parsing

This commit is contained in:
Federico Terzi 2021-02-24 21:57:23 +01:00
parent 79a1b85769
commit e26a04de67
11 changed files with 736 additions and 1 deletions

52
Cargo.lock generated
View File

@ -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",
]

View File

@ -6,4 +6,5 @@ members = [
"espanso-ui",
"espanso-inject",
"espanso-ipc",
"espanso-config",
]

13
espanso-config/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "espanso-config"
version = "0.1.0"
authors = ["Federico Terzi <federico-terzi@users.noreply.github.com>"]
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"

View File

@ -0,0 +1,15 @@
use std::collections::HashSet;
use anyhow::Result;
mod yaml;
mod path;
pub struct Config {
pub label: Option<String>,
//pub backend:
pub match_files: Vec<String>,
}
impl Config {
}

View File

@ -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<Item = &'a String>) -> HashSet<String> {
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
}

View File

@ -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<String>,
pub includes: Option<Vec<String>>,
pub excludes: Option<Vec<String>>,
pub extra_includes: Option<Vec<String>>,
pub extra_excludes: Option<Vec<String>>,
pub use_standard_includes: bool,
// Filters
pub filter_title: Option<String>,
pub filter_class: Option<String>,
pub filter_exec: Option<String>,
pub filter_os: Option<String>,
}
impl YAMLConfig {
// TODO test
pub fn aggregate_includes(&self) -> HashSet<String> {
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<String> {
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<YAMLConfig> 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<String> = Vec::from_iter(include_paths.difference(&exclude_paths).cloned());
Self {
label: yaml_config.label,
match_files,
}
}
}

58
espanso-config/src/lib.rs Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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() {
}
}

View File

@ -0,0 +1,85 @@
use serde_yaml::Mapping;
mod yaml;
#[derive(Debug, Clone, PartialEq)]
pub struct Match {
cause: MatchCause,
effect: MatchEffect,
// Metadata
label: Option<String>,
}
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<String>,
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<Variable>,
}
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,
}

View File

@ -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<String>,
#[serde(default)]
pub triggers: Option<Vec<String>>,
#[serde(default)]
pub replace: Option<String>,
#[serde(default)]
pub image_path: Option<String>, // TODO: map
#[serde(default)]
pub form: Option<String>, // TODO: map
#[serde(default)]
pub form_fields: Option<HashMap<String, Value>>, // TODO: map
#[serde(default)]
pub vars: Option<Vec<YAMLVariable>>,
#[serde(default)]
pub word: Option<bool>,
#[serde(default)]
pub left_word: Option<bool>,
#[serde(default)]
pub right_word: Option<bool>,
#[serde(default)]
pub propagate_case: Option<bool>,
#[serde(default)]
pub force_clipboard: Option<bool>,
#[serde(default)]
pub markdown: Option<String>, // TODO: map
#[serde(default)]
pub paragraph: Option<bool>, // TODO: map
#[serde(default)]
pub html: Option<String>, // 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<YAMLMatch> for super::Match {
type Error = anyhow::Error;
// TODO: test
fn try_from(yaml_match: YAMLMatch) -> Result<Self, Self::Error> {
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<Vec<Variable>> = 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<YAMLVariable> for super::Variable {
type Error = anyhow::Error;
// TODO: test
fn try_from(yaml_var: YAMLVariable) -> Result<Self, Self::Error> {
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<Match> {
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()
}
)
}
}

View File

@ -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::<Event>("testespansoipc", &std::env::temp_dir()).unwrap();
client.send(Event::Foo("hello".to_string())).unwrap();

View File

@ -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"