644 lines
16 KiB
Rust
644 lines
16 KiB
Rust
/*
|
|
* This file is part of espanso.
|
|
*
|
|
* C title: (), class: (), exec: ()opyright (C) 2019-2021 Federico Terzi
|
|
*
|
|
* espanso is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* espanso is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
use anyhow::Result;
|
|
use log::warn;
|
|
use regex::Regex;
|
|
use std::{collections::HashMap, path::Path, sync::Arc};
|
|
|
|
use self::config::LegacyConfig;
|
|
use crate::matches::{MatchEffect, group::loader::yaml::{parse::{YAMLMatch, YAMLVariable}, try_convert_into_match, try_convert_into_variable}};
|
|
use crate::{config::store::DefaultConfigStore, counter::StructId};
|
|
use crate::{
|
|
config::Config,
|
|
config::{AppProperties, ConfigStore},
|
|
counter::next_id,
|
|
matches::{
|
|
store::{MatchSet, MatchStore},
|
|
Match, Variable,
|
|
},
|
|
};
|
|
use std::convert::TryInto;
|
|
|
|
mod config;
|
|
mod model;
|
|
|
|
pub fn load(
|
|
base_dir: &Path,
|
|
package_dir: &Path,
|
|
) -> Result<(Box<dyn ConfigStore>, Box<dyn MatchStore>)> {
|
|
let config_set = config::LegacyConfigSet::load(base_dir, package_dir)?;
|
|
|
|
let mut match_deduplicate_map = HashMap::new();
|
|
let mut var_deduplicate_map = HashMap::new();
|
|
|
|
let (default_config, mut default_match_group) = split_config(config_set.default);
|
|
deduplicate_ids(
|
|
&mut default_match_group,
|
|
&mut match_deduplicate_map,
|
|
&mut var_deduplicate_map,
|
|
);
|
|
|
|
let mut match_groups = HashMap::new();
|
|
match_groups.insert("default".to_string(), default_match_group);
|
|
|
|
let mut custom_configs: Vec<Arc<dyn Config>> = Vec::new();
|
|
for custom in config_set.specific {
|
|
let (custom_config, mut custom_match_group) = split_config(custom);
|
|
deduplicate_ids(
|
|
&mut custom_match_group,
|
|
&mut match_deduplicate_map,
|
|
&mut var_deduplicate_map,
|
|
);
|
|
|
|
match_groups.insert(custom_config.name.clone(), custom_match_group);
|
|
custom_configs.push(Arc::new(custom_config));
|
|
}
|
|
|
|
let config_store = DefaultConfigStore::from_configs(Arc::new(default_config), custom_configs)?;
|
|
let match_store = LegacyMatchStore::new(match_groups);
|
|
|
|
Ok((Box::new(config_store), Box::new(match_store)))
|
|
}
|
|
|
|
fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup) {
|
|
let global_vars = config
|
|
.global_vars
|
|
.iter()
|
|
.filter_map(|var| {
|
|
let var: YAMLVariable = serde_yaml::from_value(var.clone()).ok()?;
|
|
let (var, warnings) = try_convert_into_variable(var).ok()?;
|
|
warnings.into_iter().for_each(|warning| {
|
|
warn!("{}", warning);
|
|
});
|
|
Some(var)
|
|
})
|
|
.collect();
|
|
|
|
let matches = config
|
|
.matches
|
|
.iter()
|
|
.filter_map(|var| {
|
|
let m: YAMLMatch = serde_yaml::from_value(var.clone()).ok()?;
|
|
let (m, warnings) = try_convert_into_match(m).ok()?;
|
|
warnings.into_iter().for_each(|warning| {
|
|
warn!("{}", warning);
|
|
});
|
|
Some(m)
|
|
})
|
|
.collect();
|
|
|
|
let match_group = LegacyMatchGroup {
|
|
global_vars,
|
|
matches,
|
|
};
|
|
|
|
let config: LegacyInteropConfig = config.into();
|
|
(config, match_group)
|
|
}
|
|
|
|
/// Due to the way the legacy configs are loaded (matches are copied multiple times in the various configs)
|
|
/// we need to deduplicate the ids of those matches (and global vars).
|
|
fn deduplicate_ids(
|
|
match_group: &mut LegacyMatchGroup,
|
|
match_map: &mut HashMap<Match, StructId>,
|
|
var_map: &mut HashMap<Variable, StructId>,
|
|
) {
|
|
deduplicate_vars(&mut match_group.global_vars, var_map);
|
|
deduplicate_matches(&mut match_group.matches, match_map, var_map);
|
|
}
|
|
|
|
fn deduplicate_matches(
|
|
matches: &mut [Match],
|
|
match_map: &mut HashMap<Match, StructId>,
|
|
var_map: &mut HashMap<Variable, StructId>,
|
|
) {
|
|
for m in matches.iter_mut() {
|
|
// Deduplicate variables first
|
|
if let MatchEffect::Text(effect) = &mut m.effect {
|
|
deduplicate_vars(&mut effect.vars, var_map);
|
|
}
|
|
|
|
let mut m_without_id = m.clone();
|
|
m_without_id.id = 0;
|
|
if let Some(id) = match_map.get(&m_without_id) {
|
|
m.id = *id;
|
|
} else {
|
|
match_map.insert(m_without_id, m.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: test case of matches with inner variables
|
|
fn deduplicate_vars(vars: &mut [Variable], var_map: &mut HashMap<Variable, StructId>) {
|
|
for v in vars.iter_mut() {
|
|
let mut v_without_id = v.clone();
|
|
v_without_id.id = 0;
|
|
if let Some(id) = var_map.get(&v_without_id) {
|
|
v.id = *id;
|
|
} else {
|
|
var_map.insert(v_without_id, v.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LegacyInteropConfig {
|
|
pub name: String,
|
|
match_paths: Vec<String>,
|
|
|
|
id: i32,
|
|
|
|
config: LegacyConfig,
|
|
|
|
filter_title: Option<Regex>,
|
|
filter_class: Option<Regex>,
|
|
filter_exec: Option<Regex>,
|
|
}
|
|
|
|
impl From<config::LegacyConfig> for LegacyInteropConfig {
|
|
fn from(config: config::LegacyConfig) -> Self {
|
|
Self {
|
|
id: next_id(),
|
|
config: config.clone(),
|
|
name: config.name.clone(),
|
|
match_paths: vec![config.name],
|
|
filter_title: if !config.filter_title.is_empty() {
|
|
Regex::new(&config.filter_title).ok()
|
|
} else {
|
|
None
|
|
},
|
|
filter_class: if !config.filter_class.is_empty() {
|
|
Regex::new(&config.filter_class).ok()
|
|
} else {
|
|
None
|
|
},
|
|
filter_exec: if !config.filter_exec.is_empty() {
|
|
Regex::new(&config.filter_exec).ok()
|
|
} else {
|
|
None
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config for LegacyInteropConfig {
|
|
fn id(&self) -> i32 {
|
|
self.id
|
|
}
|
|
|
|
fn label(&self) -> &str {
|
|
&self.config.name
|
|
}
|
|
|
|
fn backend(&self) -> crate::config::Backend {
|
|
match self.config.backend {
|
|
config::BackendType::Inject => crate::config::Backend::Inject,
|
|
config::BackendType::Clipboard => crate::config::Backend::Clipboard,
|
|
config::BackendType::Auto => crate::config::Backend::Auto,
|
|
}
|
|
}
|
|
|
|
fn auto_restart(&self) -> bool {
|
|
self.config.auto_restart
|
|
}
|
|
|
|
fn match_paths(&self) -> &[String] {
|
|
&self.match_paths
|
|
}
|
|
|
|
fn is_match(&self, app: &AppProperties) -> bool {
|
|
if self.filter_title.is_none() && self.filter_exec.is_none() && self.filter_class.is_none() {
|
|
return false;
|
|
}
|
|
|
|
let is_title_match = if let Some(title_regex) = self.filter_title.as_ref() {
|
|
if let Some(title) = app.title {
|
|
title_regex.is_match(title)
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
let is_exec_match = if let Some(exec_regex) = self.filter_exec.as_ref() {
|
|
if let Some(exec) = app.exec {
|
|
exec_regex.is_match(exec)
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
let is_class_match = if let Some(class_regex) = self.filter_class.as_ref() {
|
|
if let Some(class) = app.class {
|
|
class_regex.is_match(class)
|
|
} else {
|
|
false
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
// All the filters that have been specified must be true to define a match
|
|
is_exec_match && is_title_match && is_class_match
|
|
}
|
|
|
|
fn clipboard_threshold(&self) -> usize {
|
|
crate::config::default::DEFAULT_CLIPBOARD_THRESHOLD
|
|
}
|
|
|
|
fn pre_paste_delay(&self) -> usize {
|
|
crate::config::default::DEFAULT_PRE_PASTE_DELAY
|
|
}
|
|
|
|
fn toggle_key(&self) -> Option<crate::config::ToggleKey> {
|
|
match self.config.toggle_key {
|
|
model::KeyModifier::CTRL => Some(crate::config::ToggleKey::Ctrl),
|
|
model::KeyModifier::SHIFT => Some(crate::config::ToggleKey::Shift),
|
|
model::KeyModifier::ALT => Some(crate::config::ToggleKey::Alt),
|
|
model::KeyModifier::META => Some(crate::config::ToggleKey::Meta),
|
|
model::KeyModifier::BACKSPACE => None,
|
|
model::KeyModifier::OFF => None,
|
|
model::KeyModifier::LEFT_CTRL => Some(crate::config::ToggleKey::LeftCtrl),
|
|
model::KeyModifier::RIGHT_CTRL => Some(crate::config::ToggleKey::RightCtrl),
|
|
model::KeyModifier::LEFT_ALT => Some(crate::config::ToggleKey::LeftAlt),
|
|
model::KeyModifier::RIGHT_ALT => Some(crate::config::ToggleKey::RightAlt),
|
|
model::KeyModifier::LEFT_META => Some(crate::config::ToggleKey::LeftMeta),
|
|
model::KeyModifier::RIGHT_META => Some(crate::config::ToggleKey::RightMeta),
|
|
model::KeyModifier::LEFT_SHIFT => Some(crate::config::ToggleKey::LeftShift),
|
|
model::KeyModifier::RIGHT_SHIFT => Some(crate::config::ToggleKey::RightShift),
|
|
model::KeyModifier::CAPS_LOCK => None,
|
|
}
|
|
}
|
|
|
|
fn preserve_clipboard(&self) -> bool {
|
|
self.config.preserve_clipboard
|
|
}
|
|
|
|
fn restore_clipboard_delay(&self) -> usize {
|
|
self.config.restore_clipboard_delay.try_into().unwrap()
|
|
}
|
|
|
|
fn paste_shortcut_event_delay(&self) -> usize {
|
|
crate::config::default::DEFAULT_SHORTCUT_EVENT_DELAY
|
|
}
|
|
|
|
fn paste_shortcut(&self) -> Option<String> {
|
|
match self.config.paste_shortcut {
|
|
model::PasteShortcut::Default => None,
|
|
model::PasteShortcut::CtrlV => Some("CTRL+V".to_string()),
|
|
model::PasteShortcut::CtrlShiftV => Some("CTRL+SHIFT+V".to_string()),
|
|
model::PasteShortcut::ShiftInsert => Some("SHIFT+INSERT".to_string()),
|
|
model::PasteShortcut::CtrlAltV => Some("CTRL+ALT+V".to_string()),
|
|
model::PasteShortcut::MetaV => Some("META+V".to_string()),
|
|
}
|
|
}
|
|
|
|
fn disable_x11_fast_inject(&self) -> bool {
|
|
self.config.fast_inject
|
|
}
|
|
|
|
fn inject_delay(&self) -> Option<usize> {
|
|
if self.config.inject_delay == 0 {
|
|
None
|
|
} else {
|
|
Some(self.config.inject_delay.try_into().unwrap())
|
|
}
|
|
}
|
|
|
|
fn key_delay(&self) -> Option<usize> {
|
|
if self.config.backspace_delay == 0 {
|
|
None
|
|
} else {
|
|
Some(self.config.backspace_delay.try_into().unwrap())
|
|
}
|
|
}
|
|
|
|
fn word_separators(&self) -> Vec<String> {
|
|
self.config.word_separators.iter().map(|c| String::from(*c)).collect()
|
|
}
|
|
|
|
fn backspace_limit(&self) -> usize {
|
|
self.config.backspace_limit.try_into().unwrap()
|
|
}
|
|
|
|
fn apply_patch(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
struct LegacyMatchGroup {
|
|
matches: Vec<Match>,
|
|
global_vars: Vec<Variable>,
|
|
}
|
|
|
|
struct LegacyMatchStore {
|
|
groups: HashMap<String, LegacyMatchGroup>,
|
|
}
|
|
|
|
impl LegacyMatchStore {
|
|
pub fn new(groups: HashMap<String, LegacyMatchGroup>) -> Self {
|
|
Self { groups }
|
|
}
|
|
}
|
|
|
|
impl MatchStore for LegacyMatchStore {
|
|
fn query(&self, paths: &[String]) -> MatchSet {
|
|
let group = if !paths.is_empty() {
|
|
if let Some(group) = self.groups.get(&paths[0]) {
|
|
Some(group)
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(group) = group {
|
|
MatchSet {
|
|
matches: group.matches.iter().collect(),
|
|
global_vars: group.global_vars.iter().collect(),
|
|
}
|
|
} else {
|
|
MatchSet {
|
|
matches: Vec::new(),
|
|
global_vars: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn loaded_paths(&self) -> Vec<String> {
|
|
self.groups.keys().map(|key| key.clone()).collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::{fs::create_dir_all, path::Path};
|
|
use tempdir::TempDir;
|
|
|
|
pub fn use_test_directory(callback: impl FnOnce(&Path, &Path, &Path)) {
|
|
let dir = TempDir::new("tempconfig").unwrap();
|
|
let user_dir = dir.path().join("user");
|
|
create_dir_all(&user_dir).unwrap();
|
|
|
|
let package_dir = TempDir::new("tempconfig").unwrap();
|
|
|
|
callback(
|
|
&dunce::canonicalize(&dir.path()).unwrap(),
|
|
&dunce::canonicalize(&user_dir).unwrap(),
|
|
&dunce::canonicalize(&package_dir.path()).unwrap(),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn load_legacy_works_correctly() {
|
|
use_test_directory(|base, user, packages| {
|
|
std::fs::write(
|
|
base.join("default.yml"),
|
|
r#"
|
|
backend: Clipboard
|
|
|
|
global_vars:
|
|
- name: var1
|
|
type: test
|
|
|
|
matches:
|
|
- trigger: "hello"
|
|
replace: "world"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
user.join("specific.yml"),
|
|
r#"
|
|
name: specific
|
|
parent: default
|
|
|
|
matches:
|
|
- trigger: "foo"
|
|
replace: "bar"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
user.join("separate.yml"),
|
|
r#"
|
|
name: separate
|
|
filter_title: "Google"
|
|
|
|
matches:
|
|
- trigger: "eren"
|
|
replace: "mikasa"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let (config_store, match_store) = load(base, packages).unwrap();
|
|
|
|
let default_config = config_store.default();
|
|
assert_eq!(default_config.match_paths().len(), 1);
|
|
|
|
let active_config = config_store.active(&AppProperties {
|
|
title: Some("Google"),
|
|
class: None,
|
|
exec: None,
|
|
});
|
|
assert_eq!(active_config.match_paths().len(), 1);
|
|
|
|
let default_fallback = config_store.active(&AppProperties {
|
|
title: Some("Yahoo"),
|
|
class: None,
|
|
exec: None,
|
|
});
|
|
assert_eq!(default_fallback.match_paths().len(), 1);
|
|
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_config.match_paths())
|
|
.matches
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_config.match_paths())
|
|
.global_vars
|
|
.len(),
|
|
1
|
|
);
|
|
|
|
assert_eq!(
|
|
match_store.query(active_config.match_paths()).matches.len(),
|
|
3
|
|
);
|
|
assert_eq!(
|
|
match_store
|
|
.query(active_config.match_paths())
|
|
.global_vars
|
|
.len(),
|
|
1
|
|
);
|
|
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_fallback.match_paths())
|
|
.matches
|
|
.len(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_fallback.match_paths())
|
|
.global_vars
|
|
.len(),
|
|
1
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn load_legacy_deduplicates_ids_correctly() {
|
|
use_test_directory(|base, user, packages| {
|
|
std::fs::write(
|
|
base.join("default.yml"),
|
|
r#"
|
|
backend: Clipboard
|
|
|
|
global_vars:
|
|
- name: var1
|
|
type: test
|
|
|
|
matches:
|
|
- trigger: "hello"
|
|
replace: "world"
|
|
|
|
- trigger: "withvars"
|
|
replace: "{{output}}"
|
|
vars:
|
|
- name: "output"
|
|
type: "echo"
|
|
params:
|
|
echo: "test"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
std::fs::write(
|
|
user.join("specific.yml"),
|
|
r#"
|
|
name: specific
|
|
filter_title: "Google"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let (config_store, match_store) = load(base, packages).unwrap();
|
|
|
|
let default_config = config_store.default();
|
|
let active_config = config_store.active(&AppProperties {
|
|
title: Some("Google"),
|
|
class: None,
|
|
exec: None,
|
|
});
|
|
|
|
for (i, m) in match_store
|
|
.query(default_config.match_paths())
|
|
.matches
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
assert_eq!(
|
|
m.id,
|
|
match_store
|
|
.query(active_config.match_paths())
|
|
.matches
|
|
.get(i)
|
|
.unwrap()
|
|
.id
|
|
);
|
|
}
|
|
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_config.match_paths())
|
|
.global_vars
|
|
.first()
|
|
.unwrap()
|
|
.id,
|
|
match_store
|
|
.query(active_config.match_paths())
|
|
.global_vars
|
|
.first()
|
|
.unwrap()
|
|
.id,
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn load_legacy_with_packages() {
|
|
use_test_directory(|base, _, packages| {
|
|
std::fs::write(
|
|
base.join("default.yml"),
|
|
r#"
|
|
backend: Clipboard
|
|
|
|
matches:
|
|
- trigger: "hello"
|
|
replace: "world"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
create_dir_all(packages.join("test-package")).unwrap();
|
|
std::fs::write(
|
|
packages.join("test-package").join("package.yml"),
|
|
r#"
|
|
name: test-package
|
|
parent: default
|
|
|
|
matches:
|
|
- trigger: "foo"
|
|
replace: "bar"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let (config_store, match_store) = load(base, packages).unwrap();
|
|
|
|
let default_config = config_store.default();
|
|
assert_eq!(default_config.match_paths().len(), 1);
|
|
|
|
assert_eq!(
|
|
match_store
|
|
.query(default_config.match_paths())
|
|
.matches
|
|
.len(),
|
|
2
|
|
);
|
|
});
|
|
}
|
|
}
|