diff --git a/Cargo.lock b/Cargo.lock index 96bdada..6d2cfec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,6 +323,19 @@ dependencies = [ "unicase", ] +[[package]] +name = "espanso-render" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "enum-as-inner", + "lazy_static", + "log", + "regex", + "thiserror", +] + [[package]] name = "espanso-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 56b579a..a8e3bce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,4 +9,5 @@ members = [ "espanso-config", "espanso-match", "espanso-clipboard", + "espanso-render", ] \ No newline at end of file diff --git a/espanso-render/Cargo.toml b/espanso-render/Cargo.toml new file mode 100644 index 0000000..825eec2 --- /dev/null +++ b/espanso-render/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "espanso-render" +version = "0.1.0" +authors = ["Federico Terzi "] +edition = "2018" + +[dependencies] +log = "0.4.14" +anyhow = "1.0.38" +thiserror = "1.0.23" +regex = "1.4.3" +lazy_static = "1.4.0" +chrono = "0.4.19" +enum-as-inner = "0.3.3" \ No newline at end of file diff --git a/espanso-render/src/extension/mod.rs b/espanso-render/src/extension/mod.rs new file mode 100644 index 0000000..fb433fa --- /dev/null +++ b/espanso-render/src/extension/mod.rs @@ -0,0 +1,20 @@ +/* + * 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 . + */ + +pub mod date; \ No newline at end of file diff --git a/espanso-render/src/lib.rs b/espanso-render/src/lib.rs new file mode 100644 index 0000000..4ae454f --- /dev/null +++ b/espanso-render/src/lib.rs @@ -0,0 +1,149 @@ +/* + * 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 . + */ + +#[macro_use] +extern crate lazy_static; + +use std::collections::HashMap; +use enum_as_inner::EnumAsInner; + +mod renderer; +pub mod extension; + +pub trait Renderer { + fn render(&self, template: &Template, context: &Context, options: &RenderOptions) + -> RenderResult; +} + +pub fn create(extensions: Vec>) -> impl Renderer { + renderer::DefaultRenderer::new(extensions) +} + +#[derive(Debug)] +pub enum RenderResult { + Success(String), + Aborted, + Error(anyhow::Error), +} + +pub struct Context<'a> { + pub global_vars: Vec<&'a Variable>, + pub templates: Vec<&'a Template>, +} + +impl<'a> Default for Context<'a> { + fn default() -> Self { + Self { + global_vars: Vec::new(), + templates: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RenderOptions { + casing_style: CasingStyle, +} + +impl Default for RenderOptions { + fn default() -> Self { + Self { + casing_style: CasingStyle::None, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CasingStyle { + None, + Capitalize, + Uppercase, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Template { + ids: Vec, + body: String, + vars: Vec, +} + +impl Default for Template { + fn default() -> Self { + Self { + ids: Vec::new(), + body: "".to_string(), + vars: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Variable { + name: String, + var_type: String, + params: Params, +} + +impl Default for Variable { + fn default() -> Self { + Self { + name: "".to_string(), + var_type: "".to_string(), + params: Params::new(), + } + } +} + +pub type Params = HashMap; + +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Number(Number), + String(String), + Array(Vec), + Object(HashMap), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Number { + Integer(i64), + Float(f64), +} + +pub trait Extension { + fn name(&self) -> &str; + fn calculate(&self, context: &Context, scope: &Scope, params: &Params) -> ExtensionResult; +} + +pub type Scope<'a> = HashMap<&'a str, ExtensionOutput>; + +#[derive(Debug, PartialEq)] +pub enum ExtensionOutput { + Single(String), + Multiple(HashMap), +} + +#[derive(Debug, EnumAsInner)] +pub enum ExtensionResult { + Success(ExtensionOutput), + Aborted, + Error(anyhow::Error), +} diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs new file mode 100644 index 0000000..b4fd152 --- /dev/null +++ b/espanso-render/src/renderer/mod.rs @@ -0,0 +1,473 @@ +/* + * This file is part of esp name: (), var_type: (), params: ()anso. + * + * 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::collections::{HashMap, HashSet}; + +use crate::{ + CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult, + Renderer, Scope, Template, Value, Variable +}; +use log::{error, warn}; +use regex::{Captures, Regex}; +use thiserror::Error; +use util::get_body_variable_names; + +mod util; + +lazy_static! { + pub(crate) static ref VAR_REGEX: Regex = + Regex::new(r"\{\{\s*((?P\w+)(\.(?P(\w+)))?)\s*\}\}").unwrap(); +} + +pub(crate) struct DefaultRenderer { + extensions: HashMap>, +} + +impl DefaultRenderer { + pub fn new(extensions: Vec>) -> Self { + let extensions = extensions + .into_iter() + .map(|ext| (ext.name().to_string(), ext)) + .collect(); + Self { extensions } + } +} + +impl Renderer for DefaultRenderer { + fn render( + &self, + template: &Template, + context: &Context, + 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") { + let global_vars: HashMap<&str, &Variable> = context + .global_vars + .iter() + .map(|var| (&*var.name, *var)) + .collect(); + template + .vars + .iter() + .filter_map(|var| { + if var.var_type == "global" { + global_vars.get(&*var.name).copied() + } else { + Some(var) + } + }) + .collect() + } else { + template.vars.iter().map(|var| var).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()); + + // Compute the variable outputs + let mut scope = Scope::new(); + for variable in variables { + if variable.var_type == "match" { + // Recursive call + // Call render recursively + if let Some(sub_template) = get_matching_template(variable, context.templates.as_slice()) + { + match self.render(sub_template, context, options) { + RenderResult::Success(output) => { + scope.insert(&variable.name, ExtensionOutput::Single(output)); + } + result => return result, + } + } else { + error!("unable to find sub-match: {}", variable.name); + return RenderResult::Error(RendererError::MissingSubMatch.into()); + } + } else if let Some(extension) = self.extensions.get(&variable.var_type) { + match extension.calculate(context, &scope, &variable.params) { + ExtensionResult::Success(output) => { + scope.insert(&variable.name, output); + } + ExtensionResult::Aborted => { + warn!( + "rendering was aborted by extension: {}, on var: {}", + variable.var_type, variable.name + ); + return RenderResult::Aborted; + } + ExtensionResult::Error(err) => { + warn!( + "extension '{}' on var: '{}' reported an error: {}", + variable.var_type, variable.name, err + ); + return RenderResult::Error(err); + } + } + } else { + error!( + "no extension found for variable type: {}", + variable.var_type + ); + } + } + + // Replace the variables + let mut replacing_error = None; + let output = VAR_REGEX + .replace_all(&template.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 RenderResult::Error(error.into()); + } + + output + } else { + template.body.clone() + }; + + // Process the casing style + let body_with_casing = match options.casing_style { + CasingStyle::None => body, + CasingStyle::Uppercase => body.to_uppercase(), + CasingStyle::Capitalize => { + // Capitalize the first letter + let mut v: Vec = body.chars().collect(); + v[0] = v[0].to_uppercase().next().unwrap(); + v.into_iter().collect() + } + }; + + RenderResult::Success(body_with_casing) + } +} + +fn get_matching_template<'a>( + variable: &Variable, + templates: &'a [&Template], +) -> Option<&'a Template> { + // Find matching template + let id = variable.params.get("trigger")?; + if let Value::String(id) = id { + templates + .iter() + .find(|template| template.ids.contains(id)) + .copied() + } else { + None + } +} + +#[derive(Error, Debug)] +pub enum RendererError { + #[error("missing variable: `{0}`")] + MissingVariable(String), + + #[error("missing sub match")] + MissingSubMatch, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Params; + use std::iter::FromIterator; + + struct MockExtension {} + + impl Extension for MockExtension { + fn name(&self) -> &str { + "mock" + } + + fn calculate( + &self, + _context: &Context, + scope: &Scope, + params: &crate::Params, + ) -> ExtensionResult { + if let Some(value) = params.get("echo") { + if let Value::String(string) = value { + return ExtensionResult::Success(ExtensionOutput::Single(string.clone())); + } + } + // If the "read" param is present, echo the value of the corresponding result in the scope + if let Some(value) = params.get("read") { + if let Value::String(string) = value { + if let Some(ExtensionOutput::Single(value)) = scope.get(string.as_str()) { + return ExtensionResult::Success(ExtensionOutput::Single(value.to_string())); + } + } + } + if params.get("abort").is_some() { + return ExtensionResult::Aborted; + } + if params.get("error").is_some() { + return ExtensionResult::Error(RendererError::MissingVariable("missing".to_string()).into()) + } + ExtensionResult::Aborted + } + } + + pub fn get_renderer() -> impl Renderer { + DefaultRenderer::new(vec![Box::new(MockExtension {})]) + } + + pub fn template_for_str(str: &str) -> Template { + Template { + ids: vec!["id".to_string()], + body: str.to_string(), + vars: Vec::new(), + } + } + + pub fn template(body: &str, vars: &[(&str, &str)]) -> Template { + let vars = vars.iter().map(|(name, value)| { + Variable { + name: (*name).to_string(), + var_type: "mock".to_string(), + params: Params::from_iter(vec![("echo".to_string(), Value::String((*value).to_string()))].into_iter()) + } + }).collect(); + Template { + ids: vec!["id".to_string()], + body: body.to_string(), + vars, + } + } + + #[test] + fn no_variable_no_styling() { + let renderer = get_renderer(); + let res = renderer.render(&template_for_str("plain body"), &Default::default(), &Default::default()); + assert!(matches!(res, RenderResult::Success(str) if str == "plain body")); + } + + #[test] + fn no_variable_capitalize() { + let renderer = get_renderer(); + let res = renderer.render(&template_for_str("plain body"), &Default::default(), &RenderOptions { + casing_style: CasingStyle::Capitalize, + }); + assert!(matches!(res, RenderResult::Success(str) if str == "Plain body")); + } + + #[test] + fn no_variable_uppercase() { + let renderer = get_renderer(); + let res = renderer.render(&template_for_str("plain body"), &Default::default(), &RenderOptions { + casing_style: CasingStyle::Uppercase, + }); + assert!(matches!(res, RenderResult::Success(str) if str == "PLAIN BODY")); + } + + #[test] + fn basic_variable() { + 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 world")); + } + + #[test] + fn missing_variable() { + let renderer = get_renderer(); + let template = template_for_str("hello {{var}}"); + let res = renderer.render(&template, &Default::default(), &Default::default()); + assert!(matches!(res, RenderResult::Error(_))); + } + + #[test] + fn global_variable() { + 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()))]) + } + ], + ..Default::default() + }, &Default::default()); + assert!(matches!(res, RenderResult::Success(str) if str == "hello world")); + } + + #[test] + fn global_variable_explicit_ordering() { + let renderer = get_renderer(); + let template = Template { + body: "hello {{var}} {{local}}".to_string(), + vars: vec![ + Variable { + name: "local".to_string(), + var_type: "mock".to_string(), + params: Params::from_iter(vec![("echo".to_string(), Value::String("Bob".to_string()))].into_iter()) + }, + Variable { + name: "var".to_string(), + var_type: "global".to_string(), + ..Default::default() + } + ], + ..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![("read".to_string(), Value::String("local".to_string()))]) + } + ], + ..Default::default() + }, &Default::default()); + assert!(matches!(res, RenderResult::Success(str) if str == "hello Bob Bob")); + } + + #[test] + fn nested_match() { + let renderer = get_renderer(); + let template = Template { + body: "hello {{var}}".to_string(), + vars: vec![ + Variable { + name: "var".to_string(), + var_type: "match".to_string(), + params: Params::from_iter(vec![("trigger".to_string(), Value::String("nested".to_string()))].into_iter()) + }, + ], + ..Default::default() + }; + let nested_template = Template { + ids: vec!["nested".to_string()], + body: "world".to_string(), + ..Default::default() + }; + let res = renderer.render(&template, &Context { + templates: vec![&nested_template], + ..Default::default() + }, &Default::default()); + assert!(matches!(res, RenderResult::Success(str) if str == "hello world")); + } + + #[test] + fn missing_nested_match() { + let renderer = get_renderer(); + let template = Template { + body: "hello {{var}}".to_string(), + vars: vec![ + Variable { + name: "var".to_string(), + var_type: "match".to_string(), + params: Params::from_iter(vec![("trigger".to_string(), Value::String("nested".to_string()))].into_iter()) + }, + ], + ..Default::default() + }; + let res = renderer.render(&template, &Context { + ..Default::default() + }, &Default::default()); + assert!(matches!(res, RenderResult::Error(_))); + } + + #[test] + fn extension_aborting_propagates() { + 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: Params::from_iter(vec![("abort".to_string(), Value::Null)].into_iter()), + } + ], + ..Default::default() + }; + let res = renderer.render(&template, &Default::default(), &Default::default()); + assert!(matches!(res, RenderResult::Aborted)); + } + + #[test] + fn extension_error_propagates() { + 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: Params::from_iter(vec![("error".to_string(), Value::Null)].into_iter()), + } + ], + ..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 new file mode 100644 index 0000000..42aa41f --- /dev/null +++ b/espanso-render/src/renderer/util.rs @@ -0,0 +1,52 @@ +/* + * 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::collections::HashSet; +use super::VAR_REGEX; + +pub(crate) fn get_body_variable_names(body: &str) -> HashSet<&str> { + let mut variables = HashSet::new(); + for caps in VAR_REGEX.captures_iter(&body) { + let var_name = caps.name("name").unwrap().as_str(); + variables.insert(var_name); + } + variables +} + +#[cfg(test)] +mod tests { + use super::*; + use std::iter::FromIterator; + + #[test] + fn get_body_variable_names_no_vars() { + assert_eq!( + get_body_variable_names("no variables"), + HashSet::from_iter(vec![]), + ); + } + + #[test] + fn get_body_variable_names_multiple_vars() { + assert_eq!( + get_body_variable_names("hello {{world}} name {{greet}}"), + HashSet::from_iter(vec!["world", "greet"]), + ); + } +} \ No newline at end of file