mirror of
https://github.com/Llewellynvdm/zoxide.git
synced 2024-11-21 20:35:15 +00:00
Initial commit
This commit is contained in:
commit
68a426216d
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
25
Cargo.toml
Normal 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
7
LICENSE
Normal 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
1
src/config.rs
Normal file
@ -0,0 +1 @@
|
||||
pub const DB_PATH: &str = "_ZO_DBPATH";
|
122
src/db.rs
Normal file
122
src/db.rs
Normal 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
61
src/dir.rs
Normal 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
32
src/error.rs
Normal 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
127
src/main.rs
Normal 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
3
src/types.rs
Normal 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
74
src/util.rs
Normal 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
27
zoxide.plugin.zsh
Normal 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"
|
Loading…
Reference in New Issue
Block a user