Merge pull request #866 from federico-terzi/dev

Release 2.1.0-alpha
This commit is contained in:
Federico Terzi 2021-11-13 15:25:26 +01:00 committed by GitHub
commit 85f1598cf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1393 additions and 147 deletions

2
Cargo.lock generated
View File

@ -572,7 +572,7 @@ dependencies = [
[[package]] [[package]]
name = "espanso" name = "espanso"
version = "2.0.5-alpha" version = "2.1.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"caps", "caps",

View File

@ -89,7 +89,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup)
.iter() .iter()
.filter_map(|var| { .filter_map(|var| {
let var: YAMLVariable = serde_yaml::from_value(var.clone()).ok()?; let var: YAMLVariable = serde_yaml::from_value(var.clone()).ok()?;
let (var, warnings) = try_convert_into_variable(var).ok()?; let (var, warnings) = try_convert_into_variable(var, true).ok()?;
warnings.into_iter().for_each(|warning| { warnings.into_iter().for_each(|warning| {
warn!("{}", warning); warn!("{}", warning);
}); });
@ -102,7 +102,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup)
.iter() .iter()
.filter_map(|var| { .filter_map(|var| {
let m: YAMLMatch = serde_yaml::from_value(var.clone()).ok()?; let m: YAMLMatch = serde_yaml::from_value(var.clone()).ok()?;
let (m, warnings) = try_convert_into_match(m).ok()?; let (m, warnings) = try_convert_into_match(m, true).ok()?;
warnings.into_iter().for_each(|warning| { warnings.into_iter().for_each(|warning| {
warn!("{}", warning); warn!("{}", warning);
}); });

View File

@ -43,6 +43,8 @@ mod util;
lazy_static! { lazy_static! {
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap(); static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap();
static ref FORM_CONTROL_REGEX: Regex =
Regex::new("\\[\\[\\s*(\\w+)(\\.\\w+)?\\s*\\]\\]").unwrap();
} }
// Create an alias to make the meaning more explicit // Create an alias to make the meaning more explicit
@ -72,7 +74,7 @@ impl Importer for YAMLImporter {
let mut global_vars = Vec::new(); let mut global_vars = Vec::new();
for yaml_global_var in yaml_group.global_vars.as_ref().cloned().unwrap_or_default() { for yaml_global_var in yaml_group.global_vars.as_ref().cloned().unwrap_or_default() {
match try_convert_into_variable(yaml_global_var) { match try_convert_into_variable(yaml_global_var, false) {
Ok((var, warnings)) => { Ok((var, warnings)) => {
global_vars.push(var); global_vars.push(var);
non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn)); non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
@ -85,7 +87,7 @@ impl Importer for YAMLImporter {
let mut matches = Vec::new(); let mut matches = Vec::new();
for yaml_match in yaml_group.matches.as_ref().cloned().unwrap_or_default() { for yaml_match in yaml_group.matches.as_ref().cloned().unwrap_or_default() {
match try_convert_into_match(yaml_match) { match try_convert_into_match(yaml_match, false) {
Ok((m, warnings)) => { Ok((m, warnings)) => {
matches.push(m); matches.push(m);
non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn)); non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
@ -119,7 +121,10 @@ impl Importer for YAMLImporter {
} }
} }
pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warning>)> { pub fn try_convert_into_match(
yaml_match: YAMLMatch,
use_compatibility_mode: bool,
) -> Result<(Match, Vec<Warning>)> {
let mut warnings = Vec::new(); let mut warnings = Vec::new();
if yaml_match.uppercase_style.is_some() && yaml_match.propagate_case.is_none() { if yaml_match.uppercase_style.is_some() && yaml_match.propagate_case.is_none() {
@ -203,8 +208,9 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
let mut vars: Vec<Variable> = Vec::new(); let mut vars: Vec<Variable> = Vec::new();
for yaml_var in yaml_match.vars.unwrap_or_default() { for yaml_var in yaml_match.vars.unwrap_or_default() {
let (var, var_warnings) = try_convert_into_variable(yaml_var.clone()) let (var, var_warnings) =
.with_context(|| format!("failed to load variable: {:?}", yaml_var))?; try_convert_into_variable(yaml_var.clone(), use_compatibility_mode)
.with_context(|| format!("failed to load variable: {:?}", yaml_var))?;
warnings.extend(var_warnings); warnings.extend(var_warnings);
vars.push(var); vars.push(var);
} }
@ -216,21 +222,45 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
force_mode, force_mode,
}) })
} else if let Some(form_layout) = yaml_match.form { } else if let Some(form_layout) = yaml_match.form {
// TODO: test form case
// Replace all the form fields with actual variables // Replace all the form fields with actual variables
let resolved_layout = VAR_REGEX
.replace_all(&form_layout, |caps: &Captures| { // In v2.1.0-alpha the form control syntax was replaced with [[control]]
let var_name = caps.get(1).unwrap().as_str(); // instead of {{control}}, so we check if compatibility mode is being used.
format!("{{{{form1.{}}}}}", var_name) // TODO: remove once compatibility mode is removed
})
.to_string(); let (resolved_replace, resolved_layout) = if use_compatibility_mode {
(
VAR_REGEX
.replace_all(&form_layout, |caps: &Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{{{{form1.{}}}}}", var_name)
})
.to_string(),
VAR_REGEX
.replace_all(&form_layout, |caps: &Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("[[{}]]", var_name)
})
.to_string(),
)
} else {
(
FORM_CONTROL_REGEX
.replace_all(&form_layout, |caps: &Captures| {
let var_name = caps.get(1).unwrap().as_str();
format!("{{{{form1.{}}}}}", var_name)
})
.to_string(),
form_layout,
)
};
// Convert escaped brakets in forms // Convert escaped brakets in forms
let resolved_layout = resolved_layout.replace("\\{", "{ ").replace("\\}", " }"); let resolved_replace = resolved_replace.replace("\\{", "{ ").replace("\\}", " }");
// Convert the form data to valid variables // Convert the form data to valid variables
let mut params = Params::new(); let mut params = Params::new();
params.insert("layout".to_string(), Value::String(form_layout)); params.insert("layout".to_string(), Value::String(resolved_layout));
if let Some(fields) = yaml_match.form_fields { if let Some(fields) = yaml_match.form_fields {
params.insert("fields".to_string(), Value::Object(convert_params(fields)?)); params.insert("fields".to_string(), Value::Object(convert_params(fields)?));
@ -241,10 +271,11 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
name: "form1".to_owned(), name: "form1".to_owned(),
var_type: "form".to_owned(), var_type: "form".to_owned(),
params, params,
..Default::default()
}]; }];
MatchEffect::Text(TextEffect { MatchEffect::Text(TextEffect {
replace: resolved_layout, replace: resolved_replace,
vars, vars,
format: TextFormat::Plain, format: TextFormat::Plain,
force_mode, force_mode,
@ -274,13 +305,18 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
)) ))
} }
pub fn try_convert_into_variable(yaml_var: YAMLVariable) -> Result<(Variable, Vec<Warning>)> { pub fn try_convert_into_variable(
yaml_var: YAMLVariable,
use_compatibility_mode: bool,
) -> Result<(Variable, Vec<Warning>)> {
Ok(( Ok((
Variable { Variable {
name: yaml_var.name, name: yaml_var.name,
var_type: yaml_var.var_type, var_type: yaml_var.var_type,
params: convert_params(yaml_var.params)?, params: convert_params(yaml_var.params)?,
id: next_id(), id: next_id(),
inject_vars: !use_compatibility_mode && yaml_var.inject_vars.unwrap_or(true),
depends_on: yaml_var.depends_on,
}, },
Vec::new(), Vec::new(),
)) ))
@ -295,9 +331,12 @@ mod tests {
}; };
use std::fs::create_dir_all; use std::fs::create_dir_all;
fn create_match_with_warnings(yaml: &str) -> Result<(Match, Vec<Warning>)> { fn create_match_with_warnings(
yaml: &str,
use_compatibility_mode: bool,
) -> Result<(Match, Vec<Warning>)> {
let yaml_match: YAMLMatch = serde_yaml::from_str(yaml)?; let yaml_match: YAMLMatch = serde_yaml::from_str(yaml)?;
let (mut m, warnings) = try_convert_into_match(yaml_match)?; let (mut m, warnings) = try_convert_into_match(yaml_match, use_compatibility_mode)?;
// Reset the IDs to correctly compare them // Reset the IDs to correctly compare them
m.id = 0; m.id = 0;
@ -309,7 +348,7 @@ mod tests {
} }
fn create_match(yaml: &str) -> Result<Match> { fn create_match(yaml: &str) -> Result<Match> {
let (m, warnings) = create_match_with_warnings(yaml)?; let (m, warnings) = create_match_with_warnings(yaml, false)?;
if !warnings.is_empty() { if !warnings.is_empty() {
panic!("warnings were detected but not handled: {:?}", warnings); panic!("warnings were detected but not handled: {:?}", warnings);
} }
@ -529,6 +568,7 @@ mod tests {
replace: "world" replace: "world"
uppercase_style: "capitalize" uppercase_style: "capitalize"
"#, "#,
false,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -545,6 +585,7 @@ mod tests {
uppercase_style: "invalid" uppercase_style: "invalid"
propagate_case: true propagate_case: true
"#, "#,
false,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -554,6 +595,119 @@ mod tests {
assert_eq!(warnings.len(), 1); assert_eq!(warnings.len(), 1);
} }
#[test]
fn form_maps_correctly() {
let mut params = Params::new();
params.insert(
"layout".to_string(),
Value::String("Hi [[name]]!".to_string()),
);
assert_eq!(
create_match(
r#"
trigger: "Hello"
form: "Hi [[name]]!"
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "Hi {{form1.name}}!".to_string(),
vars: vec![Variable {
id: 0,
name: "form1".to_string(),
var_type: "form".to_string(),
params,
..Default::default()
}],
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn form_maps_correctly_with_variable_injection() {
let mut params = Params::new();
params.insert(
"layout".to_string(),
Value::String("Hi [[name]]! {{signature}}".to_string()),
);
assert_eq!(
create_match(
r#"
trigger: "Hello"
form: "Hi [[name]]! {{signature}}"
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "Hi {{form1.name}}! {{signature}}".to_string(),
vars: vec![Variable {
id: 0,
name: "form1".to_string(),
var_type: "form".to_string(),
params,
..Default::default()
}],
..Default::default()
}),
..Default::default()
}
)
}
#[test]
fn form_maps_correctly_legacy_format() {
let mut params = Params::new();
params.insert(
"layout".to_string(),
Value::String("Hi [[name]]!".to_string()),
);
assert_eq!(
create_match_with_warnings(
r#"
trigger: "Hello"
form: "Hi {{name}}!"
"#,
true
)
.unwrap()
.0,
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "Hi {{form1.name}}!".to_string(),
vars: vec![Variable {
id: 0,
name: "form1".to_string(),
var_type: "form".to_string(),
params,
..Default::default()
}],
..Default::default()
}),
..Default::default()
}
)
}
#[test] #[test]
fn vars_maps_correctly() { fn vars_maps_correctly() {
let mut params = Params::new(); let mut params = Params::new();
@ -592,6 +746,52 @@ mod tests {
) )
} }
#[test]
fn vars_inject_vars_and_depends_on() {
let vars = vec![
Variable {
name: "var1".to_string(),
var_type: "test".to_string(),
depends_on: vec!["test".to_owned()],
..Default::default()
},
Variable {
name: "var2".to_string(),
var_type: "test".to_string(),
inject_vars: false,
..Default::default()
},
];
assert_eq!(
create_match(
r#"
trigger: "Hello"
replace: "world"
vars:
- name: var1
type: test
depends_on: ["test"]
- name: var2
type: "test"
inject_vars: false
"#
)
.unwrap(),
Match {
cause: MatchCause::Trigger(TriggerCause {
triggers: vec!["Hello".to_string()],
..Default::default()
}),
effect: MatchEffect::Text(TextEffect {
replace: "world".to_string(),
vars,
..Default::default()
}),
..Default::default()
}
)
}
#[test] #[test]
fn vars_no_params_maps_correctly() { fn vars_no_params_maps_correctly() {
let vars = vec![Variable { let vars = vec![Variable {

View File

@ -125,6 +125,12 @@ pub struct YAMLVariable {
#[serde(default = "default_params")] #[serde(default = "default_params")]
pub params: Mapping, pub params: Mapping,
#[serde(default)]
pub inject_vars: Option<bool>,
#[serde(default)]
pub depends_on: Vec<String>,
} }
fn default_params() -> Mapping { fn default_params() -> Mapping {

View File

@ -205,6 +205,8 @@ pub struct Variable {
pub name: String, pub name: String,
pub var_type: String, pub var_type: String,
pub params: Params, pub params: Params,
pub inject_vars: bool,
pub depends_on: Vec<String>,
} }
impl Default for Variable { impl Default for Variable {
@ -214,6 +216,8 @@ impl Default for Variable {
name: String::new(), name: String::new(),
var_type: String::new(), var_type: String::new(),
params: Params::new(), params: Params::new(),
inject_vars: true,
depends_on: Vec::new(),
} }
} }
} }

View File

@ -32,7 +32,7 @@ use log::{error, trace, warn};
use anyhow::Result; use anyhow::Result;
use thiserror::Error; use thiserror::Error;
use crate::event::{HotKeyEvent, InputEvent, Key, KeyboardEvent, Variant}; use crate::event::{HotKeyEvent, InputEvent, Key, KeyboardEvent, Status, Variant};
use crate::event::{Key::*, MouseButton, MouseEvent}; use crate::event::{Key::*, MouseButton, MouseEvent};
use crate::{event::Status::*, Source, SourceCallback}; use crate::{event::Status::*, Source, SourceCallback};
use crate::{event::Variant::*, hotkey::HotKey}; use crate::{event::Variant::*, hotkey::HotKey};
@ -50,6 +50,7 @@ const INPUT_MOUSE_MIDDLE_BUTTON: i32 = 3;
// Take a look at the native.h header file for an explanation of the fields // Take a look at the native.h header file for an explanation of the fields
#[repr(C)] #[repr(C)]
#[derive(Debug)]
pub struct RawInputEvent { pub struct RawInputEvent {
pub event_type: i32, pub event_type: i32,
@ -58,6 +59,12 @@ pub struct RawInputEvent {
pub key_code: i32, pub key_code: i32,
pub status: i32, pub status: i32,
pub is_caps_lock_pressed: i32,
pub is_shift_pressed: i32,
pub is_control_pressed: i32,
pub is_option_pressed: i32,
pub is_command_pressed: i32,
} }
#[repr(C)] #[repr(C)]
@ -82,15 +89,97 @@ extern "C" {
); );
} }
#[derive(Debug, Default)]
struct ModifierState {
is_ctrl_down: bool,
is_shift_down: bool,
is_command_down: bool,
is_option_down: bool,
}
lazy_static! { lazy_static! {
static ref CURRENT_SENDER: Arc<Mutex<Option<Sender<InputEvent>>>> = Arc::new(Mutex::new(None)); static ref CURRENT_SENDER: Arc<Mutex<Option<Sender<InputEvent>>>> = Arc::new(Mutex::new(None));
static ref MODIFIER_STATE: Arc<Mutex<ModifierState>> =
Arc::new(Mutex::new(ModifierState::default()));
} }
extern "C" fn native_callback(raw_event: RawInputEvent) { extern "C" fn native_callback(raw_event: RawInputEvent) {
let lock = CURRENT_SENDER let lock = CURRENT_SENDER
.lock() .lock()
.expect("unable to acquire CocoaSource sender lock"); .expect("unable to acquire CocoaSource sender lock");
// Most of the times, when pressing a modifier key (such as Alt, Ctrl, Shift, Cmd),
// we get both a Pressed and Released event. This is important to keep Espanso's
// internal representation of modifiers in sync.
// Unfortunately, there are times when the corresponding "release" event is not sent,
// and this causes Espanso to mistakenly think that the modifier is still pressed.
// This can happen for various reasons, such as when using external bluetooth keyboards
// or certain keyboard shortcuts.
// Luckily, most key events include the "modifiers flag" information, that tells us which
// modifier keys were currently pressed at that time.
// We use this modifier flag information to detect "inconsistent" states to send the corresponding
// modifier release events, keeping espanso's state in sync.
// For more info, see:
// https://github.com/federico-terzi/espanso/issues/825
// https://github.com/federico-terzi/espanso/issues/858
let mut compensating_events = Vec::new();
if raw_event.event_type == INPUT_EVENT_TYPE_KEYBOARD {
let (key_code, _) = key_code_to_key(raw_event.key_code);
let mut current_mod_state = MODIFIER_STATE
.lock()
.expect("unable to acquire modifier state in cocoa detector");
if let Key::Alt = &key_code {
current_mod_state.is_option_down = raw_event.status == INPUT_STATUS_PRESSED;
} else if let Key::Meta = &key_code {
current_mod_state.is_command_down = raw_event.status == INPUT_STATUS_PRESSED;
} else if let Key::Shift = &key_code {
current_mod_state.is_shift_down = raw_event.status == INPUT_STATUS_PRESSED;
} else if let Key::Control = &key_code {
current_mod_state.is_ctrl_down = raw_event.status == INPUT_STATUS_PRESSED;
} else {
if current_mod_state.is_command_down && raw_event.is_command_pressed == 0 {
compensating_events.push((Key::Meta, 0x37));
current_mod_state.is_command_down = false;
}
if current_mod_state.is_ctrl_down && raw_event.is_control_pressed == 0 {
compensating_events.push((Key::Control, 0x3B));
current_mod_state.is_ctrl_down = false;
}
if current_mod_state.is_shift_down && raw_event.is_shift_pressed == 0 {
compensating_events.push((Key::Shift, 0x38));
current_mod_state.is_shift_down = false;
}
if current_mod_state.is_option_down && raw_event.is_option_pressed == 0 {
compensating_events.push((Key::Alt, 0x3A));
current_mod_state.is_option_down = false;
}
}
}
if !compensating_events.is_empty() {
warn!(
"detected inconsistent modifier state for keys {:?}, sending compensating events...",
compensating_events
);
}
if let Some(sender) = lock.as_ref() { if let Some(sender) = lock.as_ref() {
for (key, code) in compensating_events {
if let Err(error) = sender.send(InputEvent::Keyboard(KeyboardEvent {
key,
value: None,
status: Status::Released,
variant: None,
code,
})) {
error!(
"Unable to send compensating event to Cocoa Sender: {}",
error
);
}
}
let event: Option<InputEvent> = raw_event.into(); let event: Option<InputEvent> = raw_event.into();
if let Some(event) = event { if let Some(event) = event {
if let Err(error) = sender.send(event) { if let Err(error) = sender.send(event) {
@ -386,6 +475,11 @@ mod tests {
buffer_len: 0, buffer_len: 0,
key_code: 0, key_code: 0,
status: INPUT_STATUS_PRESSED, status: INPUT_STATUS_PRESSED,
is_caps_lock_pressed: 0,
is_shift_pressed: 0,
is_control_pressed: 0,
is_option_pressed: 0,
is_command_pressed: 0,
} }
} }

View File

@ -52,6 +52,16 @@ typedef struct {
// Pressed or Released status // Pressed or Released status
int32_t status; int32_t status;
// Modifier keys status, this is needed to "correct" missing modifier release events.
// For more info, see the following issues:
// https://github.com/federico-terzi/espanso/issues/825
// https://github.com/federico-terzi/espanso/issues/858
int32_t is_caps_lock_pressed;
int32_t is_shift_pressed;
int32_t is_control_pressed;
int32_t is_option_pressed;
int32_t is_command_pressed;
} InputEvent; } InputEvent;
typedef void (*EventCallback)(InputEvent data); typedef void (*EventCallback)(InputEvent data);

View File

@ -76,6 +76,19 @@ void * detect_initialize(EventCallback callback, InitializeOptions options) {
strncpy(inputEvent.buffer, chars, 23); strncpy(inputEvent.buffer, chars, 23);
inputEvent.buffer_len = event.characters.length; inputEvent.buffer_len = event.characters.length;
// We also send the modifier key status to "correct" missing modifier release events
if (([event modifierFlags] & NSEventModifierFlagShift) != 0) {
inputEvent.is_shift_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagControl) != 0) {
inputEvent.is_control_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagCapsLock) != 0) {
inputEvent.is_caps_lock_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagOption) != 0) {
inputEvent.is_option_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) {
inputEvent.is_command_pressed = 1;
}
callback(inputEvent); callback(inputEvent);
}else if (event.type == NSEventTypeLeftMouseDown || event.type == NSEventTypeRightMouseDown || event.type == NSEventTypeOtherMouseDown || }else if (event.type == NSEventTypeLeftMouseDown || event.type == NSEventTypeRightMouseDown || event.type == NSEventTypeOtherMouseDown ||
event.type == NSEventTypeLeftMouseUp || event.type == NSEventTypeRightMouseUp || event.type == NSEventTypeOtherMouseUp) { event.type == NSEventTypeLeftMouseUp || event.type == NSEventTypeRightMouseUp || event.type == NSEventTypeOtherMouseUp) {
@ -106,6 +119,20 @@ void * detect_initialize(EventCallback callback, InitializeOptions options) {
} else if (event.keyCode == kVK_Option || event.keyCode == kVK_RightOption) { } else if (event.keyCode == kVK_Option || event.keyCode == kVK_RightOption) {
inputEvent.status = (([event modifierFlags] & NSEventModifierFlagOption) == 0) ? INPUT_STATUS_RELEASED : INPUT_STATUS_PRESSED; inputEvent.status = (([event modifierFlags] & NSEventModifierFlagOption) == 0) ? INPUT_STATUS_RELEASED : INPUT_STATUS_PRESSED;
} }
// We also send the modifier key status to "correct" missing modifier release events
if (([event modifierFlags] & NSEventModifierFlagShift) != 0) {
inputEvent.is_shift_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagControl) != 0) {
inputEvent.is_control_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagCapsLock) != 0) {
inputEvent.is_caps_lock_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagOption) != 0) {
inputEvent.is_option_pressed = 1;
} else if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) {
inputEvent.is_command_pressed = 1;
}
callback(inputEvent); callback(inputEvent);
} }
}]; }];

View File

@ -17,6 +17,7 @@
* along with espanso. If not, see <https://www.gnu.org/licenses/>. * along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/ */
use regex::{Captures, Regex};
use std::{cmp::Ordering, collections::HashMap, path::PathBuf}; use std::{cmp::Ordering, collections::HashMap, path::PathBuf};
use yaml_rust::{yaml::Hash, Yaml}; use yaml_rust::{yaml::Hash, Yaml};
@ -69,24 +70,32 @@ pub fn convert(input_files: HashMap<String, Hash>) -> HashMap<String, ConvertedF
}); });
if let Some(global_vars) = yaml_global_vars { if let Some(global_vars) = yaml_global_vars {
let mut patched_global_vars: Vec<Yaml> = global_vars.clone();
patched_global_vars
.iter_mut()
.for_each(apply_form_syntax_patch_to_variable);
let output_global_vars = output_yaml let output_global_vars = output_yaml
.content .content
.entry(Yaml::String("global_vars".to_string())) .entry(Yaml::String("global_vars".to_string()))
.or_insert(Yaml::Array(Vec::new())); .or_insert(Yaml::Array(Vec::new()));
if let Yaml::Array(out_global_vars) = output_global_vars { if let Yaml::Array(out_global_vars) = output_global_vars {
out_global_vars.extend(global_vars.clone()); out_global_vars.extend(patched_global_vars);
} else { } else {
eprintln!("unable to transform global_vars for file: {}", input_path); eprintln!("unable to transform global_vars for file: {}", input_path);
} }
} }
if let Some(matches) = yaml_matches { if let Some(matches) = yaml_matches {
let mut patched_matches = matches.clone();
apply_form_syntax_patch(&mut patched_matches);
let output_matches = output_yaml let output_matches = output_yaml
.content .content
.entry(Yaml::String("matches".to_string())) .entry(Yaml::String("matches".to_string()))
.or_insert(Yaml::Array(Vec::new())); .or_insert(Yaml::Array(Vec::new()));
if let Yaml::Array(out_matches) = output_matches { if let Yaml::Array(out_matches) = output_matches {
out_matches.extend(matches.clone()); out_matches.extend(patched_matches);
} else { } else {
eprintln!("unable to transform matches for file: {}", input_path); eprintln!("unable to transform matches for file: {}", input_path);
} }
@ -324,3 +333,57 @@ fn map_field_if_present(
} }
} }
} }
// This is needed to convert the old form's {{control}} syntax to the new [[control]] one.
fn apply_form_syntax_patch(matches: &mut Vec<Yaml>) {
matches.iter_mut().for_each(|m| {
if let Yaml::Hash(fields) = m {
if let Some(Yaml::String(form_option)) = fields.get_mut(&Yaml::String("form".to_string())) {
let converted = replace_legacy_form_syntax_with_new_one(form_option);
if &converted != form_option {
form_option.clear();
form_option.push_str(&converted);
}
}
if let Some(Yaml::Array(vars)) = fields.get_mut(&Yaml::String("vars".to_string())) {
vars
.iter_mut()
.for_each(apply_form_syntax_patch_to_variable)
}
}
})
}
fn apply_form_syntax_patch_to_variable(variable: &mut Yaml) {
if let Yaml::Hash(fields) = variable {
if let Some(Yaml::String(var_type)) = fields.get(&Yaml::String("type".to_string())) {
if var_type != "form" {
return;
}
}
if let Some(Yaml::Hash(params)) = fields.get_mut(&Yaml::String("params".to_string())) {
if let Some(Yaml::String(layout)) = params.get_mut(&Yaml::String("layout".to_string())) {
let converted = replace_legacy_form_syntax_with_new_one(layout);
if &converted != layout {
layout.clear();
layout.push_str(&converted);
}
}
}
}
}
lazy_static! {
static ref LEGACY_FIELD_REGEX: Regex = Regex::new(r"\{\{(?P<name>.*?)\}\}").unwrap();
}
fn replace_legacy_form_syntax_with_new_one(layout: &str) -> String {
LEGACY_FIELD_REGEX
.replace_all(layout, |caps: &Captures| {
let field_name = caps.name("name").unwrap().as_str();
format!("[[{}]]", field_name)
})
.to_string()
}

