commit 68a426216d38db0fe286c479c96406a8e31e3be8 Author: Ajeet D'Souza <98ajeet@gmail.com> Date: Thu Mar 5 18:39:32 2020 +0530 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1315c82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Created by https://www.gitignore.io/api/rust +# Edit at https://www.gitignore.io/?templates=rust + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +/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 + +# End of https://www.gitignore.io/api/rust diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e9f0ca0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "zoxide" +version = "0.1.0" +authors = ["Ajeet D'Souza <98ajeet@gmail.com>"] +description = "A cd command that learns your habits" +repository = "https://github.com/ajeetdsouza/zoxide/" +edition = "2018" + +keywords = ["cli"] +categories = ["command-line-utilities", "filesystem"] +license = "MIT" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +bincode = "1.2.1" +clap = "2.33.0" +dirs = "2.0.2" +failure = "0.1.6" +fs2 = "0.4.3" +serde = { version = "1.0.104", features = ["derive"] } + +[profile.release] +codegen-units = 1 +lto = "fat" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd94bad --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2020 Ajeet D'Souza + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..df4c5e6 --- /dev/null +++ b/src/config.rs @@ -0,0 +1 @@ +pub const DB_PATH: &str = "_ZO_DBPATH"; diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..1eff26d --- /dev/null +++ b/src/db.rs @@ -0,0 +1,122 @@ +use crate::dir::Dir; +use crate::error::AppError; +use crate::types::Timestamp; +use failure::ResultExt; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::{self, BufReader, BufWriter}; +use std::io::{Read, Write}; +use std::path::Path; +use fs2::FileExt; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct DB { + pub dirs: Vec, + + #[serde(skip)] + pub modified: bool, +} + +impl DB { + pub fn open>(path: P) -> Result { + match File::open(path) { + Ok(file) => { + file.lock_shared().with_context(|_| AppError::FileLockError)?; + let rd = BufReader::new(file); + let db = DB::read_from(rd).with_context(|_| AppError::DBReadError)?; + Ok(db) + } + Err(err) => match err.kind() { + io::ErrorKind::NotFound => Ok(DB::default()), + _ => Err(err).with_context(|_| AppError::FileOpenError)?, + }, + } + } + + pub fn save>(&mut self, path: P) -> Result<(), failure::Error> { + if self.modified { + let file = File::create(path).with_context(|_| AppError::FileOpenError)?; + file.lock_exclusive().with_context(|_| AppError::FileLockError)?; + let wr = BufWriter::new(file); + self.write_into(wr) + .with_context(|_| AppError::DBWriteError)?; + } + + Ok(()) + } + + pub fn read_from(rd: R) -> Result { + bincode::deserialize_from(rd) + } + + pub fn write_into(&self, wr: W) -> Result<(), bincode::Error> { + bincode::serialize_into(wr, &self) + } + + pub fn add>(&mut self, path: P, now: Timestamp) -> Result<(), failure::Error> { + let path_abs = path + .as_ref() + .canonicalize() + .with_context(|_| AppError::PathAccessError)?; + let path_str = path_abs.to_str().ok_or_else(|| AppError::UnicodeError)?; + + match self.dirs.iter_mut().find(|dir| dir.path == path_str) { + None => self.dirs.push(Dir { + path: path_str.to_owned(), + last_accessed: now, + rank: 1, + }), + Some(dir) => { + dir.last_accessed = now; + dir.rank += 1; + } + }; + + self.modified = true; + Ok(()) + } + + pub fn query(&mut self, keywords: &[String], now: Timestamp) -> Option { + // TODO: expand "~" in queries + + loop { + let (idx, dir) = self + .dirs + .iter() + .enumerate() + .filter(|(_, dir)| dir.is_match(keywords)) + .max_by_key(|(_, dir)| dir.get_frecency(now))?; + + if dir.is_dir() { + return Some(dir.to_owned()); + } else { + self.dirs.remove(idx); + self.modified = true; + } + } + } + + pub fn remove>(&mut self, path: P) -> Result<(), failure::Error> { + let path_abs = path + .as_ref() + .canonicalize() + .with_context(|_| AppError::PathAccessError)?; + let path_str = path_abs.to_str().ok_or_else(|| AppError::UnicodeError)?; + + if let Some(idx) = self.dirs.iter().position(|dir| dir.path == path_str) { + self.dirs.remove(idx); + self.modified = true; + } + + Ok(()) + } + + pub fn remove_invalid(&mut self) { + let dirs_len = self.dirs.len(); + self.dirs.retain(|dir| dir.is_dir()); + + if self.dirs.len() != dirs_len { + self.modified = true; + } + } +} diff --git a/src/dir.rs b/src/dir.rs new file mode 100644 index 0000000..cf6696e --- /dev/null +++ b/src/dir.rs @@ -0,0 +1,61 @@ +use crate::types::{Rank, Timestamp}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Dir { + pub path: String, + pub rank: Rank, + pub last_accessed: Timestamp, +} + +impl Dir { + pub fn is_dir(&self) -> bool { + Path::new(&self.path).is_dir() + } + + pub fn is_match(&self, query: &[String]) -> bool { + let path = self.path.to_ascii_lowercase(); + + if let Some(dir_name) = Path::new(&path).file_name() { + if let Some(query_last) = query.last() { + if let Some(query_dir_name) = Path::new(query_last).file_name() { + // `unwrap()` here should be safe because the values are already encoded as UTF-8 + let dir_name_str = dir_name.to_str().unwrap().to_ascii_lowercase(); + let query_dir_name_str = query_dir_name.to_str().unwrap().to_ascii_lowercase(); + + if !dir_name_str.contains(&query_dir_name_str) { + return false; + } + } + } + } + + let mut subpath = path.as_str(); + for subquery in query { + match subpath.find(subquery) { + Some(idx) => subpath = &subpath[idx + subquery.len()..], + None => return false, + } + } + + true + } + + pub fn get_frecency(&self, now: Timestamp) -> Rank { + const HOUR: Timestamp = 60 * 60; + const DAY: Timestamp = 24 * HOUR; + const WEEK: Timestamp = 7 * DAY; + + let duration = now - self.last_accessed; + if duration < HOUR { + self.rank * 4 + } else if duration < DAY { + self.rank * 2 + } else if duration < WEEK { + self.rank / 2 + } else { + self.rank / 4 + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..4e9030e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,32 @@ +use failure::Fail; + +#[derive(Debug, Fail)] +pub enum AppError { + #[fail(display = "found invalid UTF-8 code sequence")] + UnicodeError, + + #[fail(display = "system clock is set to invalid time")] + SystemTimeError, + + #[fail(display = "unable to open database file")] + FileOpenError, + #[fail(display = "unable to lock database file")] + FileLockError, + + #[fail(display = "could not read from database")] + DBReadError, + #[fail(display = "could not write to database")] + DBWriteError, + + #[fail(display = "could not launch fzf")] + FzfLaunchError, + #[fail(display = "could not communicate with fzf")] + FzfIoError, + + #[fail(display = "could not retrieve home directory")] + GetHomeDirError, + #[fail(display = "could not retrieve current directory")] + GetCurrentDirError, + #[fail(display = "could not access path")] + PathAccessError, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3e277c9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,127 @@ +mod config; +mod db; +mod dir; +mod error; +mod types; +mod util; + +use crate::db::DB; +use crate::error::AppError; +use crate::types::Timestamp; +use crate::util::{fzf_helper, get_current_time, get_db_path, process_query}; +use clap::{app_from_crate, crate_authors, crate_description, crate_name, crate_version}; +use clap::{App, Arg, SubCommand}; +use failure::ResultExt; +use std::env; +use std::path::Path; + +fn zoxide_query( + db: &mut DB, + keywords: &[String], + now: Timestamp, +) -> Result, failure::Error> { + if let [path] = keywords { + if Path::new(&path).is_dir() { + return Ok(Some(path.to_owned())); + } + } + + if let Some(dir) = db.query(keywords, now) { + return Ok(Some(dir.path)); + } + + Ok(None) +} + +fn zoxide_query_interactive( + db: &mut DB, + keywords: &[String], + now: Timestamp, +) -> Result, failure::Error> { + db.remove_invalid(); + + let dirs = db + .dirs + .iter() + .filter(|dir| dir.is_match(keywords)) + .cloned() + .collect(); + + fzf_helper(now, dirs) +} + +fn zoxide_app() -> App<'static, 'static> { + app_from_crate!() + .subcommand( + SubCommand::with_name("add") + .about("Add a new directory or increment its rank") + .author(crate_authors!()) + .version(crate_version!()) + .arg(Arg::with_name("PATH")), + ) + .subcommand( + SubCommand::with_name("query") + .about("Search for a directory") + .author(crate_authors!()) + .version(crate_version!()) + .arg( + Arg::with_name("interactive") + .short("i") + .long("interactive") + .takes_value(false) + .help("Opens an interactive selection menu using fzf"), + ) + .arg(Arg::with_name("KEYWORD").min_values(0)), + ) + .subcommand( + SubCommand::with_name("remove") + .about("Remove a directory") + .author(crate_authors!()) + .version(crate_version!()) + .arg(Arg::with_name("PATH").required(true)), + ) +} + +fn zoxide() -> Result<(), failure::Error> { + let matches = zoxide_app().get_matches(); + + let db_path = get_db_path()?; + let mut db = DB::open(&db_path)?; + + if let Some(matches) = matches.subcommand_matches("query") { + let now = get_current_time()?; + let keywords = process_query(matches.values_of("KEYWORD").unwrap_or_default()); + + let path_opt = if matches.is_present("interactive") { + zoxide_query_interactive(&mut db, &keywords, now) + } else { + zoxide_query(&mut db, &keywords, now) + }?; + + if let Some(path) = path_opt { + print!("query: {}", path); + } + } else if let Some(matches) = matches.subcommand_matches("add") { + let now = get_current_time()?; + match matches.value_of_os("PATH") { + Some(path) => db.add(path, now)?, + None => { + let path = env::current_dir().with_context(|_| AppError::GetCurrentDirError)?; + db.add(path, now)?; + } + }; + } else if let Some(matches) = matches.subcommand_matches("remove") { + // unwrap is safe here because PATH has been set as a required field + let path = matches.value_of_os("PATH").unwrap(); + db.remove(path)?; + } + + db.save(db_path) +} + +fn main() { + if let Err(err) = zoxide() { + eprintln!("zoxide: {}", err); + std::process::exit(1); + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..e5ac0e5 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,3 @@ +// TODO: convert these to newtypes +pub use i32 as Rank; +pub use i64 as Timestamp; // use a signed integer so subtraction can be performed on it diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..cb71625 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,74 @@ +use crate::config::DB_PATH; +use crate::dir::Dir; +use crate::error::AppError; +use crate::types::Timestamp; +use failure::ResultExt; +use std::env; +use std::ffi::OsString; +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::SystemTime; + +pub fn get_current_time() -> Result { + let current_time = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .with_context(|_| AppError::SystemTimeError)? + .as_secs(); + + Ok(current_time as Timestamp) +} + +pub fn get_db_path() -> Result { + let path = match env::var_os(DB_PATH) { + Some(path) => path, + None => { + let mut path = dirs::home_dir().ok_or_else(|| AppError::GetHomeDirError)?; + path.push(".zo"); + path.into_os_string() + } + }; + Ok(path) +} + +pub fn process_query<'a, I: Iterator>(keywords: I) -> Vec { + keywords.map(|keyword| keyword.to_ascii_lowercase()).collect() +} + +pub fn fzf_helper(now: Timestamp, mut dirs: Vec) -> Result, failure::Error> { + let fzf = Command::new("fzf") + .arg("-n2..") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .with_context(|_| AppError::FzfLaunchError)?; + + let mut fzf_stdin = fzf.stdin.ok_or_else(|| AppError::FzfIoError)?; + + for dir in dirs.iter_mut() { + dir.rank = dir.get_frecency(now); + } + + dirs.sort_by_key(|dir| std::cmp::Reverse(dir.rank)); + + for dir in dirs.iter() { + // ensure that frecency fits in 4 characters + let mut frecency = dir.rank; + if frecency < 0 { + frecency = 0; + } else if frecency > 9999 { + frecency = 9999; + } + + writeln!(fzf_stdin, "{:>4} {}", frecency, dir.path) + .with_context(|_| AppError::FzfIoError)?; + } + + let mut fzf_stdout = fzf.stdout.ok_or_else(|| AppError::FzfIoError)?; + + let mut output = String::new(); + fzf_stdout + .read_to_string(&mut output) + .with_context(|_| AppError::FzfIoError)?; + + Ok(output.get(12..).map(str::to_owned)) +} diff --git a/zoxide.plugin.zsh b/zoxide.plugin.zsh new file mode 100644 index 0000000..ead1fb2 --- /dev/null +++ b/zoxide.plugin.zsh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh + +_zoxide_precmd() { + zoxide add +} + +precmd_functions+=_zoxide_precmd + +z() { + if [ $# -ne 0 ]; then + _Z_RESULT=$(zoxide query "$@") + case $_Z_RESULT in + "query: "*) + cd "${_Z_RESULT:7}" + ;; + *) + echo "${_Z_RESULT}" + ;; + esac + fi +} + +alias zi="z -i" + +alias za="zoxide add" +alias zq="zoxide query" +alias zr="zoxide remove"