espanso/src/matcher/mod.rs
2020-08-15 19:35:14 +02:00

667 lines
20 KiB
Rust

/*
* This file is part of espans{ name: (), var_type: (), params: ()}
*
* 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 <https://www.gnu.org/licenses/>.
*/
use crate::event::KeyEventReceiver;
use crate::event::{KeyEvent, KeyModifier};
use regex::{Captures, Regex};
use serde::{Deserialize, Deserializer, Serialize};
use serde_yaml::{Mapping, Value};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
pub(crate) mod scrolling;
#[derive(Debug, Serialize, Clone)]
pub struct Match {
pub triggers: Vec<String>,
pub content: MatchContentType,
pub word: bool,
pub passive_only: bool,
pub propagate_case: bool,
pub force_clipboard: bool,
// Automatically calculated from the triggers, used by the matcher to check for correspondences.
#[serde(skip_serializing)]
pub _trigger_sequences: Vec<Vec<TriggerEntry>>,
}
#[derive(Debug, Serialize, Clone)]
pub enum MatchContentType {
Text(TextContent),
Image(ImageContent),
}
#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct TextContent {
pub replace: String,
pub vars: Vec<MatchVariable>,
#[serde(skip_serializing)]
pub _has_vars: bool,
}
#[derive(Debug, Serialize, Clone)]
pub struct ImageContent {
pub path: PathBuf,
}
impl<'de> serde::Deserialize<'de> for Match {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let auto_match = AutoMatch::deserialize(deserializer)?;
Ok(Match::from(&auto_match))
}
}
impl<'a> From<&'a AutoMatch> for Match {
fn from(other: &'a AutoMatch) -> Self {
lazy_static! {
static ref VAR_REGEX: Regex =
Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap();
};
let mut triggers = if !other.triggers.is_empty() {
other.triggers.clone()
} else if !other.trigger.is_empty() {
vec![other.trigger.clone()]
} else {
panic!("Match does not have any trigger defined: {:?}", other)
};
// If propagate_case is true, we need to generate all the possible triggers
// For example, specifying "hello" as a trigger, we need to have:
// "hello", "Hello", "HELLO"
if other.propagate_case {
// List with first letter capitalized
let first_capitalized: Vec<String> = triggers
.iter()
.map(|trigger| {
let capitalized = trigger.clone();
let mut v: Vec<char> = capitalized.chars().collect();
// Capitalize the first alphabetic letter
// See issue #244
let first_alphabetic = v.iter().position(|c| c.is_alphabetic()).unwrap_or(0);
v[first_alphabetic] = v[first_alphabetic].to_uppercase().nth(0).unwrap();
v.into_iter().collect()
})
.collect();
let all_capitalized: Vec<String> = triggers
.iter()
.map(|trigger| trigger.to_uppercase())
.collect();
triggers.extend(first_capitalized);
triggers.extend(all_capitalized);
}
let trigger_sequences = triggers
.iter()
.map(|trigger| {
// Calculate the trigger sequence
let mut trigger_sequence = Vec::new();
let trigger_chars: Vec<char> = trigger.chars().collect();
trigger_sequence.extend(trigger_chars.into_iter().map(|c| TriggerEntry::Char(c)));
if other.word {
// If it's a word match, end with a word separator
trigger_sequence.push(TriggerEntry::WordSeparator);
}
trigger_sequence
})
.collect();
let content = if let Some(replace) = &other.replace {
// Text match
let new_replace = replace.clone();
// Check if the match contains variables
let has_vars = VAR_REGEX.is_match(replace);
let content = TextContent {
replace: new_replace,
vars: other.vars.clone(),
_has_vars: has_vars,
};
MatchContentType::Text(content)
} else if let Some(form) = &other.form {
// Form shorthand
// Replace all the form fields with actual variables
let new_replace = VAR_REGEX.replace_all(&form, |caps: &Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{{{{form1.{}}}}}", var_name)
});
let new_replace = new_replace.to_string();
// Convert the form data to valid variables
let mut params = Mapping::new();
if let Some(fields) = &other.form_fields {
let mut mapping_fields = Mapping::new();
fields.iter().for_each(|(key, value)| {
mapping_fields.insert(Value::from(key.to_owned()), Value::from(value.clone()));
});
params.insert(Value::from("fields"), Value::from(mapping_fields));
}
params.insert(Value::from("layout"), Value::from(form.to_owned()));
let vars = vec![MatchVariable {
name: "form1".to_owned(),
var_type: "form".to_owned(),
params,
}];
let content = TextContent {
replace: new_replace,
vars,
_has_vars: true,
};
MatchContentType::Text(content)
} else if let Some(image_path) = &other.image_path {
// Image match
// On Windows, we have to replace the forward / with the backslash \ in the path
let new_path = if cfg!(target_os = "windows") {
image_path.replace("/", "\\")
} else {
image_path.to_owned()
};
// Calculate variables in path
let new_path = if new_path.contains("$CONFIG") {
let config_dir = crate::context::get_config_dir();
let config_path = fs::canonicalize(&config_dir);
let config_path = if let Ok(config_path) = config_path {
config_path.to_string_lossy().into_owned()
} else {
"".to_owned()
};
new_path.replace("$CONFIG", &config_path)
} else {
new_path.to_owned()
};
let content = ImageContent {
path: PathBuf::from(new_path),
};
MatchContentType::Image(content)
} else {
eprintln!("ERROR: no action specified for match {}, please specify either 'replace', 'image_path' or 'form'", other.trigger);
std::process::exit(2);
};
Self {
triggers,
content,
word: other.word,
passive_only: other.passive_only,
_trigger_sequences: trigger_sequences,
propagate_case: other.propagate_case,
force_clipboard: other.force_clipboard,
}
}
}
/// Used to deserialize the Match struct before applying some custom elaboration.
#[derive(Debug, Serialize, Deserialize, Clone)]
struct AutoMatch {
#[serde(default = "default_trigger")]
pub trigger: String,
#[serde(default = "default_triggers")]
pub triggers: Vec<String>,
#[serde(default = "default_replace")]
pub replace: Option<String>,
#[serde(default = "default_image_path")]
pub image_path: Option<String>,
#[serde(default = "default_form")]
pub form: Option<String>,
#[serde(default = "default_form_fields")]
pub form_fields: Option<HashMap<String, Value>>,
#[serde(default = "default_vars")]
pub vars: Vec<MatchVariable>,
#[serde(default = "default_word")]
pub word: bool,
#[serde(default = "default_passive_only")]
pub passive_only: bool,
#[serde(default = "default_propagate_case")]
pub propagate_case: bool,
#[serde(default = "default_force_clipboard")]
pub force_clipboard: bool,
}
fn default_trigger() -> String {
"".to_owned()
}
fn default_triggers() -> Vec<String> {
Vec::new()
}
fn default_vars() -> Vec<MatchVariable> {
Vec::new()
}
fn default_word() -> bool {
false
}
fn default_passive_only() -> bool {
false
}
fn default_replace() -> Option<String> {
None
}
fn default_form() -> Option<String> {
None
}
fn default_form_fields() -> Option<HashMap<String, Value>> {
None
}
fn default_image_path() -> Option<String> {
None
}
fn default_propagate_case() -> bool {
false
}
fn default_force_clipboard() -> bool {
false
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub struct MatchVariable {
pub name: String,
#[serde(rename = "type")]
pub var_type: String,
#[serde(default = "default_params")]
pub params: Mapping,
}
fn default_params() -> Mapping {
Mapping::new()
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
pub enum TriggerEntry {
Char(char),
WordSeparator,
}
pub trait MatchReceiver {
fn on_match(&self, m: &Match, trailing_separator: Option<char>, trigger_offset: usize);
fn on_enable_update(&self, status: bool);
fn on_passive(&self);
fn on_undo(&self);
}
pub trait Matcher: KeyEventReceiver {
fn handle_char(&self, c: &str);
fn handle_modifier(&self, m: KeyModifier);
fn handle_other(&self);
}
impl<M: Matcher> KeyEventReceiver for M {
fn on_key_event(&self, e: KeyEvent) {
match e {
KeyEvent::Char(c) => {
self.handle_char(&c);
}
KeyEvent::Modifier(m) => {
self.handle_modifier(m);
}
KeyEvent::Other => {
self.handle_other();
}
}
}
}
// TESTS
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_match_has_vars_should_be_false() {
let match_str = r###"
trigger: ":test"
replace: "There are no variables"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Text(content) => {
assert_eq!(content._has_vars, false);
}
_ => {
assert!(false);
}
}
}
#[test]
fn test_match_has_vars_should_be_true() {
let match_str = r###"
trigger: ":test"
replace: "There are {{one}} and {{two}} variables"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Text(content) => {
assert_eq!(content._has_vars, true);
}
_ => {
assert!(false);
}
}
}
#[test]
fn test_match_has_vars_with_spaces_should_be_true() {
let match_str = r###"
trigger: ":test"
replace: "There is {{ one }} variable"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Text(content) => {
assert_eq!(content._has_vars, true);
}
_ => {
assert!(false);
}
}
}
#[test]
fn test_match_trigger_sequence_without_word() {
let match_str = r###"
trigger: "test"
replace: "This is a test"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
}
#[test]
fn test_match_trigger_sequence_with_word() {
let match_str = r###"
trigger: "test"
replace: "This is a test"
word: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][4], TriggerEntry::WordSeparator);
}
#[test]
fn test_match_with_image_content() {
let match_str = r###"
trigger: "test"
image_path: "/path/to/file"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Image(content) => {
assert_eq!(content.path, PathBuf::from("/path/to/file"));
}
_ => {
assert!(false);
}
}
}
#[test]
fn test_match_trigger_populates_triggers_vector() {
let match_str = r###"
trigger: ":test"
replace: "This is a test"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test"])
}
#[test]
fn test_match_triggers_are_correctly_parsed() {
let match_str = r###"
triggers:
- ":test1"
- :test2
replace: "This is a test"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test1", ":test2"])
}
#[test]
fn test_match_triggers_are_correctly_parsed_square_brackets() {
let match_str = r###"
triggers: [":test1", ":test2"]
replace: "This is a test"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":test1", ":test2"])
}
#[test]
fn test_match_propagate_case() {
let match_str = r###"
trigger: "hello"
replace: "This is a test"
propagate_case: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec!["hello", "Hello", "HELLO"])
}
#[test]
fn test_match_propagate_case_multi_trigger() {
let match_str = r###"
triggers: ["hello", "hi"]
replace: "This is a test"
propagate_case: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(
_match.triggers,
vec!["hello", "hi", "Hello", "Hi", "HELLO", "HI"]
)
}
#[test]
fn test_match_trigger_sequence_with_word_propagate_case() {
let match_str = r###"
trigger: "test"
replace: "This is a test"
word: true
propagate_case: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match._trigger_sequences[0][0], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[0][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[0][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[0][4], TriggerEntry::WordSeparator);
assert_eq!(_match._trigger_sequences[1][0], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[1][1], TriggerEntry::Char('e'));
assert_eq!(_match._trigger_sequences[1][2], TriggerEntry::Char('s'));
assert_eq!(_match._trigger_sequences[1][3], TriggerEntry::Char('t'));
assert_eq!(_match._trigger_sequences[1][4], TriggerEntry::WordSeparator);
assert_eq!(_match._trigger_sequences[2][0], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[2][1], TriggerEntry::Char('E'));
assert_eq!(_match._trigger_sequences[2][2], TriggerEntry::Char('S'));
assert_eq!(_match._trigger_sequences[2][3], TriggerEntry::Char('T'));
assert_eq!(_match._trigger_sequences[2][4], TriggerEntry::WordSeparator);
}
#[test]
fn test_match_empty_replace_doesnt_crash() {
let match_str = r###"
trigger: "hello"
replace: ""
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
}
#[test]
fn test_match_propagate_case_with_prefix_symbol() {
let match_str = r###"
trigger: ":hello"
replace: "This is a test"
propagate_case: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":hello", ":Hello", ":HELLO"])
}
#[test]
fn test_match_propagate_case_non_alphabetic_should_not_crash() {
let match_str = r###"
trigger: ":.."
replace: "This is a test"
propagate_case: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
assert_eq!(_match.triggers, vec![":..", ":..", ":.."])
}
#[test]
fn test_match_form_translated_correctly() {
let match_str = r###"
trigger: ":test"
form: "Hey {{name}}, how are you? {{greet}}"
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Text(content) => {
let mut mapping = Mapping::new();
mapping.insert(
Value::from("layout"),
Value::from("Hey {{name}}, how are you? {{greet}}"),
);
assert_eq!(
content,
TextContent {
replace: "Hey {{form1.name}}, how are you? {{form1.greet}}".to_owned(),
_has_vars: true,
vars: vec![MatchVariable {
name: "form1".to_owned(),
var_type: "form".to_owned(),
params: mapping,
}]
}
);
}
_ => panic!("wrong content"),
}
}
#[test]
fn test_match_form_with_fields_translated_correctly() {
let match_str = r###"
trigger: ":test"
form: "Hey {{name}}, how are you? {{greet}}"
form_fields:
name:
multiline: true
"###;
let _match: Match = serde_yaml::from_str(match_str).unwrap();
match _match.content {
MatchContentType::Text(content) => {
let mut name_mapping = Mapping::new();
name_mapping.insert(Value::from("multiline"), Value::Bool(true));
let mut submapping = Mapping::new();
submapping.insert(Value::from("name"), Value::from(name_mapping));
let mut mapping = Mapping::new();
mapping.insert(Value::from("fields"), Value::from(submapping));
mapping.insert(
Value::from("layout"),
Value::from("Hey {{name}}, how are you? {{greet}}"),
);
assert_eq!(
content,
TextContent {
replace: "Hey {{form1.name}}, how are you? {{form1.greet}}".to_owned(),
_has_vars: true,
vars: vec![MatchVariable {
name: "form1".to_owned(),
var_type: "form".to_owned(),
params: mapping,
}]
}
);
}
_ => panic!("wrong content"),
}
}
}