feat(render): implement shell extension
This commit is contained in:
parent
ec78fb9ff2
commit
09ff2178e2
|
@ -19,4 +19,6 @@
|
|||
|
||||
pub mod date;
|
||||
pub mod echo;
|
||||
pub mod clipboard;
|
||||
pub mod clipboard;
|
||||
pub mod shell;
|
||||
mod util;
|
409
espanso-render/src/extension/shell.rs
Normal file
409
espanso-render/src/extension/shell.rs
Normal file
|
@ -0,0 +1,409 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Output},
|
||||
};
|
||||
|
||||
use crate::{Extension, ExtensionOutput, ExtensionResult, Params, Value};
|
||||
use log::{info, warn};
|
||||
use thiserror::Error;
|
||||
|
||||
pub enum Shell {
|
||||
Cmd,
|
||||
Powershell,
|
||||
WSL,
|
||||
WSL2,
|
||||
Bash,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
fn execute_cmd(&self, cmd: &str, vars: &HashMap<String, String>) -> std::io::Result<Output> {
|
||||
let mut is_wsl = false;
|
||||
|
||||
let mut command = match self {
|
||||
Shell::Cmd => {
|
||||
let mut command = Command::new("cmd");
|
||||
command.args(&["/C", &cmd]);
|
||||
command
|
||||
}
|
||||
Shell::Powershell => {
|
||||
let mut command = Command::new("powershell");
|
||||
command.args(&["-Command", &cmd]);
|
||||
command
|
||||
}
|
||||
Shell::WSL => {
|
||||
is_wsl = true;
|
||||
let mut command = Command::new("bash");
|
||||
command.args(&["-c", &cmd]);
|
||||
command
|
||||
}
|
||||
Shell::WSL2 => {
|
||||
is_wsl = true;
|
||||
let mut command = Command::new("wsl");
|
||||
command.args(&["bash", "-c", &cmd]);
|
||||
command
|
||||
}
|
||||
Shell::Bash => {
|
||||
let mut command = Command::new("bash");
|
||||
command.args(&["-c", &cmd]);
|
||||
command
|
||||
}
|
||||
Shell::Sh => {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(&["-c", &cmd]);
|
||||
command
|
||||
}
|
||||
};
|
||||
|
||||
// Set the OS-specific flags
|
||||
super::util::set_command_flags(&mut command);
|
||||
|
||||
// Inject all the previous variables
|
||||
for (key, value) in vars.iter() {
|
||||
command.env(key, value);
|
||||
}
|
||||
|
||||
// In WSL environment, we have to specify which ENV variables
|
||||
// should be passed to linux.
|
||||
// For more information: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows/
|
||||
if is_wsl {
|
||||
let mut tokens: Vec<&str> = Vec::new();
|
||||
tokens.push("CONFIG/p");
|
||||
|
||||
// Add all the previous variables
|
||||
for (key, _) in vars.iter() {
|
||||
tokens.push(key);
|
||||
}
|
||||
|
||||
let wsl_env = tokens.join(":");
|
||||
command.env("WSLENV", wsl_env);
|
||||
}
|
||||
|
||||
command.output()
|
||||
}
|
||||
|
||||
fn from_string(shell: &str) -> Option<Shell> {
|
||||
match shell {
|
||||
"cmd" => Some(Shell::Cmd),
|
||||
"powershell" => Some(Shell::Powershell),
|
||||
"wsl" => Some(Shell::WSL),
|
||||
"wsl2" => Some(Shell::WSL2),
|
||||
"bash" => Some(Shell::Bash),
|
||||
"sh" => Some(Shell::Sh),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Shell {
|
||||
fn default() -> Shell {
|
||||
if cfg!(target_os = "windows") {
|
||||
Shell::Powershell
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Shell::Sh
|
||||
} else if cfg!(target_os = "linux") {
|
||||
Shell::Bash
|
||||
} else {
|
||||
panic!("invalid target os for shell")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ShellExtension {
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
#[allow(clippy::new_without_default)]
|
||||
impl ShellExtension {
|
||||
pub fn new(config_path: &Path) -> Self {
|
||||
Self {
|
||||
config_path: config_path.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Extension for ShellExtension {
|
||||
fn name(&self) -> &str {
|
||||
"shell"
|
||||
}
|
||||
|
||||
fn calculate(
|
||||
&self,
|
||||
_: &crate::Context,
|
||||
scope: &crate::Scope,
|
||||
params: &Params,
|
||||
) -> crate::ExtensionResult {
|
||||
if let Some(Value::String(cmd)) = params.get("cmd") {
|
||||
let shell = if let Some(Value::String(shell_param)) = params.get("shell") {
|
||||
if let Some(shell) = Shell::from_string(shell_param) {
|
||||
shell
|
||||
} else {
|
||||
return ExtensionResult::Error(
|
||||
ShellExtensionError::InvalidShell(shell_param.to_string()).into(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Shell::default()
|
||||
};
|
||||
|
||||
let mut env_variables = super::util::convert_to_env_variables(&scope);
|
||||
env_variables.insert(
|
||||
"CONFIG".to_string(),
|
||||
self.config_path.to_string_lossy().to_string(),
|
||||
);
|
||||
|
||||
match shell.execute_cmd(cmd, &env_variables) {
|
||||
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 command> {}", cmd);
|
||||
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.");
|
||||
}
|
||||
|
||||
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!(
|
||||
"shell command exited with code: {} and error: {}",
|
||||
output.status, error_str
|
||||
);
|
||||
|
||||
if !ignore_error {
|
||||
return ExtensionResult::Error(
|
||||
ShellExtensionError::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(
|
||||
ShellExtensionError::ExecutionFailed(cmd.to_string(), error.into()).into(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
ExtensionResult::Error(ShellExtensionError::MissingCmdParameter.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ShellExtensionError {
|
||||
#[error("missing 'cmd' parameter")]
|
||||
MissingCmdParameter,
|
||||
|
||||
#[error("invalid shell: `{0}` is not a valid one")]
|
||||
InvalidShell(String),
|
||||
|
||||
#[error("could not execute command: '`{0}`', error: '`{1}`'")]
|
||||
ExecutionFailed(String, anyhow::Error),
|
||||
|
||||
#[error("command reported error: '`{0}`'")]
|
||||
ExecutionError(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Scope;
|
||||
use std::iter::FromIterator;
|
||||
|
||||
#[test]
|
||||
fn shell_not_trimmed() {
|
||||
let extension = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param = Params::from_iter(
|
||||
vec![
|
||||
(
|
||||
"cmd".to_string(),
|
||||
Value::String("echo \"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]
|
||||
fn shell_trimmed() {
|
||||
let extension = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param = Params::from_iter(
|
||||
vec![(
|
||||
"cmd".to_string(),
|
||||
Value::String("echo \"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 pipes() {
|
||||
let extension = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param = Params::from_iter(
|
||||
vec![(
|
||||
"cmd".to_string(),
|
||||
Value::String("echo \"hello world\" | cat".to_string()),
|
||||
)]
|
||||
.into_iter(),
|
||||
);
|
||||
assert_eq!(
|
||||
extension
|
||||
.calculate(&Default::default(), &Default::default(), ¶m)
|
||||
.into_success()
|
||||
.unwrap(),
|
||||
ExtensionOutput::Single("hello world".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn var_injection() {
|
||||
let extension = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param = if cfg!(not(target_os = "windows")) {
|
||||
Params::from_iter(
|
||||
vec![(
|
||||
"cmd".to_string(),
|
||||
Value::String("echo $ESPANSO_VAR1".to_string()),
|
||||
)]
|
||||
.into_iter(),
|
||||
)
|
||||
} else {
|
||||
Params::from_iter(
|
||||
vec![(
|
||||
"cmd".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 = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param =
|
||||
Params::from_iter(vec![("cmd".to_string(), 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 = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param =
|
||||
Params::from_iter(vec![("cmd".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 = ShellExtension::new(&PathBuf::new());
|
||||
|
||||
let param = Params::from_iter(
|
||||
vec![
|
||||
("cmd".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())
|
||||
);
|
||||
}
|
||||
}
|
75
espanso-render/src/extension/util.rs
Normal file
75
espanso-render/src/extension/util.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{ExtensionOutput, Scope};
|
||||
use std::{collections::HashMap, process::Command};
|
||||
|
||||
pub fn convert_to_env_variables(scope: &Scope) -> HashMap<String, String> {
|
||||
let mut output = HashMap::new();
|
||||
|
||||
for (key, result) in scope.iter() {
|
||||
match result {
|
||||
ExtensionOutput::Single(value) => {
|
||||
let name = format!("ESPANSO_{}", key.to_uppercase());
|
||||
output.insert(name, value.clone());
|
||||
}
|
||||
ExtensionOutput::Multiple(values) => {
|
||||
for (sub_key, sub_value) in values.iter() {
|
||||
let name = format!("ESPANSO_{}_{}", key.to_uppercase(), sub_key.to_uppercase());
|
||||
output.insert(name, sub_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn set_command_flags(command: &mut Command) {
|
||||
use std::os::windows::process::CommandExt;
|
||||
// Avoid showing the shell window
|
||||
// See: https://github.com/federico-terzi/espanso/issues/249
|
||||
command.creation_flags(0x08000000);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn set_command_flags(_: &mut Command) {
|
||||
// NOOP on Linux and macOS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_convert_to_env_variables() {
|
||||
let mut vars: Scope = HashMap::new();
|
||||
let mut subvars = HashMap::new();
|
||||
subvars.insert("name".to_owned(), "John".to_owned());
|
||||
subvars.insert("lastname".to_owned(), "Snow".to_owned());
|
||||
vars.insert("form1", ExtensionOutput::Multiple(subvars));
|
||||
vars.insert("var1", ExtensionOutput::Single("test".to_owned()));
|
||||
|
||||
let output = convert_to_env_variables(&vars);
|
||||
assert_eq!(output.get("ESPANSO_FORM1_NAME").unwrap(), "John");
|
||||
assert_eq!(output.get("ESPANSO_FORM1_LASTNAME").unwrap(), "Snow");
|
||||
assert_eq!(output.get("ESPANSO_VAR1").unwrap(), "test");
|
||||
}
|
||||
}
|
|
@ -20,11 +20,11 @@
|
|||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use enum_as_inner::EnumAsInner;
|
||||
use std::collections::HashMap;
|
||||
|
||||
mod renderer;
|
||||
pub mod extension;
|
||||
mod renderer;
|
||||
|
||||
pub trait Renderer {
|
||||
fn render(&self, template: &Template, context: &Context, options: &RenderOptions)
|
||||
|
@ -113,7 +113,7 @@ impl Default for Variable {
|
|||
|
||||
pub type Params = HashMap<String, Value>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, EnumAsInner)]
|
||||
pub enum Value {
|
||||
Null,
|
||||
Bool(bool),
|
||||
|
|
Loading…
Reference in New Issue
Block a user