View File

@ -200,12 +200,14 @@ mod tests {
static BASE_CASE: Dir = include_dir!("test/base"); static BASE_CASE: Dir = include_dir!("test/base");
static ALL_PARAMS_CASE: Dir = include_dir!("test/all_params"); static ALL_PARAMS_CASE: Dir = include_dir!("test/all_params");
static OTHER_DIRS_CASE: Dir = include_dir!("test/other_dirs"); static OTHER_DIRS_CASE: Dir = include_dir!("test/other_dirs");
static FORM_SYNTAX: Dir = include_dir!("test/form_syntax");
#[allow(clippy::unused_unit)] #[allow(clippy::unused_unit)]
#[test_case(&SIMPLE_CASE; "simple case")] #[test_case(&SIMPLE_CASE; "simple case")]
#[test_case(&BASE_CASE; "base case")] #[test_case(&BASE_CASE; "base case")]
#[test_case(&ALL_PARAMS_CASE; "all config parameters case")] #[test_case(&ALL_PARAMS_CASE; "all config parameters case")]
#[test_case(&OTHER_DIRS_CASE; "other directories case")] #[test_case(&OTHER_DIRS_CASE; "other directories case")]
#[test_case(&FORM_SYNTAX; "form syntax")]
fn test_migration(test_data: &Dir) { fn test_migration(test_data: &Dir) {
run_with_temp_dir(test_data, |legacy, expected| { run_with_temp_dir(test_data, |legacy, expected| {
let tmp_out_dir = TempDir::new("espanso-migrate-out").unwrap(); let tmp_out_dir = TempDir::new("espanso-migrate-out").unwrap();

View File

@ -0,0 +1,25 @@
global_vars:
- name: global_form
type: form
params:
layout: |
Reverse [[name]]
matches:
- trigger: ":greet"
form: |
Hey [[name]],
Happy Birthday!
- trigger: ":rev"
replace: "{{reversed}}"
vars:
- name: form1
type: form
params:
layout: |
Reverse [[name]]
- name: reversed
type: shell
params:
cmd: "echo $ESPANSO_FORM1_NAME | rev"

View File

@ -0,0 +1,25 @@
global_vars:
- name: global_form
type: form
params:
layout: |
Reverse {{name}}
matches:
- trigger: ":greet"
form: |
Hey {{name}},
Happy Birthday!
- trigger: ":rev"
replace: "{{reversed}}"
vars:
- name: form1
type: form
params:
layout: |
Reverse {{name}}
- name: reversed
type: shell
params:
cmd: "echo $ESPANSO_FORM1_NAME | rev"

View File

@ -21,7 +21,8 @@ use super::split::*;
use regex::Regex; use regex::Regex;
lazy_static! { lazy_static! {
static ref FIELD_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}").unwrap(); // We need to match for both the new [[name]] syntax and the legacy {{name}} one
static ref FIELD_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}|\[\[(.*?)\]\]").unwrap();
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@ -57,6 +58,9 @@ pub fn parse_layout(layout: &str) -> Vec<Vec<Token>> {
if let Some(name) = caps.get(1) { if let Some(name) = caps.get(1) {
let name = name.as_str().to_owned(); let name = name.as_str().to_owned();
row.push(Token::Field(name)); row.push(Token::Field(name));
} else if let Some(name) = caps.get(2) {
let name = name.as_str().to_owned();
row.push(Token::Field(name));
} }
} }
} }
@ -74,7 +78,7 @@ mod tests {
#[test] #[test]
fn test_parse_layout() { fn test_parse_layout() {
let layout = "Hey {{name}},\nHow are you?\n \nCheers"; let layout = "Hey [[name]],\nHow are you?\n \nCheers";
let result = parse_layout(layout); let result = parse_layout(layout);
assert_eq!( assert_eq!(
result, result,
@ -92,7 +96,7 @@ mod tests {
#[test] #[test]
fn test_parse_layout_2() { fn test_parse_layout_2() {
let layout = "Hey {{name}} {{surname}},"; let layout = "Hey [[name]] [[surname]],";
let result = parse_layout(layout); let result = parse_layout(layout);
assert_eq!( assert_eq!(
result, result,
@ -105,4 +109,22 @@ mod tests {
],] ],]
); );
} }
#[test]
fn test_parse_layout_legacy_syntax() {
let layout = "Hey {{name}},\nHow are you?\n \nCheers";
let result = parse_layout(layout);
assert_eq!(
result,
vec![
vec![
Token::Text("Hey ".to_owned()),
Token::Field("name".to_owned()),
Token::Text(",".to_owned())
],
vec![Token::Text("How are you?".to_owned())],
vec![Token::Text("Cheers".to_owned())],
]
);
}
} }

View File

@ -17,14 +17,11 @@
* along with espanso. If not, see <https://www.gnu.org/licenses/>. * along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::renderer::VAR_REGEX;
use log::error; use log::error;
use std::collections::HashMap; use std::collections::HashMap;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{Extension, ExtensionOutput, ExtensionResult, Params, Value};
renderer::render_variables, Extension, ExtensionOutput, ExtensionResult, Params, Value,
};
lazy_static! { lazy_static! {
static ref EMPTY_PARAMS: Params = Params::new(); static ref EMPTY_PARAMS: Params = Params::new();
@ -59,7 +56,7 @@ impl<'a> Extension for FormExtension<'a> {
fn calculate( fn calculate(
&self, &self,
_: &crate::Context, _: &crate::Context,
scope: &crate::Scope, _: &crate::Scope,
params: &Params, params: &Params,
) -> crate::ExtensionResult { ) -> crate::ExtensionResult {
let layout = if let Some(Value::String(layout)) = params.get("layout") { let layout = if let Some(Value::String(layout)) = params.get("layout") {
@ -68,15 +65,12 @@ impl<'a> Extension for FormExtension<'a> {
return crate::ExtensionResult::Error(FormExtensionError::MissingLayout.into()); return crate::ExtensionResult::Error(FormExtensionError::MissingLayout.into());
}; };
let mut fields = if let Some(Value::Object(fields)) = params.get("fields") { let fields = if let Some(Value::Object(fields)) = params.get("fields") {
fields.clone() fields.clone()
} else { } else {
Params::new() Params::new()
}; };
// Inject scope variables into fields (if needed)
inject_scope(&mut fields, scope);
match self.provider.show(layout, &fields, &EMPTY_PARAMS) { match self.provider.show(layout, &fields, &EMPTY_PARAMS) {
FormProviderResult::Success(values) => { FormProviderResult::Success(values) => {
ExtensionResult::Success(ExtensionOutput::Multiple(values)) ExtensionResult::Success(ExtensionOutput::Multiple(values))
@ -87,25 +81,6 @@ impl<'a> Extension for FormExtension<'a> {
} }
} }
// TODO: test
fn inject_scope(fields: &mut HashMap<String, Value>, scope: &HashMap<&str, ExtensionOutput>) {
for value in fields.values_mut() {
if let Value::Object(field_options) = value {
if let Some(Value::String(default_value)) = field_options.get_mut("default") {
if VAR_REGEX.is_match(default_value) {
match render_variables(default_value, scope) {
Ok(rendered) => *default_value = rendered,
Err(err) => error!(
"error while injecting variable in form default value: {}",
err
),
}
}
}
}
}
}
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum FormExtensionError { pub enum FormExtensionError {
#[error("missing layout parameter")] #[error("missing layout parameter")]

View File

@ -98,7 +98,10 @@ impl Default for Template {
pub struct Variable { pub struct Variable {
pub name: String, pub name: String,
pub var_type: String, pub var_type: String,
pub inject_vars: bool,
pub params: Params, pub params: Params,
// Name of the variables this variable depends on
pub depends_on: Vec<String>,
} }
impl Default for Variable { impl Default for Variable {
@ -106,7 +109,9 @@ impl Default for Variable {
Self { Self {
name: "".to_string(), name: "".to_string(),
var_type: "".to_string(), var_type: "".to_string(),
inject_vars: true,
params: Params::new(), params: Params::new(),
depends_on: Vec::new(),
} }
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
* This file is part of esp name: (), var_type: (), params: ()anso. * This file is part of espanso.
* *
* Copyright (C) 2019-2021 Federico Terzi * Copyright (C) 2019-2021 Federico Terzi
* *
@ -17,18 +17,19 @@
* along with espanso. If not, see <https://www.gnu.org/licenses/>. * along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/ */
use std::collections::{HashMap, HashSet}; use std::{borrow::Cow, collections::HashMap};
use crate::{ use crate::{
CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult, CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult,
Renderer, Scope, Template, Value, Variable, Renderer, Scope, Template, Value, Variable,
}; };
use anyhow::Result;
use log::{error, warn}; use log::{error, warn};
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use thiserror::Error; use thiserror::Error;
use util::get_body_variable_names;
use self::util::{inject_variables_into_params, render_variables};
mod resolve;
mod util; mod util;
lazy_static! { lazy_static! {
@ -59,23 +60,6 @@ impl<'a> Renderer for DefaultRenderer<'a> {
options: &RenderOptions, options: &RenderOptions,
) -> RenderResult { ) -> RenderResult {
let body = if VAR_REGEX.is_match(&template.body) { let body = if VAR_REGEX.is_match(&template.body) {
// In order to define a variable evaluation order, we first need to find
// the global variables that are being used but for which an explicit order
// is not defined.
let body_variable_names = get_body_variable_names(&template.body);
let local_variable_names: HashSet<&str> =
template.vars.iter().map(|var| var.name.as_str()).collect();
let missing_global_variable_names: HashSet<&str> = body_variable_names
.difference(&local_variable_names)
.copied()
.collect();
let missing_global_variables: Vec<&Variable> = context
.global_vars
.iter()
.copied()
.filter(|global_var| missing_global_variable_names.contains(&*global_var.name))
.collect();
// Convert "global" variable type aliases when needed // Convert "global" variable type aliases when needed
let local_variables: Vec<&Variable> = let local_variables: Vec<&Variable> =
if template.vars.iter().any(|var| var.var_type == "global") { if template.vars.iter().any(|var| var.var_type == "global") {
@ -99,10 +83,16 @@ impl<'a> Renderer for DefaultRenderer<'a> {
template.vars.iter().collect() template.vars.iter().collect()
}; };
// The implicit global variables will be evaluated first, followed by the local vars // Here we execute a graph dependency resolution algorithm to determine a valid
let mut variables: Vec<&Variable> = Vec::new(); // evaluation order for variables.
variables.extend(missing_global_variables); let variables = match resolve::resolve_evaluation_order(
variables.extend(local_variables.iter()); &template.body,
&local_variables,
&context.global_vars,
) {
Ok(variables) => variables,
Err(err) => return RenderResult::Error(err),
};
// Compute the variable outputs // Compute the variable outputs
let mut scope = Scope::new(); let mut scope = Scope::new();
@ -123,7 +113,31 @@ impl<'a> Renderer for DefaultRenderer<'a> {
return RenderResult::Error(RendererError::MissingSubMatch.into()); return RenderResult::Error(RendererError::MissingSubMatch.into());
} }
} else if let Some(extension) = self.extensions.get(&variable.var_type) { } else if let Some(extension) = self.extensions.get(&variable.var_type) {
match extension.calculate(context, &scope, &variable.params) { let variable_params = if variable.inject_vars {
match inject_variables_into_params(&variable.params, &scope) {
Ok(augmented_params) => Cow::Owned(augmented_params),
Err(err) => {
error!(
"unable to inject variables into params of variable '{}': {}",
variable.name, err
);
if variable.var_type == "form" {
if let Some(RendererError::MissingVariable(_)) =
err.downcast_ref::<RendererError>()
{
log_new_form_syntax_tip();
}
}
return RenderResult::Error(err);
}
}
} else {
Cow::Borrowed(&variable.params)
};
match extension.calculate(context, &scope, &variable_params) {
ExtensionResult::Success(output) => { ExtensionResult::Success(output) => {
scope.insert(&variable.name, output); scope.insert(&variable.name, output);
} }
@ -161,6 +175,8 @@ impl<'a> Renderer for DefaultRenderer<'a> {
template.body.clone() template.body.clone()
}; };
let body = util::unescape_variable_inections(&body);
// Process the casing style // Process the casing style
let body_with_casing = match options.casing_style { let body_with_casing = match options.casing_style {
CasingStyle::None => body, CasingStyle::None => body,
@ -192,52 +208,6 @@ impl<'a> Renderer for DefaultRenderer<'a> {
} }
} }
// TODO: test
pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result<String> {
let mut replacing_error = None;
let output = VAR_REGEX
.replace_all(body, |caps: &Captures| {
let var_name = caps.name("name").unwrap().as_str();
let var_subname = caps.name("subname");
match scope.get(var_name) {
Some(output) => match output {
ExtensionOutput::Single(output) => output,
ExtensionOutput::Multiple(results) => match var_subname {
Some(var_subname) => {
let var_subname = var_subname.as_str();
results.get(var_subname).map_or("", |value| &*value)
}
None => {
error!(
"nested name missing from multi-value variable: {}",
var_name
);
replacing_error = Some(RendererError::MissingVariable(format!(
"nested name missing from multi-value variable: {}",
var_name
)));
""
}
},
},
None => {
replacing_error = Some(RendererError::MissingVariable(format!(
"variable '{}' is missing",
var_name
)));
""
}
}
})
.to_string();
if let Some(error) = replacing_error {
return Err(error.into());
}
Ok(output)
}
fn get_matching_template<'a>( fn get_matching_template<'a>(
variable: &Variable, variable: &Variable,
templates: &'a [&Template], templates: &'a [&Template],
@ -254,6 +224,24 @@ fn get_matching_template<'a>(
} }
} }
fn log_new_form_syntax_tip() {
error!("");
error!("TIP: This error might be happening because since version 2.1.0-alpha, Espanso changed");
error!("the syntax to define form controls. Instead of `{{{{control}}}}` you need to use");
error!("[[control]] (using square brackets instead of curly brackets).");
error!("");
error!("For example, if you have a form defined like the following:");
error!(" - trigger: test");
error!(" form: |");
error!(" Hi {{{{name}}}}!");
error!("");
error!("You'll need to replace it with:");
error!(" - trigger: test");
error!(" form: |");
error!(" Hi [[name]]!");
error!("");
}
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum RendererError { pub enum RendererError {
#[error("missing variable: `{0}`")] #[error("missing variable: `{0}`")]
@ -261,6 +249,9 @@ pub enum RendererError {
#[error("missing sub match")] #[error("missing sub match")]
MissingSubMatch, MissingSubMatch,
#[error("circular dependency: `{0}` -> `{1}`")]
CircularDependency(String, String),
} }
#[cfg(test)] #[cfg(test)]
@ -285,6 +276,13 @@ mod tests {
if let Some(Value::String(string)) = params.get("echo") { if let Some(Value::String(string)) = params.get("echo") {
return ExtensionResult::Success(ExtensionOutput::Single(string.clone())); return ExtensionResult::Success(ExtensionOutput::Single(string.clone()));
} }
if let (Some(Value::String(name)), Some(Value::String(value))) =
(params.get("name"), params.get("value"))
{
let mut map = HashMap::new();
map.insert(name.to_string(), value.to_string());
return ExtensionResult::Success(ExtensionOutput::Multiple(map));
}
// If the "read" param is present, echo the value of the corresponding result in the scope // If the "read" param is present, echo the value of the corresponding result in the scope
if let Some(Value::String(string)) = params.get("read") { if let Some(Value::String(string)) = params.get("read") {
if let Some(ExtensionOutput::Single(value)) = scope.get(string.as_str()) { if let Some(ExtensionOutput::Single(value)) = scope.get(string.as_str()) {
@ -324,6 +322,7 @@ mod tests {
params: vec![("echo".to_string(), Value::String((*value).to_string()))] params: vec![("echo".to_string(), Value::String((*value).to_string()))]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}) })
.collect(); .collect();
Template { Template {
@ -393,6 +392,28 @@ mod tests {
assert!(matches!(res, RenderResult::Success(str) if str == "hello world")); assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
} }
#[test]
fn dict_variable_variable() {
let renderer = get_renderer();
let template = Template {
body: "hello {{var.nested}}".to_string(),
vars: vec![Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: vec![
("name".to_string(), Value::String("nested".to_string())),
("value".to_string(), Value::String("dict".to_string())),
]
.into_iter()
.collect::<Params>(),
..Default::default()
}],
..Default::default()
};
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Success(str) if str == "hello dict"));
}
#[test] #[test]
fn missing_variable() { fn missing_variable() {
let renderer = get_renderer(); let renderer = get_renderer();
@ -415,6 +436,7 @@ mod tests {
"echo".to_string(), "echo".to_string(),
Value::String("world".to_string()), Value::String("world".to_string()),
)]), )]),
..Default::default()
}], }],
..Default::default() ..Default::default()
}, },
@ -423,6 +445,31 @@ mod tests {
assert!(matches!(res, RenderResult::Success(str) if str == "hello world")); assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
} }
#[test]
fn global_dict_variable() {
let renderer = get_renderer();
let template = template("hello {{var.nested}}", &[]);
let res = renderer.render(
&template,
&Context {
global_vars: vec![&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: vec![
("name".to_string(), Value::String("nested".to_string())),
("value".to_string(), Value::String("dict".to_string())),
]
.into_iter()
.collect::<Params>(),
..Default::default()
}],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Success(str) if str == "hello dict"));
}
#[test] #[test]
fn global_variable_explicit_ordering() { fn global_variable_explicit_ordering() {
let renderer = get_renderer(); let renderer = get_renderer();
@ -435,6 +482,7 @@ mod tests {
params: vec![("echo".to_string(), Value::String("Bob".to_string()))] params: vec![("echo".to_string(), Value::String("Bob".to_string()))]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}, },
Variable { Variable {
name: "var".to_string(), name: "var".to_string(),
@ -454,6 +502,7 @@ mod tests {
"read".to_string(), "read".to_string(),
Value::String("local".to_string()), Value::String("local".to_string()),
)]), )]),
..Default::default()
}], }],
..Default::default() ..Default::default()
}, },
@ -462,6 +511,147 @@ mod tests {
assert!(matches!(res, RenderResult::Success(str) if str == "hello Bob Bob")); assert!(matches!(res, RenderResult::Success(str) if str == "hello Bob Bob"));
} }
#[test]
fn nested_global_variable() {
let renderer = get_renderer();
let template = template("hello {{var2}}", &[]);
let res = renderer.render(
&template,
&Context {
global_vars: vec![
&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("world".to_string()),
)]),
..Default::default()
},
&Variable {
name: "var2".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var}}".to_string()),
)]),
..Default::default()
},
],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
}
#[test]
fn nested_global_variable_circular_dependency_should_fail() {
let renderer = get_renderer();
let template = template("hello {{var}}", &[]);
let res = renderer.render(
&template,
&Context {
global_vars: vec![
&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var2}}".to_string()),
)]),
..Default::default()
},
&Variable {
name: "var2".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var3}}".to_string()),
)]),
..Default::default()
},
&Variable {
name: "var3".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var}}".to_string()),
)]),
..Default::default()
},
],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Error(_)));
}
#[test]
fn global_variable_depends_on() {
let renderer = get_renderer();
let template = template("hello {{var}}", &[]);
let res = renderer.render(
&template,
&Context {
global_vars: vec![
&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("world".to_string()),
)]),
depends_on: vec!["var2".to_string()],
..Default::default()
},
&Variable {
name: "var2".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![("abort".to_string(), Value::Null)]),
..Default::default()
},
],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Aborted));
}
#[test]
fn local_variable_explicit_ordering() {
let renderer = get_renderer();
let template = Template {
body: "hello {{var}}".to_string(),
vars: vec![Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: vec![("echo".to_string(), Value::String("something".to_string()))]
.into_iter()
.collect::<Params>(),
depends_on: vec!["global".to_string()],
..Default::default()
}],
..Default::default()
};
let res = renderer.render(
&template,
&Context {
global_vars: vec![&Variable {
name: "global".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![("abort".to_string(), Value::Null)]),
..Default::default()
}],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Aborted));
}
#[test] #[test]
fn nested_match() { fn nested_match() {
let renderer = get_renderer(); let renderer = get_renderer();
@ -473,6 +663,7 @@ mod tests {
params: vec![("trigger".to_string(), Value::String("nested".to_string()))] params: vec![("trigger".to_string(), Value::String("nested".to_string()))]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}], }],
..Default::default() ..Default::default()
}; };
@ -503,6 +694,7 @@ mod tests {
params: vec![("trigger".to_string(), Value::String("nested".to_string()))] params: vec![("trigger".to_string(), Value::String("nested".to_string()))]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}], }],
..Default::default() ..Default::default()
}; };
@ -527,6 +719,7 @@ mod tests {
params: vec![("abort".to_string(), Value::Null)] params: vec![("abort".to_string(), Value::Null)]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}], }],
..Default::default() ..Default::default()
}; };
@ -545,10 +738,213 @@ mod tests {
params: vec![("error".to_string(), Value::Null)] params: vec![("error".to_string(), Value::Null)]
.into_iter() .into_iter()
.collect::<Params>(), .collect::<Params>(),
..Default::default()
}], }],
..Default::default() ..Default::default()
}; };
let res = renderer.render(&template, &Default::default(), &Default::default()); let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Error(_))); assert!(matches!(res, RenderResult::Error(_)));
} }
#[test]
fn variable_injection() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{fullname}}");
template.vars = vec![
Variable {
name: "firstname".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("John".to_string()),
)]),
..Default::default()
},
Variable {
name: "lastname".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("Snow".to_string()),
)]),
..Default::default()
},
Variable {
name: "fullname".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{firstname}} {{lastname}}".to_string()),
)]),
inject_vars: true,
..Default::default()
},
];
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Success(str) if str == "hello John Snow"));
}
#[test]
fn disable_variable_injection() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{second}}");
template.vars = vec![
Variable {
name: "first".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![("echo".to_string(), Value::String("one".to_string()))]),
..Default::default()
},
Variable {
name: "second".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{first}} two".to_string()),
)]),
inject_vars: false,
..Default::default()
},
];
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Success(str) if str == "hello {{first}} two"));
}
#[test]
fn escaped_variable_injection() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{second}}");
template.vars = vec![
Variable {
name: "first".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![("echo".to_string(), Value::String("one".to_string()))]),
..Default::default()
},
Variable {
name: "second".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("\\{\\{first\\}\\} two".to_string()),
)]),
..Default::default()
},
];
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Success(str) if str == "hello {{first}} two"));
}
#[test]
fn variable_injection_missing_var() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{second}}");
template.vars = vec![Variable {
name: "second".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("the next is {{missing}}".to_string()),
)]),
..Default::default()
}];
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Error(_)));
}
#[test]
fn variable_injection_with_global_variable() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{output}}");
template.vars = vec![
Variable {
name: "var".to_string(),
var_type: "global".to_string(),
..Default::default()
},
Variable {
name: "output".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var}}".to_string()),
)]),
..Default::default()
},
];
let res = renderer.render(
&template,
&Context {
global_vars: vec![&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("global".to_string()),
)]),
..Default::default()
}],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Success(str) if str == "hello global"));
}
#[test]
fn variable_injection_local_var_takes_precedence_over_global() {
let renderer = get_renderer();
let mut template = template_for_str("hello {{output}}");
template.vars = vec![
Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("local".to_string()),
)]),
..Default::default()
},
Variable {
name: "output".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("{{var}}".to_string()),
)]),
..Default::default()
},
];
let res = renderer.render(
&template,
&Context {
global_vars: vec![&Variable {
name: "var".to_string(),
var_type: "mock".to_string(),
params: Params::from_iter(vec![(
"echo".to_string(),
Value::String("global".to_string()),
)]),
..Default::default()
}],
..Default::default()
},
&Default::default(),
);
assert!(matches!(res, RenderResult::Success(str) if str == "hello local"));
}
#[test]
fn variable_escape() {
let renderer = get_renderer();
let template = template("hello \\{\\{var\\}\\}", &[("var", "world")]);
let res = renderer.render(&template, &Default::default(), &Default::default());
assert!(matches!(res, RenderResult::Success(str) if str == "hello {{var}}"));
}
} }

