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