From 73557b6af8c6a8b18897e8f7cee271096ce50742 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 2 Mar 2020 21:43:26 +0100 Subject: [PATCH] Refactor matches to support multiple triggers. Fix #144 --- src/config/mod.rs | 60 +++++++++++----------- src/engine.rs | 13 ++--- src/matcher/mod.rs | 105 +++++++++++++++++++++++++++++---------- src/matcher/scrolling.rs | 54 +++++++++++--------- src/render/default.rs | 57 +++++++++++++++++---- src/render/mod.rs | 2 +- 6 files changed, 197 insertions(+), 94 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index b1f8ea0..816dd49 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -263,10 +263,10 @@ impl Configs { let mut merged_matches = new_config.matches; let mut match_trigger_set = HashSet::new(); merged_matches.iter().for_each(|m| { - match_trigger_set.insert(m.trigger.clone()); + match_trigger_set.extend(m.triggers.clone()); }); let parent_matches : Vec = self.matches.iter().filter(|&m| { - !match_trigger_set.contains(&m.trigger) + !m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger)) }).cloned().collect(); merged_matches.extend(parent_matches); @@ -290,10 +290,10 @@ impl Configs { // Merge matches let mut match_trigger_set = HashSet::new(); self.matches.iter().for_each(|m| { - match_trigger_set.insert(m.trigger.clone()); + match_trigger_set.extend(m.triggers.clone()); }); let default_matches : Vec = default.matches.iter().filter(|&m| { - !match_trigger_set.contains(&m.trigger) + !m.triggers.iter().any(|trigger| match_trigger_set.contains(trigger)) }).cloned().collect(); self.matches.extend(default_matches); @@ -475,16 +475,16 @@ impl ConfigSet { } fn has_conflicts(default: &Configs, specific: &Vec) -> bool { - let mut sorted_triggers : Vec = default.matches.iter().map(|t| { - t.trigger.clone() + let mut sorted_triggers : Vec = default.matches.iter().flat_map(|t| { + t.triggers.clone() }).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().map(|t| { - t.trigger.clone() + let mut specific_triggers : Vec = s.matches.iter().flat_map(|t| { + t.triggers.clone() }).collect(); specific_triggers.sort(); has_conflicts |= Self::list_has_conflicts(&specific_triggers); @@ -841,9 +841,9 @@ mod tests { 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| x.trigger == "hello").is_some()); - assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":lol").is_some()); - assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":yess").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == "hello").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":lol").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":yess").is_some()); } #[test] @@ -870,12 +870,12 @@ mod tests { assert!(config_set.specific[0].matches.iter().find(|x| { if let MatchContentType::Text(content) = &x.content { - x.trigger == ":lol" && content.replace == "newstring" + x.triggers[0] == ":lol" && content.replace == "newstring" }else{ false } }).is_some()); - assert!(config_set.specific[0].matches.iter().find(|x| x.trigger == ":yess").is_some()); + assert!(config_set.specific[0].matches.iter().find(|x| x.triggers[0] == ":yess").is_some()); } #[test] @@ -904,7 +904,7 @@ mod tests { assert!(config_set.specific[0].matches.iter().find(|x| { if let MatchContentType::Text(content) = &x.content { - x.trigger == "hello" && content.replace == "newstring" + x.triggers[0] == "hello" && content.replace == "newstring" }else{ false } @@ -972,8 +972,8 @@ mod tests { 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| m.trigger == "hasta")); - assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello")); } #[test] @@ -993,9 +993,9 @@ mod tests { 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| m.trigger == "hasta")); - assert!(!config_set.default.matches.iter().any(|m| m.trigger == "hello")); - assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "hello")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(!config_set.default.matches.iter().any(|m| m.triggers[0] == "hello")); + assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "hello")); } #[test] @@ -1026,9 +1026,9 @@ mod tests { 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| m.trigger == "hasta")); - assert!(config_set.default.matches.iter().any(|m| m.trigger == "hello")); - assert!(config_set.default.matches.iter().any(|m| m.trigger == "super")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hello")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "super")); } #[test] @@ -1052,7 +1052,7 @@ mod tests { assert_eq!(config_set.default.matches.len(), 1); assert!(config_set.default.matches.iter().any(|m| { if let MatchContentType::Text(content) = &m.content { - m.trigger == "hasta" && content.replace == "world" + m.triggers[0] == "hasta" && content.replace == "world" }else{ false } @@ -1078,8 +1078,8 @@ mod tests { 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| m.trigger == "hasta")); - assert!(config_set.default.matches.iter().any(|m| m.trigger == "harry")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "harry")); } #[test] @@ -1099,8 +1099,8 @@ mod tests { 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| m.trigger == "hasta")); - assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry")); } #[test] @@ -1130,9 +1130,9 @@ mod tests { 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| m.trigger == "hasta")); - assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "harry")); - assert!(config_set.specific[0].matches.iter().any(|m| m.trigger == "ron")); + assert!(config_set.default.matches.iter().any(|m| m.triggers[0] == "hasta")); + assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "harry")); + assert!(config_set.specific[0].matches.iter().any(|m| m.triggers[0] == "ron")); } #[test] diff --git a/src/engine.rs b/src/engine.rs index ce3faca..bb3a5ed 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -22,7 +22,7 @@ use crate::keyboard::KeyboardManager; use crate::config::ConfigManager; use crate::config::BackendType; use crate::clipboard::ClipboardManager; -use log::{info, warn, error}; +use log::{info, warn, debug, error}; use crate::ui::{UIManager, MenuItem, MenuItemType}; use crate::event::{ActionEventReceiver, ActionType}; use crate::extension::Extension; @@ -132,7 +132,7 @@ lazy_static! { impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIManager, R: Renderer> MatchReceiver for Engine<'a, S, C, M, U, R>{ - fn on_match(&self, m: &Match, trailing_separator: Option) { + fn on_match(&self, m: &Match, trailing_separator: Option, trigger_offset: usize) { let config = self.config_manager.active_config(); if !config.enable_active { @@ -141,20 +141,21 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa // avoid espanso reinterpreting its own actions if self.check_last_action_and_set(self.action_noop_interval) { + debug!("Last action was too near, nooping the action."); return; } let char_count = if trailing_separator.is_none() { - m.trigger.chars().count() as i32 + m.triggers[trigger_offset].chars().count() as i32 }else{ - m.trigger.chars().count() as i32 + 1 // Count also the separator + m.triggers[trigger_offset].chars().count() as i32 + 1 // Count also the separator }; self.keyboard_manager.delete_string(char_count); let mut previous_clipboard_content : Option = None; - let rendered = self.renderer.render_match(m, config, vec![]); + let rendered = self.renderer.render_match(m, trigger_offset, config, vec![]); match rendered { RenderResult::Text(mut target_string) => { @@ -233,7 +234,7 @@ impl <'a, S: KeyboardManager, C: ClipboardManager, M: ConfigManager<'a>, U: UIMa self.keyboard_manager.trigger_paste(&config.paste_shortcut); }, RenderResult::Error => { - error!("Could not render match: {}", m.trigger); + error!("Could not render match: {}", m.triggers[trigger_offset]); }, } diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 1c8916b..d511bd4 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -29,14 +29,14 @@ pub(crate) mod scrolling; #[derive(Debug, Serialize, Clone)] pub struct Match { - pub trigger: String, + pub triggers: Vec, pub content: MatchContentType, pub word: bool, pub passive_only: bool, - // Automatically calculated from the trigger, used by the matcher to check for correspondences. + // Automatically calculated from the triggers, used by the matcher to check for correspondences. #[serde(skip_serializing)] - pub _trigger_sequence: Vec, + pub _trigger_sequences: Vec>, } #[derive(Debug, Serialize, Clone)] @@ -74,17 +74,28 @@ impl<'a> From<&'a AutoMatch> for Match{ static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap(); }; - // TODO: may need to replace windows newline (\r\n) with newline only (\n) + let 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) + }; + + let trigger_sequences = triggers.iter().map(|trigger| { + // Calculate the trigger sequence + let mut trigger_sequence = Vec::new(); + let trigger_chars : Vec = 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(); - // Calculate the trigger sequence - let mut trigger_sequence = Vec::new(); - let trigger_chars : Vec = other.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); - } let content = if let Some(replace) = &other.replace { // Text match let new_replace = replace.clone(); @@ -132,11 +143,11 @@ impl<'a> From<&'a AutoMatch> for Match{ }; Self { - trigger: other.trigger.clone(), + triggers, content, word: other.word, passive_only: other.passive_only, - _trigger_sequence: trigger_sequence, + _trigger_sequences: trigger_sequences, } } } @@ -144,8 +155,12 @@ impl<'a> From<&'a AutoMatch> for Match{ /// 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, + #[serde(default = "default_replace")] pub replace: Option, @@ -162,6 +177,8 @@ struct AutoMatch { pub passive_only: bool, } +fn default_trigger() -> String {"".to_owned()} +fn default_triggers() -> Vec {Vec::new()} fn default_vars() -> Vec {Vec::new()} fn default_word() -> bool {false} fn default_passive_only() -> bool {false} @@ -185,7 +202,7 @@ pub enum TriggerEntry { } pub trait MatchReceiver { - fn on_match(&self, m: &Match, trailing_separator: Option); + fn on_match(&self, m: &Match, trailing_separator: Option, trigger_offset: usize); fn on_enable_update(&self, status: bool); fn on_passive(&self); } @@ -281,10 +298,10 @@ mod tests { let _match : Match = serde_yaml::from_str(match_str).unwrap(); - assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); - assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); - assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); - assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); + 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] @@ -297,11 +314,11 @@ mod tests { let _match : Match = serde_yaml::from_str(match_str).unwrap(); - assert_eq!(_match._trigger_sequence[0], TriggerEntry::Char('t')); - assert_eq!(_match._trigger_sequence[1], TriggerEntry::Char('e')); - assert_eq!(_match._trigger_sequence[2], TriggerEntry::Char('s')); - assert_eq!(_match._trigger_sequence[3], TriggerEntry::Char('t')); - assert_eq!(_match._trigger_sequence[4], TriggerEntry::WordSeparator); + 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] @@ -322,4 +339,42 @@ mod tests { }, } } + + #[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"]) + } } \ No newline at end of file diff --git a/src/matcher/scrolling.rs b/src/matcher/scrolling.rs index ce1f951..9b72b07 100644 --- a/src/matcher/scrolling.rs +++ b/src/matcher/scrolling.rs @@ -39,6 +39,7 @@ pub struct ScrollingMatcher<'a, R: MatchReceiver, M: ConfigManager<'a>> { struct MatchEntry<'a> { start: usize, count: usize, + trigger_offset: usize, // The index of the trigger in the Match that matched _match: &'a Match } @@ -73,8 +74,8 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> ScrollingMatcher<'a, R, M> { self.receiver.on_enable_update(*is_enabled); } - fn is_matching(mtc: &Match, current_char: &str, start: usize, is_current_word_separator: bool) -> bool { - match mtc._trigger_sequence[start] { + fn is_matching(mtc: &Match, current_char: &str, start: usize, trigger_offset: usize, is_current_word_separator: bool) -> bool { + match mtc._trigger_sequences[trigger_offset][start] { TriggerEntry::Char(c) => { current_char.starts_with(c) }, @@ -112,38 +113,43 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa let mut current_set_queue = self.current_set_queue.borrow_mut(); - let new_matches: Vec = active_config.matches.iter() - .filter(|&x| { - // only active-enabled matches are considered - if x.passive_only { - return false; - } + let mut new_matches: Vec = Vec::new(); - let mut result = Self::is_matching(x, c, 0, is_current_word_separator); + for m in active_config.matches.iter() { + // only active-enabled matches are considered + if m.passive_only { + continue + } - if x.word { + for trigger_offset in 0..m._trigger_sequences.len() { + let mut result = Self::is_matching(m, c, 0, trigger_offset, is_current_word_separator); + + if m.word { result = result && *was_previous_word_separator } - result - }) - .map(|x | MatchEntry{ - start: 1, - count: x._trigger_sequence.len(), - _match: &x - }) - .collect(); + if result { + new_matches.push(MatchEntry{ + start: 1, + count: m._trigger_sequences[trigger_offset].len(), + trigger_offset, + _match: &m + }); + } + } + } // TODO: use an associative structure to improve the efficiency of this first "new_matches" lookup. let combined_matches: Vec = match current_set_queue.back_mut() { Some(last_matches) => { let mut updated: Vec = last_matches.iter() .filter(|&x| { - Self::is_matching(x._match, c, x.start, is_current_word_separator) + Self::is_matching(x._match, c, x.start, x.trigger_offset, is_current_word_separator) }) .map(|x | MatchEntry{ start: x.start+1, count: x.count, + trigger_offset: x.trigger_offset, _match: &x._match }) .collect(); @@ -154,11 +160,11 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa None => {new_matches}, }; - let mut found_match = None; + let mut found_entry = None; for entry in combined_matches.iter() { if entry.start == entry.count { - found_match = Some(entry._match); + found_entry = Some(entry.clone()); break; } } @@ -171,7 +177,9 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa *was_previous_word_separator = is_current_word_separator; - if let Some(mtc) = found_match { + if let Some(entry) = found_entry { + let mtc = entry._match; + if let Some(last) = current_set_queue.back_mut() { last.clear(); } @@ -194,7 +202,7 @@ impl <'a, R: MatchReceiver, M: ConfigManager<'a>> super::Matcher for ScrollingMa // Force espanso to consider the last char as a separator *was_previous_word_separator = true; - self.receiver.on_match(mtc, trailing_separator); + self.receiver.on_match(mtc, trailing_separator, entry.trigger_offset); } } diff --git a/src/render/default.rs b/src/render/default.rs index 55fa5cc..f8f3802 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -59,15 +59,18 @@ impl DefaultRenderer { } } - fn find_match(config: &Configs, trigger: &str) -> Option { + fn find_match(config: &Configs, trigger: &str) -> Option<(Match, usize)> { let mut result = None; // TODO: if performances become a problem, implement a more efficient lookup for m in config.matches.iter() { - if m.trigger == trigger { - result = Some(m.clone()); - break; + for (trigger_offset, m_trigger) in m.triggers.iter().enumerate() { + if m_trigger == trigger { + result = Some((m.clone(), trigger_offset)); + break; + } } + } result @@ -75,7 +78,7 @@ impl DefaultRenderer { } impl super::Renderer for DefaultRenderer { - fn render_match(&self, m: &Match, config: &Configs, args: Vec) -> RenderResult { + fn render_match(&self, m: &Match, trigger_offset: usize, config: &Configs, args: Vec) -> RenderResult { // Manage the different types of matches match &m.content { // Text Match @@ -104,11 +107,11 @@ impl super::Renderer for DefaultRenderer { continue } - let inner_match = inner_match.unwrap(); + let (inner_match, trigger_offset) = inner_match.unwrap(); // Render the inner match // TODO: inner arguments - let result = self.render_match(&inner_match, config, vec![]); + let result = self.render_match(&inner_match, trigger_offset, config, vec![]); // Inner matches are only supported for text-expansions, warn the user otherwise match result { @@ -155,6 +158,8 @@ impl super::Renderer for DefaultRenderer { // Render any argument that may be present let target_string = utils::render_args(&target_string, &args); + // TODO: add case affect expansion here + RenderResult::Text(target_string) }, @@ -202,9 +207,9 @@ impl super::Renderer for DefaultRenderer { config.passive_arg_delimiter, config.passive_arg_escape); - let m = m.unwrap(); + let (m, trigger_offset) = m.unwrap(); // Render the actual match - let result = self.render_match(&m, &config, args); + let result = self.render_match(&m, trigger_offset, &config, args); match result { RenderResult::Text(out) => { @@ -521,4 +526,38 @@ mod tests { verify_render(rendered, "this is my {{unknown}}"); } + + #[test] + fn test_render_passive_simple_match_multi_trigger_no_args() { + let text = "this is a :yolo and :test"; + + let config = get_config_for(r###" + matches: + - triggers: [':test', ':yolo'] + replace: result + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is a result and result"); + } + + #[test] + fn test_render_passive_simple_match_multi_trigger_with_args() { + let text = ":yolo/Jon/"; + + let config = get_config_for(r###" + matches: + - triggers: [':greet', ':yolo'] + replace: "Hi $0$" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "Hi Jon"); + } } \ No newline at end of file diff --git a/src/render/mod.rs b/src/render/mod.rs index 80bf645..b7a3946 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -26,7 +26,7 @@ pub(crate) mod utils; pub trait Renderer { // Render a match output - fn render_match(&self, m: &Match, config: &Configs, args: Vec) -> RenderResult; + fn render_match(&self, m: &Match, trigger_offset: usize, config: &Configs, args: Vec) -> RenderResult; // Render a passive expansion text fn render_passive(&self, text: &str, config: &Configs) -> RenderResult;