View File

@ -0,0 +1,204 @@
/*
* This file is part of espanso.
*
* Copyright (C) 2019-2021 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
};
use anyhow::{anyhow, Result};
use log::error;
use crate::Variable;
use super::RendererError;
struct Node<'a> {
name: &'a str,
variable: Option<&'a Variable>,
dependencies: Option<HashSet<&'a str>>,
}
pub(crate) fn resolve_evaluation_order<'a>(
body: &'a str,
local_vars: &'a [&'a Variable],
global_vars: &'a [&'a Variable],
) -> Result<Vec<&'a Variable>> {
let node_map = generate_nodes(body, local_vars, global_vars);
let body_node = node_map
.get("__match_body")
.ok_or_else(|| anyhow!("missing body node"))?;
let eval_order = RefCell::new(Vec::new());
let resolved = RefCell::new(HashSet::new());
let seen = RefCell::new(HashSet::new());
{
resolve_dependencies(body_node, &node_map, &eval_order, &resolved, &seen)?;
}
let eval_order_ref = eval_order.borrow();
let mut ordered_variables = Vec::new();
for var_name in (*eval_order_ref).iter() {
let node = node_map
.get(var_name)
.ok_or_else(|| anyhow!("could not find dependency node for variable: {}", var_name))?;
if let Some(var) = node.variable {
ordered_variables.push(var);
}
}
Ok(ordered_variables)
}
fn generate_nodes<'a>(
body: &'a str,
local_vars: &'a [&'a Variable],
global_vars: &'a [&'a Variable],
) -> HashMap<&'a str, Node<'a>> {
let mut local_vars_nodes = Vec::new();
for (index, var) in local_vars.iter().enumerate() {
let mut dependencies = HashSet::new();
if var.inject_vars {
dependencies.extend(super::util::get_params_variable_names(&var.params));
}
dependencies.extend(var.depends_on.iter().map(|v| v.as_str()));
// Every local variable depends on the one before it.
// Needed to guarantee execution order within local vars.
if index > 0 {
let previous_var = local_vars.get(index - 1);
if let Some(previous_var) = previous_var {
dependencies.insert(&previous_var.name);
}
}
local_vars_nodes.push(Node {
name: &var.name,
variable: Some(var),
dependencies: Some(dependencies),
});
}
let global_vars_nodes = global_vars.iter().map(|var| create_node_from_var(*var));
// The body depends on all local variables + the variables read inside it (which might be global)
let mut body_dependencies: HashSet<&str> =
local_vars_nodes.iter().map(|node| node.name).collect();
body_dependencies.extend(super::util::get_body_variable_names(body));
let body_node = Node {
name: "__match_body",
variable: None,
dependencies: Some(body_dependencies),
};
let mut node_map = HashMap::new();
node_map.insert(body_node.name, body_node);
global_vars_nodes.into_iter().for_each(|node| {
node_map.insert(node.name, node);
});
local_vars_nodes.into_iter().for_each(|node| {
node_map.insert(node.name, node);
});
node_map
}
fn create_node_from_var(var: &Variable) -> Node {
let dependencies = if var.inject_vars || !var.depends_on.is_empty() {
let mut vars = HashSet::new();
if var.inject_vars {
vars.extend(super::util::get_params_variable_names(&var.params))
}
vars.extend(var.depends_on.iter().map(|s| s.as_str()));
Some(vars)
} else {
None
};
Node {
name: &var.name,
variable: Some(var),
dependencies,
}
}
fn resolve_dependencies<'a>(
node: &'a Node,
node_map: &'a HashMap<&'a str, Node<'a>>,
eval_order: &'a RefCell<Vec<&'a str>>,
resolved: &'a RefCell<HashSet<&'a str>>,
seen: &'a RefCell<HashSet<&'a str>>,
) -> Result<()> {
{
let mut seen_ref = seen.borrow_mut();
seen_ref.insert(node.name);
}
if let Some(dependencies) = &node.dependencies {
for dependency in dependencies.iter() {
let has_been_resolved = {
let resolved_ref = resolved.borrow();
resolved_ref.contains(dependency)
};
let has_been_seen = {
let seen_ref = seen.borrow();
seen_ref.contains(dependency)
};
if !has_been_resolved {
if has_been_seen {
return Err(
RendererError::CircularDependency(node.name.to_string(), dependency.to_string()).into(),
);
}
match node_map.get(dependency) {
Some(dependency_node) => {
resolve_dependencies(dependency_node, node_map, eval_order, resolved, seen)?
}
None => {
error!("could not resolve variable {:?}", dependency);
if let Some(variable) = &node.variable {
if variable.var_type == "form" {
super::log_new_form_syntax_tip();
}
}
return Err(RendererError::MissingVariable(dependency.to_string()).into());
}
}
}
}
}
{
let mut eval_order_ref = eval_order.borrow_mut();
eval_order_ref.push(node.name);
let mut resolved_ref = resolved.borrow_mut();
resolved_ref.insert(node.name);
}
Ok(())
}

View File

@ -17,6 +17,11 @@
* along with espanso. If not, see <https://www.gnu.org/licenses/>. * along with espanso. If not, see <https://www.gnu.org/licenses/>.
*/ */
use crate::{renderer::RendererError, ExtensionOutput, Params, Scope, Value};
use anyhow::Result;
use log::error;
use regex::Captures;
use super::VAR_REGEX; use super::VAR_REGEX;
use std::collections::HashSet; use std::collections::HashSet;
@ -29,10 +34,128 @@ pub(crate) fn get_body_variable_names(body: &str) -> HashSet<&str> {
variables variables
} }
pub(crate) fn get_params_variable_names(params: &Params) -> HashSet<&str> {
let mut names = HashSet::new();
for (_, value) in params.iter() {
let local_names = get_value_variable_names_recursively(value);
names.extend(local_names);
}
names
}
fn get_value_variable_names_recursively(value: &Value) -> HashSet<&str> {
match value {
Value::String(s_value) => get_body_variable_names(s_value),
Value::Array(values) => {
let mut local_names: HashSet<&str> = HashSet::new();
for value in values {
local_names.extend(get_value_variable_names_recursively(value));
}
local_names
}
Value::Object(fields) => {
let mut local_names: HashSet<&str> = HashSet::new();
for value in fields.values() {
local_names.extend(get_value_variable_names_recursively(value));
}
local_names
}
_ => HashSet::new(),
}
}
pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result<String> {
let mut replacing_error = None;
let output = VAR_REGEX
.replace_all(body, |caps: &Captures| {
let var_name = caps.name("name").unwrap().as_str();
let var_subname = caps.name("subname");
match scope.get(var_name) {
Some(output) => match output {
ExtensionOutput::Single(output) => output,
ExtensionOutput::Multiple(results) => match var_subname {
Some(var_subname) => {
let var_subname = var_subname.as_str();
results.get(var_subname).map_or("", |value| &*value)
}
None => {
error!(
"nested name missing from multi-value variable: {}",
var_name
);
replacing_error = Some(RendererError::MissingVariable(format!(
"nested name missing from multi-value variable: {}",
var_name
)));
""
}
},
},
None => {
replacing_error = Some(RendererError::MissingVariable(format!(
"variable '{}' is missing",
var_name
)));
""
}
}
})
.to_string();
if let Some(error) = replacing_error {
return Err(error.into());
}
let unescaped_output = unescape_variable_inections(&output);
Ok(unescaped_output)
}
pub(crate) fn unescape_variable_inections(body: &str) -> String {
body.replace("\\{\\{", "{{").replace("\\}\\}", "}}")
}
pub(crate) fn inject_variables_into_params(params: &Params, scope: &Scope) -> Result<Params> {
let mut params = params.clone();
for (_, value) in params.iter_mut() {
inject_variables_into_value(value, scope)?;
}
Ok(params)
}
fn inject_variables_into_value(value: &mut Value, scope: &Scope) -> Result<()> {
match value {
Value::String(s_value) => {
let new_value = render_variables(s_value, scope)?;
if &new_value != s_value {
s_value.clear();
s_value.push_str(&new_value);
}
}
Value::Array(values) => {
for value in values {
inject_variables_into_value(value, scope)?;
}
}
Value::Object(fields) => {
for value in fields.values_mut() {
inject_variables_into_value(value, scope)?;
}
}
_ => {}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::iter::FromIterator; use std::{collections::HashMap, iter::FromIterator};
#[test] #[test]
fn get_body_variable_names_no_vars() { fn get_body_variable_names_no_vars() {
@ -49,4 +172,44 @@ mod tests {
HashSet::from_iter(vec!["world", "greet"]), HashSet::from_iter(vec!["world", "greet"]),
); );
} }
#[test]
fn test_inject_variables_into_params() {
let mut params = Params::new();
params.insert(
"field1".to_string(),
Value::String("this contains {{first}}".to_string()),
);
params.insert("field2".to_string(), Value::Bool(true));
params.insert(
"field3".to_string(),
Value::Array(vec![Value::String("this contains {{first}}".to_string())]),
);
let mut nested = HashMap::new();
nested.insert(
"subfield1".to_string(),
Value::String("also contains {{first}}".to_string()),
);
params.insert("field4".to_string(), Value::Object(nested));
let mut scope = Scope::new();
scope.insert("first", ExtensionOutput::Single("one".to_string()));
let result = inject_variables_into_params(&params, &scope).unwrap();
assert_eq!(result.len(), 4);
assert_eq!(
result.get("field1").unwrap(),
&Value::String("this contains one".to_string())
);
assert_eq!(result.get("field2").unwrap(), &Value::Bool(true));
assert_eq!(
result.get("field3").unwrap(),
&Value::Array(vec![Value::String("this contains one".to_string())])
);
assert!(
matches!(result.get("field4").unwrap(), Value::Object(fields) if fields.get("subfield1").unwrap() == &Value::String("also contains one".to_string()))
);
}
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "espanso" name = "espanso"
version = "2.0.5-alpha" version = "2.1.0-alpha"
authors = ["Federico Terzi <federicoterzi96@gmail.com>"] authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
license = "GPL-3.0" license = "GPL-3.0"
description = "Cross-platform Text Expander written in Rust" description = "Cross-platform Text Expander written in Rust"

View File

@ -63,14 +63,7 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
.cloned(), .cloned(),
values: params values: params
.get("values") .get("values")
.and_then(|val| val.as_array()) .and_then(|v| extract_values(v, params.get("trim_string_values")))
.map(|arr| {
arr
.iter()
.flat_map(|choice| choice.as_string())
.cloned()
.collect()
})
.unwrap_or_default(), .unwrap_or_default(),
}), }),
Some(Value::String(field_type)) if field_type == "list" => Some(FormField::List { Some(Value::String(field_type)) if field_type == "list" => Some(FormField::List {
@ -80,14 +73,7 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
.cloned(), .cloned(),
values: params values: params
.get("values") .get("values")
.and_then(|val| val.as_array()) .and_then(|v| extract_values(v, params.get("trim_string_values")))
.map(|arr| {
arr
.iter()
.flat_map(|choice| choice.as_string())
.cloned()
.collect()
})
.unwrap_or_default(), .unwrap_or_default(),
}), }),
// By default, it's considered type 'text' // By default, it's considered type 'text'
@ -113,3 +99,38 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
} }
out out
} }
fn extract_values(value: &Value, trim_string_values: Option<&Value>) -> Option<Vec<String>> {
let trim_string_values = *trim_string_values
.and_then(|v| v.as_bool())
.unwrap_or(&true);
match value {
Value::Array(values) => Some(
values
.iter()
.flat_map(|choice| choice.as_string())
.cloned()
.collect(),
),
Value::String(values) => Some(
values
.lines()
.filter_map(|line| {
if trim_string_values {
let trimmed_line = line.trim();
if !trimmed_line.is_empty() {
Some(trimmed_line)
} else {
None
}
} else {
Some(line)
}
})
.map(String::from)
.collect(),
),
_ => None,
}
}

View File

@ -151,6 +151,8 @@ fn convert_var(var: espanso_config::matches::Variable) -> espanso_render::Variab
name: var.name, name: var.name,
var_type: var.var_type, var_type: var.var_type,
params: convert_params(var.params), params: convert_params(var.params),
inject_vars: var.inject_vars,
depends_on: var.depends_on,
} }
} }
@ -225,6 +227,8 @@ impl<'a> Renderer<'a> for RendererAdapter<'a> {
name, name,
var_type: "echo".to_string(), var_type: "echo".to_string(),
params, params,
inject_vars: false,
..Default::default()
}, },
) )
} }

View File

@ -1,5 +1,5 @@
name: espanso name: espanso
version: 2.0.5-alpha version: 2.1.0-alpha
summary: A Cross-platform Text Expander written in Rust summary: A Cross-platform Text Expander written in Rust
description: | description: |
espanso is a Cross-platform, Text Expander written in Rust. espanso is a Cross-platform, Text Expander written in Rust.