From aeae5e769e82faac7ff36b3cd4c1dec8d65a9141 Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Fri, 22 May 2026 12:43:33 +0530 Subject: [PATCH] Add extension placeholder --- CHANGELOG.md | 1 + src/cli.rs | 3 +++ src/exec/mod.rs | 16 ++++++++++++++++ src/fmt/input.rs | 11 +++++++++++ src/fmt/mod.rs | 30 +++++++++++++++++++++++++++--- tests/tests.rs | 20 ++++++++++++++++++++ 6 files changed, 78 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 179631e33..86afdb64f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Features - Add `--ignore-parent` option to override `--no-ignore-parent`, see #1958 (@tmchow) - Add `--exact` option to match the entire filename exactly (literal, non-substring). +- Add `{.ext}` placeholder for file extensions in `--format`, `--exec`, and `--exec-batch`, see #1818 (@puneetdixit200). ## Bugfixes - Handle invalid working directories gracefully when using `--full-path`, see #1900 (@Xavrir). diff --git a/src/cli.rs b/src/cli.rs index 477f7fdf9..e76d1f836 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -476,6 +476,7 @@ pub struct Opts { /// '{/}': basename /// '{//}': parent directory /// '{.}': path without file extension + /// '{.ext}': file extension /// '{/.}': basename without file extension #[arg( long, @@ -901,6 +902,7 @@ impl clap::Args for Exec { '{/}': basename\n \ '{//}': parent directory\n \ '{.}': path without file extension\n \ + '{.ext}': file extension\n \ '{/.}': basename without file extension\n \ '{{': literal '{' (for escaping)\n \ '}}': literal '}' (for escaping)\n\n\ @@ -936,6 +938,7 @@ impl clap::Args for Exec { '{/}': basename\n \ '{//}': parent directory\n \ '{.}': path without file extension\n \ + '{.ext}': file extension\n \ '{/.}': basename without file extension\n \ '{{': literal '{' (for escaping)\n \ '}}': literal '}' (for escaping)\n\n\ diff --git a/src/exec/mod.rs b/src/exec/mod.rs index c22e0bd2a..a20ae47d9 100644 --- a/src/exec/mod.rs +++ b/src/exec/mod.rs @@ -315,6 +315,22 @@ mod tests { ); } + #[test] + fn tokens_with_extension() { + assert_eq!( + CommandSet::new(vec![vec!["echo", "{.ext}"]]).unwrap(), + CommandSet { + commands: vec![CommandTemplate { + args: vec![ + FormatTemplate::Text("echo".into()), + FormatTemplate::Tokens(vec![Token::Extension]), + ], + }], + mode: ExecutionMode::OneByOne, + } + ); + } + #[test] fn tokens_with_basename() { assert_eq!( diff --git a/src/fmt/input.rs b/src/fmt/input.rs index a5994324b..1cf58eb5f 100644 --- a/src/fmt/input.rs +++ b/src/fmt/input.rs @@ -18,6 +18,11 @@ pub fn remove_extension(path: &Path) -> OsString { strip_current_dir(&path).to_owned().into_os_string() } +/// Returns the extension of the path. +pub fn extension(path: &Path) -> &OsStr { + path.extension().unwrap_or_default() +} + /// Removes the basename from the path. pub fn dirname(path: &Path) -> OsString { path.parent() @@ -60,6 +65,12 @@ mod path_tests { remove_ext_utf8: remove_extension for "💖.txt" => "💖" remove_ext_empty: remove_extension for "" => "" + extension_simple: extension for "foo.txt" => "txt" + extension_dir: extension for "dir/foo.txt" => "txt" + extension_hidden: extension for ".foo" => "" + extension_no_ext: extension for "foo" => "" + extension_utf8: extension for "dir/foo.💖" => "💖" + basename_simple: basename for "foo.txt" => "foo.txt" basename_dir: basename for "dir/foo.txt" => "foo.txt" basename_empty: basename for "" => "" diff --git a/src/fmt/mod.rs b/src/fmt/mod.rs index 87ee41923..30945cb1d 100644 --- a/src/fmt/mod.rs +++ b/src/fmt/mod.rs @@ -8,7 +8,7 @@ use std::sync::OnceLock; use aho_corasick::AhoCorasick; -use self::input::{basename, dirname, remove_extension}; +use self::input::{basename, dirname, extension, remove_extension}; /// Designates what should be written to a buffer /// @@ -20,6 +20,7 @@ pub enum Token { Basename, Parent, NoExt, + Extension, BasenameNoExt, Text(String), } @@ -31,6 +32,7 @@ impl Display for Token { Token::Basename => f.write_str("{/}")?, Token::Parent => f.write_str("{//}")?, Token::NoExt => f.write_str("{.}")?, + Token::Extension => f.write_str("{.ext}")?, Token::BasenameNoExt => f.write_str("{/.}")?, Token::Text(ref string) => f.write_str(string)?, } @@ -62,7 +64,7 @@ impl FormatTemplate { let mut remaining = fmt; let mut buf = String::new(); let placeholders = PLACEHOLDERS.get_or_init(|| { - AhoCorasick::new(["{{", "}}", "{}", "{/}", "{//}", "{.}", "{/.}"]).unwrap() + AhoCorasick::new(["{{", "}}", "{}", "{/}", "{//}", "{.}", "{.ext}", "{/.}"]).unwrap() }); while let Some(m) = placeholders.find(remaining) { match m.pattern().as_u32() { @@ -127,6 +129,9 @@ impl FormatTemplate { &remove_extension(path), path_separator, )), + Extension => { + s.push(Self::replace_separator(extension(path), path_separator)) + } Parent => s.push(Self::replace_separator(&dirname(path), path_separator)), Placeholder => { s.push(Self::replace_separator(path.as_ref(), path_separator)) @@ -205,7 +210,8 @@ fn token_from_pattern_id(id: u32) -> Token { 3 => Basename, 4 => Parent, 5 => NoExt, - 6 => BasenameNoExt, + 6 => Extension, + 7 => BasenameNoExt, _ => unreachable!(), } } @@ -242,6 +248,7 @@ mod fmt_tests { basename={/} \ parent={//} \ noExt={.} \ + ext={.ext} \ basenameNoExt={/.} \ }}", ); @@ -256,6 +263,8 @@ mod fmt_tests { Parent, Text(" noExt=".into()), NoExt, + Text(" ext=".into()), + Extension, Text(" basenameNoExt=".into()), BasenameNoExt, Text(" }".into()), @@ -275,7 +284,22 @@ mod fmt_tests { basename=file.txt \ parent=a/folder \ noExt=a/folder/file \ + ext=txt \ basenameNoExt=file }" ); } + + #[test] + fn extension_placeholder() { + let templ = FormatTemplate::parse("extension={.ext}"); + + let mut path = PathBuf::new(); + path.push("a"); + path.push("folder"); + path.push("file.txt"); + + let expanded = templ.generate(&path, Some("/")).into_string().unwrap(); + + assert_eq!(expanded, "extension=txt"); + } } diff --git a/tests/tests.rs b/tests/tests.rs index 2a87f75a1..6adb907af 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1701,6 +1701,16 @@ fn format() { noExt=one/two/three/directory_foo", ); + te.assert_output( + &["foo", "--format", "extension={.ext}", "--path-separator=/"], + "extension=foo + extension=foo + extension=Foo2 + extension=foo + extension=foo + extension=", + ); + te.assert_output( &["foo", "--format", "basename={/}", "--path-separator=/"], "basename=a.foo @@ -1781,6 +1791,16 @@ fn test_exec() { one/two/three/directory_foo", ); + te.assert_output( + &["foo", "--exec", "echo", "{.}_encoded.{.ext}"], + "a_encoded.foo + one/b_encoded.foo + one/two/C_encoded.Foo2 + one/two/c_encoded.foo + one/two/three/d_encoded.foo + one/two/three/directory_foo_encoded.", + ); + te.assert_output( &["foo", "--exec", "echo", "{/}"], "a.foo