Skip to content

Commit

Permalink
feat: Add a way to escape { and }in exec templates.
Browse files Browse the repository at this point in the history
fixes #1303
  • Loading branch information
tmccombs committed May 20, 2023
1 parent 0884b83 commit 9337df0
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 45 deletions.
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ path = "src/main.rs"
version_check = "0.9"

[dependencies]
aho-corasick = "1.0"
nu-ansi-term = "0.47"
argmax = "0.3.1"
atty = "0.2"
Expand Down
5 changes: 5 additions & 0 deletions doc/fd.1
Original file line number Diff line number Diff line change
Expand Up @@ -376,10 +376,15 @@ parent directory
path without file extension
.IP {/.}
basename without file extension
.IP {{}
literal '{'
.RE

If no placeholder is present, an implicit "{}" at the end is assumed.

If you need to include the literal text of one of the placeholders, you can use "{{}" to
escape the first "{". For example "{{}}" expands to "{}", and "{{}{{}}}" expands to "{{}".

Examples:

- find all *.zip files and unzip them:
Expand Down
6 changes: 4 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,8 @@ impl clap::Args for Exec {
'{/}': basename\n \
'{//}': parent directory\n \
'{.}': path without file extension\n \
'{/.}': basename without file extension\n\n\
'{/.}': basename without file extension\n \
'{{}': literal '{' (for escaping)\n\n\
If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\
Examples:\n\n \
- find all *.zip files and unzip them:\n\n \
Expand Down Expand Up @@ -829,7 +830,8 @@ impl clap::Args for Exec {
'{/}': basename\n \
'{//}': parent directory\n \
'{.}': path without file extension\n \
'{/.}': basename without file extension\n\n\
'{/.}': basename without file extension\n \
'{{}': literal '{' (for escaping)\n\n\
If no placeholder is present, an implicit \"{}\" at the end is assumed.\n\n\
Examples:\n\n \
- Find all test_*.py files and open them in your favorite editor:\n\n \
Expand Down
68 changes: 27 additions & 41 deletions src/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,13 @@ use std::sync::Mutex;

use anyhow::{bail, Result};
use argmax::Command;
use once_cell::sync::Lazy;
use regex::Regex;

use crate::exit_codes::{merge_exitcodes, ExitCode};

use self::command::{execute_commands, handle_cmd_error};
use self::input::{basename, dirname, remove_extension};
pub use self::job::{batch, job};
use self::token::Token;
use self::token::{Token, tokenize};

/// Execution mode of the command
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -231,50 +229,15 @@ impl CommandTemplate {
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
static PLACEHOLDER_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\{(/?\.?|//)\}").unwrap());

let mut args = Vec::new();
let mut has_placeholder = false;

for arg in input {
let arg = arg.as_ref();

let mut tokens = Vec::new();
let mut start = 0;

for placeholder in PLACEHOLDER_PATTERN.find_iter(arg) {
// Leading text before the placeholder.
if placeholder.start() > start {
tokens.push(Token::Text(arg[start..placeholder.start()].to_owned()));
}

start = placeholder.end();

match placeholder.as_str() {
"{}" => tokens.push(Token::Placeholder),
"{.}" => tokens.push(Token::NoExt),
"{/}" => tokens.push(Token::Basename),
"{//}" => tokens.push(Token::Parent),
"{/.}" => tokens.push(Token::BasenameNoExt),
_ => unreachable!("Unhandled placeholder"),
}

has_placeholder = true;
}

// Without a placeholder, the argument is just fixed text.
if tokens.is_empty() {
args.push(ArgumentTemplate::Text(arg.to_owned()));
continue;
}

if start < arg.len() {
// Trailing text after last placeholder.
tokens.push(Token::Text(arg[start..].to_owned()));
}

args.push(ArgumentTemplate::Tokens(tokens));
let tmpl = tokenize(arg);
has_placeholder |= tmpl.has_tokens();
args.push(tmpl);
}

// We need to check that we have at least one argument, because if not
Expand Down Expand Up @@ -420,6 +383,14 @@ impl ArgumentTemplate {
mod tests {
use super::*;

fn generate_str(template: &CommandTemplate, input: &str) -> Vec<String> {
template
.args
.iter()
.map(|arg| arg.generate(input, None).into_string().unwrap())
.collect()
}

#[test]
fn tokens_with_placeholder() {
assert_eq!(
Expand Down Expand Up @@ -501,6 +472,21 @@ mod tests {
);
}

#[test]
fn tokens_with_literal_braces() {
let template = CommandTemplate::new(vec!["{{}}", "{{", "{.}}"]).unwrap();
assert_eq!(
generate_str(&template, "foo"),
vec!["{}", "{", "{.}", "foo"]
);
}

#[test]
fn tokens_with_literal_braces_and_placeholder() {
let template = CommandTemplate::new(vec!["{{{},end}"]).unwrap();
assert_eq!(generate_str(&template, "foo"), vec!["{foo,end}"]);
}

#[test]
fn tokens_multiple() {
assert_eq!(
Expand Down
75 changes: 75 additions & 0 deletions src/exec/token.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
use std::fmt::{self, Display, Formatter};
use aho_corasick::AhoCorasick;
use once_cell::sync::Lazy;

use super::ArgumentTemplate;

/// Designates what should be written to a buffer
///
Expand Down Expand Up @@ -27,3 +31,74 @@ impl Display for Token {
Ok(())
}
}

static PLACEHOLDERS: Lazy<AhoCorasick> = Lazy::new( || AhoCorasick::new(&[
"{{",
"}}",
"{}",
"{/}",
"{//}",
"{.}",
"{/.}",
]).unwrap());

pub(super) fn tokenize(input: &str) -> ArgumentTemplate {
// NOTE: we assume that { and } have the same length
const BRACE_LEN: usize = '{'.len_utf8();
let mut tokens = Vec::new();
let mut remaining = input;
let mut buf = String::new();
while let Some(m) = PLACEHOLDERS.find(remaining) {
match m.pattern().as_u32() {
0 | 1 => {
// we found an escaped {{ or }}, so add
// everything up to the first char to the buffer
// then skipp the second one.
buf += &remaining[..m.start() + BRACE_LEN];
remaining = &remaining[m.end()..];
}
id if !remaining[m.end()..].starts_with('}') => {
buf += &remaining[..m.start()];
if !buf.is_empty() {
tokens.push(Token::Text(std::mem::take(&mut buf)));
}
tokens.push(token_from_pattern_id(id));
remaining = &remaining[m.end()..];
}
_ => {
// We got a normal pattern, but the final "}"
// is escaped, so add up to that to the buffer, then
// skip the final }
buf += &remaining[..m.end()];
remaining = &remaining[m.end() + BRACE_LEN..];
}
}
}
// Add the rest of the string to the buffer, and add the final buffer to the tokens
if !remaining.is_empty() {
buf += remaining;
}
if tokens.is_empty() {
// No placeholders were found, so just return the text
return ArgumentTemplate::Text(buf);
}
// Add final text segment
if !buf.is_empty() {
tokens.push(Token::Text(buf));
}
debug_assert!(!tokens.is_empty());
ArgumentTemplate::Tokens(tokens)
}


fn token_from_pattern_id(id: u32) -> Token {
use Token::*;
match id {
2 => Placeholder,
3 => Basename,
4 => Parent,
5 => NoExt,
6 => BasenameNoExt,
_ => unreachable!(),
}
}

0 comments on commit 9337df0

Please sign in to comment.