Initial commit

This commit is contained in:
Ajeet D'Souza 2020-03-05 18:39:32 +05:30
commit 68a426216d
11 changed files with 495 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -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

25
Cargo.toml Normal file
View File

@ -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"

7
LICENSE Normal file
View File

@ -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.

1
src/config.rs Normal file
View File

@ -0,0 +1 @@
pub const DB_PATH: &str = "_ZO_DBPATH";

122
src/db.rs Normal file
View File

@ -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<Dir>,
#[serde(skip)]
pub modified: bool,
}
impl DB {
pub fn open<P: AsRef<Path>>(path: P) -> Result<DB, failure::Error> {
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<P: AsRef<Path>>(&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<R: Read>(rd: R) -> Result<DB, bincode::Error> {
bincode::deserialize_from(rd)
}
pub fn write_into<W: Write>(&self, wr: W) -> Result<(), bincode::Error> {
bincode::serialize_into(wr, &self)
}
pub fn add<P: AsRef<Path>>(&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<Dir> {
// 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<P: AsRef<Path>>(&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;
}
}
}

61
src/dir.rs Normal file
View File

@ -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
}
}
}

32
src/error.rs Normal file
View File

@ -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,
}

127
src/main.rs Normal file
View File

@ -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<Option<String>, 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<Option<String>, 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);
}
}

3
src/types.rs Normal file
View File

@ -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

74
src/util.rs Normal file
View File

@ -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<Timestamp, failure::Error> {
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<OsString, failure::Error> {
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<Item = &'a str>>(keywords: I) -> Vec<String> {
keywords.map(|keyword| keyword.to_ascii_lowercase()).collect()
}
pub fn fzf_helper(now: Timestamp, mut dirs: Vec<Dir>) -> Result<Option<String>, 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))
}

27
zoxide.plugin.zsh Normal file
View File

@ -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"