diff --git a/.github/workflows/cargo-clippy.yml b/.github/workflows/cargo-clippy.yml index 9717ef4..b0ee9bb 100644 --- a/.github/workflows/cargo-clippy.yml +++ b/.github/workflows/cargo-clippy.yml @@ -17,5 +17,5 @@ jobs: components: clippy - uses: actions-rs/clippy-check@v1 with: - args: --workspace --all-targets --all-features -- -D warnings -D clippy::all + args: --all-targets --all-features -- -D warnings -D clippy::all token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/cargo-fmt.yml b/.github/workflows/cargo-fmt.yml index c4ac685..5edca49 100644 --- a/.github/workflows/cargo-fmt.yml +++ b/.github/workflows/cargo-fmt.yml @@ -18,4 +18,4 @@ jobs: - uses: actions-rs/cargo@v1 with: command: fmt - args: --all -- --check + args: -- --check diff --git a/.github/workflows/cargo-test.yml b/.github/workflows/cargo-test.yml index 04b52d1..61d6996 100644 --- a/.github/workflows/cargo-test.yml +++ b/.github/workflows/cargo-test.yml @@ -25,4 +25,4 @@ jobs: - uses: actions-rs/cargo@v1 with: command: test - args: --workspace --all-features --no-fail-fast + args: --all-features --no-fail-fast diff --git a/.github/workflows/cargo-udeps.yml b/.github/workflows/cargo-udeps.yml index e37e23d..0366207 100644 --- a/.github/workflows/cargo-udeps.yml +++ b/.github/workflows/cargo-udeps.yml @@ -19,4 +19,4 @@ jobs: crate: cargo-udeps version: latest use-tool-cache: true - - run: cargo udeps --all-features --all-targets --workspace + - run: cargo udeps --all-features --all-targets diff --git a/Cargo.lock b/Cargo.lock index 122b0bb..aff44ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "hermit-abi" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c30f6d0bc6b00693347368a67d41b58f2fb851215ff1da49e90fe2c5c667151" +checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" dependencies = [ "libc", ] @@ -242,9 +242,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.78" +version = "0.2.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa7087f49d294270db4e1928fc110c976cd4b9e5a16348e0a1df09afa99e6c98" +checksum = "4d58d1b70b004888f764dfbf6a26a3b0342a1632d33968e4a179d8011c760614" [[package]] name = "memchr" @@ -434,18 +434,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.116" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fe57af81d28386a513cbc6858332abc6117cfdb5999647c6444b8f43a370a5" +checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.116" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f630a6370fd8e457873b4bd2ffdae75408bc291ba72be773772a4c2a065d9ae8" +checksum = "cbd1ae72adb44aab48f325a02444a5fc079349a8d804c1fc922aed3f7454c74e" dependencies = [ "proc-macro2", "quote", @@ -460,9 +460,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "1.0.45" +version = "1.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9c5432ff16d6152371f808fb5a871cd67368171b09bb21b43df8e4a47a3556" +checksum = "cc371affeffc477f42a221a1e4297aedcea33d47d19b61455588bd9d8f6b19ac" dependencies = [ "proc-macro2", "quote", @@ -588,32 +588,16 @@ name = "zoxide" version = "0.4.3" dependencies = [ "anyhow", + "askama", + "assert_cmd", + "bincode", "clap", "dirs-next", "dunce", "glob", "once_cell", - "zoxide-engine", - "zoxide-shell", -] - -[[package]] -name = "zoxide-engine" -version = "0.1.0" -dependencies = [ - "anyhow", - "bincode", "ordered-float", + "rand", "serde", "tempfile", ] - -[[package]] -name = "zoxide-shell" -version = "0.1.0" -dependencies = [ - "anyhow", - "askama", - "assert_cmd", - "once_cell", -] diff --git a/Cargo.toml b/Cargo.toml index dff2544..a6b6f84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,15 +11,22 @@ license = "MIT" [dependencies] anyhow = "1.0.32" +askama = { version = "0.10.3", default-features = false } +bincode = "1.3.1" clap = "3.0.0-beta.2" dirs-next = "1.0.2" dunce = "1.0.1" glob = "0.3.0" once_cell = "1.4.1" -zoxide-engine = { path = "crates/zoxide-engine" } -zoxide-shell = { path = "crates/zoxide-shell" } +ordered-float = "2.0.0" +serde = { version = "1.0.116", features = ["derive"] } +tempfile = "3.1.0" -[workspace] +[target.'cfg(windows)'.dependencies] +rand = "0.7.3" + +[dev-dependencies] +assert_cmd = "1.0.1" [profile.release] codegen-units = 1 diff --git a/ranger_zoxide.py b/contrib/ranger.py similarity index 100% rename from ranger_zoxide.py rename to contrib/ranger.py diff --git a/crates/zoxide-engine/.gitignore b/crates/zoxide-engine/.gitignore deleted file mode 100644 index ff47c2d..0000000 --- a/crates/zoxide-engine/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - -# These are backup files generated by rustfmt -**/*.rs.bk diff --git a/crates/zoxide-engine/Cargo.toml b/crates/zoxide-engine/Cargo.toml deleted file mode 100644 index 4d5e077..0000000 --- a/crates/zoxide-engine/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "zoxide-engine" -version = "0.1.0" -authors = ["Ajeet D'Souza <98ajeet@gmail.com>"] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -anyhow = "1.0.33" -bincode = "1.3.1" -ordered-float = "2.0.0" -serde = { version = "1.0.116", features = ["derive"] } -tempfile = "3.1.0" diff --git a/crates/zoxide-engine/src/dir.rs b/crates/zoxide-engine/src/dir.rs deleted file mode 100644 index 5d24738..0000000 --- a/crates/zoxide-engine/src/dir.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::query::Query; - -use serde::{Deserialize, Serialize}; - -use std::path::Path; - -#[derive(Debug, Deserialize, Serialize)] -pub struct Dir { - pub path: String, - pub rank: Rank, - pub last_accessed: Epoch, -} - -impl Dir { - pub fn is_dir(&self) -> bool { - Path::new(&self.path).is_dir() - } - - pub fn is_match(&self, query: &Query) -> bool { - query.matches(&self.path) - } - - pub fn get_score(&self, now: Epoch) -> Rank { - const HOUR: Epoch = 60 * 60; - const DAY: Epoch = 24 * HOUR; - const WEEK: Epoch = 7 * DAY; - - let duration = now.saturating_sub(self.last_accessed); - if duration < HOUR { - self.rank * 4.0 - } else if duration < DAY { - self.rank * 2.0 - } else if duration < WEEK { - self.rank * 0.5 - } else { - self.rank * 0.25 - } - } -} - -pub type Rank = f64; -pub type Epoch = u64; diff --git a/crates/zoxide-engine/src/lib.rs b/crates/zoxide-engine/src/lib.rs deleted file mode 100644 index 4e2622c..0000000 --- a/crates/zoxide-engine/src/lib.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod dir; -mod query; -mod store; - -pub use dir::{Dir, Epoch}; -pub use query::Query; -pub use store::Store; diff --git a/crates/zoxide-engine/tests/store.rs b/crates/zoxide-engine/tests/store.rs deleted file mode 100644 index a271b61..0000000 --- a/crates/zoxide-engine/tests/store.rs +++ /dev/null @@ -1,43 +0,0 @@ -use zoxide_engine::Store; - -#[test] -fn test_add() { - let path = "/foo/bar"; - let now = 946684800; - - let data_dir = tempfile::tempdir().unwrap(); - { - let mut store = Store::open(data_dir.path()).unwrap(); - store.add(path, now); - store.add(path, now); - } - { - let store = Store::open(data_dir.path()).unwrap(); - assert_eq!(store.dirs.len(), 1); - - let dir = &store.dirs[0]; - assert_eq!(dir.path, path); - assert_eq!(dir.last_accessed, now); - } -} - -#[test] -fn test_remove() { - let path = "/foo/bar"; - let now = 946684800; - - let data_dir = tempfile::tempdir().unwrap(); - { - let mut store = Store::open(data_dir.path()).unwrap(); - store.add(path, now); - } - { - let mut store = Store::open(data_dir.path()).unwrap(); - assert!(store.remove(path)); - } - { - let mut store = Store::open(data_dir.path()).unwrap(); - assert!(store.dirs.is_empty()); - assert!(!store.remove(path)); - } -} diff --git a/crates/zoxide-shell/.gitignore b/crates/zoxide-shell/.gitignore deleted file mode 100644 index ff47c2d..0000000 --- a/crates/zoxide-shell/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -debug/ -target/ - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - -# These are backup files generated by rustfmt -**/*.rs.bk diff --git a/crates/zoxide-shell/Cargo.toml b/crates/zoxide-shell/Cargo.toml deleted file mode 100644 index 2713ad5..0000000 --- a/crates/zoxide-shell/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "zoxide-shell" -version = "0.1.0" -authors = ["Ajeet D'Souza <98ajeet@gmail.com>"] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -anyhow = "1.0.33" -askama = { version = "0.10.3", default-features = false } - -[dev-dependencies] -assert_cmd = "1.0.1" -once_cell = "1.4.1" diff --git a/crates/zoxide-shell/src/lib.rs b/crates/zoxide-shell/src/lib.rs deleted file mode 100644 index 8cf8506..0000000 --- a/crates/zoxide-shell/src/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -use anyhow::{Context, Result}; -use askama::Template; - -use std::io::Write; -use std::ops::Deref; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Hook { - None, - Prompt, - Pwd, -} - -pub trait Generator { - fn generate(&self, writer: &mut W) -> Result<()>; -} - -impl Generator for T { - fn generate(&self, writer: &mut W) -> Result<()> { - let source = &self.render().context("could not render template")?; - writeln!(writer, "{}", source).context("could not write to output")?; - Ok(()) - } -} - -#[derive(Debug)] -pub struct Opts<'a> { - pub cmd: Option<&'a str>, - pub hook: Hook, - pub echo: bool, - pub resolve_symlinks: bool, -} - -impl Opts<'_> { - pub const DEVNULL: &'static str = if cfg!(windows) { "NUL" } else { "/dev/null" }; -} - -#[derive(Debug, Template)] -#[template(path = "bash.txt")] -pub struct Bash<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for Bash<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} - -#[derive(Debug, Template)] -#[template(path = "fish.txt")] -pub struct Fish<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for Fish<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} - -#[derive(Debug, Template)] -#[template(path = "posix.txt")] -pub struct Posix<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for Posix<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} - -#[derive(Debug, Template)] -#[template(path = "powershell.txt")] -pub struct PowerShell<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for PowerShell<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} - -#[derive(Debug, Template)] -#[template(path = "xonsh.txt")] -pub struct Xonsh<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for Xonsh<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} -#[derive(Debug, Template)] -#[template(path = "zsh.txt")] -pub struct Zsh<'a>(pub &'a Opts<'a>); - -impl<'a> Deref for Zsh<'a> { - type Target = Opts<'a>; - fn deref(&self) -> &Self::Target { - self.0 - } -} diff --git a/crates/zoxide-shell/tests/syntax.rs b/crates/zoxide-shell/tests/syntax.rs deleted file mode 100644 index 2904d52..0000000 --- a/crates/zoxide-shell/tests/syntax.rs +++ /dev/null @@ -1,181 +0,0 @@ -use askama::Template; -use assert_cmd::Command; -use once_cell::sync::OnceCell; -use zoxide_shell::{Bash, Fish, Hook, Opts, Posix, PowerShell, Xonsh, Zsh}; - -fn opts() -> &'static [Opts<'static>] { - static OPTS: OnceCell> = OnceCell::new(); - OPTS.get_or_init(|| { - let mut opts = Vec::new(); - for &echo in &[false, true] { - for &resolve_symlinks in &[false, true] { - for &hook in &[Hook::None, Hook::Prompt, Hook::Pwd] { - for &cmd in &[None, Some("z")] { - opts.push(Opts { - echo, - resolve_symlinks, - hook, - cmd, - }); - } - } - } - } - opts - }) -} - -#[test] -fn test_bash() { - for opts in opts() { - let source = crate::Bash(opts).render().unwrap(); - Command::new("bash") - .args(&["-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_bash_posix() { - for opts in opts() { - let source = crate::Posix(opts).render().unwrap(); - let assert = Command::new("bash") - .args(&["--posix", "-c", &source]) - .assert() - .success() - .stderr(""); - - if opts.hook != Hook::Pwd { - assert.stdout(""); - } - } -} - -#[test] -fn test_dash() { - for opts in opts() { - let source = crate::Posix(opts).render().unwrap(); - let assert = Command::new("bash") - .args(&["--posix", "-c", &source]) - .assert() - .success() - .stderr(""); - - if opts.hook != Hook::Pwd { - assert.stdout(""); - } - } -} - -#[test] -fn test_fish() { - for opts in opts() { - let source = crate::Fish(opts).render().unwrap(); - Command::new("fish") - .args(&["-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_pwsh() { - for opts in opts() { - let source = crate::PowerShell(opts).render().unwrap(); - Command::new("pwsh") - .args(&["-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_shellcheck_bash() { - for opts in opts() { - let source = crate::Bash(opts).render().unwrap(); - Command::new("shellcheck") - .args(&["--shell", "bash", "-"]) - .write_stdin(source.as_bytes()) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_shellcheck_sh() { - for opts in opts() { - let source = crate::Posix(opts).render().unwrap(); - Command::new("shellcheck") - .args(&["--shell", "sh", "-"]) - .write_stdin(source.as_bytes()) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_shfmt_bash() { - for opts in opts() { - let source = crate::Bash(opts).render().unwrap(); - Command::new("shfmt") - .args(&["-d", "-s", "-ln", "bash", "-i", "4", "-ci", "-"]) - .write_stdin(source.as_bytes()) - .write_stdin(b"\n".as_ref()) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_shfmt_posix() { - for opts in opts() { - let source = crate::Posix(opts).render().unwrap(); - Command::new("shfmt") - .args(&["-d", "-s", "-ln", "posix", "-i", "4", "-ci", "-"]) - .write_stdin(source.as_bytes()) - .write_stdin(b"\n".as_ref()) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_xonsh() { - for opts in opts() { - let source = crate::Xonsh(opts).render().unwrap(); - Command::new("xonsh") - .args(&["-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } -} - -#[test] -fn test_zsh() { - for opts in opts() { - let source = crate::Zsh(opts).render().unwrap(); - Command::new("zsh") - .args(&["-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } -} diff --git a/src/cmd/add.rs b/src/cmd/add.rs new file mode 100644 index 0000000..028889f --- /dev/null +++ b/src/cmd/add.rs @@ -0,0 +1,49 @@ +use crate::cmd::Cmd; +use crate::config; +use crate::util; + +use crate::store::Store; +use anyhow::Result; +use clap::Clap; + +use std::path::PathBuf; + +/// Add a new directory or increment its rank +#[derive(Clap, Debug)] +pub struct Add { + path: Option, +} + +impl Cmd for Add { + fn run(&self) -> Result<()> { + let path = match &self.path { + Some(path) => { + if config::zo_resolve_symlinks() { + util::canonicalize(&path) + } else { + util::resolve_path(&path) + } + } + None => util::current_dir(), + }?; + + if config::zo_exclude_dirs()? + .iter() + .any(|pattern| pattern.matches_path(&path)) + { + return Ok(()); + } + + let path = util::path_to_str(&path)?; + let now = util::current_time()?; + + let data_dir = config::zo_data_dir()?; + let max_age = config::zo_maxage()?; + + let mut store = Store::open(&data_dir)?; + store.add(path, now); + store.age(max_age); + + Ok(()) + } +} diff --git a/src/cmd/import.rs b/src/cmd/import.rs new file mode 100644 index 0000000..1c2da1a --- /dev/null +++ b/src/cmd/import.rs @@ -0,0 +1,51 @@ +use crate::cmd::Cmd; +use crate::config; +use crate::import::{Autojump, Import as _, Z}; +use crate::util; + +use crate::store::Store; +use anyhow::{bail, Result}; +use clap::{ArgEnum, Clap}; + +use std::path::PathBuf; + +/// Import entries from another database +#[derive(Clap, Debug)] +pub struct Import { + path: PathBuf, + + /// Application to import from + #[clap(arg_enum, long, default_value = "z")] + from: From, + + /// Merge into existing database + #[clap(long)] + merge: bool, +} + +impl Cmd for Import { + fn run(&self) -> Result<()> { + let data_dir = config::zo_data_dir()?; + + let mut store = Store::open(&data_dir)?; + if !self.merge && !store.dirs.is_empty() { + bail!("zoxide database is not empty, specify --merge to continue anyway") + } + + let resolve_symlinks = config::zo_resolve_symlinks(); + match self.from { + From::Autojump => Autojump { + resolve_symlinks, + now: util::current_time()?, + } + .import(&mut store, &self.path), + From::Z => Z { resolve_symlinks }.import(&mut store, &self.path), + } + } +} + +#[derive(ArgEnum, Debug)] +enum From { + Autojump, + Z, +} diff --git a/src/cmd/init.rs b/src/cmd/init.rs new file mode 100644 index 0000000..f24488e --- /dev/null +++ b/src/cmd/init.rs @@ -0,0 +1,95 @@ +use crate::cmd::Cmd; +use crate::config; +use crate::shell::{self, Hook, Opts}; + +use anyhow::{Context, Result}; +use askama::Template; +use clap::{ArgEnum, Clap}; +use once_cell::sync::OnceCell; + +/// Generates shell configuration +#[derive(Clap, Debug)] +#[clap(after_help(env_help()))] +pub struct Init { + #[clap(arg_enum)] + shell: Shell, + + /// Prevents zoxide from defining any commands + #[clap(long)] + no_aliases: bool, + + /// Renames the 'z' command and corresponding aliases + #[clap(long, default_value = "z")] + cmd: String, + + /// Chooses event upon which an entry is added to the database + #[clap(arg_enum, long, default_value = "pwd")] + hook: Hook, +} + +impl Cmd for Init { + fn run(&self) -> Result<()> { + let cmd = if self.no_aliases { + None + } else { + Some(self.cmd.as_str()) + }; + + let echo = config::zo_echo(); + let resolve_symlinks = config::zo_resolve_symlinks(); + + let opts = &Opts { + cmd, + hook: self.hook, + echo, + resolve_symlinks, + }; + + let source = match self.shell { + Shell::Bash => shell::Bash(opts).render(), + Shell::Fish => shell::Fish(opts).render(), + Shell::Posix => shell::Posix(opts).render(), + Shell::Powershell => shell::PowerShell(opts).render(), + Shell::Xonsh => shell::Xonsh(opts).render(), + Shell::Zsh => shell::Zsh(opts).render(), + } + .context("could not render template")?; + println!("{}", source); + + Ok(()) + } +} + +#[derive(ArgEnum, Debug)] +enum Shell { + Bash, + Fish, + Posix, + Powershell, + Xonsh, + Zsh, +} + +fn env_help() -> &'static str { + static ENV_HELP: OnceCell = OnceCell::new(); + ENV_HELP.get_or_init(|| { + const PATH_SPLIT_SEPARATOR: u8 = if cfg!(any(target_os = "redox", target_os = "windows")) { + b';' + } else { + b':' + }; + + format!( + "\ +ENVIRONMENT VARIABLES: + _ZO_DATA_DIR Path for zoxide data files + [current: {data_dir}] + _ZO_ECHO Prints the matched directory before navigating to it when set to 1 + _ZO_EXCLUDE_DIRS List of directory globs to be excluded, separated by '{path_split_separator}' + _ZO_FZF_OPTS Custom flags to pass to fzf + _ZO_MAXAGE Maximum total age after which entries start getting deleted + _ZO_RESOLVE_SYMLINKS Resolve symlinks when storing paths", + data_dir=config::zo_data_dir().unwrap_or_else(|_| "none".into()).display(), + path_split_separator=PATH_SPLIT_SEPARATOR as char) + }) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..05bc4cf --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,17 @@ +mod add; +mod import; +mod init; +mod query; +mod remove; + +use anyhow::Result; + +pub use add::Add; +pub use import::Import; +pub use init::Init; +pub use query::Query; +pub use remove::Remove; + +pub trait Cmd { + fn run(&self) -> Result<()>; +} diff --git a/src/cmd/query.rs b/src/cmd/query.rs new file mode 100644 index 0000000..7e3cf0f --- /dev/null +++ b/src/cmd/query.rs @@ -0,0 +1,77 @@ +use crate::cmd::Cmd; +use crate::config; +use crate::fzf::Fzf; +use crate::util; + +use crate::store::{self, Store}; +use anyhow::{Context, Result}; +use clap::Clap; + +use std::io::{self, Write}; + +/// Searches for a directory +#[derive(Clap, Debug)] +pub struct Query { + keywords: Vec, + + /// Lists all matching directories + #[clap(long, short, conflicts_with = "list")] + interactive: bool, + + /// Lists all matching directories + #[clap(long, short, conflicts_with = "interactive")] + list: bool, + + /// Prints score with results + #[clap(long, short)] + score: bool, +} + +impl Cmd for Query { + fn run(&self) -> Result<()> { + let data_dir = config::zo_data_dir()?; + let mut store = Store::open(&data_dir)?; + + let query = store::Query::new(&self.keywords); + let now = util::current_time()?; + + let mut matches = store.iter_matches(&query, now); + + if self.interactive { + let mut fzf = Fzf::new()?; + let handle = fzf.stdin(); + for dir in matches { + writeln!(handle, "{}", dir.display_score(now)).context("could not write to fzf")?; + } + let selection = fzf.wait_select()?; + if self.score { + print!("{}", selection); + } else { + let path = selection + .get(5..) + .context("could not read selection from fzf")?; + print!("{}", path) + } + } else if self.list { + let stdout = io::stdout(); + let handle = &mut stdout.lock(); + for dir in matches { + if self.score { + writeln!(handle, "{}", dir.display_score(now)) + } else { + writeln!(handle, "{}", dir.display()) + } + .unwrap() + } + } else { + let dir = matches.next().context("no match found")?; + if self.score { + println!("{}", dir.display_score(now)) + } else { + println!("{}", dir.display()) + } + } + + Ok(()) + } +} diff --git a/src/cmd/remove.rs b/src/cmd/remove.rs new file mode 100644 index 0000000..c2152fb --- /dev/null +++ b/src/cmd/remove.rs @@ -0,0 +1,57 @@ +use crate::cmd::Cmd; +use crate::config; +use crate::fzf::Fzf; +use crate::store::Query; +use crate::store::Store; +use crate::util; + +use anyhow::{bail, Context, Result}; +use clap::Clap; + +use std::io::Write; + +/// Removes a directory +#[derive(Clap, Debug)] +pub struct Remove { + #[clap(conflicts_with = "path", long, short, value_name = "keywords")] + interactive: Option>, + #[clap( + conflicts_with = "interactive", + required_unless_present = "interactive" + )] + path: Option, +} + +impl Cmd for Remove { + fn run(&self) -> Result<()> { + let data_dir = config::zo_data_dir()?; + let mut store = Store::open(&data_dir)?; + + let selection; + let path = match &self.interactive { + Some(keywords) => { + let query = Query::new(keywords); + let now = util::current_time()?; + + let mut fzf = Fzf::new()?; + let handle = fzf.stdin(); + for dir in store.iter_matches(&query, now) { + writeln!(handle, "{}", dir.display_score(now)) + .context("could not write to fzf")?; + } + + selection = fzf.wait_select()?; + selection + .get(5..selection.len().saturating_sub(1)) + .context("fzf returned invalid output")? + } + None => self.path.as_ref().unwrap(), + }; + + if !store.remove(path) { + bail!("path not found in store: {}", &path) + } + + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs index 0e0a10b..8d7fe35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ +use crate::store::Rank; use anyhow::{bail, Context, Result}; use dirs_next as dirs; -use zoxide_engine::dir::Rank; use std::env; use std::ffi::OsString; @@ -21,6 +21,13 @@ pub fn zo_data_dir() -> Result { Ok(data_dir) } +pub fn zo_echo() -> bool { + match env::var_os("_ZO_ECHO") { + Some(var) => var == "1", + None => false, + } +} + pub fn zo_exclude_dirs() -> Result> { match env::var_os("_ZO_EXCLUDE_DIRS") { Some(dirs_osstr) => env::split_paths(&dirs_osstr) @@ -55,13 +62,6 @@ pub fn zo_maxage() -> Result { } } -pub fn zo_echo() -> bool { - match env::var_os("_ZO_ECHO") { - Some(var) => var == "1", - None => false, - } -} - pub fn zo_resolve_symlinks() -> bool { match env::var_os("_ZO_RESOLVE_SYMLINKS") { Some(var) => var == "1", diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..824741d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,12 @@ +use std::fmt::{self, Display, Formatter}; + +#[derive(Debug)] +pub struct SilentExit { + pub code: i32, +} + +impl Display for SilentExit { + fn fmt(&self, _: &mut Formatter) -> fmt::Result { + Ok(()) + } +} diff --git a/src/fzf.rs b/src/fzf.rs new file mode 100644 index 0000000..b308b23 --- /dev/null +++ b/src/fzf.rs @@ -0,0 +1,57 @@ +use crate::config; +use crate::error::SilentExit; + +use anyhow::{bail, Context, Result}; + +use std::process::{Child, ChildStdin, Command, Stdio}; + +pub struct Fzf { + child: Child, +} + +impl Fzf { + pub fn new() -> Result { + let mut command = Command::new("fzf"); + command + .arg("-n2..") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()); + + if let Some(fzf_opts) = config::zo_fzf_opts() { + command.env("FZF_DEFAULT_OPTS", fzf_opts); + } + + Ok(Fzf { + child: command.spawn().context("could not launch fzf")?, + }) + } + + pub fn stdin(&mut self) -> &mut ChildStdin { + self.child.stdin.as_mut().unwrap() + } + + pub fn wait_select(self) -> Result { + let output = self + .child + .wait_with_output() + .context("wait failed on fzf")?; + + match output.status.code() { + // normal exit + Some(0) => String::from_utf8(output.stdout).context("invalid unicode in fzf output"), + + // no match + Some(1) => bail!("no match found"), + + // error + Some(2) => bail!("fzf returned an error"), + + // terminated by a signal + Some(code @ 130) => bail!(SilentExit { code }), + Some(128..=254) | None => bail!("fzf was terminated"), + + // unknown + _ => bail!("fzf returned an unknown error"), + } + } +} diff --git a/src/import/autojump.rs b/src/import/autojump.rs new file mode 100644 index 0000000..548e839 --- /dev/null +++ b/src/import/autojump.rs @@ -0,0 +1,60 @@ +use super::Import; +use crate::util; + +use crate::store::{Dir, Epoch, Store}; +use anyhow::{Context, Result}; + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +pub struct Autojump { + pub resolve_symlinks: bool, + pub now: Epoch, +} + +impl Import for Autojump { + fn import>(&self, store: &mut Store, path: P) -> Result<()> { + let file = File::open(path).context("could not open autojump database")?; + let reader = BufReader::new(file); + + for (idx, line) in reader.lines().enumerate() { + (|| -> Result<()> { + let line = line?; + if line.is_empty() { + return Ok(()); + } + + let split_idx = line + .find('\t') + .with_context(|| format!("invalid entry: {}", line))?; + let (rank, path) = line.split_at(split_idx); + + let rank = rank + .parse() + .with_context(|| format!("invalid rank: {}", rank))?; + + let path = if self.resolve_symlinks { + util::canonicalize + } else { + util::resolve_path + }(&path)?; + let path = util::path_to_str(&path)?; + + if store.dirs.iter_mut().find(|dir| dir.path == path).is_none() { + store.dirs.push(Dir { + path: path.into(), + rank, + last_accessed: self.now, + }); + store.modified = true; + } + + Ok(()) + })() + .with_context(|| format!("line {}: error reading from z database", idx + 1))?; + } + + Ok(()) + } +} diff --git a/src/import/mod.rs b/src/import/mod.rs new file mode 100644 index 0000000..1643973 --- /dev/null +++ b/src/import/mod.rs @@ -0,0 +1,14 @@ +mod autojump; +mod z; + +use crate::store::Store; +use anyhow::Result; + +use std::path::Path; + +pub use autojump::Autojump; +pub use z::Z; + +pub trait Import { + fn import>(&self, store: &mut Store, path: P) -> Result<()>; +} diff --git a/src/import/z.rs b/src/import/z.rs new file mode 100644 index 0000000..94cdaac --- /dev/null +++ b/src/import/z.rs @@ -0,0 +1,71 @@ +use super::Import; +use crate::util; + +use crate::store::{Dir, Store}; +use anyhow::{Context, Result}; + +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +pub struct Z { + pub resolve_symlinks: bool, +} + +impl Import for Z { + fn import>(&self, store: &mut Store, path: P) -> Result<()> { + let file = File::open(path).context("could not open z database")?; + let reader = BufReader::new(file); + + for (idx, line) in reader.lines().enumerate() { + (|| -> Result<()> { + let line = line?; + if line.is_empty() { + return Ok(()); + } + + let (path, rank, last_accessed) = (|| { + let mut split = line.rsplitn(3, '|'); + let last_accessed = split.next()?; + let rank = split.next()?; + let path = split.next()?; + Some((path, rank, last_accessed)) + })() + .with_context(|| format!("invalid entry: {}", line))?; + + let path = if self.resolve_symlinks { + util::canonicalize + } else { + util::resolve_path + }(&path)?; + let path = util::path_to_str(&path)?; + + let rank = rank + .parse() + .with_context(|| format!("invalid rank: {}", rank))?; + + let last_accessed = last_accessed + .parse() + .with_context(|| format!("invalid epoch: {}", last_accessed))?; + + match store.dirs.iter_mut().find(|dir| dir.path == path) { + Some(dir) => { + dir.rank += rank; + dir.last_accessed = dir.last_accessed.max(last_accessed); + } + None => store.dirs.push(Dir { + path: path.into(), + rank, + last_accessed, + }), + } + store.modified = true; + + Ok(()) + })() + .with_context(|| format!("line {}: error reading from z database", idx + 1))?; + } + + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 165cd84..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod config; -pub mod util; diff --git a/src/main.rs b/src/main.rs index 5902f73..e43ca62 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,20 @@ -use anyhow::{Context, Result}; -use clap::{AppSettings, ArgEnum, Clap}; -use once_cell::sync::OnceCell; -use zoxide::{config, util}; -use zoxide_engine::{Dir, Query, Store}; -use zoxide_shell::{self as zs, Generator}; +mod cmd; +mod config; +mod error; +mod fzf; +mod import; +mod shell; +mod store; +mod util; -use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use crate::cmd::{Add, Cmd, Import, Init, Query, Remove}; +use crate::error::SilentExit; -fn env_help() -> &'static str { - static ENV_HELP: OnceCell = OnceCell::new(); - ENV_HELP.get_or_init(|| { - const PATH_SPLIT_SEPARATOR: u8 = if cfg!(any(target_os = "redox", target_os = "windows")) { - b';' - } else { - b':' - }; +use anyhow::Result; +use clap::{AppSettings, Clap}; - format!( - "\ -ENVIRONMENT VARIABLES: - _ZO_DATA_DIR Path for zoxide data files - [current: {data_dir}] - _ZO_ECHO Prints the matched directory before navigating to it when set to 1 - _ZO_EXCLUDE_DIRS List of directory globs to be excluded, separated by '{split_paths_separator}' - _ZO_FZF_OPTS Custom flags to pass to fzf - _ZO_MAXAGE Maximum total age after which entries start getting deleted - _ZO_RESOLVE_SYMLINKS Resolve symlinks when storing paths", - data_dir=config::zo_data_dir().unwrap_or_else(|_| "none".into()).display(), - split_paths_separator=PATH_SPLIT_SEPARATOR as char) - }) -} +use std::process; -// TODO: import -// TODO: query interactive #[derive(Debug, Clap)] #[clap( about, @@ -42,191 +23,27 @@ ENVIRONMENT VARIABLES: global_setting(AppSettings::VersionlessSubcommands), version = env!("ZOXIDE_VERSION"))] enum Opts { - /// Adds a new directory or increments its rank - Add { path: Option }, - - /// Generates shell configuration - #[clap(after_help(env_help()))] - Init { - #[clap(arg_enum)] - shell: Shell, - - /// Prevents zoxide from defining any commands - #[clap(long)] - no_aliases: bool, - - /// Renames the 'z' command and corresponding aliases - #[clap(long, default_value = "z")] - cmd: String, - - /// Chooses event upon which an entry is added to the database - #[clap(arg_enum, long, default_value = "pwd")] - hook: Hook, - }, - - /// Searches for a directory - Query { - keywords: Vec, - - /// Lists all matching directories - #[clap(long, short)] - list: bool, - - /// Prints score with results - #[clap(long, short)] - score: bool, - }, - - /// Removes a directory - Remove { path: String }, -} - -#[derive(ArgEnum, Debug)] -enum Shell { - Bash, - Fish, - Posix, - Powershell, - Xonsh, - Zsh, -} - -#[derive(ArgEnum, Debug)] -enum Hook { - None, - Prompt, - Pwd, + Add(Add), + Import(Import), + Init(Init), + Query(Query), + Remove(Remove), } pub fn main() -> Result<()> { let opts = Opts::parse(); - match opts { - Opts::Add { path } => { - let path = match path { - Some(path) => { - if config::zo_resolve_symlinks() { - util::canonicalize(&path) - } else { - util::resolve_path(&path) - } - } - None => util::current_dir(), - }?; + let result: Result<()> = match opts { + Opts::Add(cmd) => cmd.run(), + Opts::Import(cmd) => cmd.run(), + Opts::Init(cmd) => cmd.run(), + Opts::Query(cmd) => cmd.run(), + Opts::Remove(cmd) => cmd.run(), + }; - if config::zo_exclude_dirs()? - .iter() - .any(|pattern| pattern.matches_path(&path)) - { - return Ok(()); - } - - let path = util::path_to_str(&path)?; - let now = util::current_time()?; - - let data_dir = config::zo_data_dir()?; - let max_age = config::zo_maxage()?; - - let mut store = Store::open(&data_dir)?; - store.add(path, now); - store.age(max_age); - - Ok(()) - } - - Opts::Init { - shell, - no_aliases, - cmd, - hook, - } => { - let cmd = if no_aliases { None } else { Some(cmd.as_str()) }; - - let hook = match hook { - Hook::None => zs::Hook::None, - Hook::Prompt => zs::Hook::Prompt, - Hook::Pwd => zs::Hook::Pwd, - }; - - let echo = config::zo_echo(); - let resolve_symlinks = config::zo_resolve_symlinks(); - - let opts = &zs::Opts { - cmd, - hook, - echo, - resolve_symlinks, - }; - - let stdout = io::stdout(); - let handle = &mut stdout.lock(); - - match shell { - Shell::Bash => zs::Bash(opts).generate(handle), - Shell::Fish => zs::Fish(opts).generate(handle), - Shell::Posix => zs::Posix(opts).generate(handle), - Shell::Powershell => zs::PowerShell(opts).generate(handle), - Shell::Xonsh => zs::Xonsh(opts).generate(handle), - Shell::Zsh => zs::Zsh(opts).generate(handle), - }?; - - Ok(()) - } - - Opts::Query { - keywords, - list, - score, - } => { - let data_dir = config::zo_data_dir()?; - let mut store = Store::open(&data_dir)?; - - let query = Query::new(&keywords); - let now = util::current_time()?; - - let stdout = io::stdout(); - let mut handle = stdout.lock(); - - let mut print_dir = |dir: &Dir| { - if score { - let dir_score = dir.get_score(now); - let dir_score_clamped = if dir_score > 9999.0 { - 9999 - } else if dir_score > 0.0 { - dir_score as _ - } else { - 0 - }; - writeln!(&mut handle, "{:>4} {}", dir_score_clamped, dir.path) - } else { - writeln!(&mut handle, "{}", dir.path) - } - .unwrap() - }; - - let mut matches = store - .iter_matches(&query, now) - .filter(|dir| Path::new(&dir.path).is_dir()); - - if list { - for dir in matches { - print_dir(dir); - } - } else { - let dir = matches.next().context("no match found")?; - print_dir(dir); - } - - Ok(()) - } - - Opts::Remove { path } => { - let data_dir = config::zo_data_dir()?; - - let mut store = Store::open(&data_dir)?; - store.remove(path); - - Ok(()) - } - } + result.map_err(|e| match e.downcast::() { + Ok(SilentExit { code }) => process::exit(code), + // TODO: change the error prefix to `zoxide:` + Err(e) => e, + }) } diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..63868a7 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,234 @@ +use clap::ArgEnum; + +#[derive(Debug)] +pub struct Opts<'a> { + pub cmd: Option<&'a str>, + pub hook: Hook, + pub echo: bool, + pub resolve_symlinks: bool, +} + +impl Opts<'_> { + pub const DEVNULL: &'static str = if cfg!(windows) { "NUL" } else { "/dev/null" }; +} + +macro_rules! make_template { + ($name:ident, $path:expr) => { + #[derive(::std::fmt::Debug, ::askama::Template)] + #[template(path = $path)] + pub struct $name<'a>(pub &'a self::Opts<'a>); + + impl<'a> ::std::ops::Deref for $name<'a> { + type Target = self::Opts<'a>; + fn deref(&self) -> &Self::Target { + self.0 + } + } + }; +} + +make_template!(Bash, "bash.txt"); +make_template!(Fish, "fish.txt"); +make_template!(Posix, "posix.txt"); +make_template!(PowerShell, "powershell.txt"); +make_template!(Xonsh, "xonsh.txt"); +make_template!(Zsh, "zsh.txt"); + +#[derive(ArgEnum, Clone, Copy, Debug, PartialEq)] +pub enum Hook { + None, + Prompt, + Pwd, +} + +#[cfg(test)] +mod tests { + use super::*; + + use askama::Template; + use assert_cmd::Command; + use once_cell::sync::OnceCell; + + fn opts() -> &'static [Opts<'static>] { + static OPTS: OnceCell> = OnceCell::new(); + OPTS.get_or_init(|| { + const BOOLS: &[bool] = &[false, true]; + const HOOKS: &[Hook] = &[Hook::None, Hook::Prompt, Hook::Pwd]; + const CMDS: &[Option<&str>] = &[None, Some("z")]; + + let mut opts = Vec::new(); + for &echo in BOOLS { + for &resolve_symlinks in BOOLS { + for &hook in HOOKS { + for &cmd in CMDS { + let opt = Opts { + echo, + resolve_symlinks, + hook, + cmd, + }; + opts.push(opt); + } + } + } + } + + opts + }) + } + + #[test] + fn test_bash() { + for opts in opts() { + let source = Bash(opts).render().unwrap(); + Command::new("bash") + .args(&["-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_bash_posix() { + for opts in opts() { + let source = Posix(opts).render().unwrap(); + let assert = Command::new("bash") + .args(&["--posix", "-c", &source]) + .assert() + .success() + .stderr(""); + + if opts.hook != Hook::Pwd { + assert.stdout(""); + } + } + } + + #[test] + fn test_dash() { + for opts in opts() { + let source = Posix(opts).render().unwrap(); + let assert = Command::new("bash") + .args(&["--posix", "-c", &source]) + .assert() + .success() + .stderr(""); + + if opts.hook != Hook::Pwd { + assert.stdout(""); + } + } + } + + #[test] + fn test_fish() { + for opts in opts() { + let source = Fish(opts).render().unwrap(); + Command::new("fish") + .args(&["-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_pwsh() { + for opts in opts() { + let source = PowerShell(opts).render().unwrap(); + Command::new("pwsh") + .args(&["-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_shellcheck_bash() { + for opts in opts() { + let source = Bash(opts).render().unwrap(); + Command::new("shellcheck") + .args(&["--shell", "bash", "-"]) + .write_stdin(source.as_bytes()) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_shellcheck_sh() { + for opts in opts() { + let source = Posix(opts).render().unwrap(); + Command::new("shellcheck") + .args(&["--shell", "sh", "-"]) + .write_stdin(source.as_bytes()) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_shfmt_bash() { + for opts in opts() { + let source = Bash(opts).render().unwrap(); + Command::new("shfmt") + .args(&["-d", "-s", "-ln", "bash", "-i", "4", "-ci", "-"]) + .write_stdin(source.as_bytes()) + .write_stdin(b"\n".as_ref()) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_shfmt_posix() { + for opts in opts() { + let source = Posix(opts).render().unwrap(); + Command::new("shfmt") + .args(&["-d", "-s", "-ln", "posix", "-i", "4", "-ci", "-"]) + .write_stdin(source.as_bytes()) + .write_stdin(b"\n".as_ref()) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_xonsh() { + for opts in opts() { + let source = Xonsh(opts).render().unwrap(); + Command::new("xonsh") + .args(&["-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + } + + #[test] + fn test_zsh() { + for opts in opts() { + let source = Zsh(opts).render().unwrap(); + Command::new("zsh") + .args(&["-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + } +} diff --git a/src/store/dir.rs b/src/store/dir.rs new file mode 100644 index 0000000..f8aa47e --- /dev/null +++ b/src/store/dir.rs @@ -0,0 +1,73 @@ +use super::{Epoch, Query, Rank}; + +use serde::{Deserialize, Serialize}; + +use std::fmt::{self, Display, Formatter}; +use std::path::Path; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Dir { + pub path: String, + pub rank: Rank, + pub last_accessed: Epoch, +} + +impl Dir { + pub fn is_match(&self, query: &Query) -> bool { + query.matches(&self.path) && Path::new(&self.path).is_dir() + } + + pub fn get_score(&self, now: Epoch) -> Rank { + const HOUR: Epoch = 60 * 60; + const DAY: Epoch = 24 * HOUR; + const WEEK: Epoch = 7 * DAY; + + let duration = now.saturating_sub(self.last_accessed); + if duration < HOUR { + self.rank * 4.0 + } else if duration < DAY { + self.rank * 2.0 + } else if duration < WEEK { + self.rank * 0.5 + } else { + self.rank * 0.25 + } + } + + pub fn display(&self) -> DirDisplay { + DirDisplay { dir: self } + } + + pub fn display_score(&self, now: Epoch) -> DirDisplayScore { + DirDisplayScore { dir: self, now } + } +} + +pub struct DirDisplay<'a> { + dir: &'a Dir, +} + +impl Display for DirDisplay<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.dir.path) + } +} + +pub struct DirDisplayScore<'a> { + dir: &'a Dir, + now: Epoch, +} + +impl Display for DirDisplayScore<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let score = self.dir.get_score(self.now); + let score = if score > 9999.0 { + 9999 + } else if score > 0.0 { + score as _ + } else { + 0 + }; + write!(f, "{:>4} {}", score, self.dir.path) + } +} diff --git a/crates/zoxide-engine/src/store.rs b/src/store/mod.rs similarity index 71% rename from crates/zoxide-engine/src/store.rs rename to src/store/mod.rs index e52319e..afadd9b 100644 --- a/crates/zoxide-engine/src/store.rs +++ b/src/store/mod.rs @@ -1,5 +1,5 @@ -use crate::dir::{Dir, Epoch, Rank}; -use crate::query::Query; +mod dir; +mod query; use anyhow::{bail, Context, Result}; use bincode::Options; @@ -12,6 +12,12 @@ use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; +pub use dir::Dir; +pub use query::Query; + +pub type Rank = f64; +pub type Epoch = u64; + #[derive(Debug)] pub struct Store { pub dirs: Vec, @@ -199,28 +205,84 @@ impl Drop for Store { #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct StoreVersion(pub u32); +#[cfg(windows)] fn persist>(mut file: NamedTempFile, path: P) -> Result<(), PersistError> { - if cfg!(windows) { - use std::thread; - use std::time::Duration; + use rand::distributions::{Distribution, Uniform}; + use std::thread; + use std::time::Duration; - // 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; - for _ in 0..MAX_TRIES { - match file.persist(&path) { - Ok(_) => break, - Err(e) if e.error.kind() == io::ErrorKind::PermissionDenied => { - file = e.file; - thread::sleep(Duration::from_millis(50)); - } - Err(e) => return Err(e), + // 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(rand::thread_rng); + 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), } - } else { - file.persist(&path)?; } Ok(()) } + +#[cfg(unix)] +fn persist>(file: NamedTempFile, path: P) -> Result<(), PersistError> { + file.persist(&path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add() { + let path = "/foo/bar"; + let now = 946684800; + + let data_dir = tempfile::tempdir().unwrap(); + { + let mut store = Store::open(data_dir.path()).unwrap(); + store.add(path, now); + store.add(path, now); + } + { + let store = Store::open(data_dir.path()).unwrap(); + assert_eq!(store.dirs.len(), 1); + + let dir = &store.dirs[0]; + assert_eq!(dir.path, path); + assert_eq!(dir.last_accessed, now); + } + } + + #[test] + fn test_remove() { + let path = "/foo/bar"; + let now = 946684800; + + let data_dir = tempfile::tempdir().unwrap(); + { + let mut store = Store::open(data_dir.path()).unwrap(); + store.add(path, now); + } + { + let mut store = Store::open(data_dir.path()).unwrap(); + assert!(store.remove(path)); + } + { + let mut store = Store::open(data_dir.path()).unwrap(); + assert!(store.dirs.is_empty()); + assert!(!store.remove(path)); + } + } +} diff --git a/crates/zoxide-engine/src/query.rs b/src/store/query.rs similarity index 100% rename from crates/zoxide-engine/src/query.rs rename to src/store/query.rs diff --git a/src/util.rs b/src/util.rs index 9bdafdc..2f6a589 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,4 @@ -use zoxide_engine::Epoch; +use crate::store::Epoch; use anyhow::{bail, Context, Result}; @@ -35,7 +35,6 @@ pub fn path_to_str>(path: &P) -> Result<&str> { /// If path is already absolute, the path is still processed to be cleaned, as it can contained ".." or "." (or other) /// character. /// If path is relative, use the current directory to build the absolute path. -#[cfg(any(unix, windows))] pub fn resolve_path>(path: &P) -> Result { let path = path.as_ref(); let base_path; @@ -44,18 +43,7 @@ pub fn resolve_path>(path: &P) -> Result { let mut stack = Vec::new(); // initialize root - if cfg!(unix) { - match components.peek() { - Some(Component::RootDir) => { - let root = components.next().unwrap(); - stack.push(root); - } - _ => { - base_path = current_dir()?; - stack.extend(base_path.components()); - } - } - } else if cfg!(windows) { + if cfg!(windows) { use std::path::Prefix; fn get_drive_letter>(path: P) -> Option { @@ -133,6 +121,17 @@ pub fn resolve_path>(path: &P) -> Result { stack.extend(base_path.components()); } } + } else { + match components.peek() { + Some(Component::RootDir) => { + let root = components.next().unwrap(); + stack.push(root); + } + _ => { + base_path = current_dir()?; + stack.extend(base_path.components()); + } + } } for component in components { diff --git a/crates/zoxide-shell/templates/bash.txt b/templates/bash.txt similarity index 97% rename from crates/zoxide-shell/templates/bash.txt rename to templates/bash.txt index 201d25d..b5e17d2 100644 --- a/crates/zoxide-shell/templates/bash.txt +++ b/templates/bash.txt @@ -74,6 +74,8 @@ function __zoxide_z() { echo "zoxide: \\$OLDPWD is not set" return 1 fi + elif [ "$#" -eq 1 ] && [ -d "$1" ]; then + __zoxide_cd "$1" else local __zoxide_result __zoxide_result="$(zoxide query -- "$@")" && __zoxide_cd "$__zoxide_result" @@ -108,8 +110,7 @@ function __zoxide_zr() { # Remove an entry from the database using interactive selection. function __zoxide_zri() { - local __zoxide_result - __zoxide_result="$(zoxide query -i -- "$@")" && zoxide remove "$__zoxide_result" + zoxide remove -i "$@" } {{ SECTION }} diff --git a/crates/zoxide-shell/templates/fish.txt b/templates/fish.txt similarity index 96% rename from crates/zoxide-shell/templates/fish.txt rename to templates/fish.txt index 3387ddc..63f269d 100644 --- a/crates/zoxide-shell/templates/fish.txt +++ b/templates/fish.txt @@ -56,6 +56,8 @@ function __zoxide_z __zoxide_cd $HOME else if begin; test $argc -eq 1; and test $argv[1] = '-'; end __zoxide_cd - + else if begin; test $argc -eq 1; and test -d $argv[1]; end + __zoxide_cd $argv[1] else set -l __zoxide_result (zoxide query -- $argv) and __zoxide_cd $__zoxide_result @@ -90,8 +92,7 @@ end # Remove an entry from the database using interactive selection. function __zoxide_zri - set -l __zoxide_result (zoxide query -i -- $argv) - and zoxide remove $__zoxide_result + zoxide remove -i $argv end {{ SECTION }} diff --git a/crates/zoxide-shell/templates/posix.txt b/templates/posix.txt similarity index 97% rename from crates/zoxide-shell/templates/posix.txt rename to templates/posix.txt index 4056b60..4603cc7 100644 --- a/crates/zoxide-shell/templates/posix.txt +++ b/templates/posix.txt @@ -77,6 +77,8 @@ __zoxide_z() { echo "zoxide: \\$OLDPWD is not set" return 1 fi + elif [ "$#" -eq 1 ] && [ -d "$1" ]; then + __zoxide_cd "$1" else __zoxide_result="$(zoxide query -- "$@")" && __zoxide_cd "$__zoxide_result" fi @@ -109,7 +111,7 @@ __zoxide_zr() { # Remove an entry from the database using interactive selection. __zoxide_zri() { - __zoxide_result="$(zoxide query -i -- "$@")" && zoxide remove "$__zoxide_result" + zoxide remove -i "$@" } {{ SECTION }} diff --git a/crates/zoxide-shell/templates/powershell.txt b/templates/powershell.txt similarity index 89% rename from crates/zoxide-shell/templates/powershell.txt rename to templates/powershell.txt index 6dfb2de..8cf85db 100644 --- a/crates/zoxide-shell/templates/powershell.txt +++ b/templates/powershell.txt @@ -65,6 +65,9 @@ function __zoxide_z { elseif ($args.Length -eq 1 -and $args[0] -eq '-') { __zoxide_cd - } + elseif ($args.Length -eq 1 -and ( Test-Path $args[0] -PathType Container) ) { + __zoxide_cd $args[0] + } else { $__zoxide_result = zoxide query -- @args if ($LASTEXITCODE -eq 0) { @@ -74,7 +77,7 @@ function __zoxide_z { } # Jump to a directory using interactive search. -function zi { +function __zoxide_zi { $__zoxide_result = zoxide query -i -- @args if ($LASTEXITCODE -eq 0) { __zoxide_cd $__zoxide_result @@ -103,10 +106,7 @@ function __zoxide_zr { # Remove an entry from the database using interactive selection. function __zoxide_zri { - $_zoxide_result = zoxide query -i -- @args - if ($LASTEXITCODE -eq 0) { - zoxide remove $_zoxide_result - } + zoxide remove -i @args } {{ SECTION }} @@ -136,11 +136,4 @@ Set-Alias {{cmd}}ri __zoxide_zri # To initialize zoxide with PowerShell, add the following line to your # PowerShell configuration file (the location is stored in $profile): # -# Invoke-Expression (& { -# $hook = if ($PSVersionTable.PSVersion.Major -ge 6) { -# 'pwd' -# } else { -# 'prompt' -# } -# (zoxide init powershell --hook $hook) -join "`n" -# }) +# Invoke-Expression (& { $hook = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwd' } else { 'prompt' } (zoxide init powershell --hook $hook) -join "`n" }) diff --git a/crates/zoxide-shell/templates/xonsh.txt b/templates/xonsh.txt similarity index 88% rename from crates/zoxide-shell/templates/xonsh.txt rename to templates/xonsh.txt index ffec935..96db21e 100644 --- a/crates/zoxide-shell/templates/xonsh.txt +++ b/templates/xonsh.txt @@ -105,19 +105,8 @@ def __zoxide_zr(args: [str]): zoxide remove @(args) # Remove an entry from the database using interactive selection. -def __zoxide_zri(keywords: [str]): - try: - __zoxide_cmd = subprocess.run(["zoxide", "query", "--"] + keywords, check=True, stdout=subprocess.PIPE) - except CalledProcessError as e: - return e.returncode - - try: - __zoxide_result = __zoxide_cmd.stdout[:-1].decode("utf-8") - except UnicodeDecodeError: - print(f"zoxide: invalid unicode in result: {__zoxide_result}", file=sys.stderr) - return 1 - - zoxide remove @(__zoxide_result) +def __zoxide_zri(args: [str]): + zoxide remove -i @(args) {{ SECTION }} # Convenient aliases for zoxide. Disable these using --no-aliases. diff --git a/crates/zoxide-shell/templates/zsh.txt b/templates/zsh.txt similarity index 96% rename from crates/zoxide-shell/templates/zsh.txt rename to templates/zsh.txt index 55ca1c9..97c5d76 100644 --- a/crates/zoxide-shell/templates/zsh.txt +++ b/templates/zsh.txt @@ -95,8 +95,7 @@ function __zoxide_zr() { # Remove an entry from the database using interactive selection. function __zoxide_zri() { - local __zoxide_result - __zoxide_result="$(zoxide query -i -- "$@")" && zoxide remove "$__zoxide_result" + zoxide remove -i "$@" } {{ SECTION }}