use std::{
collections::HashMap,
io::Write,
path::PathBuf,
process::{Command, Stdio},
sync::{OnceLock, mpsc},
thread,
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use async_lsp::lsp_types::{CodeAction, CodeActionKind, CodeActionParams};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::{
loader::{Dirs, config_dir},
parser::{Parser, StrOrSeq, parse},
variables::{VariableInit, Variables},
};
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Action {
title: String,
filter: StrOrSeq,
shell: StrOrSeq, description: Option<StrOrSeq>,
}
impl Action {
fn to_code_action_item(
&self,
variable_init: &VariableInit,
data: &ActionData,
) -> Option<(CodeAction, ActionData)> {
let shell = self.shell.to_string();
let shell = Variables::replace_all(&shell, variable_init);
let action = CodeAction {
title: self.title.clone(),
kind: Some(CodeActionKind::EMPTY),
data: None,
..Default::default()
};
Some((action, data.with_command(shell)))
}
#[allow(dead_code)]
fn description(&self) -> String {
match &self.description {
Some(s) => s.to_string(),
None => String::new(),
}
}
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ActionData {
pub params: CodeActionParams,
pub command: Option<String>,
}
impl ActionData {
pub fn with_command(&self, command: String) -> Self {
ActionData {
command: Some(command),
..self.clone()
}
}
}
impl From<CodeActionParams> for ActionData {
fn from(value: CodeActionParams) -> Self {
ActionData {
params: value.clone(),
command: None,
}
}
}
fn actions_list() -> &'static Mutex<HashMap<String, Actions>> {
static ACTIONS: OnceLock<Mutex<HashMap<String, Actions>>> = OnceLock::new();
ACTIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn actions_list_clear() {
let mut actions_list = actions_list().lock();
actions_list.clear();
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Actions {
name: String,
actions: HashMap<String, Action>,
}
impl Default for Actions {
fn default() -> Self {
Actions::new("default".to_owned(), HashMap::new())
}
}
impl Parser for Actions {
type Item = Action;
fn set_name(&mut self, name: String) {
self.name = name;
}
fn set_hasmap(&mut self, hs: HashMap<String, Self::Item>) {
self.actions = hs;
}
}
impl Actions {
pub fn new(name: String, actions: HashMap<String, Action>) -> Actions {
Actions { name, actions }
}
pub fn get_lang(lang_name: String, init: &VariableInit) -> Actions {
let mut actions_list = actions_list().lock();
let mut actions = match actions_list.get(&lang_name) {
Some(has) => has.clone(),
None => {
let file_name = format!("{}.json", lang_name.clone().to_lowercase());
let lang_actions = from_files(
lang_name.clone(),
[
init.work_path
.join(".helix")
.join(Dirs::Actions.to_string())
.join(&file_name),
config_dir(Dirs::Actions).join(&file_name),
]
.to_vec(),
);
actions_list.insert(lang_name, lang_actions.clone());
lang_actions
}
};
actions.filter(init);
actions
}
pub fn extend(&mut self, other: Actions) {
self.actions.extend(other.actions);
}
pub fn to_code_action_items(
&self,
variable_init: &VariableInit,
data: &ActionData,
) -> Vec<(CodeAction, ActionData)> {
self.actions
.iter()
.filter_map(|(_name, action)| action.to_code_action_item(variable_init, data))
.collect()
}
fn filter(&mut self, init: &VariableInit) {
let actions = self
.actions
.clone()
.into_iter()
.filter_map(|(name, action)| {
if action.filter.to_string().is_empty() {
return Some((name, action));
}
let shell_script = action.filter.to_string();
let shell_script = Variables::replace_all(&shell_script, init);
let filter = match shell(&shell_script, &Some(init.selected_text.clone())) {
Ok(s) => matches!(s.to_lowercase().as_str(), "true" | "1"),
Err(_) => false,
};
match filter {
true => Some((name, action)),
false => None,
}
})
.collect();
self.actions = actions;
}
}
fn from_files(name: String, files: Vec<PathBuf>) -> Actions {
files
.into_iter()
.rev()
.filter(|p| p.exists())
.filter_map(|p| parse::<Actions>(&p, name.to_owned()).ok())
.fold(
Actions::new(name.trim().to_owned(), HashMap::new()),
|mut acc, map| {
acc.extend(map);
acc
},
)
}
pub fn shell(cmd: &str, input: &Option<String>) -> Result<String> {
let shell = get_shell();
let mut process = Command::new(&shell[0]);
process
.args(&shell[1..])
.arg(cmd)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if input.is_some() || cfg!(windows) {
process.stdin(Stdio::piped());
} else {
process.stdin(Stdio::null());
}
let mut process = process.spawn().context("Failed to spawn child process")?;
if let Some(input) = input {
let mut stdin = process
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("Failed to open stdin"))?;
stdin
.write_all(input.to_string().as_bytes())
.context("Failed to write to stdin")?;
drop(stdin);
}
let timeout = Duration::from_secs(5);
let (tx, rx) = mpsc::channel();
let start_time = Instant::now();
thread::spawn(move || {
let output = process.wait_with_output();
let _ = tx.send(output);
});
let output = match rx.recv_timeout(timeout) {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(e).context("Child process error"),
Err(_) => {
let elapsed = start_time.elapsed().as_secs();
anyhow::bail!(
"Command timed out after {}s (max {}s)",
elapsed,
timeout.as_secs()
)
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Command failed ({}): {}",
output.status,
stderr.trim_end()
));
}
String::from_utf8(output.stdout)
.map(|s| s.trim_end().to_owned())
.or_else(|e| Ok(String::from_utf8_lossy(e.as_bytes()).into_owned()))
}
#[cfg(unix)]
fn get_shell() -> Vec<String> {
vec!["sh".to_owned(), "-c".to_owned()]
}
#[cfg(windows)]
fn get_shell() -> Vec<String> {
vec!["cmd".to_owned(), "/C".to_owned()]
}
#[cfg(test)]
mod test {
use super::shell;
use anyhow::Result;
#[allow(dead_code)]
fn test_basic_command() -> Result<()> {
#[cfg(unix)]
let (cmd, input, expected) = ("echo hello", &Some(String::from("text")), "hello");
#[cfg(windows)]
let (cmd, input, expected) = ("echo hello", &Some(String::from("text")), "hello");
let output = shell(cmd, input)?;
assert_eq!(output.trim_end(), expected.trim_end());
Ok(())
}
}