From a9eb218a5be4059633b9978c183537f28bd1203b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E9=9B=85=20=C2=B7=20Misaki=20Masa?= Date: Mon, 22 Jul 2024 19:06:02 +0800 Subject: [PATCH] feat: add support for `%@` argument extension on Windows to align with Unix platforms (#1319) --- Cargo.lock | 1 + cspell.json | 2 +- yazi-dds/Cargo.toml | 3 + yazi-dds/src/client.rs | 5 +- yazi-plugin/src/cha/cha.rs | 4 +- yazi-plugin/src/fs/fs.rs | 9 +- yazi-plugin/src/utils/text.rs | 6 +- yazi-scheduler/src/process/shell.rs | 252 +++++++++++++++------------- yazi-scheduler/src/scheduler.rs | 10 +- yazi-shared/src/escape/mod.rs | 41 +++++ yazi-shared/src/escape/unix.rs | 97 +++++++++++ yazi-shared/src/escape/windows.rs | 149 ++++++++++++++++ yazi-shared/src/fs/path.rs | 28 ++-- yazi-shared/src/lib.rs | 1 + 14 files changed, 461 insertions(+), 147 deletions(-) create mode 100644 yazi-shared/src/escape/mod.rs create mode 100644 yazi-shared/src/escape/unix.rs create mode 100644 yazi-shared/src/escape/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 8f1921f4..32818939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2935,6 +2935,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "tracing", "uzers", "vergen-gitcl", "yazi-boot", diff --git a/cspell.json b/cspell.json index 27e19d8b..8ac02e8e 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"version":"0.2","words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","️ Überzug","️ Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","imagesize","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP"],"language":"en","flagWords":[]} \ No newline at end of file +{"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","️ Überzug","️ Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","imagesize","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip"],"version":"0.2","language":"en","flagWords":[]} \ No newline at end of file diff --git a/yazi-dds/Cargo.toml b/yazi-dds/Cargo.toml index 2e3eb6ba..ccd51013 100644 --- a/yazi-dds/Cargo.toml +++ b/yazi-dds/Cargo.toml @@ -26,6 +26,9 @@ tokio = { version = "1.38.1", features = [ "full" ] } tokio-stream = "0.1.15" tokio-util = "0.7.11" +# Logging +tracing = { version = "0.1.40", features = [ "max_level_debug", "release_max_level_warn" ] } + [build-dependencies] vergen-gitcl = { version = "1.0.0", features = [ "build" ] } diff --git a/yazi-dds/src/client.rs b/yazi-dds/src/client.rs index 0c29cd1c..ed45f700 100644 --- a/yazi-dds/src/client.rs +++ b/yazi-dds/src/client.rs @@ -4,6 +4,7 @@ use anyhow::{bail, Context, Result}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use tokio::{io::AsyncWriteExt, select, sync::mpsc, task::JoinHandle, time}; +use tracing::error; use yazi_shared::RoCell; use crate::{body::{Body, BodyBye, BodyHi}, ClientReader, ClientWriter, Payload, Pubsub, Server, Stream}; @@ -54,8 +55,8 @@ impl Client { continue; } else if line.starts_with("hey,") { Self::handle_hey(&line); - } else { - Payload::from_str(&line).map(|p| p.emit()).ok(); + } else if let Err(e) = Payload::from_str(&line).map(|p| p.emit()) { + error!("Could not parse payload:\n{line}\n\nError:\n{e}"); } } } diff --git a/yazi-plugin/src/cha/cha.rs b/yazi-plugin/src/cha/cha.rs index c369c9c8..c2de1b04 100644 --- a/yazi-plugin/src/cha/cha.rs +++ b/yazi-plugin/src/cha/cha.rs @@ -41,10 +41,10 @@ impl Cha { reg.add_field_method_get("accessed", |_, me| { Ok(me.atime.and_then(|t| t.duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).ok())) }); - reg.add_method("permissions", |_, me, ()| { + reg.add_method("permissions", |_, _me, ()| { Ok( #[cfg(unix)] - Some(yazi_shared::fs::permissions(me.perm, me.is_dummy())), + Some(yazi_shared::fs::permissions(_me.perm, _me.is_dummy())), #[cfg(windows)] None::, ) diff --git a/yazi-plugin/src/fs/fs.rs b/yazi-plugin/src/fs/fs.rs index 158b931c..fca87fe0 100644 --- a/yazi-plugin/src/fs/fs.rs +++ b/yazi-plugin/src/fs/fs.rs @@ -1,7 +1,7 @@ use mlua::{IntoLuaMulti, Lua, Value}; use tokio::fs; -use crate::{bindings::Cast, cha::Cha, url::UrlRef}; +use crate::{bindings::Cast, cha::Cha, url::{Url, UrlRef}}; pub fn install(lua: &Lua) -> mlua::Result<()> { lua.globals().raw_set( @@ -34,6 +34,13 @@ pub fn install(lua: &Lua) -> mlua::Result<()> { } })?, ), + ( + "unique_name", + lua.create_async_function(|lua, url: UrlRef| async move { + // FIXME: handle errors + Url::cast(lua, yazi_shared::fs::unique_name(url.clone()).await) + })?, + ), ])?, ) } diff --git a/yazi-plugin/src/utils/text.rs b/yazi-plugin/src/utils/text.rs index b95f9887..9df2fd51 100644 --- a/yazi-plugin/src/utils/text.rs +++ b/yazi-plugin/src/utils/text.rs @@ -12,9 +12,9 @@ impl Utils { "quote", lua.create_function(|_, (s, unix): (mlua::String, Option)| { let s = match unix { - Some(true) => shell_escape::unix::escape(s.to_str()?.into()), - Some(false) => shell_escape::windows::escape(s.to_str()?.into()), - None => shell_escape::escape(s.to_str()?.into()), + Some(true) => yazi_shared::escape::unix(s.to_str()?), + Some(false) => yazi_shared::escape::windows(s.to_str()?), + None => yazi_shared::escape::native(s.to_str()?), }; Ok(s.into_owned()) })?, diff --git a/yazi-scheduler/src/process/shell.rs b/yazi-scheduler/src/process/shell.rs index fb3ad667..84978b05 100644 --- a/yazi-scheduler/src/process/shell.rs +++ b/yazi-scheduler/src/process/shell.rs @@ -46,13 +46,10 @@ pub fn shell(opt: ShellOpt) -> Result { #[cfg(windows)] { - let args: Vec = opt.args.iter().map(|s| s.to_string_lossy().into_owned()).collect(); - let args_: Vec<&str> = args.iter().map(|s| s.as_ref()).collect(); - let expanded = parser::parse(opt.cmd.to_string_lossy().as_ref(), &args_); Ok( - Command::new("cmd") - .arg("/C") - .args(&expanded) + Command::new("cmd.exe") + .raw_arg("/C") + .raw_arg(parser::parse(&opt.cmd, &opt.args)) .stdin(opt.stdio()) .stdout(opt.stdio()) .stderr(opt.stdio()) @@ -64,156 +61,173 @@ pub fn shell(opt: ShellOpt) -> Result { #[cfg(windows)] mod parser { - use std::{iter::Peekable, str::Chars}; + use std::{ffi::{OsStr, OsString}, iter::Peekable, os::windows::ffi::{EncodeWide, OsStrExt, OsStringExt}}; - pub(super) fn parse(cmd: &str, args: &[&str]) -> Vec { - let mut it = cmd.chars().peekable(); - let mut expanded = vec![]; - - while let Some(c) = it.next() { - if c.is_whitespace() { - continue; - } - let mut s = String::new(); - - if c == '\'' { - while let Some(c) = it.next() { - if c == '\'' { - break; - } - next_string(&mut it, args, &mut s, c); - } - expanded.push(s); - } else if c == '"' { - while let Some(c) = it.next() { - if c == '"' { - break; - } - next_string(&mut it, args, &mut s, c); - } - expanded.push(s); - } else if c == '%' && it.peek().is_some_and(|&c| c == '*') { - it.next(); - expanded.extend(args.iter().skip(1).map(|&s| s.to_owned())); - } else { - next_string(&mut it, args, &mut s, c); - - while let Some(c) = it.next() { - if c.is_whitespace() { - break; - } - next_string(&mut it, args, &mut s, c); - } - expanded.push(s); - } - } - - expanded + macro_rules! w { + ($c:literal) => { + $c as u16 + }; } - fn next_string(it: &mut Peekable>, args: &[&str], s: &mut String, c: char) { - if c == '\\' { - match it.next() { - Some('\\') => s.push('\\'), // \\ ==> \ - Some('\'') => s.push('\''), // \' ==> ' - Some('"') => s.push('"'), // \" ==> " - Some('%') => s.push('%'), // \% ==> % - Some('n') => s.push('\n'), // \n ==> '\n' - Some('t') => s.push('\t'), // \t ==> '\t' - Some('r') => s.push('\r'), // \r ==> '\r' - Some(c) => { - s.push('\\'); - s.push(c); - } - None => s.push('\\'), - } - } else if c == '%' { - match it.peek() { - Some('*') => { - if args.len() > 1 { - s.push_str(&args[1..].join(" ")); - } - it.next(); - } - Some(n) if n.is_ascii_digit() => { - let mut pos = n.to_string(); - - it.next(); - while let Some(&n) = it.peek() { - if n.is_ascii_digit() { - pos.push(it.next().unwrap()); - } else { - break; - } - } - - if let Some(arg) = args.get(pos.parse::().unwrap()) { - s.push_str(arg); - } - } - _ => s.push('%'), - } - } else { - s.push(c); + pub(super) fn parse(cmd: &OsStr, args: &[OsString]) -> OsString { + if cmd.len() < 2 { + return cmd.to_owned(); } + + let mut buf = Vec::with_capacity(cmd.len()); + let mut it = cmd.encode_wide().peekable(); + + while let Some(c) = it.next() { + if c == w!('%') { + let n = visit_percent(it.clone(), &mut buf, &args, false); + if n == 0 { + buf.push(c); + } else { + it.by_ref().take(n).for_each(drop); + } + } else if c == w!('"') && it.peek().is_some_and(|&c| c == w!('%')) { + it.next(); + + let n = visit_percent(it.clone(), &mut buf, &args, true); + if n == 0 { + buf.push(c); + buf.push(w!('%')); + } else { + it.by_ref().take(n).for_each(drop); + } + } else { + buf.push(c); + } + } + + OsString::from_wide(&buf) + } + + fn visit_percent( + mut it: Peekable, + buf: &mut Vec, + args: &[OsString], + quote: bool, + ) -> usize { + let Some(c) = it.next().and_then(|c| char::from_u32(c as _)) else { + return 0; + }; + + let mut pos = None; + if c.is_ascii_digit() { + let mut p = c.to_string(); + while let Some(n) = it.peek().and_then(|&c| char::from_u32(c as _)) { + if n.is_ascii_digit() { + it.next(); + p.push(n); + } else { + break; + } + } + pos = Some(p); + } + + if quote && !it.next().is_some_and(|e| e == w!('"')) { + return 0; + } + + if let Some(p) = pos { + if let Some(arg) = args.get(p.parse::().unwrap()) { + if quote { + buf.extend(yazi_shared::escape::os_str(arg).encode_wide()); + } else { + buf.extend(arg.encode_wide()); + } + } + return p.len() + quote as usize; + } + + if c != '*' && c != '@' { + return 0; + } + + let mut s = OsString::new(); + for (i, arg) in args.iter().skip(1).enumerate() { + if i > 0 { + s.push(" "); + } + if c == '*' { + s.push(yazi_shared::escape::os_str(arg)); + } else { + s.push(arg); + } + } + if quote { + buf.extend(yazi_shared::escape::os_str(&s).encode_wide()); + } else { + buf.extend(s.encode_wide()); + } + + 1 + quote as usize } #[cfg(test)] mod tests { - use super::*; + use std::ffi::OsString; + + fn parse(cmd: &str, args: &[&str]) -> String { + let cmd = OsString::from(cmd); + let args: Vec<_> = args.iter().map(|&s| OsString::from(s)).collect(); + super::parse(&cmd, &args).to_str().unwrap().to_owned() + } #[test] fn test_no_quote() { - let args = parse("echo abc xyz %0 %2", &["000", "111", "222"]); - assert_eq!(args, ["echo", "abc", "xyz", "000", "222"]); + let s = parse("echo abc xyz %0 %2", &["000", "111", "222"]); + assert_eq!(s, "echo abc xyz 000 222"); - let args = parse(" echo abc xyz %1 %2 ", &["", "111", "222"]); - assert_eq!(args, ["echo", "abc", "xyz", "111", "222"]); + let s = parse(" echo abc xyz %1 %2 ", &["", "111", "222"]); + assert_eq!(s, " echo abc xyz 111 222 "); } #[test] fn test_single_quote() { - let args = parse("echo 'abc xyz' '%1' %2", &["000", "111", "222"]); - assert_eq!(args, ["echo", "abc xyz", "111", "222"]); + let s = parse("echo 'abc xyz' '%1' %2", &["000", "111", "222"]); + assert_eq!(s, "echo 'abc xyz' '111' 222"); - let args = parse(r#"echo 'abc ""xyz' '%1' %2"#, &["", "111", "222"]); - assert_eq!(args, ["echo", r#"abc ""xyz"#, "111", "222"]); + let s = parse(r#"echo 'abc ""xyz' '%1' %2"#, &["", "111", "222"]); + assert_eq!(s, r#"echo 'abc ""xyz' '111' 222"#); } #[test] fn test_double_quote() { - let args = parse("echo \"abc ' 'xyz\" \"%1\" %2 %3", &["", "111", "222"]); - assert_eq!(args, ["echo", "abc ' 'xyz", "111", "222", ""]); + let s = parse(r#"echo "abc ' 'xyz" "%1" %2 %3"#, &["", "111", "222"]); + assert_eq!(s, r#"echo "abc ' 'xyz" 111 222 "#); } #[test] fn test_escaped() { - let args = parse("echo \"a\tbc ' 'x\nyz\" \"\\%1\" %2 %3", &["", "111", "22 2"]); - assert_eq!(args, ["echo", "a\tbc ' 'x\nyz", "%1", "22 2", ""]); + let s = parse(r#"echo "a bc ' 'x\nyz" "\%1" "\"%2"" %3"#, &["", "111", "22 2"]); + assert_eq!(s, r#"echo "a bc ' 'x\nyz" "\111" "\"22 2"" "#); } #[test] fn test_percent_star() { - let args = parse("echo %* xyz", &[]); - assert_eq!(args, ["echo", "xyz"]); + let s = parse("echo %* xyz", &[]); + assert_eq!(s, "echo xyz"); - let args = parse("echo %* xyz", &["000", "111", "222"]); - assert_eq!(args, ["echo", "111", "222", "xyz"]); + let s = parse("echo %* xyz", &["000", "111", "222"]); + assert_eq!(s, "echo 111 222 xyz"); - let args = parse("echo '%*' xyz", &["000", "111", "222"]); - assert_eq!(args, ["echo", "111 222", "xyz"]); + let s = parse("echo '%*' xyz", &["000", "111", "22 2"]); + assert_eq!(s, r#"echo '111 "22 2"' xyz"#); - let args = parse("echo -C%* xyz", &[]); - assert_eq!(args, ["echo", "-C", "xyz"]); + let s = parse("echo -C%* xyz", &[]); + assert_eq!(s, "echo -C xyz"); - let args = parse("echo -C%* xyz", &["000", "111", "222"]); - assert_eq!(args, ["echo", "-C111 222", "xyz"]); + let s = parse("echo -C%* xyz", &["000", " 111", "222"]); + assert_eq!(s, r#"echo -C" 111" 222 xyz"#); } #[test] fn test_env_var() { - let args = parse(" %EDITOR% %* xyz", &["000", "111", "222"]); - assert_eq!(args, ["%EDITOR%", "111", "222", "xyz"]); + let s = parse(r#"%EDITOR% %@ "%@" %* "%*" xyz"#, &["000", "1 11", "222"]); + assert_eq!(s, r#"%EDITOR% 1 11 222 "1 11 222" "1 11" 222 "\"1 11\" 222" xyz"#); } } } diff --git a/yazi-scheduler/src/scheduler.rs b/yazi-scheduler/src/scheduler.rs index a4137958..0daa9cef 100644 --- a/yazi-scheduler/src/scheduler.rs +++ b/yazi-scheduler/src/scheduler.rs @@ -6,7 +6,7 @@ use parking_lot::Mutex; use tokio::{fs, select, sync::{mpsc::{self, UnboundedReceiver}, oneshot}, task::JoinHandle}; use yazi_config::{open::Opener, plugin::{Fetcher, Preloader}, TASKS}; use yazi_dds::Pump; -use yazi_shared::{event::Data, fs::{unique_path, Url}, Throttle}; +use yazi_shared::{event::Data, fs::{unique_name, Url}, Throttle}; use super::{Ongoing, TaskProg, TaskStage}; use crate::{file::{File, FileOpDelete, FileOpHardlink, FileOpLink, FileOpPaste, FileOpTrash}, plugin::{Plugin, PluginOpEntry}, prework::{Prework, PreworkOpFetch, PreworkOpLoad, PreworkOpSize}, process::{Process, ProcessOpBg, ProcessOpBlock, ProcessOpOrphan}, TaskKind, TaskOp, HIGH, LOW, NORMAL}; @@ -97,7 +97,7 @@ impl Scheduler { _ = self.micro.try_send( async move { if !force { - to = unique_path(to).await; + to = unique_name(to).await; } file .paste(FileOpPaste { id, from, to, meta: None, cut: true, follow: false, retry: 0 }) @@ -122,7 +122,7 @@ impl Scheduler { _ = self.micro.try_send( async move { if !force { - to = unique_path(to).await; + to = unique_name(to).await; } file .paste(FileOpPaste { id, from, to, meta: None, cut: false, follow, retry: 0 }) @@ -142,7 +142,7 @@ impl Scheduler { _ = self.micro.try_send( async move { if !force { - to = unique_path(to).await; + to = unique_name(to).await; } file .link(FileOpLink { id, from, to, meta: None, resolve: false, relative, delete: false }) @@ -167,7 +167,7 @@ impl Scheduler { _ = self.micro.try_send( async move { if !force { - to = unique_path(to).await; + to = unique_name(to).await; } file.hardlink(FileOpHardlink { id, from, to, meta: None, follow }).await.ok(); } diff --git a/yazi-shared/src/escape/mod.rs b/yazi-shared/src/escape/mod.rs new file mode 100644 index 00000000..297f05e3 --- /dev/null +++ b/yazi-shared/src/escape/mod.rs @@ -0,0 +1,41 @@ +//! Escape characters that may have special meaning in a shell, including +//! spaces. This is a modified version of the [`shell-escape`] crate and [`this +//! PR`]. +//! +//! [`shell-escape`]: https://crates.io/crates/shell-escape +//! [`this PR`]: https://github.com/sfackler/shell-escape/pull/9 + +use std::{borrow::Cow, ffi::OsStr}; + +mod unix; +mod windows; + +#[inline] +pub fn unix(s: &str) -> Cow { unix::from_str(s) } + +#[inline] +pub fn windows(s: &str) -> Cow { windows::from_str(s) } + +#[inline] +pub fn native(s: &str) -> Cow { + #[cfg(unix)] + { + unix::from_str(s) + } + #[cfg(windows)] + { + windows::from_str(s) + } +} + +#[inline] +pub fn os_str(s: &OsStr) -> Cow { + #[cfg(unix)] + { + unix::from_os_str(s) + } + #[cfg(windows)] + { + windows::from_os_str(s) + } +} diff --git a/yazi-shared/src/escape/unix.rs b/yazi-shared/src/escape/unix.rs new file mode 100644 index 00000000..8bad1574 --- /dev/null +++ b/yazi-shared/src/escape/unix.rs @@ -0,0 +1,97 @@ +use std::borrow::Cow; + +pub fn from_str(s: &str) -> Cow { + match from_slice(s.as_bytes()) { + Cow::Borrowed(_) => Cow::Borrowed(s), + Cow::Owned(v) => String::from_utf8(v).expect("Invalid bytes returned from from_slice()").into(), + } +} + +#[cfg(unix)] +pub fn from_os_str(s: &std::ffi::OsStr) -> Cow { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + match from_slice(s.as_bytes()) { + Cow::Borrowed(_) => Cow::Borrowed(s), + Cow::Owned(v) => std::ffi::OsString::from_vec(v).into(), + } +} + +fn from_slice(s: &[u8]) -> Cow<[u8]> { + if !s.is_empty() && s.iter().copied().all(allowed) { + return Cow::Borrowed(s); + } + + let mut escaped = Vec::with_capacity(s.len() + 2); + escaped.push(b'\''); + + for &b in s { + match b { + b'\'' | b'!' => { + escaped.reserve(4); + escaped.push(b'\''); + escaped.push(b'\\'); + escaped.push(b); + escaped.push(b'\''); + } + _ => escaped.push(b), + } + } + + escaped.push(b'\''); + escaped.into() +} + +fn allowed(b: u8) -> bool { + matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'=' | b'/' | b',' | b'.' | b'+') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_str() { + assert_eq!(from_str(""), r#"''"#); + assert_eq!(from_str(" "), r#"' '"#); + assert_eq!(from_str("*"), r#"'*'"#); + + assert_eq!(from_str("--aaa=bbb-ccc"), "--aaa=bbb-ccc"); + assert_eq!(from_str(r#"--features="default""#), r#"'--features="default"'"#); + assert_eq!(from_str("linker=gcc -L/foo -Wl,bar"), r#"'linker=gcc -L/foo -Wl,bar'"#); + + assert_eq!( + from_str("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+"), + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+", + ); + assert_eq!(from_str(r#"'!\$`\\\n "#), r#"''\'''\!'\$`\\\n '"#); + } + + #[cfg(unix)] + #[test] + fn test_from_os_str() { + use std::{ffi::OsStr, os::unix::ffi::OsStrExt}; + + fn from_str(input: &str, expected: &str) { from_bytes(input.as_bytes(), expected.as_bytes()) } + + fn from_bytes(input: &[u8], expected: &[u8]) { + assert_eq!(from_os_str(OsStr::from_bytes(input)), OsStr::from_bytes(expected)); + } + + from_str("", r#"''"#); + from_str(" ", r#"' '"#); + from_str("*", r#"'*'"#); + + from_str("--aaa=bbb-ccc", "--aaa=bbb-ccc"); + from_str(r#"--features="default""#, r#"'--features="default"'"#); + from_str("linker=gcc -L/foo -Wl,bar", r#"'linker=gcc -L/foo -Wl,bar'"#); + + from_str( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_=/,.+", + ); + from_str(r#"'!\$`\\\n "#, r#"''\'''\!'\$`\\\n '"#); + + from_bytes(&[0x66, 0x6f, 0x80, 0x6f], &[b'\'', 0x66, 0x6f, 0x80, 0x6f, b'\'']); + } +} diff --git a/yazi-shared/src/escape/windows.rs b/yazi-shared/src/escape/windows.rs new file mode 100644 index 00000000..bd08631b --- /dev/null +++ b/yazi-shared/src/escape/windows.rs @@ -0,0 +1,149 @@ +use std::{borrow::Cow, iter::repeat}; + +pub fn from_str(s: &str) -> Cow { + let bytes = s.as_bytes(); + if !bytes.is_empty() && !bytes.iter().any(|&c| matches!(c, b' ' | b'"' | b'\n' | b'\t')) { + return Cow::Borrowed(s); + } + + let mut escaped = String::with_capacity(bytes.len() + 2); + escaped.push('"'); + + let mut chars = bytes.iter().copied().peekable(); + loop { + let mut slashes = 0; + while chars.next_if_eq(&b'\\').is_some() { + slashes += 1; + } + match chars.next() { + Some(b'"') => { + escaped.reserve(slashes * 2 + 2); + escaped.extend(repeat('\\').take(slashes * 2 + 1)); + escaped.push('"'); + } + Some(c) => { + escaped.reserve(slashes + 1); + escaped.extend(repeat('\\').take(slashes)); + escaped.push(c as _); + } + None => { + escaped.reserve(slashes * 2); + escaped.extend(repeat('\\').take(slashes * 2)); + break; + } + } + } + + escaped.push('"'); + escaped.into() +} + +#[cfg(windows)] +pub fn from_os_str(s: &std::ffi::OsStr) -> Cow { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let wide = s.encode_wide(); + if !s.is_empty() && !wide.clone().into_iter().any(disallowed) { + return Cow::Borrowed(s); + } + + let mut escaped: Vec = Vec::with_capacity(s.len() + 2); + escaped.push(b'"' as _); + + let mut chars = wide.into_iter().peekable(); + loop { + let mut slashes = 0; + while chars.next_if_eq(&(b'\\' as _)).is_some() { + slashes += 1; + } + match chars.next() { + Some(c) if c == b'"' as _ => { + escaped.reserve(slashes * 2 + 2); + escaped.extend(repeat(b'\\' as u16).take(slashes * 2 + 1)); + escaped.push(b'"' as _); + } + Some(c) => { + escaped.reserve(slashes + 1); + escaped.extend(repeat(b'\\' as u16).take(slashes)); + escaped.push(c); + } + None => { + escaped.reserve(slashes * 2); + escaped.extend(repeat(b'\\' as u16).take(slashes * 2)); + break; + } + } + } + + escaped.push(b'"' as _); + std::ffi::OsString::from_wide(&escaped).into() +} + +#[cfg(windows)] +fn disallowed(b: u16) -> bool { + match char::from_u32(b as u32) { + Some(c) => matches!(c, ' ' | '"' | '\n' | '\t'), + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_str() { + assert_eq!(from_str(""), r#""""#); + assert_eq!(from_str(r#""""#), r#""\"\"""#); + + assert_eq!(from_str("--aaa=bbb-ccc"), "--aaa=bbb-ccc"); + assert_eq!(from_str(r#"\path\to\my documents\"#), r#""\path\to\my documents\\""#); + + assert_eq!(from_str(r#"--features="default""#), r#""--features=\"default\"""#); + assert_eq!(from_str(r#""--features=\"default\"""#), r#""\"--features=\\\"default\\\"\"""#); + assert_eq!(from_str("linker=gcc -L/foo -Wl,bar"), r#""linker=gcc -L/foo -Wl,bar""#); + } + + #[cfg(windows)] + #[test] + fn test_from_os_str() { + use std::{ffi::OsString, os::windows::ffi::OsStringExt}; + + fn from_str(input: &str, expected: &str) { + let observed = OsString::from(input); + let expected = OsString::from(expected); + assert_eq!(from_os_str(observed.as_os_str()), expected.as_os_str()); + } + + fn from_bytes(input: &[u16], expected: &[u16]) { + let observed = OsString::from_wide(input); + let expected = OsString::from_wide(expected); + assert_eq!(from_os_str(observed.as_os_str()), expected.as_os_str()); + } + + from_str("", r#""""#); + from_str(r#""""#, r#""\"\"""#); + + from_str("--aaa=bbb-ccc", "--aaa=bbb-ccc"); + from_str(r#"\path\to\my documents\"#, r#""\path\to\my documents\\""#); + + from_str(r#"--features="default""#, r#""--features=\"default\"""#); + from_str(r#""--features=\"default\"""#, r#""\"--features=\\\"default\\\"\"""#); + from_str("linker=gcc -L/foo -Wl,bar", r#""linker=gcc -L/foo -Wl,bar""#); + + from_bytes(&[0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065], &[ + 0x1055, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065, + ]); + from_bytes(&[0xd801, 0x006e, 0x0069, 0x0063, 0x006f, 0x0064, 0x0065], &[ + b'"' as u16, + 0xd801, + 0x006e, + 0x0069, + 0x0063, + 0x006f, + 0x0064, + 0x0065, + b'"' as u16, + ]); + } +} diff --git a/yazi-shared/src/fs/path.rs b/yazi-shared/src/fs/path.rs index d21847ae..4aa318e6 100644 --- a/yazi-shared/src/fs/path.rs +++ b/yazi-shared/src/fs/path.rs @@ -55,12 +55,14 @@ pub fn ends_with_slash(p: &Path) -> bool { if let [.., last] = b { *last == MAIN_SEPARATOR as u8 } else { false } } -pub async fn unique_path(mut p: Url) -> Url { - let Some(stem) = p.file_stem().map(|s| s.to_owned()) else { - return p; +// FIXME: should return a `std::io::Result` to handle errors such as +// permission denied +pub async fn unique_name(mut u: Url) -> Url { + let Some(stem) = u.file_stem().map(|s| s.to_owned()) else { + return u; }; - let ext = p + let ext = u .extension() .map(|s| { let mut n = OsString::with_capacity(s.len() + 1); @@ -70,20 +72,18 @@ pub async fn unique_path(mut p: Url) -> Url { }) .unwrap_or_default(); - let mut i = 0; - while maybe_exists(&p).await { - i += 1; - + let mut i = 1u64; + while maybe_exists(&u).await { let mut name = OsString::with_capacity(stem.len() + ext.len() + 5); name.push(&stem); - name.push(format!("_{i}")); - if !ext.is_empty() { - name.push(&ext); - } + name.push("_"); + name.push(i.to_string()); + name.push(&ext); - p.set_file_name(name); + u.set_file_name(name); + i += 1; } - p + u } // Parameters diff --git a/yazi-shared/src/lib.rs b/yazi-shared/src/lib.rs index 2d7d86ea..0fef293d 100644 --- a/yazi-shared/src/lib.rs +++ b/yazi-shared/src/lib.rs @@ -5,6 +5,7 @@ mod condition; mod debounce; mod env; mod errors; +pub mod escape; pub mod event; pub mod fs; mod layer;