From 8acca4a36685063ffea52d79595f4a85996da56d Mon Sep 17 00:00:00 2001 From: Federico Terzi Date: Wed, 10 Nov 2021 23:17:23 +0100 Subject: [PATCH] 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