diff --git a/espanso-render/src/extension/mod.rs b/espanso-render/src/extension/mod.rs index 56f5557..9711f62 100644 --- a/espanso-render/src/extension/mod.rs +++ b/espanso-render/src/extension/mod.rs @@ -21,4 +21,5 @@ pub mod date; pub mod echo; pub mod clipboard; pub mod shell; +pub mod script; mod util; \ No newline at end of file diff --git a/espanso-render/src/extension/script.rs b/espanso-render/src/extension/script.rs new file mode 100644 index 0000000..14491dd --- /dev/null +++ b/espanso-render/src/extension/script.rs @@ -0,0 +1,351 @@ +/* + * 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::{ + path::{Path, PathBuf}, + process::Command, +}; + +use crate::{Extension, ExtensionOutput, ExtensionResult, Params, Value}; +use log::{info, warn}; +use thiserror::Error; + +pub struct ScriptExtension { + home_path: PathBuf, + config_path: PathBuf, + packages_path: PathBuf, +} + +#[allow(clippy::new_without_default)] +impl ScriptExtension { + pub fn new(config_path: &Path, home_path: &Path, packages_path: &Path) -> Self { + Self { + config_path: config_path.to_owned(), + home_path: home_path.to_owned(), + packages_path: packages_path.to_owned(), + } + } +} + +impl Extension for ScriptExtension { + fn name(&self) -> &str { + "script" + } + + fn calculate( + &self, + _: &crate::Context, + scope: &crate::Scope, + params: &Params, + ) -> crate::ExtensionResult { + if let Some(Value::Array(args)) = params.get("args") { + let mut args: Vec = args + .iter() + .filter_map(|arg| arg.as_string()) + .cloned() + .collect(); + + // Replace %HOME% with current user home directory to + // create cross-platform paths. See issue #265 + // Also replace %CONFIG% and %PACKAGES% path. See issue #380 + args.iter_mut().for_each(|arg| { + if arg.contains("%HOME%") { + *arg = arg.replace("%HOME%", &self.home_path.to_string_lossy().to_string()); + } + if arg.contains("%CONFIG%") { + *arg = arg.replace("%CONFIG%", &self.config_path.to_string_lossy().to_string()); + } + if arg.contains("%PACKAGES%") { + *arg = arg.replace( + "%PACKAGES%", + &self.packages_path.to_string_lossy().to_string(), + ); + } + + // On Windows, correct paths separators + if cfg!(target_os = "windows") { + let path = PathBuf::from(&arg); + if path.exists() { + *arg = path.to_string_lossy().to_string() + } + } + }); + + let mut command = Command::new(&args[0]); + command.env("CONFIG", self.config_path.to_string_lossy().to_string()); + for (key, value) in super::util::convert_to_env_variables(&scope) { + command.env(key, value); + } + + // Set the OS-specific flags + super::util::set_command_flags(&mut command); + + let output = if args.len() > 1 { + command.args(&args[1..]).output() + } else { + command.output() + }; + + match output { + Ok(output) => { + let output_str = String::from_utf8_lossy(&output.stdout); + let error_str = String::from_utf8_lossy(&output.stderr); + + let debug = params + .get("debug") + .and_then(|v| v.as_bool()) + .copied() + .unwrap_or(false); + + if debug { + info!("debug information for script> {:?}", args); + info!("exit status: '{}'", output.status); + info!("stdout: '{}'", output_str); + info!("stderr: '{}'", error_str); + info!("this debug information was shown because the 'debug' option is true."); + } + + let ignore_error = params + .get("ignore_error") + .and_then(|v| v.as_bool()) + .copied() + .unwrap_or(false); + + if !output.status.success() || !error_str.trim().is_empty() { + warn!( + "script command exited with code: {} and error: {}", + output.status, error_str + ); + + if !ignore_error { + return ExtensionResult::Error( + ScriptExtensionError::ExecutionError(error_str.to_string()).into(), + ); + } + } + + let trim = params + .get("trim") + .and_then(|v| v.as_bool()) + .copied() + .unwrap_or(true); + + let output = if trim { + output_str.trim().to_owned() + } else { + output_str.to_string() + }; + + ExtensionResult::Success(ExtensionOutput::Single(output)) + } + Err(error) => ExtensionResult::Error( + ScriptExtensionError::ExecutionFailed(args[0].to_string(), error.into()).into(), + ), + } + } else { + ExtensionResult::Error(ScriptExtensionError::MissingArgsParameter.into()) + } + } +} + +#[derive(Error, Debug)] +pub enum ScriptExtensionError { + #[error("missing 'args' parameter")] + MissingArgsParameter, + + #[error("could not execute script: '`{0}`', error: '`{1}`'")] + ExecutionFailed(String, anyhow::Error), + + #[error("script reported error: '`{0}`'")] + ExecutionError(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Scope; + use std::iter::FromIterator; + + fn get_extension() -> ScriptExtension { + ScriptExtension::new(&PathBuf::new(), &PathBuf::new(), &PathBuf::new()) + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn basic() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![( + "args".to_string(), + Value::Array(vec![ + Value::String("echo".to_string()), + Value::String("hello world".to_string()), + ]), + )] + .into_iter(), + ); + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("hello world".to_string()) + ); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn basic_no_trim() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![ + ( + "args".to_string(), + Value::Array(vec![ + Value::String("echo".to_string()), + Value::String("hello world".to_string()), + ]), + ), + ("trim".to_string(), Value::Bool(false)), + ] + .into_iter(), + ); + if cfg!(target_os = "windows") { + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("hello world\r\n".to_string()) + ); + } else { + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("hello world\n".to_string()) + ); + } + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn var_injection() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![ + ( + "args".to_string(), + Value::Array(vec![ + Value::String("sh".to_string()), + Value::String("-c".to_string()), + Value::String("echo $ESPANSO_VAR1".to_string()), + ]), + ), + ] + .into_iter(), + ); + let mut scope = Scope::new(); + scope.insert("var1", ExtensionOutput::Single("hello world".to_string())); + assert_eq!( + extension + .calculate(&Default::default(), &scope, ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("hello world".to_string()) + ); + } + + #[test] + fn invalid_command() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![ + ( + "args".to_string(), + Value::Array(vec![ + Value::String("nonexistentcommand".to_string()), + ]), + ), + ] + .into_iter(), + ); + assert!(matches!( + extension.calculate(&Default::default(), &Default::default(), ¶m), + ExtensionResult::Error(_) + )); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn exit_error() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![ + ( + "args".to_string(), + Value::Array(vec![ + Value::String("sh".to_string()), + Value::String("-c".to_string()), + Value::String("exit 1".to_string()), + ]), + ), + ] + .into_iter(), + ); + assert!(matches!( + extension.calculate(&Default::default(), &Default::default(), ¶m), + ExtensionResult::Error(_) + )); + } + + #[test] + #[cfg(not(target_os = "windows"))] + fn ignore_error() { + let extension = get_extension(); + + let param = Params::from_iter( + vec![ + ( + "args".to_string(), + Value::Array(vec![ + Value::String("sh".to_string()), + Value::String("-c".to_string()), + Value::String("exit 1".to_string()), + ]), + ), + ("ignore_error".to_string(), Value::Bool(true)), + ] + .into_iter(), + ); + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("".to_string()) + ); + } +} diff --git a/espanso-render/src/extension/shell.rs b/espanso-render/src/extension/shell.rs index cfbdfb1..db4ec56 100644 --- a/espanso-render/src/extension/shell.rs +++ b/espanso-render/src/extension/shell.rs @@ -188,7 +188,7 @@ impl Extension for ShellExtension { info!("exit status: '{}'", output.status); info!("stdout: '{}'", output_str); info!("stderr: '{}'", error_str); - info!("this debug information was shown because the match 'debug' option is true."); + info!("this debug information was shown because the 'debug' option is true."); } let ignore_error = params