From 9a2c27a202438fbab717cb291ebb873f4bea073f Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sat, 6 Nov 2021 22:31:23 +0100 Subject: [PATCH 01/17] chore(misc): version bump --- Cargo.lock | 2 +- espanso/Cargo.toml | 2 +- snapcraft.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9572b17..778d4d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -572,7 +572,7 @@ dependencies = [ [[package]] name = "espanso" -version = "2.0.5-alpha" +version = "2.0.6-alpha" dependencies = [ "anyhow", "caps", diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml index a80bffe..1f9aeb1 100644 --- a/espanso/Cargo.toml +++ b/espanso/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "2.0.5-alpha" +version = "2.0.6-alpha" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" diff --git a/snapcraft.yaml b/snapcraft.yaml index 4141d80..87d5178 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,5 +1,5 @@ name: espanso -version: 2.0.5-alpha +version: 2.0.6-alpha summary: A Cross-platform Text Expander written in Rust description: | espanso is a Cross-platform, Text Expander written in Rust. From 40e8dace330509bef56c3e2d9e1b3d4471b218dc Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sat, 6 Nov 2021 23:32:14 +0100 Subject: [PATCH 02/17] feat(render): add variable injection mechanism to renderer #856 --- espanso-render/src/extension/form.rs | 31 +---- espanso-render/src/lib.rs | 2 + espanso-render/src/renderer/mod.rs | 163 +++++++++++++++++++-------- espanso-render/src/renderer/util.rs | 128 ++++++++++++++++++++- 4 files changed, 245 insertions(+), 79 deletions(-) diff --git a/espanso-render/src/extension/form.rs b/espanso-render/src/extension/form.rs index c75c75d..623b482 100644 --- a/espanso-render/src/extension/form.rs +++ b/espanso-render/src/extension/form.rs @@ -17,14 +17,11 @@ * along with espanso. If not, see . */ -use crate::renderer::VAR_REGEX; use log::error; use std::collections::HashMap; use thiserror::Error; -use crate::{ - renderer::render_variables, Extension, ExtensionOutput, ExtensionResult, Params, Value, -}; +use crate::{Extension, ExtensionOutput, ExtensionResult, Params, Value}; lazy_static! { static ref EMPTY_PARAMS: Params = Params::new(); @@ -59,7 +56,7 @@ impl<'a> Extension for FormExtension<'a> { fn calculate( &self, _: &crate::Context, - scope: &crate::Scope, + _: &crate::Scope, params: &Params, ) -> crate::ExtensionResult { 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()); }; - 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() } else { Params::new() }; - // Inject scope variables into fields (if needed) - inject_scope(&mut fields, scope); - match self.provider.show(layout, &fields, &EMPTY_PARAMS) { FormProviderResult::Success(values) => { ExtensionResult::Success(ExtensionOutput::Multiple(values)) @@ -87,25 +81,6 @@ impl<'a> Extension for FormExtension<'a> { } } -// TODO: test -fn inject_scope(fields: &mut HashMap, 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)] pub enum FormExtensionError { #[error("missing layout parameter")] diff --git a/espanso-render/src/lib.rs b/espanso-render/src/lib.rs index b9932da..c72bb81 100644 --- a/espanso-render/src/lib.rs +++ b/espanso-render/src/lib.rs @@ -98,6 +98,7 @@ impl Default for Template { pub struct Variable { pub name: String, pub var_type: String, + pub inject_vars: bool, pub params: Params, } @@ -106,6 +107,7 @@ impl Default for Variable { Self { name: "".to_string(), var_type: "".to_string(), + inject_vars: true, params: Params::new(), } } diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index 917d921..8fb2a17 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -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 * @@ -17,18 +17,22 @@ * along with espanso. If not, see . */ -use std::collections::{HashMap, HashSet}; +use std::{ + borrow::Cow, + collections::{HashMap, HashSet}, +}; use crate::{ CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult, Renderer, Scope, Template, Value, Variable, }; -use anyhow::Result; use log::{error, warn}; use regex::{Captures, Regex}; use thiserror::Error; use util::get_body_variable_names; +use self::util::{inject_variables_into_params, render_variables}; + mod util; lazy_static! { @@ -123,7 +127,22 @@ impl<'a> Renderer for DefaultRenderer<'a> { return RenderResult::Error(RendererError::MissingSubMatch.into()); } } 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 + ); + return RenderResult::Error(err); + } + } + } else { + Cow::Borrowed(&variable.params) + }; + + match extension.calculate(context, &scope, &variable_params) { ExtensionResult::Success(output) => { scope.insert(&variable.name, output); } @@ -192,52 +211,6 @@ impl<'a> Renderer for DefaultRenderer<'a> { } } -// TODO: test -pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result { - 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>( variable: &Variable, templates: &'a [&Template], @@ -324,6 +297,7 @@ mod tests { params: vec![("echo".to_string(), Value::String((*value).to_string()))] .into_iter() .collect::(), + ..Default::default() }) .collect(); Template { @@ -415,6 +389,7 @@ mod tests { "echo".to_string(), Value::String("world".to_string()), )]), + ..Default::default() }], ..Default::default() }, @@ -435,6 +410,7 @@ mod tests { params: vec![("echo".to_string(), Value::String("Bob".to_string()))] .into_iter() .collect::(), + ..Default::default() }, Variable { name: "var".to_string(), @@ -454,6 +430,7 @@ mod tests { "read".to_string(), Value::String("local".to_string()), )]), + ..Default::default() }], ..Default::default() }, @@ -473,6 +450,7 @@ mod tests { params: vec![("trigger".to_string(), Value::String("nested".to_string()))] .into_iter() .collect::(), + ..Default::default() }], ..Default::default() }; @@ -503,6 +481,7 @@ mod tests { params: vec![("trigger".to_string(), Value::String("nested".to_string()))] .into_iter() .collect::(), + ..Default::default() }], ..Default::default() }; @@ -527,6 +506,7 @@ mod tests { params: vec![("abort".to_string(), Value::Null)] .into_iter() .collect::(), + ..Default::default() }], ..Default::default() }; @@ -545,10 +525,93 @@ mod tests { params: vec![("error".to_string(), Value::Null)] .into_iter() .collect::(), + ..Default::default() }], ..Default::default() }; let res = renderer.render(&template, &Default::default(), &Default::default()); 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, + }, + ]; + + 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, + }, + ]; + + 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(_))); + } } diff --git a/espanso-render/src/renderer/util.rs b/espanso-render/src/renderer/util.rs index e053cf7..52ecdf3 100644 --- a/espanso-render/src/renderer/util.rs +++ b/espanso-render/src/renderer/util.rs @@ -17,6 +17,11 @@ * along with espanso. If not, see . */ +use crate::{renderer::RendererError, ExtensionOutput, Params, Scope, Value}; +use anyhow::Result; +use log::error; +use regex::Captures; + use super::VAR_REGEX; use std::collections::HashSet; @@ -29,10 +34,91 @@ pub(crate) fn get_body_variable_names(body: &str) -> HashSet<&str> { variables } +pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result { + 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) +} + +pub(crate) fn inject_variables_into_params(params: &Params, scope: &Scope) -> Result { + 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)] mod tests { use super::*; - use std::iter::FromIterator; + use std::{collections::HashMap, iter::FromIterator}; #[test] fn get_body_variable_names_no_vars() { @@ -49,4 +135,44 @@ mod tests { 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(¶ms, &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())) + ); + } } From 29edcb900b67852867d8322fe96e8666736b75d3 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 12:22:23 +0100 Subject: [PATCH 03/17] chore(misc): version bump --- Cargo.lock | 2 +- espanso/Cargo.toml | 2 +- snapcraft.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 778d4d0..244170a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -572,7 +572,7 @@ dependencies = [ [[package]] name = "espanso" -version = "2.0.6-alpha" +version = "2.1.0-alpha" dependencies = [ "anyhow", "caps", diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml index 1f9aeb1..7248484 100644 --- a/espanso/Cargo.toml +++ b/espanso/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "espanso" -version = "2.0.6-alpha" +version = "2.1.0-alpha" authors = ["Federico Terzi "] license = "GPL-3.0" description = "Cross-platform Text Expander written in Rust" diff --git a/snapcraft.yaml b/snapcraft.yaml index 87d5178..ad623ef 100644 --- a/snapcraft.yaml +++ b/snapcraft.yaml @@ -1,5 +1,5 @@ name: espanso -version: 2.0.6-alpha +version: 2.1.0-alpha summary: A Cross-platform Text Expander written in Rust description: | espanso is a Cross-platform, Text Expander written in Rust. From fa26b1ffded3e8aa5e4d2033f1f6e7755807ae59 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 12:44:24 +0100 Subject: [PATCH 04/17] feat(modulo): refactor form parser to accept new control syntax #856 --- espanso-modulo/src/form/parser/layout.rs | 28 +++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/espanso-modulo/src/form/parser/layout.rs b/espanso-modulo/src/form/parser/layout.rs index 0f77587..4336b1e 100644 --- a/espanso-modulo/src/form/parser/layout.rs +++ b/espanso-modulo/src/form/parser/layout.rs @@ -21,7 +21,8 @@ use super::split::*; use regex::Regex; 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)] @@ -57,6 +58,9 @@ pub fn parse_layout(layout: &str) -> Vec> { if let Some(name) = caps.get(1) { let name = name.as_str().to_owned(); 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] 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); assert_eq!( result, @@ -92,7 +96,7 @@ mod tests { #[test] fn test_parse_layout_2() { - let layout = "Hey {{name}} {{surname}},"; + let layout = "Hey [[name]] [[surname]],"; let result = parse_layout(layout); assert_eq!( 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())], + ] + ); + } } From 7244d34c7c0ec3651a595ccd6c2d51b567dcf2bd Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 14:21:59 +0100 Subject: [PATCH 05/17] feat(migrate): implement form syntax auto migration #856 --- espanso-migrate/src/convert.rs | 67 ++++++++++++++++++- espanso-migrate/src/lib.rs | 2 + .../form_syntax/expected/config/default.yml | 0 .../test/form_syntax/expected/match/base.yml | 25 +++++++ .../test/form_syntax/legacy/default.yml | 25 +++++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 espanso-migrate/test/form_syntax/expected/config/default.yml create mode 100644 espanso-migrate/test/form_syntax/expected/match/base.yml create mode 100644 espanso-migrate/test/form_syntax/legacy/default.yml diff --git a/espanso-migrate/src/convert.rs b/espanso-migrate/src/convert.rs index 9fd8613..4e9b4c5 100644 --- a/espanso-migrate/src/convert.rs +++ b/espanso-migrate/src/convert.rs @@ -17,6 +17,7 @@ * along with espanso. If not, see . */ +use regex::{Captures, Regex}; use std::{cmp::Ordering, collections::HashMap, path::PathBuf}; use yaml_rust::{yaml::Hash, Yaml}; @@ -69,24 +70,32 @@ pub fn convert(input_files: HashMap) -> HashMap = global_vars.clone(); + patched_global_vars + .iter_mut() + .for_each(apply_form_syntax_patch_to_variable); + let output_global_vars = output_yaml .content .entry(Yaml::String("global_vars".to_string())) .or_insert(Yaml::Array(Vec::new())); 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 { eprintln!("unable to transform global_vars for file: {}", input_path); } } if let Some(matches) = yaml_matches { + let mut patched_matches = matches.clone(); + apply_form_syntax_patch(&mut patched_matches); + let output_matches = output_yaml .content .entry(Yaml::String("matches".to_string())) .or_insert(Yaml::Array(Vec::new())); if let Yaml::Array(out_matches) = output_matches { - out_matches.extend(matches.clone()); + out_matches.extend(patched_matches); } else { 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) { + 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.*?)\}\}").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() +} diff --git a/espanso-migrate/src/lib.rs b/espanso-migrate/src/lib.rs index 1208053..d3c9710 100644 --- a/espanso-migrate/src/lib.rs +++ b/espanso-migrate/src/lib.rs @@ -200,12 +200,14 @@ mod tests { static BASE_CASE: Dir = include_dir!("test/base"); static ALL_PARAMS_CASE: Dir = include_dir!("test/all_params"); static OTHER_DIRS_CASE: Dir = include_dir!("test/other_dirs"); + static FORM_SYNTAX: Dir = include_dir!("test/form_syntax"); #[allow(clippy::unused_unit)] #[test_case(&SIMPLE_CASE; "simple case")] #[test_case(&BASE_CASE; "base case")] #[test_case(&ALL_PARAMS_CASE; "all config parameters case")] #[test_case(&OTHER_DIRS_CASE; "other directories case")] + #[test_case(&FORM_SYNTAX; "form syntax")] fn test_migration(test_data: &Dir) { run_with_temp_dir(test_data, |legacy, expected| { let tmp_out_dir = TempDir::new("espanso-migrate-out").unwrap(); diff --git a/espanso-migrate/test/form_syntax/expected/config/default.yml b/espanso-migrate/test/form_syntax/expected/config/default.yml new file mode 100644 index 0000000..e69de29 diff --git a/espanso-migrate/test/form_syntax/expected/match/base.yml b/espanso-migrate/test/form_syntax/expected/match/base.yml new file mode 100644 index 0000000..474aecc --- /dev/null +++ b/espanso-migrate/test/form_syntax/expected/match/base.yml @@ -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" \ No newline at end of file diff --git a/espanso-migrate/test/form_syntax/legacy/default.yml b/espanso-migrate/test/form_syntax/legacy/default.yml new file mode 100644 index 0000000..22324a1 --- /dev/null +++ b/espanso-migrate/test/form_syntax/legacy/default.yml @@ -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" \ No newline at end of file From 34ba1e39e4fc0ba5a410d2ba8f8c267a56d72741 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 15:29:45 +0100 Subject: [PATCH 06/17] feat(config): refactor YAML match parsing to account for new form syntax #856 --- espanso-config/src/legacy/mod.rs | 2 +- .../src/matches/group/loader/yaml/mod.rs | 174 ++++++++++++++++-- 2 files changed, 160 insertions(+), 16 deletions(-) diff --git a/espanso-config/src/legacy/mod.rs b/espanso-config/src/legacy/mod.rs index 674b08a..7c3126e 100644 --- a/espanso-config/src/legacy/mod.rs +++ b/espanso-config/src/legacy/mod.rs @@ -102,7 +102,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup) .iter() .filter_map(|var| { 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| { warn!("{}", warning); }); diff --git a/espanso-config/src/matches/group/loader/yaml/mod.rs b/espanso-config/src/matches/group/loader/yaml/mod.rs index 1d3a61c..6e1e3d1 100644 --- a/espanso-config/src/matches/group/loader/yaml/mod.rs +++ b/espanso-config/src/matches/group/loader/yaml/mod.rs @@ -43,6 +43,8 @@ mod util; lazy_static! { 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 @@ -85,7 +87,7 @@ impl Importer for YAMLImporter { let mut matches = Vec::new(); 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)) => { matches.push(m); 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)> { +pub fn try_convert_into_match( + yaml_match: YAMLMatch, + use_compatibility_mode: bool, +) -> Result<(Match, Vec)> { let mut warnings = Vec::new(); if yaml_match.uppercase_style.is_some() && yaml_match.propagate_case.is_none() { @@ -216,21 +221,45 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec Result<(Match, Vec Result<(Match, Vec)> { + fn create_match_with_warnings( + yaml: &str, + use_compatibility_mode: bool, + ) -> Result<(Match, Vec)> { 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 m.id = 0; @@ -309,7 +341,7 @@ mod tests { } fn create_match(yaml: &str) -> Result { - let (m, warnings) = create_match_with_warnings(yaml)?; + let (m, warnings) = create_match_with_warnings(yaml, false)?; if !warnings.is_empty() { panic!("warnings were detected but not handled: {:?}", warnings); } @@ -529,6 +561,7 @@ mod tests { replace: "world" uppercase_style: "capitalize" "#, + false, ) .unwrap(); assert_eq!( @@ -545,6 +578,7 @@ mod tests { uppercase_style: "invalid" propagate_case: true "#, + false, ) .unwrap(); assert_eq!( @@ -554,6 +588,116 @@ mod tests { 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() + } + ) + } + + #[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() + } + ) + } + + #[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() + } + ) + } + #[test] fn vars_maps_correctly() { let mut params = Params::new(); From 9fb1d2a22a75ab3f264a2bc9eae314de390c4752 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 15:43:24 +0100 Subject: [PATCH 07/17] feat(config): add inject_vars option to matches. #856 --- espanso-config/src/legacy/mod.rs | 2 +- .../src/matches/group/loader/yaml/mod.rs | 23 +++++++++++++------ .../src/matches/group/loader/yaml/parse.rs | 3 +++ espanso-config/src/matches/mod.rs | 2 ++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/espanso-config/src/legacy/mod.rs b/espanso-config/src/legacy/mod.rs index 7c3126e..8347c8e 100644 --- a/espanso-config/src/legacy/mod.rs +++ b/espanso-config/src/legacy/mod.rs @@ -89,7 +89,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup) .iter() .filter_map(|var| { 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| { warn!("{}", warning); }); diff --git a/espanso-config/src/matches/group/loader/yaml/mod.rs b/espanso-config/src/matches/group/loader/yaml/mod.rs index 6e1e3d1..785cea3 100644 --- a/espanso-config/src/matches/group/loader/yaml/mod.rs +++ b/espanso-config/src/matches/group/loader/yaml/mod.rs @@ -74,7 +74,7 @@ impl Importer for YAMLImporter { let mut global_vars = Vec::new(); 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)) => { global_vars.push(var); non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn)); @@ -208,8 +208,9 @@ pub fn try_convert_into_match( let mut vars: Vec = Vec::new(); for yaml_var in yaml_match.vars.unwrap_or_default() { - let (var, var_warnings) = try_convert_into_variable(yaml_var.clone()) - .with_context(|| format!("failed to load variable: {:?}", yaml_var))?; + let (var, var_warnings) = + try_convert_into_variable(yaml_var.clone(), use_compatibility_mode) + .with_context(|| format!("failed to load variable: {:?}", yaml_var))?; warnings.extend(var_warnings); vars.push(var); } @@ -270,6 +271,7 @@ pub fn try_convert_into_match( name: "form1".to_owned(), var_type: "form".to_owned(), params, + ..Default::default() }]; MatchEffect::Text(TextEffect { @@ -303,13 +305,17 @@ pub fn try_convert_into_match( )) } -pub fn try_convert_into_variable(yaml_var: YAMLVariable) -> Result<(Variable, Vec)> { +pub fn try_convert_into_variable( + yaml_var: YAMLVariable, + use_compatibility_mode: bool, +) -> Result<(Variable, Vec)> { Ok(( Variable { name: yaml_var.name, var_type: yaml_var.var_type, params: convert_params(yaml_var.params)?, id: next_id(), + inject_vars: !use_compatibility_mode && yaml_var.inject_vars.unwrap_or(true), }, Vec::new(), )) @@ -615,7 +621,8 @@ mod tests { id: 0, name: "form1".to_string(), var_type: "form".to_string(), - params + params, + ..Default::default() }], ..Default::default() }), @@ -651,7 +658,8 @@ mod tests { id: 0, name: "form1".to_string(), var_type: "form".to_string(), - params + params, + ..Default::default() }], ..Default::default() }), @@ -689,7 +697,8 @@ mod tests { id: 0, name: "form1".to_string(), var_type: "form".to_string(), - params + params, + ..Default::default() }], ..Default::default() }), diff --git a/espanso-config/src/matches/group/loader/yaml/parse.rs b/espanso-config/src/matches/group/loader/yaml/parse.rs index 39a18fa..52ca447 100644 --- a/espanso-config/src/matches/group/loader/yaml/parse.rs +++ b/espanso-config/src/matches/group/loader/yaml/parse.rs @@ -125,6 +125,9 @@ pub struct YAMLVariable { #[serde(default = "default_params")] pub params: Mapping, + + #[serde(default)] + pub inject_vars: Option, } fn default_params() -> Mapping { diff --git a/espanso-config/src/matches/mod.rs b/espanso-config/src/matches/mod.rs index 7e3fb13..5d74bdd 100644 --- a/espanso-config/src/matches/mod.rs +++ b/espanso-config/src/matches/mod.rs @@ -205,6 +205,7 @@ pub struct Variable { pub name: String, pub var_type: String, pub params: Params, + pub inject_vars: bool, } impl Default for Variable { @@ -214,6 +215,7 @@ impl Default for Variable { name: String::new(), var_type: String::new(), params: Params::new(), + inject_vars: true, } } } From a0412cdc7ef7a63dc1e50da2612a6dcd125a6afe Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 15:43:58 +0100 Subject: [PATCH 08/17] feat(core): wire up inject vars option #856 --- espanso/src/cli/worker/engine/process/middleware/render/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs index e2f73ea..dcb0c6a 100644 --- a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs +++ b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs @@ -151,6 +151,7 @@ fn convert_var(var: espanso_config::matches::Variable) -> espanso_render::Variab name: var.name, var_type: var.var_type, params: convert_params(var.params), + inject_vars: var.inject_vars, } } @@ -225,6 +226,7 @@ impl<'a> Renderer<'a> for RendererAdapter<'a> { name, var_type: "echo".to_string(), params, + inject_vars: false, }, ) } From d02c63dccf0d133d3108cdb62a2dfa3f49b58f6b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 15:53:32 +0100 Subject: [PATCH 09/17] feat(render): add tip in logs when legacy form syntax throws error #856 --- espanso-render/src/renderer/mod.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index 8fb2a17..4fd11c0 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -135,6 +135,29 @@ impl<'a> Renderer for DefaultRenderer<'a> { "unable to inject variables into params of variable '{}': {}", variable.name, err ); + + if variable.var_type == "form" { + if let Some(RendererError::MissingVariable(_)) = + err.downcast_ref::() + { + 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, 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!(""); + } + } + return RenderResult::Error(err); } } From 57450bee3268426d0a450726ca0c033ca99c5ff4 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Sun, 7 Nov 2021 16:46:12 +0100 Subject: [PATCH 10/17] feat(core): refactor form's choice and list values to accept multiline strings. Fix #855 --- .../middleware/render/extension/form.rs | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs b/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs index fb0510b..16da24f 100644 --- a/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs +++ b/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs @@ -63,14 +63,7 @@ fn convert_fields(fields: &Params) -> HashMap { .cloned(), values: params .get("values") - .and_then(|val| val.as_array()) - .map(|arr| { - arr - .iter() - .flat_map(|choice| choice.as_string()) - .cloned() - .collect() - }) + .and_then(|v| extract_values(v, params.get("trim_string_values"))) .unwrap_or_default(), }), Some(Value::String(field_type)) if field_type == "list" => Some(FormField::List { @@ -80,14 +73,7 @@ fn convert_fields(fields: &Params) -> HashMap { .cloned(), values: params .get("values") - .and_then(|val| val.as_array()) - .map(|arr| { - arr - .iter() - .flat_map(|choice| choice.as_string()) - .cloned() - .collect() - }) + .and_then(|v| extract_values(v, params.get("trim_string_values"))) .unwrap_or_default(), }), // By default, it's considered type 'text' @@ -113,3 +99,38 @@ fn convert_fields(fields: &Params) -> HashMap { } out } + +fn extract_values(value: &Value, trim_string_values: Option<&Value>) -> Option> { + 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, + } +} From 8acca4a36685063ffea52d79595f4a85996da56d Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Wed, 10 Nov 2021 23:17:23 +0100 Subject: [PATCH 11/17] feat(render): implement new variable resolution algorithm --- espanso-render/src/lib.rs | 3 + espanso-render/src/renderer/mod.rs | 268 ++++++++++++++++++++++--- espanso-render/src/renderer/resolve.rs | 197 ++++++++++++++++++ espanso-render/src/renderer/util.rs | 32 +++ 4 files changed, 474 insertions(+), 26 deletions(-) create mode 100644 espanso-render/src/renderer/resolve.rs diff --git a/espanso-render/src/lib.rs b/espanso-render/src/lib.rs index c72bb81..0517f9f 100644 --- a/espanso-render/src/lib.rs +++ b/espanso-render/src/lib.rs @@ -100,6 +100,8 @@ pub struct Variable { pub var_type: String, pub inject_vars: bool, pub params: Params, + // Name of the variables this variable depends on + pub depends_on: Vec, } impl Default for Variable { @@ -109,6 +111,7 @@ impl Default for Variable { var_type: "".to_string(), inject_vars: true, params: Params::new(), + depends_on: Vec::new(), } } } diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index 4fd11c0..e6d27e7 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -17,10 +17,7 @@ * along with espanso. If not, see . */ -use std::{ - borrow::Cow, - collections::{HashMap, HashSet}, -}; +use std::{borrow::Cow, collections::HashMap}; use crate::{ CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult, @@ -29,10 +26,10 @@ use crate::{ use log::{error, warn}; use regex::{Captures, Regex}; use thiserror::Error; -use util::get_body_variable_names; use self::util::{inject_variables_into_params, render_variables}; +mod resolve; mod util; lazy_static! { @@ -63,23 +60,6 @@ impl<'a> Renderer for DefaultRenderer<'a> { options: &RenderOptions, ) -> RenderResult { 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 let local_variables: Vec<&Variable> = if template.vars.iter().any(|var| var.var_type == "global") { @@ -103,10 +83,16 @@ impl<'a> Renderer for DefaultRenderer<'a> { template.vars.iter().collect() }; - // The implicit global variables will be evaluated first, followed by the local vars - let mut variables: Vec<&Variable> = Vec::new(); - variables.extend(missing_global_variables); - variables.extend(local_variables.iter()); + // Here we execute a graph dependency resolution algorithm to determine a valid + // evaluation order for variables. + let variables = match resolve::resolve_evaluation_order( + &template.body, + &local_variables, + &context.global_vars, + ) { + Ok(variables) => variables, + Err(err) => return RenderResult::Error(err), + }; // Compute the variable outputs let mut scope = Scope::new(); @@ -257,6 +243,9 @@ pub enum RendererError { #[error("missing sub match")] MissingSubMatch, + + #[error("circular dependency: `{0}` -> `{1}`")] + CircularDependency(String, String), } #[cfg(test)] @@ -462,6 +451,147 @@ mod tests { 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::(), + 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] fn nested_match() { let renderer = get_renderer(); @@ -587,6 +717,7 @@ mod tests { Value::String("{{firstname}} {{lastname}}".to_string()), )]), inject_vars: true, + ..Default::default() }, ]; @@ -613,6 +744,7 @@ mod tests { Value::String("{{first}} two".to_string()), )]), inject_vars: false, + ..Default::default() }, ]; @@ -637,4 +769,88 @@ mod tests { 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")); + } } diff --git a/espanso-render/src/renderer/resolve.rs b/espanso-render/src/renderer/resolve.rs new file mode 100644 index 0000000..13ea967 --- /dev/null +++ b/espanso-render/src/renderer/resolve.rs @@ -0,0 +1,197 @@ +/* + * 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 . + */ + +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, +}; + +use anyhow::{anyhow, Result}; + +use crate::Variable; + +use super::RendererError; + +struct Node<'a> { + name: &'a str, + variable: Option<&'a Variable>, + dependencies: Option>, +} + +pub(crate) fn resolve_evaluation_order<'a>( + body: &'a str, + local_vars: &'a [&'a Variable], + global_vars: &'a [&'a Variable], +) -> Result> { + 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>, + resolved: &'a RefCell>, + seen: &'a RefCell>, +) -> 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 => { + 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(()) +} diff --git a/espanso-render/src/renderer/util.rs b/espanso-render/src/renderer/util.rs index 52ecdf3..0e13772 100644 --- a/espanso-render/src/renderer/util.rs +++ b/espanso-render/src/renderer/util.rs @@ -34,6 +34,38 @@ pub(crate) fn get_body_variable_names(body: &str) -> HashSet<&str> { 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 { let mut replacing_error = None; let output = VAR_REGEX From aa26f27ed9a5504fbe11ef49629f90a7f4569e88 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Wed, 10 Nov 2021 23:22:53 +0100 Subject: [PATCH 12/17] feat(config): implement depends_on field for variables --- .../src/matches/group/loader/yaml/mod.rs | 47 +++++++++++++++++++ .../src/matches/group/loader/yaml/parse.rs | 3 ++ espanso-config/src/matches/mod.rs | 2 + 3 files changed, 52 insertions(+) diff --git a/espanso-config/src/matches/group/loader/yaml/mod.rs b/espanso-config/src/matches/group/loader/yaml/mod.rs index 785cea3..1559fda 100644 --- a/espanso-config/src/matches/group/loader/yaml/mod.rs +++ b/espanso-config/src/matches/group/loader/yaml/mod.rs @@ -316,6 +316,7 @@ pub fn try_convert_into_variable( params: convert_params(yaml_var.params)?, id: next_id(), inject_vars: !use_compatibility_mode && yaml_var.inject_vars.unwrap_or(true), + depends_on: yaml_var.depends_on, }, Vec::new(), )) @@ -745,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] fn vars_no_params_maps_correctly() { let vars = vec![Variable { diff --git a/espanso-config/src/matches/group/loader/yaml/parse.rs b/espanso-config/src/matches/group/loader/yaml/parse.rs index 52ca447..50c1463 100644 --- a/espanso-config/src/matches/group/loader/yaml/parse.rs +++ b/espanso-config/src/matches/group/loader/yaml/parse.rs @@ -128,6 +128,9 @@ pub struct YAMLVariable { #[serde(default)] pub inject_vars: Option, + + #[serde(default)] + pub depends_on: Vec, } fn default_params() -> Mapping { diff --git a/espanso-config/src/matches/mod.rs b/espanso-config/src/matches/mod.rs index 5d74bdd..8d2a5bb 100644 --- a/espanso-config/src/matches/mod.rs +++ b/espanso-config/src/matches/mod.rs @@ -206,6 +206,7 @@ pub struct Variable { pub var_type: String, pub params: Params, pub inject_vars: bool, + pub depends_on: Vec, } impl Default for Variable { @@ -216,6 +217,7 @@ impl Default for Variable { var_type: String::new(), params: Params::new(), inject_vars: true, + depends_on: Vec::new(), } } } From 317d3f20510f6dc474d76ee35ba1b6b312989373 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Wed, 10 Nov 2021 23:23:10 +0100 Subject: [PATCH 13/17] feat(core): wire up depends_on field for variables --- espanso/src/cli/worker/engine/process/middleware/render/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs index dcb0c6a..9b5d7ae 100644 --- a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs +++ b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs @@ -152,6 +152,7 @@ fn convert_var(var: espanso_config::matches::Variable) -> espanso_render::Variab var_type: var.var_type, params: convert_params(var.params), inject_vars: var.inject_vars, + depends_on: var.depends_on, } } @@ -227,6 +228,7 @@ impl<'a> Renderer<'a> for RendererAdapter<'a> { var_type: "echo".to_string(), params, inject_vars: false, + ..Default::default() }, ) } From c7d6d69b726283a8e42492edce66f15f6a775f1c Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Thu, 11 Nov 2021 21:31:51 +0100 Subject: [PATCH 14/17] feat(render): add tests for dict variables --- espanso-render/src/renderer/mod.rs | 54 ++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index e6d27e7..f90ac8a 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -270,6 +270,13 @@ mod tests { if let Some(Value::String(string)) = params.get("echo") { 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 let Some(Value::String(string)) = params.get("read") { if let Some(ExtensionOutput::Single(value)) = scope.get(string.as_str()) { @@ -379,6 +386,28 @@ mod tests { 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::(), + ..Default::default() + }], + ..Default::default() + }; + let res = renderer.render(&template, &Default::default(), &Default::default()); + assert!(matches!(res, RenderResult::Success(str) if str == "hello dict")); + } + #[test] fn missing_variable() { let renderer = get_renderer(); @@ -410,6 +439,31 @@ mod tests { 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::(), + ..Default::default() + }], + ..Default::default() + }, + &Default::default(), + ); + assert!(matches!(res, RenderResult::Success(str) if str == "hello dict")); + } + #[test] fn global_variable_explicit_ordering() { let renderer = get_renderer(); From 2745257ce9e8413d0e845de2e167815561dcab5a Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Fri, 12 Nov 2021 20:49:56 +0100 Subject: [PATCH 15/17] fix(detect): add workaround to fix inconsistent modifier states on macOS. Fix #825 Fix #858 --- espanso-detect/src/mac/mod.rs | 96 +++++++++++++++++++++++++++++++- espanso-detect/src/mac/native.h | 10 ++++ espanso-detect/src/mac/native.mm | 27 +++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/espanso-detect/src/mac/mod.rs b/espanso-detect/src/mac/mod.rs index 5b93558..5f49ea0 100644 --- a/espanso-detect/src/mac/mod.rs +++ b/espanso-detect/src/mac/mod.rs @@ -32,7 +32,7 @@ use log::{error, trace, warn}; use anyhow::Result; 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::Status::*, Source, SourceCallback}; 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 #[repr(C)] +#[derive(Debug)] pub struct RawInputEvent { pub event_type: i32, @@ -58,6 +59,12 @@ pub struct RawInputEvent { pub key_code: 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)] @@ -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! { static ref CURRENT_SENDER: Arc>>> = Arc::new(Mutex::new(None)); + static ref MODIFIER_STATE: Arc> = + Arc::new(Mutex::new(ModifierState::default())); } extern "C" fn native_callback(raw_event: RawInputEvent) { let lock = CURRENT_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() { + 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 = raw_event.into(); if let Some(event) = event { if let Err(error) = sender.send(event) { @@ -386,6 +475,11 @@ mod tests { buffer_len: 0, key_code: 0, 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, } } diff --git a/espanso-detect/src/mac/native.h b/espanso-detect/src/mac/native.h index a86d943..50836e6 100644 --- a/espanso-detect/src/mac/native.h +++ b/espanso-detect/src/mac/native.h @@ -52,6 +52,16 @@ typedef struct { // Pressed or Released 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; typedef void (*EventCallback)(InputEvent data); diff --git a/espanso-detect/src/mac/native.mm b/espanso-detect/src/mac/native.mm index 6eb5e9a..b96d7ac 100644 --- a/espanso-detect/src/mac/native.mm +++ b/espanso-detect/src/mac/native.mm @@ -76,6 +76,19 @@ void * detect_initialize(EventCallback callback, InitializeOptions options) { strncpy(inputEvent.buffer, chars, 23); 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); }else if (event.type == NSEventTypeLeftMouseDown || event.type == NSEventTypeRightMouseDown || event.type == NSEventTypeOtherMouseDown || 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) { 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); } }]; From 38edd67bd088d918937b30cf240c205f7fa5ba3b Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Fri, 12 Nov 2021 21:10:04 +0100 Subject: [PATCH 16/17] feat(render): improve error log when variable is missing in forms --- espanso-render/src/renderer/mod.rs | 34 ++++++++++++++------------ espanso-render/src/renderer/resolve.rs | 7 ++++++ 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index f90ac8a..7ed547e 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -126,21 +126,7 @@ impl<'a> Renderer for DefaultRenderer<'a> { if let Some(RendererError::MissingVariable(_)) = err.downcast_ref::() { - 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, 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!(""); + log_new_form_syntax_tip(); } } @@ -236,6 +222,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)] pub enum RendererError { #[error("missing variable: `{0}`")] diff --git a/espanso-render/src/renderer/resolve.rs b/espanso-render/src/renderer/resolve.rs index 13ea967..c153408 100644 --- a/espanso-render/src/renderer/resolve.rs +++ b/espanso-render/src/renderer/resolve.rs @@ -23,6 +23,7 @@ use std::{ }; use anyhow::{anyhow, Result}; +use log::error; use crate::Variable; @@ -179,6 +180,12 @@ fn resolve_dependencies<'a>( 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()); } } From 0387ba811813dc169548c7108d9a95de43dd17a0 Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Fri, 12 Nov 2021 22:13:37 +0100 Subject: [PATCH 17/17] feat(render): add logic to enable variable injection escape --- espanso-render/src/renderer/mod.rs | 36 +++++++++++++++++++++++++++++ espanso-render/src/renderer/util.rs | 7 +++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs index 7ed547e..92b38e6 100644 --- a/espanso-render/src/renderer/mod.rs +++ b/espanso-render/src/renderer/mod.rs @@ -175,6 +175,8 @@ impl<'a> Renderer for DefaultRenderer<'a> { template.body.clone() }; + let body = util::unescape_variable_inections(&body); + // Process the casing style let body_with_casing = match options.casing_style { CasingStyle::None => body, @@ -810,6 +812,32 @@ mod tests { 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(); @@ -911,4 +939,12 @@ mod tests { ); 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}}")); + } } diff --git a/espanso-render/src/renderer/util.rs b/espanso-render/src/renderer/util.rs index 0e13772..299e164 100644 --- a/espanso-render/src/renderer/util.rs +++ b/espanso-render/src/renderer/util.rs @@ -108,7 +108,12 @@ pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result { return Err(error.into()); } - Ok(output) + 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 {