diff --git a/CHANGELOG.md b/CHANGELOG.md index edaf23d..097eb03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Bash/Zsh: rename `_z` completion function to avoid conflicts with other shell plugins. +- Bash/Zsh: rename `_z` completion function to avoid conflicts with other shell + plugins. - Elvish: upgrade to new lambda syntax. -- Fzf: added `--keep-right` option by default, upgraded minimum version to v0.21.0. +- Fzf: added `--keep-right` option by default, upgraded minimum version to + v0.21.0. - Bash: only enable completions on 4.4+. - Fzf: bypass `ls` alias in preview window. +- Retain ownership of database file. ## [0.8.0] - 2021-12-25 @@ -30,7 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Fzf: better default options. -- Fish: interactive completions are only triggered when the last argument is empty. +- Fish: interactive completions are only triggered when the last argument is + empty. - PowerShell: installation instructions. ### Fixed @@ -131,7 +135,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Auto-generated shell completions. - `zoxide query --all` for listing deleted directories. -- Lazy deletion for removed directories that have not been accessed in > 90 days. +- Lazy deletion for removed directories that have not been accessed in > 90 + days. - Nushell: support for 0.32.0+. ### Fixed @@ -158,7 +163,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `cd -` on fish shells. -- `__zoxide_hook` no longer changes value of `$?` within `$PROMPT_COMMAND` on bash. +- `__zoxide_hook` no longer changes value of `$?` within `$PROMPT_COMMAND` on + bash. ### Removed @@ -195,7 +201,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `$_ZO_EXCLUDE_DIRS` now supports globs. -- `zoxide init` now defines `__zoxide_z*` functions that can be aliased as needed. +- `zoxide init` now defines `__zoxide_z*` functions that can be aliased as + needed. - Support for the [xonsh](https://xon.sh/) shell. - `zoxide import` can now import from Autojump. diff --git a/Cargo.lock b/Cargo.lock index 9f91ed4..b125936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.55" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159bb86af3a200e19a068f4224eae4c8bb2d0fa054c7e5d1cacd5cef95e684cd" +checksum = "4361135be9122e0870de935d7c439aef945b9f9ddd4199a553b5270b49c82a27" [[package]] name = "askama" @@ -117,6 +117,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + [[package]] name = "cfg-if" version = "1.0.0" @@ -125,9 +131,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "3.1.5" +version = "3.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced1892c55c910c1219e98d6fc8d71f6bddba7905866ce740066d8bfea859312" +checksum = "d8c93436c21e4698bacadf42917db28b23017027a4deccb35dbe47a7e7840123" dependencies = [ "atty", "bitflags", @@ -365,6 +371,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.16" @@ -387,6 +402,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "nix" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f866317acbd3a240710c63f065ffb1e4fd466259045ccb504130b7f668f35c6" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.0" @@ -500,24 +528,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7" -dependencies = [ - "getrandom", -] - [[package]] name = "redox_syscall" version = "0.2.11" @@ -539,9 +549,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" dependencies = [ "aho-corasick", "memchr", @@ -695,26 +705,6 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" -[[package]] -name = "thiserror" -version = "1.0.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "thread_local" version = "1.1.4" @@ -825,12 +815,12 @@ dependencies = [ "clap_complete_fig", "dirs", "dunce", + "fastrand", "glob", + "nix", "ordered-float", - "rand", "rstest", "rstest_reuse", "serde", "tempfile", - "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index e67083b..27d729f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,17 +23,13 @@ bincode = "1.3.1" clap = { version = "3.1.0", features = ["derive"] } dirs = "4.0.0" dunce = "1.0.1" +fastrand = "1.7.0" glob = "0.3.0" ordered-float = "2.0.0" serde = { version = "1.0.116", features = ["derive"] } -tempfile = "3.1.0" -thiserror = "1.0.30" -[target.'cfg(windows)'.dependencies] -rand = { version = "0.8.4", features = [ - "getrandom", - "small_rng", -], default-features = false } +[target.'cfg(unix)'.dependencies] +nix = "0.23.1" [build-dependencies] clap = { version = "3.1.0", features = ["derive"] } @@ -44,10 +40,11 @@ clap_complete_fig = "3.1.0" assert_cmd = "2.0.0" rstest = "0.12.0" rstest_reuse = "0.3.0" +tempfile = "3.1.0" [features] default = [] -nix = [] +nix-dev = [] [profile.release] codegen-units = 1 diff --git a/src/cmd/query.rs b/src/cmd/query.rs index 109640b..f095501 100644 --- a/src/cmd/query.rs +++ b/src/cmd/query.rs @@ -3,10 +3,10 @@ use std::io::{self, Write}; use anyhow::{Context, Result}; use crate::cmd::{Query, Run}; +use crate::config; use crate::db::{Database, DatabaseFile}; use crate::error::BrokenPipeHandler; -use crate::fzf::Fzf; -use crate::{config, util}; +use crate::util::{self, Fzf}; impl Run for Query { fn run(&self) -> Result<()> { diff --git a/src/cmd/remove.rs b/src/cmd/remove.rs index 7cf5212..7805517 100644 --- a/src/cmd/remove.rs +++ b/src/cmd/remove.rs @@ -3,9 +3,9 @@ use std::io::{self, Write}; use anyhow::{bail, Context, Result}; use crate::cmd::{Remove, Run}; +use crate::config; use crate::db::DatabaseFile; -use crate::fzf::Fzf; -use crate::{config, util}; +use crate::util::{self, Fzf}; impl Run for Remove { fn run(&self) -> Result<()> { diff --git a/src/db/mod.rs b/src/db/mod.rs index 08062f8..f0e99d1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,13 +2,14 @@ mod dir; mod stream; use std::fs; -use std::io::{self, Write}; +use std::io; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; pub use dir::{Dir, DirList, Epoch, Rank}; pub use stream::Stream; -use tempfile::{NamedTempFile, PersistError}; + +use crate::util; #[derive(Debug)] pub struct Database<'file> { @@ -24,18 +25,8 @@ impl<'file> Database<'file> { } let buffer = self.dirs.to_bytes()?; - let mut file = NamedTempFile::new_in(self.data_dir) - .with_context(|| format!("could not create temporary database in: {}", self.data_dir.display()))?; - - // Preallocate enough space on the file, preventing copying later on. This optimization may - // fail on some filesystems, but it is safe to ignore it and proceed. - let _ = file.as_file().set_len(buffer.len() as _); - file.write_all(&buffer) - .with_context(|| format!("could not write to temporary database: {}", file.path().display()))?; - let path = db_path(&self.data_dir); - persist(file, &path).with_context(|| format!("could not replace database: {}", path.display()))?; - + util::write(&path, &buffer).context("could not write to database")?; self.modified = false; Ok(()) } @@ -120,44 +111,6 @@ impl<'file> Database<'file> { } } -#[cfg(unix)] -fn persist>(file: NamedTempFile, path: P) -> Result<(), PersistError> { - file.persist(path)?; - Ok(()) -} - -#[cfg(windows)] -fn persist>(mut file: NamedTempFile, path: P) -> Result<(), PersistError> { - use std::thread; - use std::time::Duration; - - use rand::distributions::{Distribution, Uniform}; - use rand::rngs::SmallRng; - use rand::SeedableRng; - - // File renames on Windows are not atomic and sometimes fail with `PermissionDenied`. This is - // extremely unlikely unless it's running in a loop on multiple threads. Nevertheless, we guard - // against it by retrying the rename a fixed number of times. - const MAX_TRIES: usize = 10; - let mut rng = None; - - for _ in 0..MAX_TRIES { - match file.persist(&path) { - Ok(_) => break, - Err(e) if e.error.kind() == io::ErrorKind::PermissionDenied => { - let mut rng = rng.get_or_insert_with(SmallRng::from_entropy); - let between = Uniform::from(50..150); - let duration = Duration::from_millis(between.sample(&mut rng)); - thread::sleep(duration); - file = e.file; - } - Err(e) => return Err(e), - } - } - - Ok(()) -} - pub struct DatabaseFile { buffer: Vec, data_dir: PathBuf, diff --git a/src/error.rs b/src/error.rs index 782e59f..4a482b8 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,19 +1,20 @@ +use std::fmt::{self, Display, Formatter}; use std::io; use anyhow::{bail, Context, Result}; -use thiserror::Error; - -#[derive(Debug, Error)] -#[error("could not find fzf, is it installed?")] -pub struct FzfNotFound; /// Custom error type for early exit. -#[derive(Debug, Error)] -#[error("")] +#[derive(Debug)] pub struct SilentExit { pub code: i32, } +impl Display for SilentExit { + fn fmt(&self, _: &mut Formatter<'_>) -> fmt::Result { + Ok(()) + } +} + pub trait BrokenPipeHandler { fn pipe_exit(self, device: &str) -> Result<()>; } diff --git a/src/fzf.rs b/src/fzf.rs deleted file mode 100644 index ac4b30b..0000000 --- a/src/fzf.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::io::{self, Read}; -use std::mem; -use std::process::{Child, ChildStdin, Stdio}; - -use anyhow::{bail, Context, Result}; - -use crate::error::{FzfNotFound, SilentExit}; -use crate::{config, util}; - -pub struct Fzf { - child: Child, -} - -impl Fzf { - pub fn new(multiple: bool) -> Result { - let bin = if cfg!(windows) { "fzf.exe" } else { "fzf" }; - let mut command = util::get_command(bin).map_err(|_| FzfNotFound)?; - if multiple { - command.arg("-m"); - } - command.arg("-n2..").stdin(Stdio::piped()).stdout(Stdio::piped()); - if let Some(fzf_opts) = config::fzf_opts() { - command.env("FZF_DEFAULT_OPTS", fzf_opts); - } else { - command.args(&[ - // Search result - "--no-sort", - // Interface - "--keep-right", - // Layout - "--height=40%", - "--info=inline", - "--layout=reverse", - // Scripting - "--exit-0", - "--select-1", - // Key/Event bindings - "--bind=ctrl-z:ignore", - ]); - if cfg!(unix) { - command.env("SHELL", "sh"); - command.arg(r"--preview=\command -p ls -p {2..}"); - } - } - - let child = match command.spawn() { - Ok(child) => child, - Err(e) if e.kind() == io::ErrorKind::NotFound => bail!(FzfNotFound), - Err(e) => Err(e).context("could not launch fzf")?, - }; - - Ok(Fzf { child }) - } - - pub fn stdin(&mut self) -> &mut ChildStdin { - self.child.stdin.as_mut().unwrap() - } - - pub fn select(mut self) -> Result { - // Drop stdin to prevent deadlock. - mem::drop(self.child.stdin.take()); - - let mut stdout = self.child.stdout.take().unwrap(); - let mut output = String::new(); - stdout.read_to_string(&mut output).context("failed to read from fzf")?; - - let status = self.child.wait().context("wait failed on fzf")?; - match status.code() { - Some(0) => Ok(output), - Some(1) => bail!("no match found"), - Some(2) => bail!("fzf returned an error"), - Some(code @ 130) => bail!(SilentExit { code }), - Some(128..=254) | None => bail!("fzf was terminated"), - _ => bail!("fzf returned an unknown error"), - } - } -} diff --git a/src/main.rs b/src/main.rs index 8062110..b22b216 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,6 @@ mod cmd; mod config; mod db; mod error; -mod fzf; mod shell; mod util; diff --git a/src/shell.rs b/src/shell.rs index 09418c6..79554f4 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -32,7 +32,7 @@ make_template!(Powershell, "powershell.txt"); make_template!(Xonsh, "xonsh.txt"); make_template!(Zsh, "zsh.txt"); -#[cfg(feature = "nix")] +#[cfg(feature = "nix-dev")] #[cfg(test)] mod tests { use askama::Template; diff --git a/src/util.rs b/src/util.rs index 16025c7..2ad0b16 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,11 +1,172 @@ use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::mem; use std::path::{Component, Path, PathBuf}; use std::process::Command; +use std::process::{Child, ChildStdin, Stdio}; use std::time::SystemTime; -use anyhow::{bail, Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use crate::config; use crate::db::Epoch; +use crate::error::SilentExit; + +pub struct Fzf { + child: Child, +} + +impl Fzf { + const ERR_NOT_FOUND: &'static str = "could not find fzf, is it installed?"; + + pub fn new(multiple: bool) -> Result { + let bin = if cfg!(windows) { "fzf.exe" } else { "fzf" }; + let mut command = get_command(bin).map_err(|_| anyhow!(Self::ERR_NOT_FOUND))?; + if multiple { + command.arg("-m"); + } + command.arg("-n2..").stdin(Stdio::piped()).stdout(Stdio::piped()); + if let Some(fzf_opts) = config::fzf_opts() { + command.env("FZF_DEFAULT_OPTS", fzf_opts); + } else { + command.args(&[ + // Search result + "--no-sort", + // Interface + "--keep-right", + // Layout + "--height=40%", + "--info=inline", + "--layout=reverse", + // Scripting + "--exit-0", + "--select-1", + // Key/Event bindings + "--bind=ctrl-z:ignore", + ]); + if cfg!(unix) { + command.env("SHELL", "sh"); + command.arg(r"--preview=\command -p ls -p {2..}"); + } + } + + let child = match command.spawn() { + Ok(child) => child, + Err(e) if e.kind() == io::ErrorKind::NotFound => bail!(Self::ERR_NOT_FOUND), + Err(e) => Err(e).context("could not launch fzf")?, + }; + + Ok(Fzf { child }) + } + + pub fn stdin(&mut self) -> &mut ChildStdin { + self.child.stdin.as_mut().unwrap() + } + + pub fn select(mut self) -> Result { + // Drop stdin to prevent deadlock. + mem::drop(self.child.stdin.take()); + + let mut stdout = self.child.stdout.take().unwrap(); + let mut output = String::new(); + stdout.read_to_string(&mut output).context("failed to read from fzf")?; + + let status = self.child.wait().context("wait failed on fzf")?; + match status.code() { + Some(0) => Ok(output), + Some(1) => bail!("no match found"), + Some(2) => bail!("fzf returned an error"), + Some(code @ 130) => bail!(SilentExit { code }), + Some(128..=254) | None => bail!("fzf was terminated"), + _ => bail!("fzf returned an unknown error"), + } + } +} + +/// Similar to [`fs::write`], but atomic (best effort on Windows). +pub fn write, C: AsRef<[u8]>>(path: P, contents: C) -> Result<()> { + let path = path.as_ref(); + let contents = contents.as_ref(); + let dir = path.parent().unwrap(); + + // Create a tmpfile. + let (mut tmp_file, tmp_path) = tmpfile(dir)?; + let result = (|| { + // Write to the tmpfile. + let _ = tmp_file.set_len(contents.len() as u64); + tmp_file.write_all(contents).with_context(|| format!("could not write to file: {}", tmp_path.display()))?; + + // Set the owner of the tmpfile (UNIX only). + #[cfg(unix)] + if let Ok(metadata) = path.metadata() { + use nix::unistd::{self, Gid, Uid}; + use std::os::unix::fs::MetadataExt; + use std::os::unix::io::AsRawFd; + + let uid = Uid::from_raw(metadata.uid()); + let gid = Gid::from_raw(metadata.gid()); + let _ = unistd::fchown(tmp_file.as_raw_fd(), Some(uid), Some(gid)); + } + + // Close and rename the tmpfile. + mem::drop(tmp_file); + rename(&tmp_path, path) + })(); + // In case of an error, delete the tmpfile. + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result +} + +/// Atomically create a tmpfile in the given directory. +fn tmpfile>(dir: P) -> Result<(File, PathBuf)> { + const MAX_ATTEMPTS: usize = 5; + const TMP_NAME_LEN: usize = 16; + let dir = dir.as_ref(); + + let mut attempts = 0; + loop { + attempts += 1; + + // Generate a random name for the tmpfile. + let mut name = String::with_capacity(TMP_NAME_LEN); + name.push_str("tmp_"); + while name.len() < TMP_NAME_LEN { + name.push(fastrand::alphanumeric()); + } + let path = dir.join(name); + + // Atomically create the tmpfile. + match OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => break Ok((file, path)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists && attempts < MAX_ATTEMPTS => (), + Err(e) => break Err(e).with_context(|| format!("could not create file: {}", path.display())), + } + } +} + +/// Similar to [`fs::rename`], but retries on Windows. +fn rename, Q: AsRef>(from: P, to: Q) -> Result<()> { + const MAX_ATTEMPTS: usize = 5; + let from = from.as_ref(); + let to = to.as_ref(); + + if cfg!(windows) { + let mut attempts = 0; + loop { + attempts += 1; + match fs::rename(from, to) { + Err(e) if e.kind() == io::ErrorKind::PermissionDenied && attempts < MAX_ATTEMPTS => (), + result => break result, + } + } + } else { + fs::rename(from, to) + } + .with_context(|| format!("could not rename file: {} -> {}", from.display(), to.display())) +} pub fn canonicalize>(path: &P) -> Result { dunce::canonicalize(path).with_context(|| format!("could not resolve path: {}", path.as_ref().display())) diff --git a/templates/fish.txt b/templates/fish.txt index 403622a..19cb234 100644 --- a/templates/fish.txt +++ b/templates/fish.txt @@ -84,8 +84,8 @@ function __zoxide_z_complete # If the last argument is empty, use interactive selection. set -l query $tokens[2..-1] set -l result (zoxide query -i -- $query) - and commandline -p "$tokens[1] "(string escape $result) - commandline -f repaint + commandline --current-process "$tokens[1] "(string escape $result) + commandline --function repaint end end diff --git a/tests/completions.rs b/tests/completions.rs index 56975d8..881242c 100644 --- a/tests/completions.rs +++ b/tests/completions.rs @@ -1,5 +1,5 @@ //! Test clap generated completions. -#![cfg(feature = "nix")] +#![cfg(feature = "nix-dev")] use assert_cmd::Command;