diff --git a/.gitignore b/.gitignore index aea32ed..f4ff355 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ profile *.moved-aside DerivedData .idea/ + +*.snap + +venv/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ecf6760..357886f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "espanso" -version = "0.5.0" +version = "0.5.1" dependencies = [ "backtrace 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 6c34967..eae189e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "0.5.0" +version = "0.5.1" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" diff --git a/native/liblinuxbridge/bridge.cpp b/native/liblinuxbridge/bridge.cpp index 4fcdd58..f353eac 100644 --- a/native/liblinuxbridge/bridge.cpp +++ b/native/liblinuxbridge/bridge.cpp @@ -307,6 +307,10 @@ void trigger_alt_shift_ins_paste() { xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Shift+Alt+Insert", 8000); } +void trigger_ctrl_alt_paste() { + xdo_send_keysequence_window(xdo_context, CURRENTWINDOW, "Control_L+Alt+v", 8000); +} + void trigger_copy() { // Release the other keys, for an explanation, read the 'trigger_paste' method @@ -467,8 +471,8 @@ int32_t is_current_window_special() { if (res > 0) { if (strstr(class_buffer, "terminal") != NULL) { return 1; - }else if (strstr(class_buffer, "URxvt") != NULL) { // Manjaro terminal - return 1; + }else if (strstr(class_buffer, "URxvt") != NULL) { // urxvt terminal + return 4; }else if (strstr(class_buffer, "XTerm") != NULL) { // XTerm and UXTerm return 1; }else if (strstr(class_buffer, "Termite") != NULL) { // Termite diff --git a/native/liblinuxbridge/bridge.h b/native/liblinuxbridge/bridge.h index 2389cf8..f4b857a 100644 --- a/native/liblinuxbridge/bridge.h +++ b/native/liblinuxbridge/bridge.h @@ -92,6 +92,11 @@ extern "C" void trigger_shift_ins_paste(); */ extern "C" void trigger_alt_shift_ins_paste(); +/* + * Trigger CTRL+ALT+V pasting + */ +extern "C" void trigger_ctrl_alt_paste(); + /* * Trigger copy shortcut ( Pressing CTRL+C ) */ diff --git a/packager.py b/packager.py index 2cc55cd..b558e47 100644 --- a/packager.py +++ b/packager.py @@ -7,6 +7,7 @@ import click import shutil import toml import hashlib +import glob import urllib.request from dataclasses import dataclass @@ -77,9 +78,23 @@ def build_windows(package_info): TARGET_DIR = os.path.join(PACKAGER_TARGET_DIR, "win") os.makedirs(TARGET_DIR, exist_ok=True) - print("Downloading Visual C++ redistributable") - vc_redist_file = os.path.join(TARGET_DIR, "vc_redist.x64.exe") - urllib.request.urlretrieve("https://aka.ms/vs/16/release/vc_redist.x64.exe", vc_redist_file) + print("Gathering CRT DLLs...") + msvc_dirs = glob.glob("C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\*\\VC\\Redist\\MSVC\\*") + print("Found Redists: ", msvc_dirs) + + msvc_dir = msvc_dirs[0] + print("Using: ",msvc_dir) + if len(msvc_dir) == 0: + raise Exception("Cannot find redistributable dlls") + dll_files = glob.glob(msvc_dir + "\\x64\\*CRT\\*.dll") + + print("Found DLLs:") + dll_include_list = [] + for dll in dll_files: + print("Including: "+dll) + dll_include_list.append("Source: \""+dll+"\"; DestDir: \"{app}\"; Flags: ignoreversion") + + dll_include = "\r\n".join(dll_include_list) INSTALLER_NAME = f"espanso-win-installer" @@ -100,6 +115,7 @@ def build_windows(package_info): content = content.replace("{{{executable_path}}}", os.path.abspath("target/release/espanso.exe")) content = content.replace("{{{output_dir}}}", os.path.abspath(TARGET_DIR)) content = content.replace("{{{output_name}}}", INSTALLER_NAME) + content = content.replace("{{{dll_include}}}", dll_include) with open(os.path.join(TARGET_DIR, "setupscript.iss"), "w") as output_script: output_script.write(content) @@ -174,6 +190,7 @@ def build_mac(package_info): print("Done!") + if __name__ == '__main__': print("[[ espanso packager ]]") diff --git a/packager/win/setupscript.iss b/packager/win/setupscript.iss index 0e24a1b..b177a9d 100644 --- a/packager/win/setupscript.iss +++ b/packager/win/setupscript.iss @@ -30,7 +30,6 @@ Compression=lzma SolidCompression=yes WizardStyle=modern ChangesEnvironment=yes -AlwaysRestart = yes [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -38,7 +37,7 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Files] Source: "{{{executable_path}}}"; DestDir: "{app}"; Flags: ignoreversion Source: "{{{app_icon}}}"; DestDir: "{app}"; Flags: ignoreversion -Source: "vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall +{{{dll_include}}} ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] @@ -53,18 +52,13 @@ Name: "StartMenuEntry" ; Description: "Start espanso at Windows startup" ; const ModPathName = 'modifypath'; ModPathType = 'user'; - function ModPathDir(): TArrayOfString; begin setArrayLength(Result, 1) Result[0] := ExpandConstant('{app}'); end; #include "modpath.iss" - [Run] -Filename: {tmp}\vc_redist.x64.exe; \ - Parameters: "/install /quiet /norestart"; \ - StatusMsg: "Installing Visual C++ 2019 Redistributable"; Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent [UninstallRun] diff --git a/snapcraft.yaml b/snapcraft.yaml new file mode 100644 index 0000000..bd492b3 --- /dev/null +++ b/snapcraft.yaml @@ -0,0 +1,70 @@ +name: espanso +version: 0.5.1 +summary: A Cross-platform Text Expander written in Rust +description: | + espanso is a Cross-platform, Text Expander written in Rust. + + ## What is a Text Expander? + + A text expander is a program that detects when you type + a specific keyword and replaces it with something else. + This is useful in many ways: + * Save a lot of typing, expanding common sentences. + * Create system-wide code snippets. + * Execute custom scripts + * Use emojis like a pro. + ___ + + ## Key Features + + * Works on Windows, macOS and Linux + * Works with almost any program + * Works with Emojis 😄 + * Works with Images + * Date expansion support + * Custom scripts support + * Shell commands support + * App-specific configurations + * Expandable with packages + * Built-in package manager for espanso hub: https://hub.espanso.org/ + * File based configuration + + ## Get Started + + Visit the official documentation: https://espanso.org/docs/ + + ## Support + + If you need some help to setup espanso, want to ask a question or simply get involved + in the community, Join the official Subreddit: https://www.reddit.com/r/espanso/ + +confinement: classic +base: core18 + +parts: + espanso: + plugin: rust + source: . + build-packages: + - libssl-dev + - pkg-config + - cmake + - libxtst-dev + - libx11-dev + - libxdo-dev + stage-packages: + - libx11-6 + - libxau6 + - libxcb1 + - libxdmcp6 + - libxdo3 + - libxext6 + - libxinerama1 + - libxkbcommon0 + - libxtst6 + - libnotify-bin + - xclip + +apps: + espanso: + command: bin/espanso \ No newline at end of file diff --git a/src/bridge/linux.rs b/src/bridge/linux.rs index 967aa9d..08f65cc 100644 --- a/src/bridge/linux.rs +++ b/src/bridge/linux.rs @@ -44,5 +44,6 @@ extern { pub fn trigger_terminal_paste(); pub fn trigger_shift_ins_paste(); pub fn trigger_alt_shift_ins_paste(); + pub fn trigger_ctrl_alt_paste(); pub fn trigger_copy(); } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 9dec411..f841121 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -38,7 +38,7 @@ pub(crate) mod runtime; const DEFAULT_CONFIG_FILE_CONTENT : &str = include_str!("../res/config.yml"); pub const DEFAULT_CONFIG_FILE_NAME : &str = "default.yml"; -const USER_CONFIGS_FOLDER_NAME: &str = "user"; +pub const USER_CONFIGS_FOLDER_NAME: &str = "user"; // Default values for primitives fn default_name() -> String{ "default".to_owned() } @@ -54,7 +54,7 @@ fn default_config_caching_interval() -> i32 { 800 } fn default_word_separators() -> Vec { vec![' ', ',', '.', '\r', '\n', 22u8 as char] } fn default_toggle_interval() -> u32 { 230 } fn default_toggle_key() -> KeyModifier { KeyModifier::ALT } -fn default_preserve_clipboard() -> bool {false} +fn default_preserve_clipboard() -> bool {true} fn default_passive_match_regex() -> String{ "(?P:\\p{L}+)(/(?P.*)/)?".to_owned() } fn default_passive_arg_delimiter() -> char { '/' } fn default_passive_arg_escape() -> char { '\\' } @@ -70,7 +70,7 @@ fn default_global_vars() -> Vec { Vec::new() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Configs { -#[serde(default = "default_name")] + #[serde(default = "default_name")] pub name: String, #[serde(default = "default_parent")] @@ -253,10 +253,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); @@ -280,10 +280,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); @@ -465,16 +465,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); @@ -831,9 +831,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] @@ -860,12 +860,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] @@ -894,7 +894,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 } @@ -962,8 +962,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] @@ -983,9 +983,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] @@ -1016,9 +1016,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] @@ -1042,7 +1042,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 } @@ -1068,8 +1068,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] @@ -1089,8 +1089,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] @@ -1120,9 +1120,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/context/linux.rs b/src/context/linux.rs index 2595d1b..9bed592 100644 --- a/src/context/linux.rs +++ b/src/context/linux.rs @@ -23,7 +23,7 @@ use crate::event::*; use crate::event::KeyModifier::*; use crate::bridge::linux::*; use std::process::exit; -use log::{error, info}; +use log::{debug, error, info}; use std::ffi::CStr; use std::{thread, time}; @@ -97,7 +97,7 @@ extern fn keypress_callback(_self: *mut c_void, raw_buffer: *const u8, len: i32, (*_self).send_channel.send(event).unwrap(); }, Err(e) => { - error!("Unable to receive char: {}",e); + debug!("Unable to receive char: {}",e); }, } }else{ // Modifier event diff --git a/src/edit.rs b/src/edit.rs new file mode 100644 index 0000000..9290bfb --- /dev/null +++ b/src/edit.rs @@ -0,0 +1,63 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 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 . + */ + +use std::path::Path; + +#[cfg(target_os = "linux")] +fn default_editor() -> String{ "/bin/nano".to_owned() } +#[cfg(target_os = "macos")] +fn default_editor() -> String{ "/usr/bin/nano".to_owned() } +#[cfg(target_os = "windows")] +fn default_editor() -> String{ "C:\\Windows\\System32\\notepad.exe".to_owned() } + +pub fn open_editor(file_path: &Path) -> bool { + use std::process::Command; + + // Check if another editor is defined in the environment variables + let editor_var = std::env::var_os("EDITOR"); + let visual_var = std::env::var_os("VISUAL"); + + // Prioritize the editors specified by the environment variable, use the default one + let editor : String = if let Some(editor_var) = editor_var { + editor_var.to_string_lossy().to_string() + }else if let Some(visual_var) = visual_var { + visual_var.to_string_lossy().to_string() + }else{ + default_editor() + }; + + // Start the editor and wait for its termination + let status = Command::new(&editor) + .arg(file_path) + .spawn(); + + if let Ok(mut child) = status { + // Wait for the user to edit the configuration + let result = child.wait(); + + if let Ok(exit_status) = result { + exit_status.success() + }else{ + false + } + }else{ + println!("Error: could not start editor at: {}", &editor); + false + } +} \ No newline at end of file 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/extension/clipboard.rs b/src/extension/clipboard.rs new file mode 100644 index 0000000..778bbfe --- /dev/null +++ b/src/extension/clipboard.rs @@ -0,0 +1,43 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2020 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 . + */ + +use serde_yaml::{Mapping, Value}; +use crate::clipboard::ClipboardManager; + +pub struct ClipboardExtension { + clipboard_manager: Box, +} + +impl ClipboardExtension { + pub fn new(clipboard_manager: Box) -> ClipboardExtension { + ClipboardExtension{ + clipboard_manager + } + } +} + +impl super::Extension for ClipboardExtension { + fn name(&self) -> String { + String::from("clipboard") + } + + fn calculate(&self, params: &Mapping, _: &Vec) -> Option { + self.clipboard_manager.get_clipboard() + } +} \ No newline at end of file diff --git a/src/extension/mod.rs b/src/extension/mod.rs index 047060d..9db489a 100644 --- a/src/extension/mod.rs +++ b/src/extension/mod.rs @@ -18,24 +18,27 @@ */ use serde_yaml::Mapping; +use crate::clipboard::ClipboardManager; mod date; mod shell; mod script; mod random; -mod dummy; +mod clipboard; +pub mod dummy; pub trait Extension { fn name(&self) -> String; fn calculate(&self, params: &Mapping, args: &Vec) -> Option; } -pub fn get_extensions() -> Vec> { +pub fn get_extensions(clipboard_manager: Box) -> Vec>{ vec![ Box::new(date::DateExtension::new()), Box::new(shell::ShellExtension::new()), Box::new(script::ScriptExtension::new()), Box::new(random::RandomExtension::new()), Box::new(dummy::DummyExtension::new()), + Box::new(clipboard::ClipboardExtension::new(clipboard_manager)), ] } \ No newline at end of file diff --git a/src/keyboard/linux.rs b/src/keyboard/linux.rs index 78e0961..04c3c3a 100644 --- a/src/keyboard/linux.rs +++ b/src/keyboard/linux.rs @@ -52,6 +52,8 @@ impl super::KeyboardManager for LinuxKeyboardManager { trigger_alt_shift_ins_paste(); }else if is_special == 3 { // Special case for Emacs trigger_shift_ins_paste(); + }else if is_special == 4 { // CTRL+ALT+V used in some terminals (urxvt) + trigger_ctrl_alt_paste(); }else{ trigger_terminal_paste(); } @@ -65,6 +67,9 @@ impl super::KeyboardManager for LinuxKeyboardManager { PasteShortcut::ShiftInsert=> { trigger_shift_ins_paste(); }, + PasteShortcut::CtrlAltV => { + trigger_ctrl_alt_paste(); + }, _ => { error!("Linux backend does not support this Paste Shortcut, please open an issue on GitHub if you need it.") } diff --git a/src/keyboard/mod.rs b/src/keyboard/mod.rs index 66ebe77..045be16 100644 --- a/src/keyboard/mod.rs +++ b/src/keyboard/mod.rs @@ -43,6 +43,7 @@ pub enum PasteShortcut { CtrlV, // Classic Ctrl+V shortcut CtrlShiftV, // Could be used to paste without formatting in many applications ShiftInsert, // Often used in Linux systems + CtrlAltV, // Used in some Linux terminals (urxvt) MetaV, // Corresponding to Win+V on Windows and Linux, CMD+V on macOS } diff --git a/src/main.rs b/src/main.rs index 12c7561..78e8d0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ use crate::package::default::DefaultPackageManager; use crate::package::{PackageManager, InstallResult, UpdateResult, RemoveResult}; mod ui; +mod edit; mod event; mod check; mod utils; @@ -96,6 +97,10 @@ fn main() { .subcommand(SubCommand::with_name("toggle") .about("Toggle the status of the espanso replacement engine.")) ) + .subcommand(SubCommand::with_name("edit") + .about("Open the default text editor to edit config files and reload them automatically when exiting") + .arg(Arg::with_name("config") + .help("Defaults to \"default\". The configuration file name to edit (without the .yml extension)."))) .subcommand(SubCommand::with_name("dump") .about("Prints all current configuration options.")) .subcommand(SubCommand::with_name("detect") @@ -146,6 +151,14 @@ fn main() { let matches = clap_instance.clone().get_matches(); + // The edit subcommand must be run before the configuration parsing. Otherwise, if the + // configuration is corrupted, the edit command won't work, which makes it pretty useless. + if let Some(matches) = matches.subcommand_matches("edit") { + edit_main(matches); + return; + } + + let log_level = matches.occurrences_of("v") as i32; // Load the configuration @@ -156,7 +169,7 @@ fn main() { config_set.default.log_level = log_level; - // Match the correct subcommand + // Commands that require the configuration if let Some(matches) = matches.subcommand_matches("cmd") { cmd_main(config_set, matches); @@ -331,7 +344,7 @@ fn daemon_background(receive_channel: Receiver, config_set: ConfigSet) { let keyboard_manager = keyboard::get_manager(); - let extensions = extension::get_extensions(); + let extensions = extension::get_extensions(Box::new(clipboard::get_manager())); let renderer = render::default::DefaultRenderer::new(extensions, config_manager.default_config().clone()); @@ -873,6 +886,74 @@ fn path_main(_config_set: ConfigSet, matches: &ArgMatches) { } } +fn edit_main(matches: &ArgMatches) { + // Determine which is the file to edit + let config = matches.value_of("config").unwrap_or("default"); + + let config_dir = crate::context::get_config_dir(); + + let config_path = match config { + "default" => { + config_dir.join(crate::config::DEFAULT_CONFIG_FILE_NAME) + }, + name => { // Otherwise, search in the user/ config folder + config_dir.join(crate::config::USER_CONFIGS_FOLDER_NAME) + .join(name.to_owned() + ".yml") + } + }; + + println!("Editing file: {:?}", &config_path); + + // Based on the fact that the file already exists or not, we should detect in different + // ways if a reload is needed + let should_reload =if config_path.exists() { + // Get the last modified date, so that we can detect if the user actually edits the file + // before reloading + let metadata = std::fs::metadata(&config_path).expect("cannot gather file metadata"); + let last_modified = metadata.modified().expect("cannot read file last modified date"); + + let result = crate::edit::open_editor(&config_path); + if result { + let new_metadata = std::fs::metadata(&config_path).expect("cannot gather file metadata"); + let new_last_modified = new_metadata.modified().expect("cannot read file last modified date"); + + if last_modified != new_last_modified { + println!("File has been modified, reloading configuration"); + true + }else{ + println!("File has not been modified, avoiding reload"); + false + } + }else{ + false + } + }else{ + let result = crate::edit::open_editor(&config_path); + if result { + // If the file has been created, we should reload the espanso config + if config_path.exists() { + println!("A new file has been created, reloading configuration"); + true + }else{ + println!("No file has been created, avoiding reload"); + false + } + }else{ + false + } + }; + + if should_reload { + // Load the configuration + let mut config_set = ConfigSet::load_default().unwrap_or_else(|e| { + eprintln!("{}", e); + eprintln!("Unable to reload espanso due to previous configuration error."); + exit(1); + }); + + restart_main(config_set) + } +} fn acquire_lock() -> Option { let espanso_dir = context::get_data_dir(); diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 1c8916b..e322355 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -29,14 +29,15 @@ 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, + pub propagate_case: 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,18 +75,49 @@ 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 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) + }; - // 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); + // 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(); + 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(); + + let content = if let Some(replace) = &other.replace { // Text match let new_replace = replace.clone(); @@ -132,11 +164,12 @@ 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, + propagate_case: other.propagate_case, } } } @@ -144,8 +177,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, @@ -160,13 +197,19 @@ 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()} +fn default_triggers() -> Vec {Vec::new()} fn default_vars() -> Vec {Vec::new()} 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 { @@ -175,9 +218,12 @@ pub struct MatchVariable { #[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), @@ -185,7 +231,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 +327,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 +343,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 +368,108 @@ 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"]) + } + + #[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(); + } } \ 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 feafac6..dca2c70 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -29,6 +29,7 @@ use crate::extension::Extension; lazy_static! { static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P\\w+)\\s*\\}\\}").unwrap(); + static ref UNKNOWN_VARIABLE : String = "".to_string(); } pub struct DefaultRenderer { @@ -58,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 @@ -74,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 @@ -103,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 { @@ -138,7 +142,7 @@ impl super::Renderer for DefaultRenderer { let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| { let var_name = caps.name("name").unwrap().as_str(); let output = output_map.get(var_name); - output.unwrap() + output.unwrap_or(&UNKNOWN_VARIABLE) }); result.to_string() @@ -146,9 +150,55 @@ impl super::Renderer for DefaultRenderer { content.replace.clone() }; + // Unescape any brackets (needed to be able to insert double brackets in replacement + // text, without triggering the variable system). See issue #187 + let target_string = target_string.replace("\\{", "{") + .replace("\\}", "}"); + // Render any argument that may be present let target_string = utils::render_args(&target_string, &args); + // 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) }, @@ -196,9 +246,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) => { @@ -221,7 +271,7 @@ mod tests { use super::*; fn get_renderer(config: Configs) -> DefaultRenderer { - DefaultRenderer::new(crate::extension::get_extensions(), config) + DefaultRenderer::new(vec![Box::new(crate::extension::dummy::DummyExtension::new())], config) } fn get_config_for(s: &str) -> Configs { @@ -481,4 +531,132 @@ mod tests { verify_render(rendered, "this is my local"); } + + #[test] + fn test_render_match_with_unknown_variable_does_not_crash() { + let text = "this is :test"; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: "my {{unknown}}" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + verify_render(rendered, "this is my "); + } + + #[test] + fn test_render_escaped_double_brackets_should_not_consider_them_variable() { + let text = "this is :test"; + + let config = get_config_for(r###" + matches: + - trigger: ':test' + replace: "my \\{\\{unknown\\}\\}" + "###); + + let renderer = get_renderer(config.clone()); + + let rendered = renderer.render_passive(text, &config); + + 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"); + } + + #[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 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;