use {
crate::{
app_context::AppContext,
app_state::AppStateCmdResult,
errors::{ConfError, ProgramError},
external, keys,
screens::Screen,
selection_type::SelectionType,
status::Status,
verb_invocation::VerbInvocation,
},
crossterm::event::{
KeyCode,
KeyEvent,
},
minimad::Composite,
regex::{self, Captures, Regex},
std::{
collections::HashMap,
fs::OpenOptions,
io::Write,
path::{Path, PathBuf},
},
};
#[derive(Debug, Clone)]
pub struct Verb {
pub invocation: VerbInvocation, pub key: Option<KeyEvent>,
pub key_desc: String, pub args_parser: Option<Regex>,
pub shortcut: Option<String>, pub execution: String, pub description: Option<String>, pub from_shell: bool, pub leave_broot: bool, pub confirm: bool, pub selection_condition: SelectionType,
}
lazy_static! {
static ref GROUP: Regex = Regex::new(r"\{([^{}:]+)(?::([^{}:]+))?\}").unwrap();
}
pub trait VerbExecutor {
fn execute_verb(
&mut self,
verb: &Verb,
invocation: &VerbInvocation,
screen: &mut Screen,
con: &AppContext,
) -> Result<AppStateCmdResult, ProgramError>;
}
fn make_invocation_args_regex(spec: &str) -> Result<Regex, ConfError> {
let spec = GROUP.replace_all(spec, r"(?P<$1>.+)");
let spec = format!("^{}$", spec);
Regex::new(&spec.to_string())
.or_else(|_| Err(ConfError::InvalidVerbInvocation { invocation: spec }))
}
fn path_to_string(path: &Path, for_shell: bool) -> String {
if for_shell {
external::escape_for_shell(path)
} else {
path.to_string_lossy().to_string()
}
}
impl Verb {
pub fn create_external(
invocation_str: &str,
key: Option<KeyEvent>,
shortcut: Option<String>,
execution: String,
description: Option<String>,
from_shell: bool,
leave_broot: bool,
confirm: bool,
) -> Result<Verb, ConfError> {
let invocation = VerbInvocation::from(invocation_str);
let args_parser = invocation
.args
.as_ref()
.map(|args| make_invocation_args_regex(&args))
.transpose()?;
let selection_condition = match key {
Some(KeyEvent{code:KeyCode::Enter, ..}) => SelectionType::File,
_ => SelectionType::Any,
};
Ok(Verb {
invocation,
key_desc: key.map_or("".to_string(), keys::key_event_desc),
key,
args_parser,
shortcut,
execution,
description,
from_shell,
leave_broot,
confirm,
selection_condition,
})
}
pub fn create_builtin(
name: &str,
key: Option<KeyEvent>,
shortcut: Option<String>,
description: &str,
) -> Verb {
Verb {
invocation: VerbInvocation {
name: name.to_string(),
args: None,
},
key_desc: key.map_or("".to_string(), keys::key_event_desc),
key,
args_parser: None,
shortcut,
execution: format!(":{}", name),
description: Some(description.to_string()),
from_shell: false,
leave_broot: true, confirm: false, selection_condition: SelectionType::Any,
}
}
pub fn match_error(&self, invocation: &VerbInvocation) -> Option<String> {
match (&invocation.args, &self.args_parser) {
(None, None) => None,
(None, Some(ref regex)) => {
if regex.is_match("") {
None
} else {
Some(self.invocation.to_string_for_name(&invocation.name))
}
}
(Some(ref s), Some(ref regex)) => {
if regex.is_match(&s) {
None
} else {
Some(self.invocation.to_string_for_name(&invocation.name))
}
}
(Some(_), None) => Some(format!("{} doesn't take arguments", invocation.name)),
}
}
fn replacement_map(
&self,
file: &Path,
args: &Option<String>,
for_shell: bool,
) -> HashMap<String, String> {
let mut map = HashMap::new();
let parent = file.parent().unwrap_or(file); let file_str = path_to_string(file, for_shell);
let parent_str = path_to_string(parent, for_shell);
map.insert("file".to_string(), file_str.to_string());
map.insert("parent".to_string(), parent_str.to_string());
let dir_str = if file.is_dir() { file_str } else { parent_str };
map.insert("directory".to_string(), dir_str.to_string());
if let Some(args) = args {
if let Some(r) = &self.args_parser {
if let Some(input_cap) = r.captures(&args) {
for name in r.capture_names().flatten() {
if let Some(c) = input_cap.name(name) {
map.insert(name.to_string(), c.as_str().to_string());
}
}
}
} else {
warn!("invocation args given but none expected");
}
}
map
}
pub fn write_status(
&self,
w: &mut impl Write,
task: Option<&'static str>,
path: PathBuf,
invocation: &VerbInvocation,
screen: &Screen,
) -> Result<(), ProgramError> {
if let Some(err) = self.match_error(invocation) {
Status::new(task, Composite::from_inline(&err), true).display(w, screen)
} else {
let verb_description;
let markdown;
let composite = if let Some(description) = &self.description {
markdown = format!(
"Hit *enter* to **{}**: {}",
&self.invocation.name,
description,
);
Composite::from_inline(&markdown)
} else {
verb_description = self.shell_exec_string(&path, &invocation.args);
mad_inline!(
"Hit *enter* to **$0**: `$1`",
&self.invocation.name,
&verb_description,
)
};
Status::new(
task,
composite,
false
).display(w, screen)
}
}
pub fn to_cmd_result(
&self,
file: &Path,
args: &Option<String>,
_screen: &mut Screen,
con: &AppContext,
) -> Result<AppStateCmdResult, ProgramError> {
Ok(if self.from_shell {
if let Some(ref export_path) = con.launch_args.cmd_export_path {
let f = OpenOptions::new().append(true).open(export_path)?;
writeln!(&f, "{}", self.shell_exec_string(file, args))?;
AppStateCmdResult::Quit
} else if let Some(ref export_path) = con.launch_args.file_export_path {
let f = OpenOptions::new().append(true).open(export_path)?;
writeln!(&f, "{}", file.to_string_lossy())?;
AppStateCmdResult::Quit
} else {
AppStateCmdResult::DisplayError(
"this verb needs broot to be launched as `br`. Try `broot --install` if necessary.".to_string()
)
}
} else {
let launchable = external::Launchable::program(self.exec_token(file, args))?;
if self.leave_broot {
AppStateCmdResult::from(launchable)
} else {
info!("Executing not leaving, launchable {:?}", launchable);
let execution = launchable.execute();
match execution {
Ok(()) => {
debug!("ok");
AppStateCmdResult::RefreshState { clear_cache: true }
}
Err(e) => {
warn!("launchable failed : {:?}", e);
AppStateCmdResult::DisplayError(e.to_string())
}
}
}
})
}
pub fn exec_token(&self, file: &Path, args: &Option<String>) -> Vec<String> {
let map = self.replacement_map(file, args, false);
self.execution
.split_whitespace()
.map(|token| {
GROUP
.replace_all(token, |ec: &Captures<'_>| do_exec_replacement(ec, &map))
.to_string()
})
.collect()
}
pub fn shell_exec_string(&self, file: &Path, args: &Option<String>) -> String {
let map = self.replacement_map(file, args, true);
GROUP
.replace_all(&self.execution, |ec: &Captures<'_>| {
do_exec_replacement(ec, &map)
})
.to_string()
.split_whitespace()
.map(|token| {
let path = Path::new(token);
if path.exists() {
if let Some(path) = path.to_str() {
return path.to_string();
}
}
token.to_string()
})
.collect::<Vec<String>>()
.join(" ")
}
}
fn do_exec_replacement(ec: &Captures<'_>, replacement_map: &HashMap<String, String>) -> String {
let name = ec.get(1).unwrap().as_str();
if let Some(cap) = replacement_map.get(name) {
let cap = cap.as_str();
if let Some(fmt) = ec.get(2) {
match fmt.as_str() {
"path-from-directory" => {
if cap.starts_with('/') {
cap.to_string()
} else {
normalize_path(format!(
"{}/{}",
replacement_map.get("directory").unwrap(),
cap
))
}
}
"path-from-parent" => {
if cap.starts_with('/') {
cap.to_string()
} else {
normalize_path(format!(
"{}/{}",
replacement_map.get("parent").unwrap(),
cap
))
}
}
_ => format!("invalid format: {:?}", fmt.as_str()),
}
} else {
cap.to_string()
}
} else {
format!("{{{}}}", name)
}
}
pub fn normalize_path(mut path: String) -> String {
let mut len_before = path.len();
loop {
path = regex!(r"/[^/.\\]+/\.\.").replace(&path, "").to_string();
let len = path.len();
if len == len_before {
return path;
}
len_before = len;
}
}
#[cfg(test)]
mod path_normalize_tests {
use crate::verbs::normalize_path;
fn check(before: &str, after: &str) {
assert_eq!(normalize_path(before.to_string()), after.to_string());
}
#[test]
fn test_path_normalization() {
check("/abc/test/../thing.png", "/abc/thing.png");
check("/abc/def/../../thing.png", "/thing.png");
check("/home/dys/test", "/home/dys/test");
check("/home/dys/..", "/home");
check("/home/dys/../", "/home/");
check("/..", "/..");
check("../test", "../test");
check("/home/dys/../../../test", "/../test");
check(
"/home/dys/dev/broot/../../../canop/test",
"/home/canop/test",
);
}
}