662 lines
21 KiB
Rust
662 lines
21 KiB
Rust
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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<name>\\w+)\\s*\\}\\}").unwrap();
|
|
static ref UNKNOWN_VARIABLE : String = "".to_string();
|
|
}
|
|
|
|
pub struct DefaultRenderer {
|
|
extension_map: HashMap<String, Box<dyn Extension>>,
|
|
|
|
// Regex used to identify matches (and arguments) in passive expansions
|
|
passive_match_regex: Regex,
|
|
}
|
|
|
|
impl DefaultRenderer {
|
|
pub fn new(extensions: Vec<Box<dyn Extension>>, 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<String>) -> 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<char> = 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<String> = 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");
|
|
}
|
|
} |