diff --git a/Cargo.lock b/Cargo.lock index 1fa0dc3..3fcde21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,12 +335,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "once_cell" -version = "1.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" - [[package]] name = "ordered-float" version = "2.2.0" @@ -356,6 +350,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afb2e1c3ee07430c2cf76151675e583e0f19985fa6efae47d6848a3e2c824f85" +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + [[package]] name = "ppv-lite86" version = "0.2.10" @@ -554,6 +557,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "rstest" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "041bb0202c14f6a158bbbf086afb03d0c6e975c2dec7d4912f8061ed44f290af" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver", +] + [[package]] name = "ryu" version = "1.0.5" @@ -561,10 +586,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" [[package]] -name = "seq-macro" -version = "0.2.2" +name = "semver" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9f47faea3cad316faa914d013d24f471cd90bfca1a0c70f05a3f42c6441e99" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] [[package]] name = "serde" @@ -653,6 +690,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + [[package]] name = "unicode-segmentation" version = "1.7.1" @@ -754,10 +797,9 @@ dependencies = [ "dirs-next", "dunce", "glob", - "once_cell", "ordered-float", "rand 0.7.3", - "seq-macro", + "rstest", "serde", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 92b272e..d48c30b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,8 +26,7 @@ rand = "0.7.3" [dev-dependencies] assert_cmd = "1.0.1" -once_cell = "1.4.1" -seq-macro = "0.2.1" +rstest = "0.10.0" [build-dependencies] clap = "3.0.0-beta.2" diff --git a/man/zoxide.1 b/man/zoxide.1 index f3788fb..54edce3 100644 --- a/man/zoxide.1 +++ b/man/zoxide.1 @@ -8,6 +8,7 @@ zoxide is a smarter replacement for your cd command. It keeps track of the directories you use most frequently, and uses a ranking algorithm to navigate to the best match. .SH USAGE +.nf \fBz\fR \fIfoo\fR # cd into highest ranked directory matching foo \fBz\fR \fIfoo bar\fR # cd into highest ranked directory matching foo and bar .sp @@ -17,6 +18,7 @@ to the best match. \fBz\fR \fI-\fR # cd into previous directory .sp \fBzi\fR \fIfoo\fR # cd with interactive selection (using fzf) +.fi .SH SUBCOMMANDS .TP \fBzoxide-add\fR(1) diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..1e7bc52 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,7 @@ +# group_imports = "StdExternalCrate" +# imports_granularity = "Module" +newline_style = "Native" +use_field_init_shorthand = true +use_small_heuristics = "Max" +use_try_shorthand = true +# wrap_comments = true diff --git a/src/app/_app.rs b/src/app/_app.rs index 4989883..64fd6ca 100644 --- a/src/app/_app.rs +++ b/src/app/_app.rs @@ -116,7 +116,7 @@ pub struct Query { pub score: bool, /// Exclude a path from results - #[clap(long, hidden = true)] + #[clap(long, value_name = "path")] pub exclude: Option, } @@ -126,9 +126,6 @@ pub struct Remove { // Use interactive selection #[clap(conflicts_with = "path", long, short, value_name = "keywords")] pub interactive: Option>, - #[clap( - conflicts_with = "interactive", - required_unless_present = "interactive" - )] + #[clap(conflicts_with = "interactive", required_unless_present = "interactive")] pub path: Option, } diff --git a/src/app/add.rs b/src/app/add.rs index 490e8fe..725f32f 100644 --- a/src/app/add.rs +++ b/src/app/add.rs @@ -13,10 +13,7 @@ impl Run for Add { util::resolve_path(&self.path) }?; - if config::zo_exclude_dirs()? - .iter() - .any(|pattern| pattern.matches_path(&path)) - { + if config::zo_exclude_dirs()?.iter().any(|pattern| pattern.matches_path(&path)) { return Ok(()); } diff --git a/src/app/import.rs b/src/app/import.rs index 2cde1c3..736aaa7 100644 --- a/src/app/import.rs +++ b/src/app/import.rs @@ -31,11 +31,8 @@ fn from_autojump>(db: &mut Database, path: P) -> Result<()> { let buffer = fs::read_to_string(path) .with_context(|| format!("could not open autojump database: {}", path.display()))?; - let mut dirs = db - .dirs - .iter() - .map(|dir| (dir.path.as_ref(), dir.clone())) - .collect::>(); + let mut dirs = + db.dirs.iter().map(|dir| (dir.path.as_ref(), dir.clone())).collect::>(); for line in buffer.lines() { if line.is_empty() { @@ -43,28 +40,20 @@ fn from_autojump>(db: &mut Database, path: P) -> Result<()> { } let mut split = line.splitn(2, '\t'); - let rank = split - .next() - .with_context(|| format!("invalid entry: {}", line))?; - let mut rank = rank - .parse::() - .with_context(|| format!("invalid rank: {}", rank))?; + let rank = split.next().with_context(|| format!("invalid entry: {}", line))?; + let mut rank = rank.parse::().with_context(|| format!("invalid rank: {}", rank))?; // Normalize the rank using a sigmoid function. Don't import actual // ranks from autojump, since its scoring algorithm is very different, // and might take a while to get normalized. rank = 1.0 / (1.0 + (-rank).exp()); - let path = split - .next() - .with_context(|| format!("invalid entry: {}", line))?; + let path = split.next().with_context(|| format!("invalid entry: {}", line))?; - dirs.entry(path) - .and_modify(|dir| dir.rank += rank) - .or_insert_with(|| Dir { - path: path.to_string().into(), - rank, - last_accessed: 0, - }); + dirs.entry(path).and_modify(|dir| dir.rank += rank).or_insert_with(|| Dir { + path: path.to_string().into(), + rank, + last_accessed: 0, + }); } db.dirs = DirList(dirs.into_iter().map(|(_, dir)| dir).collect()); @@ -78,11 +67,8 @@ fn from_z>(db: &mut Database, path: P) -> Result<()> { let buffer = fs::read_to_string(path) .with_context(|| format!("could not open z database: {}", path.display()))?; - let mut dirs = db - .dirs - .iter() - .map(|dir| (dir.path.as_ref(), dir.clone())) - .collect::>(); + let mut dirs = + db.dirs.iter().map(|dir| (dir.path.as_ref(), dir.clone())).collect::>(); for line in buffer.lines() { if line.is_empty() { @@ -90,23 +76,14 @@ fn from_z>(db: &mut Database, path: P) -> Result<()> { } let mut split = line.rsplitn(3, '|'); - let last_accessed = split - .next() - .with_context(|| format!("invalid entry: {}", line))?; - let last_accessed = last_accessed - .parse() - .with_context(|| format!("invalid epoch: {}", last_accessed))?; + let last_accessed = split.next().with_context(|| format!("invalid entry: {}", line))?; + let last_accessed = + last_accessed.parse().with_context(|| format!("invalid epoch: {}", last_accessed))?; - let rank = split - .next() - .with_context(|| format!("invalid entry: {}", line))?; - let rank = rank - .parse() - .with_context(|| format!("invalid rank: {}", rank))?; + let rank = split.next().with_context(|| format!("invalid entry: {}", line))?; + let rank = rank.parse().with_context(|| format!("invalid rank: {}", rank))?; - let path = split - .next() - .with_context(|| format!("invalid entry: {}", line))?; + let path = split.next().with_context(|| format!("invalid entry: {}", line))?; dirs.entry(path) .and_modify(|dir| { @@ -115,11 +92,7 @@ fn from_z>(db: &mut Database, path: P) -> Result<()> { dir.last_accessed = last_accessed; } }) - .or_insert(Dir { - path: path.to_string().into(), - rank, - last_accessed, - }); + .or_insert(Dir { path: path.to_string().into(), rank, last_accessed }); } db.dirs = DirList(dirs.into_iter().map(|(_, dir)| dir).collect()); diff --git a/src/app/init.rs b/src/app/init.rs index 19f9562..e4ac5db 100644 --- a/src/app/init.rs +++ b/src/app/init.rs @@ -10,21 +10,12 @@ use std::io::{self, Write}; impl Run for Init { fn run(&self) -> Result<()> { - let cmd = if self.no_aliases { - None - } else { - Some(self.cmd.as_str()) - }; + 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 opts = &Opts { cmd, hook: self.hook, echo, resolve_symlinks }; let source = match self.shell { InitShell::Bash => shell::Bash(opts).render(), diff --git a/src/app/query.rs b/src/app/query.rs index 0e4e8e7..63e9569 100644 --- a/src/app/query.rs +++ b/src/app/query.rs @@ -1,6 +1,6 @@ use crate::app::{Query, Run}; use crate::config; -use crate::db::{DatabaseFile, Matcher}; +use crate::db::DatabaseFile; use crate::error::BrokenPipeHandler; use crate::fzf::Fzf; use crate::util; @@ -16,19 +16,18 @@ impl Run for Query { let mut db = db.open()?; let now = util::current_time()?; - let mut matcher = Matcher::new().with_keywords(&self.keywords); + let mut stream = db.stream(now).with_keywords(&self.keywords); if !self.all { let resolve_symlinks = config::zo_resolve_symlinks(); - matcher = matcher.with_exists(resolve_symlinks); + stream = stream.with_exists(resolve_symlinks); + } + if let Some(path) = &self.exclude { + stream = stream.with_exclude(path); } - - let mut matches = db - .iter(&matcher, now) - .filter(|dir| Some(dir.path.as_ref()) != self.exclude.as_deref()); if self.interactive { let mut fzf = Fzf::new(false)?; - for dir in matches { + while let Some(dir) = stream.next() { writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?; } @@ -36,9 +35,7 @@ impl Run for Query { if self.score { print!("{}", selection); } else { - let path = selection - .get(5..) - .context("could not read selection from fzf")?; + let path = selection.get(5..).context("could not read selection from fzf")?; print!("{}", path) } } else if self.list { @@ -49,7 +46,7 @@ impl Run for Query { let stdout = stdout.lock(); let mut handle = BufWriter::new(stdout); - for dir in matches { + while let Some(dir) = stream.next() { if self.score { writeln!(handle, "{}", dir.display_score(now)) } else { @@ -59,7 +56,7 @@ impl Run for Query { } handle.flush().pipe_exit("stdout")?; } else { - let dir = matches.next().context("no match found")?; + let dir = stream.next().context("no match found")?; if self.score { writeln!(io::stdout(), "{}", dir.display_score(now)) } else { diff --git a/src/app/remove.rs b/src/app/remove.rs index d9295fe..906a3ba 100644 --- a/src/app/remove.rs +++ b/src/app/remove.rs @@ -1,6 +1,6 @@ use crate::app::{Remove, Run}; use crate::config; -use crate::db::{DatabaseFile, Matcher}; +use crate::db::DatabaseFile; use crate::error::BrokenPipeHandler; use crate::fzf::Fzf; use crate::util; @@ -18,11 +18,11 @@ impl Run for Remove { let selection; match &self.interactive { Some(keywords) => { - let matcher = Matcher::new().with_keywords(keywords); let now = util::current_time()?; + let mut stream = db.stream(now).with_keywords(keywords); let mut fzf = Fzf::new(true)?; - for dir in db.iter(&matcher, now) { + while let Some(dir) = stream.next() { writeln!(fzf.stdin(), "{}", dir.display_score(now)).pipe_exit("fzf")?; } diff --git a/src/config.rs b/src/config.rs index d471891..a9ae1fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,9 +34,7 @@ pub fn zo_exclude_dirs() -> Result> { match env::var_os("_ZO_EXCLUDE_DIRS") { Some(dirs_osstr) => env::split_paths(&dirs_osstr) .map(|path| { - let pattern = path - .to_str() - .context("invalid unicode in _ZO_EXCLUDE_DIRS")?; + let pattern = path.to_str().context("invalid unicode in _ZO_EXCLUDE_DIRS")?; Pattern::new(&pattern) .with_context(|| format!("invalid glob in _ZO_EXCLUDE_DIRS: {}", pattern)) }) @@ -61,9 +59,7 @@ pub fn zo_fzf_opts() -> Option { pub fn zo_maxage() -> Result { match env::var_os("_ZO_MAXAGE") { Some(maxage_osstr) => { - let maxage_str = maxage_osstr - .to_str() - .context("invalid unicode in _ZO_MAXAGE")?; + let maxage_str = maxage_osstr.to_str().context("invalid unicode in _ZO_MAXAGE")?; let maxage = maxage_str.parse::().with_context(|| { format!("unable to parse _ZO_MAXAGE as integer: {}", maxage_str) })?; diff --git a/src/db/dir.rs b/src/db/dir.rs index 6880c36..d1aa891 100644 --- a/src/db/dir.rs +++ b/src/db/dir.rs @@ -20,9 +20,7 @@ impl DirList<'_> { // Assume a maximum size for the database. This prevents bincode from // throwing strange errors when it encounters invalid data. const MAX_SIZE: u64 = 32 << 20; // 32 MiB - let deserializer = &mut bincode::options() - .with_fixint_encoding() - .with_limit(MAX_SIZE); + let deserializer = &mut bincode::options().with_fixint_encoding().with_limit(MAX_SIZE); // Split bytes into sections. let version_size = deserializer.serialized_size(&Self::VERSION).unwrap() as _; @@ -36,11 +34,9 @@ impl DirList<'_> { let version = deserializer.deserialize(bytes_version)?; match version { Self::VERSION => Ok(deserializer.deserialize(bytes_dirs)?), - version => bail!( - "unsupported version (got {}, supports {})", - version, - Self::VERSION, - ), + version => { + bail!("unsupported version (got {}, supports {})", version, Self::VERSION,) + } } })() .context("could not deserialize database") @@ -159,11 +155,7 @@ mod tests { #[test] fn zero_copy() { - let dirs = DirList(vec![Dir { - path: "/".into(), - rank: 0.0, - last_accessed: 0, - }]); + let dirs = DirList(vec![Dir { path: "/".into(), rank: 0.0, last_accessed: 0 }]); let bytes = dirs.to_bytes().unwrap(); let dirs = DirList::from_bytes(&bytes).unwrap(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 5a93d66..138087b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,26 +1,24 @@ mod dir; -mod query; +mod stream; pub use dir::{Dir, DirList, Epoch, Rank}; -pub use query::Matcher; +pub use stream::Stream; use anyhow::{Context, Result}; -use ordered_float::OrderedFloat; use tempfile::{NamedTempFile, PersistError}; use std::borrow::Cow; -use std::cmp::Reverse; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -pub struct Database<'a> { - pub dirs: DirList<'a>, +pub struct Database<'file> { + pub dirs: DirList<'file>, pub modified: bool, - data_dir: &'a Path, + data_dir: &'file PathBuf, } -impl<'a> Database<'a> { +impl<'file> Database<'file> { pub fn save(&mut self) -> Result<()> { if !self.modified { return Ok(()); @@ -28,10 +26,7 @@ impl<'a> Database<'a> { 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() - ) + format!("could not create temporary database in: {}", self.data_dir.display()) })?; // Preallocate enough space on the file, preventing copying later on. @@ -39,10 +34,7 @@ impl<'a> Database<'a> { // 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() - ) + format!("could not write to temporary database: {}", file.path().display()) })?; let path = db_path(&self.data_dir); @@ -58,11 +50,9 @@ impl<'a> Database<'a> { let path = path.as_ref(); match self.dirs.iter_mut().find(|dir| dir.path == path) { - None => self.dirs.push(Dir { - path: Cow::Owned(path.into()), - last_accessed: now, - rank: 1.0, - }), + None => { + self.dirs.push(Dir { path: Cow::Owned(path.into()), last_accessed: now, rank: 1.0 }) + } Some(dir) => { dir.last_accessed = now; dir.rank += 1.0; @@ -72,12 +62,9 @@ impl<'a> Database<'a> { self.modified = true; } - pub fn iter<'i>(&'i mut self, m: &'i Matcher, now: Epoch) -> impl Iterator { - self.dirs - .sort_unstable_by_key(|dir| Reverse(OrderedFloat(dir.score(now)))); - self.dirs - .iter() - .filter(move |dir| m.matches(dir.path.as_ref())) + // Streaming iterator for directories. + pub fn stream(&mut self, now: Epoch) -> Stream<'_, 'file> { + Stream::new(self, now) } /// Removes the directory with `path` from the store. @@ -159,16 +146,13 @@ fn persist>(file: NamedTempFile, path: P) -> Result<(), PersistEr } pub struct DatabaseFile { - data_dir: PathBuf, buffer: Vec, + data_dir: PathBuf, } impl DatabaseFile { - pub fn new>(data_dir: P) -> DatabaseFile { - DatabaseFile { - data_dir: data_dir.into(), - buffer: Vec::new(), - } + pub fn new>(data_dir: P) -> Self { + DatabaseFile { buffer: Vec::new(), data_dir: data_dir.into() } } pub fn open(&mut self) -> Result { @@ -182,27 +166,16 @@ impl DatabaseFile { let dirs = DirList::from_bytes(&self.buffer).with_context(|| { format!("could not deserialize database: {}", path.display()) })?; - Ok(Database { - dirs, - modified: false, - data_dir: &self.data_dir, - }) + Ok(Database { dirs, modified: false, data_dir: &self.data_dir }) } Err(e) if e.kind() == io::ErrorKind::NotFound => { // Create data directory, but don't create any file yet. // The file will be created later by [`Database::save`] // if any data is modified. fs::create_dir_all(&self.data_dir).with_context(|| { - format!( - "unable to create data directory: {}", - self.data_dir.display() - ) + format!("unable to create data directory: {}", self.data_dir.display()) })?; - Ok(Database { - dirs: DirList::new(), - modified: false, - data_dir: &self.data_dir, - }) + Ok(Database { dirs: DirList::new(), modified: false, data_dir: &self.data_dir }) } Err(e) => { Err(e).with_context(|| format!("could not read from database: {}", path.display())) @@ -222,11 +195,7 @@ mod tests { #[test] fn add() { - let path = if cfg!(windows) { - r"C:\foo\bar" - } else { - "/foo/bar" - }; + let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" }; let now = 946684800; let data_dir = tempfile::tempdir().unwrap(); @@ -249,11 +218,7 @@ mod tests { #[test] fn remove() { - let path = if cfg!(windows) { - r"C:\foo\bar" - } else { - "/foo/bar" - }; + let path = if cfg!(windows) { r"C:\foo\bar" } else { "/foo/bar" }; let now = 946684800; let data_dir = tempfile::tempdir().unwrap(); diff --git a/src/db/query.rs b/src/db/query.rs deleted file mode 100644 index add5483..0000000 --- a/src/db/query.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crate::util; - -use std::fs; -use std::path; - -#[derive(Debug, Default)] -pub struct Matcher { - keywords: Vec, - check_exists: bool, - resolve_symlinks: bool, -} - -impl Matcher { - pub fn new() -> Matcher { - Matcher::default() - } - - pub fn with_exists(mut self, resolve_symlinks: bool) -> Matcher { - self.check_exists = true; - self.resolve_symlinks = resolve_symlinks; - self - } - - pub fn with_keywords>(mut self, keywords: &[S]) -> Matcher { - self.keywords = keywords.iter().map(util::to_lowercase).collect(); - self - } - - pub fn matches>(&self, path: S) -> bool { - self.matches_keywords(&path) && self.matches_exists(path) - } - - fn matches_exists>(&self, path: S) -> bool { - if !self.check_exists { - return true; - } - - let resolver = if self.resolve_symlinks { - fs::symlink_metadata - } else { - fs::metadata - }; - - resolver(path.as_ref()) - .map(|m| m.is_dir()) - .unwrap_or_default() - } - - fn matches_keywords>(&self, path: S) -> bool { - let (keywords_last, keywords) = match self.keywords.split_last() { - Some(split) => split, - None => return true, - }; - - let path = util::to_lowercase(path); - let mut path = path.as_str(); - match path.rfind(keywords_last) { - Some(idx) => { - if path[idx + keywords_last.len()..].contains(path::is_separator) { - return false; - } - path = &path[..idx]; - } - None => return false, - } - - for keyword in keywords.iter().rev() { - match path.rfind(keyword) { - Some(idx) => path = &path[..idx], - None => return false, - } - } - - true - } -} - -#[cfg(test)] -mod tests { - use super::Matcher; - - #[test] - fn query() { - const CASES: &[(&[&str], &str, bool)] = &[ - // Case normalization - (&["fOo", "bAr"], "/foo/bar", true), - // Last component - (&["ba"], "/foo/bar", true), - (&["fo"], "/foo/bar", false), - // Slash as suffix - (&["foo/"], "/foo", false), - (&["foo/"], "/foo/bar", true), - (&["foo/"], "/foo/bar/baz", false), - (&["foo", "/"], "/foo", false), - (&["foo", "/"], "/foo/bar", true), - (&["foo", "/"], "/foo/bar/baz", true), - // Split components - (&["/", "fo", "/", "ar"], "/foo/bar", true), - (&["oo/ba"], "/foo/bar", true), - // Overlap - (&["foo", "o", "bar"], "/foo/bar", false), - (&["/foo/", "/bar"], "/foo/bar", false), - (&["/foo/", "/bar"], "/foo/baz/bar", true), - ]; - - for &(keywords, path, is_match) in CASES { - let matcher = Matcher::new().with_keywords(keywords); - assert_eq!(is_match, matcher.matches(path)) - } - } -} diff --git a/src/db/stream.rs b/src/db/stream.rs new file mode 100644 index 0000000..3abab4a --- /dev/null +++ b/src/db/stream.rs @@ -0,0 +1,161 @@ +use super::{Database, Dir, Epoch}; +use crate::util; + +use ordered_float::OrderedFloat; + +use std::fs; +use std::iter::Rev; +use std::ops::Range; +use std::path; + +pub struct Stream<'db, 'file> { + db: &'db mut Database<'file>, + idxs: Rev>, + + keywords: Vec, + + check_exists: bool, + expire_below: Epoch, + resolve_symlinks: bool, + + exclude_path: Option, +} + +impl<'db, 'file> Stream<'db, 'file> { + pub fn new(db: &'db mut Database<'file>, now: Epoch) -> Self { + // Iterate in descending order of score. + db.dirs.sort_unstable_by_key(|dir| OrderedFloat(dir.score(now))); + let idxs = (0..db.dirs.len()).rev(); + + // If a directory is deleted and hasn't been used for 90 days, delete + // it from the database. + let expire_below = now.saturating_sub(90 * 24 * 60 * 60); + + Stream { + db, + idxs, + keywords: Vec::new(), + check_exists: false, + expire_below, + resolve_symlinks: false, + exclude_path: None, + } + } + + pub fn with_exclude>(mut self, path: S) -> Self { + self.exclude_path = Some(path.into()); + self + } + + pub fn with_exists(mut self, resolve_symlinks: bool) -> Self { + self.check_exists = true; + self.resolve_symlinks = resolve_symlinks; + self + } + + pub fn with_keywords>(mut self, keywords: &[S]) -> Self { + self.keywords = keywords.iter().map(util::to_lowercase).collect(); + self + } + + pub fn next(&mut self) -> Option<&Dir<'file>> { + while let Some(idx) = self.idxs.next() { + let dir = &self.db.dirs[idx]; + + if !self.matches_keywords(&dir.path) { + continue; + } + + if !self.matches_exists(&dir.path) { + if dir.last_accessed < self.expire_below { + self.db.dirs.swap_remove(idx); + self.db.modified = true; + } + continue; + } + + if Some(dir.path.as_ref()) == self.exclude_path.as_deref() { + continue; + } + + let dir = &self.db.dirs[idx]; + return Some(dir); + } + + None + } + + fn matches_exists>(&self, path: S) -> bool { + if !self.check_exists { + return true; + } + + let resolver = if self.resolve_symlinks { fs::symlink_metadata } else { fs::metadata }; + + resolver(path.as_ref()).map(|m| m.is_dir()).unwrap_or_default() + } + + fn matches_keywords>(&self, path: S) -> bool { + let (keywords_last, keywords) = match self.keywords.split_last() { + Some(split) => split, + None => return true, + }; + + let path = util::to_lowercase(path); + let mut path = path.as_str(); + match path.rfind(keywords_last) { + Some(idx) => { + if path[idx + keywords_last.len()..].contains(path::is_separator) { + return false; + } + path = &path[..idx]; + } + None => return false, + } + + for keyword in keywords.iter().rev() { + match path.rfind(keyword) { + Some(idx) => path = &path[..idx], + None => return false, + } + } + + true + } +} + +#[cfg(test)] +mod tests { + use super::Database; + + use rstest::rstest; + + use std::path::PathBuf; + + #[rstest] + // Case normalization + #[case(&["fOo", "bAr"], "/foo/bar", true)] + // Last component + #[case(&["ba"], "/foo/bar", true)] + #[case(&["fo"], "/foo/bar", false)] + // Slash as suffix + #[case(&["foo/"], "/foo", false)] + #[case(&["foo/"], "/foo/bar", true)] + #[case(&["foo/"], "/foo/bar/baz", false)] + #[case(&["foo", "/"], "/foo", false)] + #[case(&["foo", "/"], "/foo/bar", true)] + #[case(&["foo", "/"], "/foo/bar/baz", true)] + // Split components + #[case(&["/", "fo", "/", "ar"], "/foo/bar", true)] + #[case(&["oo/ba"], "/foo/bar", true)] + // Overlap + #[case(&["foo", "o", "bar"], "/foo/bar", false)] + #[case(&["/foo/", "/bar"], "/foo/bar", false)] + #[case(&["/foo/", "/bar"], "/foo/baz/bar", true)] + fn query(#[case] keywords: &[&str], #[case] path: &str, #[case] is_match: bool) { + let mut db = + Database { dirs: Vec::new().into(), modified: false, data_dir: &PathBuf::new() }; + let stream = db.stream(0).with_keywords(keywords); + assert_eq!(is_match, stream.matches_keywords(path)); + } +} diff --git a/src/fzf.rs b/src/fzf.rs index 2e87bad..a03685a 100644 --- a/src/fzf.rs +++ b/src/fzf.rs @@ -16,10 +16,7 @@ impl Fzf { if multiple { command.arg("-m"); } - command - .arg("-n2..") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()); + 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); } @@ -41,10 +38,7 @@ impl Fzf { } pub fn wait_select(self) -> Result { - let output = self - .child - .wait_with_output() - .context("wait failed on fzf")?; + let output = self.child.wait_with_output().context("wait failed on fzf")?; match output.status.code() { // normal exit diff --git a/src/shell.rs b/src/shell.rs index c4dfabc..8a3ef78 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -39,329 +39,369 @@ mod tests { use askama::Template; use assert_cmd::Command; - use once_cell::sync::OnceCell; - use seq_macro::seq; + use rstest::rstest; - macro_rules! with_opts_size { - ($macro:ident) => { - $macro!(24); - }; + #[rstest] + fn bash_bash( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Bash(&opts).render().unwrap(); + + Command::new("bash") + .args(&["--noprofile", "--norc", "-c", &source]) + .assert() + .success() + .stdout("") + .stderr(""); } - fn opts() -> &'static [Opts<'static>] { - static OPTS: OnceCell> = OnceCell::new(); - const BOOLS: &[bool] = &[false, true]; - const HOOKS: &[InitHook] = &[InitHook::None, InitHook::Prompt, InitHook::Pwd]; - const CMDS: &[Option<&str>] = &[None, Some("z")]; + #[rstest] + fn bash_shellcheck( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Bash(&opts).render().unwrap(); - OPTS.get_or_init(|| { - let mut opts = Vec::new(); - for &echo in BOOLS { - for &resolve_symlinks in BOOLS { - for &hook in HOOKS { - for &cmd in CMDS { - opts.push(Opts { - cmd, - hook, - echo, - resolve_symlinks, - }); - } - } - } - } - - // Verify that the value hardcoded into `with_opts_size` is correct. - macro_rules! id { - ($x:literal) => { - $x - }; - } - assert_eq!(opts.len(), with_opts_size!(id)); - - opts - }) + Command::new("shellcheck") + .args(&["--enable", "all", "--shell", "bash", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout("") + .stderr(""); } - macro_rules! make_tests { - ($N:literal) => { - seq!(i in 0..$N { - #[test] - fn bash_bash_#i() { - let opts = dbg!(&opts()[i]); - let source = Bash(opts).render().unwrap(); - Command::new("bash") - .args(&["--noprofile", "--norc", "-c", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } + #[rstest] + fn bash_shfmt( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = Bash(&opts).render().unwrap(); + source.push('\n'); - #[test] - fn bash_shellcheck_#i() { - let opts = dbg!(&opts()[i]); - let source = Bash(opts).render().unwrap(); - Command::new("shellcheck") - .args(&["--enable", "all", "--shell", "bash", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout("") - .stderr(""); - } + Command::new("shfmt") + .args(&["-d", "-s", "-ln", "bash", "-i", "4", "-ci", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout("") + .stderr(""); + } - #[test] - fn bash_shfmt_#i() { - let opts = dbg!(&opts()[i]); - let mut source = Bash(opts).render().unwrap(); - source.push('\n'); + #[rstest] + fn elvish_elvish( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = String::new(); - Command::new("shfmt") - .args(&["-d", "-s", "-ln", "bash", "-i", "4", "-ci", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout("") - .stderr(""); - } + // Filter out lines using edit:*, since those functions + // are only available in the interactive editor. + for line in + Elvish(&opts).render().unwrap().split('\n').filter(|line| !line.contains("edit:")) + { + source.push_str(line); + source.push('\n'); + } - #[test] - fn elvish_elvish_#i() { - let opts = dbg!(&opts()[i]); - let mut source = String::new(); + Command::new("elvish") + .args(&["-c", &source, "-norc"]) + .assert() + .success() + .stdout("") + .stderr(""); + } - // Filter out lines using edit:*, since those functions - // are only available in the interactive editor. - for line in Elvish(opts) - .render() - .unwrap() - .split('\n') - .filter(|line| !line.contains("edit:")) - { - source.push_str(line); - source.push('\n'); - } + #[rstest] + fn fish_fish( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Fish(&opts).render().unwrap(); - Command::new("elvish") - .args(&["-c", &source, "-norc"]) - .assert() - .success() - .stdout("") - .stderr(""); - } + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); - #[test] - fn fish_fish_#i() { - let opts = dbg!(&opts()[i]); - let source = Fish(opts).render().unwrap(); + Command::new("fish") + .env("HOME", tempdir) + .args(&["--command", &source, "--private"]) + .assert() + .success() + .stdout("") + .stderr(""); + } - let tempdir = tempfile::tempdir().unwrap(); - let tempdir = tempdir.path().to_str().unwrap(); + #[rstest] + fn fish_fishindent( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = Fish(&opts).render().unwrap(); + source.push('\n'); - Command::new("fish") - .env("HOME", tempdir) - .args(&["--command", &source, "--private"]) - .assert() - .success() - .stdout("") - .stderr(""); - } + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); - #[test] - fn fish_fishindent_#i() { - let opts = dbg!(&opts()[i]); - let mut source = Fish(opts).render().unwrap(); - source.push('\n'); + Command::new("fish") + .env("HOME", tempdir) + .args(&["--command", "fish_indent", "--private"]) + .write_stdin(source.to_string()) + .assert() + .success() + .stdout(source) + .stderr(""); + } - let tempdir = tempfile::tempdir().unwrap(); - let tempdir = tempdir.path().to_str().unwrap(); + #[rstest] + fn nushell_nushell( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Nushell(&opts).render().unwrap(); - Command::new("fish") - .env("HOME", tempdir) - .args(&["--command", "fish_indent", "--private"]) - .write_stdin(source.to_string()) - .assert() - .success() - .stdout(source) - .stderr(""); - } + let tempdir = tempfile::tempdir().unwrap(); + let tempdir = tempdir.path().to_str().unwrap(); - #[test] - fn nushell_nushell_#i() { - let opts = dbg!(&opts()[i]); - let source = Nushell(opts).render().unwrap(); + let assert = Command::new("nu") + .env("HOME", tempdir) + .args(&["--commands", &source]) + .assert() + .success() + .stderr(""); - let tempdir = tempfile::tempdir().unwrap(); - let tempdir = tempdir.path().to_str().unwrap(); - - let assert = Command::new("nu") - .env("HOME", tempdir) - .args(&["--commands", &source]) - .assert() - .success() - .stderr(""); - - if opts.hook != InitHook::Pwd { - assert.stdout(""); - } - } - - #[test] - fn posix_bashposix_#i() { - let opts = dbg!(&opts()[i]); - let source = Posix(opts).render().unwrap(); - let assert = Command::new("bash") - .args(&["--posix", "--noprofile", "--norc", "-c", &source]) - .assert() - .success() - .stderr(""); - - if opts.hook != InitHook::Pwd { - assert.stdout(""); - } - } - - #[test] - fn posix_dash_#i() { - let opts = dbg!(&opts()[i]); - let source = Posix(opts).render().unwrap(); - let assert = Command::new("dash") - .args(&["-c", &source]) - .assert() - .success() - .stderr(""); - - if opts.hook != InitHook::Pwd { - assert.stdout(""); - } - } - - #[test] - fn posix_shellcheck_#i() { - let opts = dbg!(&opts()[i]); - let source = Posix(opts).render().unwrap(); - Command::new("shellcheck") - .args(&["--enable", "all", "--shell", "sh", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout("") - .stderr(""); - } - - #[test] - fn posix_shfmt_#i() { - let opts = dbg!(&opts()[i]); - let mut source = Posix(opts).render().unwrap(); - source.push('\n'); - Command::new("shfmt") - .args(&["-d", "-s", "-ln", "posix", "-i", "4", "-ci", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout("") - .stderr(""); - } - - #[test] - fn powershell_pwsh_#i() { - let opts = dbg!(&opts()[i]); - let source = Powershell(opts).render().unwrap(); - Command::new("pwsh") - .args(&["-NoLogo", "-NonInteractive", "-NoProfile", "-Command", &source]) - .assert() - .success() - .stdout("") - .stderr(""); - } - - #[test] - fn xonsh_black_#i() { - let opts = dbg!(&opts()[i]); - let mut source = Xonsh(opts).render().unwrap(); - source.push('\n'); - Command::new("black") - .args(&["--check", "--diff", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout(""); - } - - #[test] - fn xonsh_mypy_#i() { - let opts = dbg!(&opts()[i]); - let source = Xonsh(opts).render().unwrap(); - Command::new("mypy") - .args(&["--command", &source]) - .assert() - .success() - .stderr(""); - } - - #[test] - fn xonsh_pylint_#i() { - let opts = dbg!(&opts()[i]); - let mut source = Xonsh(opts).render().unwrap(); - source.push('\n'); - Command::new("pylint") - .args(&["--from-stdin", "zoxide"]) - .write_stdin(source) - .assert() - .success() - .stderr(""); - } - - #[test] - fn xonsh_xonsh_#i() { - let opts = dbg!(&opts()[i]); - let source = Xonsh(opts).render().unwrap(); - - // We can't pass the source directly to `xonsh -c` due to - // a bug: - Command::new("xonsh") - .args(&[ - "-c", - "import sys; execx(sys.stdin.read(), 'exec', __xonsh__.ctx, filename='zoxide')", - "--no-rc" - ]) - .write_stdin(source.as_bytes()) - .assert() - .success() - .stdout("") - .stderr(""); - } - - #[test] - fn zsh_shellcheck_#i() { - let opts = dbg!(&opts()[i]); - let source = Zsh(opts).render().unwrap(); - // ShellCheck doesn't support zsh yet. - // https://github.com/koalaman/shellcheck/issues/809 - Command::new("shellcheck") - .args(&["--enable", "all", "--shell", "bash", "-"]) - .write_stdin(source) - .assert() - .success() - .stdout("") - .stderr(""); - } - - #[test] - fn zsh_zsh_#i() { - let opts = dbg!(&opts()[i]); - let source = Zsh(opts).render().unwrap(); - Command::new("zsh") - .args(&["-c", &source, "--no-rcs"]) - .assert() - .success() - .stdout("") - .stderr(""); - } - }); + if opts.hook != InitHook::Pwd { + assert.stdout(""); } } - with_opts_size!(make_tests); + #[rstest] + fn posix_bashposix( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Posix(&opts).render().unwrap(); + + let assert = Command::new("bash") + .args(&["--posix", "--noprofile", "--norc", "-c", &source]) + .assert() + .success() + .stderr(""); + + if opts.hook != InitHook::Pwd { + assert.stdout(""); + } + } + + #[rstest] + fn posix_dash( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Posix(&opts).render().unwrap(); + + let assert = Command::new("dash").args(&["-c", &source]).assert().success().stderr(""); + if opts.hook != InitHook::Pwd { + assert.stdout(""); + } + } + + #[rstest] + fn posix_shellcheck_( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Posix(&opts).render().unwrap(); + + Command::new("shellcheck") + .args(&["--enable", "all", "--shell", "sh", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout("") + .stderr(""); + } + + #[rstest] + fn posix_shfmt( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = Posix(&opts).render().unwrap(); + source.push('\n'); + + Command::new("shfmt") + .args(&["-d", "-s", "-ln", "posix", "-i", "4", "-ci", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout("") + .stderr(""); + } + + #[rstest] + fn powershell_pwsh( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Powershell(&opts).render().unwrap(); + + Command::new("pwsh") + .args(&["-NoLogo", "-NonInteractive", "-NoProfile", "-Command", &source]) + .assert() + .success() + .stdout("") + .stderr(""); + } + + #[rstest] + fn xonsh_black( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = Xonsh(&opts).render().unwrap(); + source.push('\n'); + + Command::new("black") + .args(&["--check", "--diff", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout(""); + } + + #[rstest] + fn xonsh_mypy( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Xonsh(&opts).render().unwrap(); + + Command::new("mypy").args(&["--command", &source]).assert().success().stderr(""); + } + + #[rstest] + fn xonsh_pylint( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let mut source = Xonsh(&opts).render().unwrap(); + source.push('\n'); + + Command::new("pylint") + .args(&["--from-stdin", "zoxide"]) + .write_stdin(source) + .assert() + .success() + .stderr(""); + } + + #[rstest] + fn xonsh_xonsh( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Xonsh(&opts).render().unwrap(); + + // We can't pass the source directly to `xonsh -c` due to + // a bug: + Command::new("xonsh") + .args(&[ + "-c", + "import sys; execx(sys.stdin.read(), 'exec', __xonsh__.ctx, filename='zoxide')", + "--no-rc", + ]) + .write_stdin(source.as_bytes()) + .assert() + .success() + .stdout("") + .stderr(""); + } + + #[rstest] + fn zsh_shellcheck( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Zsh(&opts).render().unwrap(); + + // ShellCheck doesn't support zsh yet. + // https://github.com/koalaman/shellcheck/issues/809 + Command::new("shellcheck") + .args(&["--enable", "all", "--shell", "bash", "-"]) + .write_stdin(source) + .assert() + .success() + .stdout("") + .stderr(""); + } + + #[rstest] + fn zsh_zsh( + #[values(None, Some("z"))] cmd: Option<&str>, + #[values(InitHook::None, InitHook::Prompt, InitHook::Pwd)] hook: InitHook, + #[values(false, true)] echo: bool, + #[values(false, true)] resolve_symlinks: bool, + ) { + let opts = Opts { cmd, hook, echo, resolve_symlinks }; + let source = Zsh(&opts).render().unwrap(); + + Command::new("zsh") + .args(&["-c", &source, "--no-rcs"]) + .assert() + .success() + .stdout("") + .stderr(""); + } } diff --git a/src/util.rs b/src/util.rs index 5089ae6..e101351 100644 --- a/src/util.rs +++ b/src/util.rs @@ -26,8 +26,7 @@ pub fn current_time() -> Result { pub fn path_to_str>(path: &P) -> Result<&str> { let path = path.as_ref(); - path.to_str() - .with_context(|| format!("invalid unicode in path: {}", path.display())) + path.to_str().with_context(|| format!("invalid unicode in path: {}", path.display())) } /// Resolves the absolute version of a path. diff --git a/tests/completion.rs b/tests/completion.rs index f9136bb..118e18e 100644 --- a/tests/completion.rs +++ b/tests/completion.rs @@ -38,13 +38,7 @@ fn completions_fish() { fn completions_powershell() { let source = include_str!("../contrib/completions/_zoxide.ps1"); Command::new("pwsh") - .args(&[ - "-NoLogo", - "-NonInteractive", - "-NoProfile", - "-Command", - source, - ]) + .args(&["-NoLogo", "-NonInteractive", "-NoProfile", "-Command", source]) .assert() .success() .stdout("") @@ -62,10 +56,5 @@ fn completions_zsh() { compinit -u "#; - Command::new("zsh") - .args(&["-c", source, "--no-rcs"]) - .assert() - .success() - .stdout("") - .stderr(""); + Command::new("zsh").args(&["-c", source, "--no-rcs"]).assert().success().stdout("").stderr(""); }