/*
* This file is part of espanso.
*
* Copyright (C) 2019 Federico Terzi
*
* espanso is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* espanso is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with espanso. If not, see .
*/
use serde_yaml::{Mapping, Value};
use std::path::PathBuf;
use std::collections::HashMap;
use regex::{Regex, Captures};
use log::{warn, error};
use super::*;
use crate::matcher::{Match, MatchContentType};
use crate::config::Configs;
use crate::extension::Extension;
lazy_static! {
static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(?P\\w+)\\s*\\}\\}").unwrap();
static ref UNKNOWN_VARIABLE : String = "".to_string();
}
pub struct DefaultRenderer {
extension_map: HashMap>,
// Regex used to identify matches (and arguments) in passive expansions
passive_match_regex: Regex,
}
impl DefaultRenderer {
pub fn new(extensions: Vec>, config: Configs) -> DefaultRenderer {
// Register all the extensions
let mut extension_map = HashMap::new();
for extension in extensions.into_iter() {
extension_map.insert(extension.name(), extension);
}
// Compile the regexes
let passive_match_regex = Regex::new(&config.passive_match_regex)
.unwrap_or_else(|e| {
panic!("Invalid passive match regex");
});
DefaultRenderer{
extension_map,
passive_match_regex,
}
}
fn find_match(config: &Configs, trigger: &str) -> Option<(Match, usize)> {
let mut result = None;
// TODO: if performances become a problem, implement a more efficient lookup
for m in config.matches.iter() {
for (trigger_offset, m_trigger) in m.triggers.iter().enumerate() {
if m_trigger == trigger {
result = Some((m.clone(), trigger_offset));
break;
}
}
}
result
}
}
impl super::Renderer for DefaultRenderer {
fn render_match(&self, m: &Match, trigger_offset: usize, config: &Configs, args: Vec) -> RenderResult {
// Manage the different types of matches
match &m.content {
// Text Match
MatchContentType::Text(content) => {
let target_string = if content._has_vars || !config.global_vars.is_empty(){
let mut output_map = HashMap::new();
// Cycle through both the local and global variables
for variable in config.global_vars.iter().chain(&content.vars) {
// In case of variables of type match, we need to recursively call
// the render function
if variable.var_type == "match" {
// Extract the match trigger from the variable params
let trigger = variable.params.get(&Value::from("trigger"));
if trigger.is_none() {
warn!("Missing param 'trigger' in match variable: {}", variable.name);
continue;
}
let trigger = trigger.unwrap();
// Find the given match from the active configs
let inner_match = DefaultRenderer::find_match(config, trigger.as_str().unwrap_or(""));
if inner_match.is_none() {
warn!("Could not find inner match with trigger: '{}'", trigger.as_str().unwrap_or("undefined"));
continue
}
let (inner_match, trigger_offset) = inner_match.unwrap();
// Render the inner match
// TODO: inner arguments
let result = self.render_match(&inner_match, trigger_offset, config, vec![]);
// Inner matches are only supported for text-expansions, warn the user otherwise
match result {
RenderResult::Text(inner_content) => {
output_map.insert(variable.name.clone(), inner_content);
},
_ => {
warn!("Inner matches must be of TEXT type. Mixing images is not supported yet.")
},
}
}else{ // Normal extension variables
let extension = self.extension_map.get(&variable.var_type);
if let Some(extension) = extension {
let ext_out = extension.calculate(&variable.params, &args);
if let Some(output) = ext_out {
output_map.insert(variable.name.clone(), output);
}else{
output_map.insert(variable.name.clone(), "".to_owned());
warn!("Could not generate output for variable: {}", variable.name);
}
}else{
error!("No extension found for variable type: {}", variable.var_type);
}
}
}
// Replace the variables
let result = VAR_REGEX.replace_all(&content.replace, |caps: &Captures| {
let var_name = caps.name("name").unwrap().as_str();
let output = output_map.get(var_name);
output.unwrap_or(&UNKNOWN_VARIABLE)
});
result.to_string()
}else{ // No variables, simple text substitution
content.replace.clone()
};
// Unescape any brackets (needed to be able to insert double brackets in replacement
// text, without triggering the variable system). See issue #187
let target_string = target_string.replace("\\{", "{")
.replace("\\}", "}");
// Render any argument that may be present
let target_string = utils::render_args(&target_string, &args);
// Handle case propagation
let target_string = if m.propagate_case {
let trigger = &m.triggers[trigger_offset];
let first_char = trigger.chars().nth(0);
let second_char = trigger.chars().nth(1);
let mode: i32 = if let Some(first_char) = first_char {
if first_char.is_uppercase() {
if let Some(second_char) = second_char {
if second_char.is_uppercase() {
2 // Full CAPITALIZATION
}else{
1 // Only first letter capitalized: Capitalization
}
}else{
2 // Single char, defaults to full CAPITALIZATION
}
}else{
0 // Lowercase, no action
}
}else{
0
};
match mode {
1 => {
// Capitalize the first letter
let mut v: Vec = target_string.chars().collect();
v[0] = v[0].to_uppercase().nth(0).unwrap();
v.into_iter().collect()
},
2 => { // Full capitalization
target_string.to_uppercase()
},
_ => { // Noop
target_string
}
}
}else{
target_string
};
RenderResult::Text(target_string)
},
// Image Match
MatchContentType::Image(content) => {
// Make sure the image exist beforehand
if content.path.exists() {
RenderResult::Image(content.path.clone())
}else{
error!("Image not found in path: {:?}", content.path);
RenderResult::Error
}
},
}
}
fn render_passive(&self, text: &str, config: &Configs) -> RenderResult {
// Render the matches
let result = self.passive_match_regex.replace_all(&text, |caps: &Captures| {
let match_name = if let Some(name) = caps.name("name") {
name.as_str()
}else{
""
};
// Get the original matching string, useful to return the match untouched
let original_match = caps.get(0).unwrap().as_str();
// Find the corresponding match
let m = DefaultRenderer::find_match(config, match_name);
// If no match is found, leave the match without modifications
if m.is_none() {
return original_match.to_owned();
}
// Compute the args by separating them
let match_args = if let Some(args) = caps.name("args") {
args.as_str()
}else{
""
};
let args : Vec = utils::split_args(match_args,
config.passive_arg_delimiter,
config.passive_arg_escape);
let (m, trigger_offset) = m.unwrap();
// Render the actual match
let result = self.render_match(&m, trigger_offset, &config, args);
match result {
RenderResult::Text(out) => {
out
},
_ => {
original_match.to_owned()
}
}
});
RenderResult::Text(result.into_owned())
}
}
// TESTS
#[cfg(test)]
mod tests {
use super::*;
fn get_renderer(config: Configs) -> DefaultRenderer {
DefaultRenderer::new(vec![Box::new(crate::extension::dummy::DummyExtension::new())], config)
}
fn get_config_for(s: &str) -> Configs {
let config : Configs = serde_yaml::from_str(s).unwrap();
config
}
fn verify_render(rendered: RenderResult, target: &str) {
match rendered {
RenderResult::Text(rendered) => {
assert_eq!(rendered, target);
},
_ => {
assert!(false)
}
}
}
#[test]
fn test_render_passive_no_matches() {
let text = r###"
this text contains no matches
"###;
let config = get_config_for(r###"
matches:
- trigger: test
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, text);
}
#[test]
fn test_render_passive_simple_match_no_args() {
let text = "this is a :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is a result");
}
#[test]
fn test_render_passive_multiple_match_no_args() {
let text = "this is a :test and then another :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is a result and then another result");
}
#[test]
fn test_render_passive_simple_match_multiline_no_args() {
let text = r###"this is a
:test
"###;
let result= r###"this is a
result
"###;
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, result);
}
#[test]
fn test_render_passive_nested_matches_no_args() {
let text = ":greet";
let config = get_config_for(r###"
matches:
- trigger: ':greet'
replace: "hi {{name}}"
vars:
- name: name
type: match
params:
trigger: ":name"
- trigger: ':name'
replace: john
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "hi john");
}
#[test]
fn test_render_passive_simple_match_with_args() {
let text = ":greet/Jon/";
let config = get_config_for(r###"
matches:
- trigger: ':greet'
replace: "Hi $0$"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "Hi Jon");
}
#[test]
fn test_render_passive_simple_match_with_multiple_args() {
let text = ":greet/Jon/Snow/";
let config = get_config_for(r###"
matches:
- trigger: ':greet'
replace: "Hi $0$, there is $1$ outside"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "Hi Jon, there is Snow outside");
}
#[test]
fn test_render_passive_simple_match_with_escaped_args() {
let text = ":greet/Jon/10\\/12/";
let config = get_config_for(r###"
matches:
- trigger: ':greet'
replace: "Hi $0$, today is $1$"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "Hi Jon, today is 10/12");
}
#[test]
fn test_render_passive_simple_match_with_args_not_closed() {
let text = ":greet/Jon/Snow";
let config = get_config_for(r###"
matches:
- trigger: ':greet'
replace: "Hi $0$"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "Hi JonSnow");
}
#[test]
fn test_render_passive_local_var() {
let text = "this is :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: "my {{output}}"
vars:
- name: output
type: dummy
params:
echo: "result"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my result");
}
#[test]
fn test_render_passive_global_var() {
let text = "this is :test";
let config = get_config_for(r###"
global_vars:
- name: output
type: dummy
params:
echo: "result"
matches:
- trigger: ':test'
replace: "my {{output}}"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my result");
}
#[test]
fn test_render_passive_global_var_is_overridden_by_local() {
let text = "this is :test";
let config = get_config_for(r###"
global_vars:
- name: output
type: dummy
params:
echo: "result"
matches:
- trigger: ':test'
replace: "my {{output}}"
vars:
- name: "output"
type: dummy
params:
echo: "local"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my local");
}
#[test]
fn test_render_match_with_unknown_variable_does_not_crash() {
let text = "this is :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: "my {{unknown}}"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my ");
}
#[test]
fn test_render_escaped_double_brackets_should_not_consider_them_variable() {
let text = "this is :test";
let config = get_config_for(r###"
matches:
- trigger: ':test'
replace: "my \\{\\{unknown\\}\\}"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is my {{unknown}}");
}
#[test]
fn test_render_passive_simple_match_multi_trigger_no_args() {
let text = "this is a :yolo and :test";
let config = get_config_for(r###"
matches:
- triggers: [':test', ':yolo']
replace: result
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "this is a result and result");
}
#[test]
fn test_render_passive_simple_match_multi_trigger_with_args() {
let text = ":yolo/Jon/";
let config = get_config_for(r###"
matches:
- triggers: [':greet', ':yolo']
replace: "Hi $0$"
"###);
let renderer = get_renderer(config.clone());
let rendered = renderer.render_passive(text, &config);
verify_render(rendered, "Hi Jon");
}
#[test]
fn test_render_match_case_propagation_no_case() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "test").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "result");
}
#[test]
fn test_render_match_case_propagation_first_capital() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "Test").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "Result");
}
#[test]
fn test_render_match_case_propagation_all_capital() {
let config = get_config_for(r###"
matches:
- trigger: 'test'
replace: result
propagate_case: true
"###);
let renderer = get_renderer(config.clone());
let m = config.matches[0].clone();
let trigger_offset = m.triggers.iter().position(|x| x== "TEST").unwrap();
let rendered = renderer.render_match(&m, trigger_offset, &config, vec![]);
verify_render(rendered, "RESULT");
}
}