From 73557b6af8c6a8b18897e8f7cee271096ce50742 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 2 Mar 2020 21:43:26 +0100 Subject: [PATCH 1/3] 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; From 0c37ccec061055e98ee422a251673abf30ff37c0 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 2 Mar 2020 22:03:56 +0100 Subject: [PATCH 2/3] Add propagate_case option to matches and related automatic trigger generation --- src/matcher/mod.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index d511bd4..0f2f191 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -33,6 +33,7 @@ pub struct Match { pub content: MatchContentType, pub word: bool, pub passive_only: bool, + pub propagate_case: bool, // Automatically calculated from the triggers, used by the matcher to check for correspondences. #[serde(skip_serializing)] @@ -74,7 +75,7 @@ impl<'a> From<&'a AutoMatch> for Match{ static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)\\s*\\}\\}").unwrap(); }; - let triggers = if !other.triggers.is_empty() { + let mut triggers = if !other.triggers.is_empty() { other.triggers.clone() }else if !other.trigger.is_empty() { vec!(other.trigger.clone()) @@ -82,6 +83,26 @@ impl<'a> From<&'a AutoMatch> for Match{ 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 = triggers.iter().map(|trigger| { + let mut capitalized = trigger.clone(); + let mut v: Vec = capitalized.chars().collect(); + v[0] = v[0].to_uppercase().nth(0).unwrap(); + v.into_iter().collect() + }).collect(); + + let all_capitalized : Vec = 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(); @@ -148,6 +169,7 @@ impl<'a> From<&'a AutoMatch> for Match{ word: other.word, passive_only: other.passive_only, _trigger_sequences: trigger_sequences, + propagate_case: other.propagate_case, } } } @@ -175,6 +197,9 @@ struct AutoMatch { #[serde(default = "default_passive_only")] pub passive_only: bool, + + #[serde(default = "default_propagate_case")] + pub propagate_case: bool, } fn default_trigger() -> String {"".to_owned()} @@ -184,6 +209,7 @@ fn default_word() -> bool {false} fn default_passive_only() -> bool {false} fn default_replace() -> Option {None} fn default_image_path() -> Option {None} +fn default_propagate_case() -> bool {false} #[derive(Debug, Serialize, Deserialize, Clone)] pub struct MatchVariable { @@ -377,4 +403,60 @@ mod tests { 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); + } } \ No newline at end of file From 4e6de02410196f32943f93a0678cee9b2f2c5727 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Mon, 2 Mar 2020 23:51:31 +0100 Subject: [PATCH 3/3] Implement case propagation. Fix #152 --- src/matcher/mod.rs | 10 +++++ src/render/default.rs | 101 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 0f2f191..3e9c294 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -459,4 +459,14 @@ mod tests { 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(); + } } \ No newline at end of file diff --git a/src/render/default.rs b/src/render/default.rs index f8f3802..adfed8e 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -158,7 +158,46 @@ 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 + // Handle case propagation + let target_string = if m.propagate_case { + let trigger = &m.triggers[trigger_offset]; + let first_char = trigger.chars().nth(0); + let second_char = trigger.chars().nth(1); + let mode: i32 = if let Some(first_char) = first_char { + if first_char.is_uppercase() { + if let Some(second_char) = second_char { + if second_char.is_uppercase() { + 2 // Full CAPITALIZATION + }else{ + 1 // Only first letter capitalized: Capitalization + } + }else{ + 2 // Single char, defaults to full CAPITALIZATION + } + }else{ + 0 // Lowercase, no action + } + }else{ + 0 + }; + + match mode { + 1 => { + // Capitalize the first letter + let mut v: Vec = target_string.chars().collect(); + v[0] = v[0].to_uppercase().nth(0).unwrap(); + v.into_iter().collect() + }, + 2 => { // Full capitalization + target_string.to_uppercase() + }, + _ => { // Noop + target_string + } + } + }else{ + target_string + }; RenderResult::Text(target_string) }, @@ -560,4 +599,64 @@ mod tests { verify_render(rendered, "Hi Jon"); } + + #[test] + fn test_render_match_case_propagation_no_case() { + let config = get_config_for(r###" + matches: + - trigger: 'test' + replace: result + propagate_case: true + "###); + + let renderer = get_renderer(config.clone()); + + let m = config.matches[0].clone(); + + let trigger_offset = m.triggers.iter().position(|x| x== "test").unwrap(); + + let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]); + + verify_render(rendered, "result"); + } + + #[test] + fn test_render_match_case_propagation_first_capital() { + let config = get_config_for(r###" + matches: + - trigger: 'test' + replace: result + propagate_case: true + "###); + + let renderer = get_renderer(config.clone()); + + let m = config.matches[0].clone(); + + let trigger_offset = m.triggers.iter().position(|x| x== "Test").unwrap(); + + let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]); + + verify_render(rendered, "Result"); + } + + #[test] + fn test_render_match_case_propagation_all_capital() { + let config = get_config_for(r###" + matches: + - trigger: 'test' + replace: result + propagate_case: true + "###); + + let renderer = get_renderer(config.clone()); + + let m = config.matches[0].clone(); + + let trigger_offset = m.triggers.iter().position(|x| x== "TEST").unwrap(); + + let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]); + + verify_render(rendered, "RESULT"); + } } \ No newline at end of file