Use ouroboros

This commit is contained in:
Ajeet D'Souza 2022-12-09 07:10:57 +05:30
parent 8da87f0881
commit 22e862b4a5
15 changed files with 304 additions and 89 deletions

View File

@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- `edit` subcommand to adjust the scores of entries.
### Fixed
- Zsh: completions clashing with `zsh-autocomplete`.
@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fzf: `<TAB>` now cycles through completions.
- Fzf: enable colors in preview when possible on macOS / BSD.
### Removed
- `remove` subcommand: use `edit` instead.
## [0.8.3] - 2022-09-02
### Added

36
Cargo.lock generated
View File

@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "aho-corasick"
version = "0.7.19"
@ -11,6 +17,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "aliasable"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
[[package]]
name = "anyhow"
version = "1.0.66"
@ -411,6 +423,29 @@ version = "6.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3baf96e39c5359d2eb0dd6ccb42c62b91d9678aa68160d261b9e0ccbf9e9dea9"
[[package]]
name = "ouroboros"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbb50b356159620db6ac971c6d5c9ab788c9cc38a6f49619fca2a27acb062ca"
dependencies = [
"aliasable",
"ouroboros_macro",
]
[[package]]
name = "ouroboros_macro"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0d9d1a6191c4f391f87219d1ea42b23f09ee84d64763cd05ee6ea88d9f384d"
dependencies = [
"Inflector",
"proc-macro-error",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "predicates"
version = "2.1.1"
@ -806,6 +841,7 @@ dependencies = [
"fastrand",
"glob",
"nix",
"ouroboros",
"rstest",
"rstest_reuse",
"serde",

View File

@ -35,6 +35,7 @@ nix = { version = "0.25.0", default-features = false, features = [
"fs",
"user",
] }
ouroboros = "0.15.5"
rstest = { version = "0.15.0", default-features = false }
rstest_reuse = "0.4.0"
serde = { version = "1.0.116", features = ["derive"] }
@ -51,6 +52,7 @@ dirs.workspace = true
dunce.workspace = true
fastrand.workspace = true
glob.workspace = true
ouroboros.workspace = true
serde.workspace = true
[target.'cfg(unix)'.dependencies]

View File

@ -134,8 +134,6 @@ _arguments "${_arguments_options[@]}" \
;;
(remove)
_arguments "${_arguments_options[@]}" \
'-i[Use interactive selection]' \
'--interactive[Use interactive selection]' \
'-h[Print help information]' \
'--help[Print help information]' \
'-V[Print version information]' \

View File

@ -114,8 +114,6 @@ Register-ArgumentCompleter -Native -CommandName 'zoxide' -ScriptBlock {
break
}
'zoxide;remove' {
[CompletionResult]::new('-i', 'i', [CompletionResultType]::ParameterName, 'Use interactive selection')
[CompletionResult]::new('--interactive', 'interactive', [CompletionResultType]::ParameterName, 'Use interactive selection')
[CompletionResult]::new('-h', 'h', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('--help', 'help', [CompletionResultType]::ParameterName, 'Print help information')
[CompletionResult]::new('-V', 'V', [CompletionResultType]::ParameterName, 'Print version information')

View File

@ -205,7 +205,7 @@ _zoxide() {
return 0
;;
zoxide__remove)
opts="-i -h -V --interactive --help --version [PATHS]..."
opts="-h -V --help --version [PATHS]..."
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
return 0

View File

@ -101,8 +101,6 @@ set edit:completion:arg-completer[zoxide] = {|@words|
cand --version 'Print version information'
}
&'zoxide;remove'= {
cand -i 'Use interactive selection'
cand --interactive 'Use interactive selection'
cand -h 'Print help information'
cand --help 'Print help information'
cand -V 'Print version information'

View File

@ -38,6 +38,5 @@ complete -c zoxide -n "__fish_seen_subcommand_from query" -s l -l list -d 'List
complete -c zoxide -n "__fish_seen_subcommand_from query" -s s -l score -d 'Print score with results'
complete -c zoxide -n "__fish_seen_subcommand_from query" -s h -l help -d 'Print help information'
complete -c zoxide -n "__fish_seen_subcommand_from query" -s V -l version -d 'Print version information'
complete -c zoxide -n "__fish_seen_subcommand_from remove" -s i -l interactive -d 'Use interactive selection'
complete -c zoxide -n "__fish_seen_subcommand_from remove" -s h -l help -d 'Print help information'
complete -c zoxide -n "__fish_seen_subcommand_from remove" -s V -l version -d 'Print version information'

View File

@ -243,10 +243,6 @@ const completion: Fig.Spec = {
name: "remove",
description: "Remove a directory from the database",
options: [
{
name: ["-i", "--interactive"],
description: "Use interactive selection",
},
{
name: ["-h", "--help"],
description: "Print help information",

View File

@ -141,9 +141,6 @@ pub struct Query {
/// Remove a directory from the database
#[derive(Debug, Parser)]
pub struct Remove {
/// Use interactive selection
#[clap(long, short)]
pub interactive: bool,
#[clap(value_hint = ValueHint::DirPath)]
pub paths: Vec<String>,
}

View File

@ -4,76 +4,61 @@ use std::process::Command;
use anyhow::Result;
use crate::cmd::{Edit, EditCommand, Run};
use crate::db::{Database, DatabaseFile, Epoch};
use crate::{config, util};
use crate::store::{Epoch, Store};
use crate::util;
impl Run for Edit {
fn run(&self) -> Result<()> {
let now = util::current_time()?;
let data_dir = config::data_dir()?;
let mut db = DatabaseFile::new(data_dir);
let db = &mut db.open()?;
let db = &mut Store::open()?;
match &self.cmd {
Some(EditCommand::Decrement { path }) => {
if let Some(dir) = db.dirs.iter_mut().find(|dir| &dir.path == path) {
dir.rank = (dir.rank - 1.0).max(0.0);
}
db.modified = true;
db.increment(path, -1.0, now);
db.save()?;
print_dirs(db, now);
}
Some(EditCommand::Delete { path }) => {
if let Some(idx) = db.dirs.iter().position(|dir| &dir.path == path) {
db.dirs.remove(idx);
}
db.modified = true;
db.remove(path);
db.save()?;
print_dirs(db, now);
}
Some(EditCommand::Increment { path }) => {
if let Some(dir) = db.dirs.iter_mut().find(|dir| &dir.path == path) {
dir.rank += 1.0;
}
db.modified = true;
db.increment(path, 1.0, now);
db.save()?;
print_dirs(db, now);
}
Some(EditCommand::Reload) => print_dirs(db, now),
None => {
db.dirs.sort_unstable_by(|dir1, dir2| dir2.score(now).total_cmp(&dir1.score(now)));
db.modified = true;
db.sort_by_score(now);
db.save()?;
let mut fzf = Command::new("fzf");
fzf.args([
"--bind=\
ctrl-r:reload(zoxide edit reload),\
ctrl-w:reload(zoxide edit delete {2..}),\
ctrl-a:reload(zoxide edit increment {2..}),\
ctrl-d:reload(zoxide edit decrement {2..}),\
double-click:ignore,\
enter:abort",
"--header=\
ctrl-r:reload ctrl-w:delete
ctrl-a:increment ctrl-d:decrement
SCORE PATH",
//
// Search mode
"--delimiter=\\x00 ",
"--nth=2",
// Search result
"--no-sort",
// Interface
"--bind=\
ctrl-r:reload(zoxide edit reload),\
ctrl-w:reload(zoxide edit delete {2..}),\
ctrl-a:reload(zoxide edit increment {2..}),\
ctrl-d:reload(zoxide edit decrement {2..}),\
ctrl-z:ignore,\
double-click:ignore,\
enter:abort",
"--cycle",
"--keep-right",
// Layout
"--header=\
ctrl-r:reload ctrl-w:delete
ctrl-a:increment ctrl-d:decrement
SCORE PATH",
"--info=inline",
"--layout=reverse",
// Key/Event bindings
"--bind=ctrl-z:ignore",
])
.envs([("FZF_DEFAULT_COMMAND", "zoxide edit reload")]);
@ -86,9 +71,9 @@ SCORE PATH",
}
}
fn print_dirs(db: &Database, now: Epoch) {
fn print_dirs(db: &Store, now: Epoch) {
let stdout = &mut io::stdout().lock();
for dir in db.dirs.iter() {
for dir in db.dirs().iter().rev() {
writeln!(stdout, "{:>5}\x00 {}", dir.score(now), &dir.path).unwrap();
}
}

View File

@ -1,11 +1,8 @@
use std::io::{self, Write};
use anyhow::{bail, Context, Result};
use anyhow::{bail, Result};
use crate::cmd::{Remove, Run};
use crate::config;
use crate::db::DatabaseFile;
use crate::util::{self, Fzf};
use crate::{config, util};
impl Run for Remove {
fn run(&self) -> Result<()> {
@ -13,38 +10,13 @@ impl Run for Remove {
let mut db = DatabaseFile::new(data_dir);
let mut db = db.open()?;
if self.interactive {
let keywords = &self.paths;
let now = util::current_time()?;
let mut stream = db.stream(now).with_keywords(keywords);
let mut fzf = Fzf::new(true)?;
let stdin = fzf.stdin();
let selection = loop {
let Some(dir) = stream.next() else { break fzf.select()? };
match writeln!(stdin, "{}", dir.display_score(now)) {
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break fzf.select()?,
result => result.context("could not write to fzf")?,
}
};
let paths = selection.lines().filter_map(|line| line.get(5..));
for path in paths {
if !db.remove(path) {
for path in &self.paths {
if !db.remove(path) {
let path_abs = util::resolve_path(path)?;
let path_abs = util::path_to_str(&path_abs)?;
if path_abs == path || !db.remove(path_abs) {
db.modified = false;
bail!("path not found in database: {path}");
}
}
} else {
for path in &self.paths {
if !db.remove(path) {
let path_abs = util::resolve_path(path)?;
let path_abs = util::path_to_str(&path_abs)?;
if path_abs == path || !db.remove(path_abs) {
db.modified = false;
bail!("path not found in database: {path}")
}
bail!("path not found in database: {path}")
}
}
}

View File

@ -9,6 +9,7 @@ mod config;
mod db;
mod error;
mod shell;
mod store;
mod util;
use std::env;

223
src/store/mod.rs Normal file
View File

@ -0,0 +1,223 @@
use std::borrow::Cow;
use std::path::PathBuf;
use std::{fs, io};
use anyhow::{bail, Context, Result};
use bincode::Options;
use ouroboros::self_referencing;
use serde::{Deserialize, Serialize};
use crate::{config, util};
#[self_referencing]
pub struct Store {
path: PathBuf,
bytes: Vec<u8>,
#[borrows(bytes)]
#[covariant]
dirs: Vec<Dir<'this>>,
dirty: bool,
}
impl Store {
const VERSION: u32 = 3;
pub fn open() -> Result<Self> {
let data_dir = config::data_dir()?;
let path = data_dir.join("db.zo");
match fs::read(&path) {
Ok(bytes) => Self::try_new(path, bytes, |bytes| Self::deserialize(bytes), false),
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(&data_dir)
.with_context(|| format!("unable to create data directory: {}", data_dir.display()))?;
Ok(Self::new(data_dir, Vec::new(), |_| Vec::new(), false))
}
Err(e) => Err(e).with_context(|| format!("could not read from database: {}", path.display())),
}
}
pub fn save(&mut self) -> Result<()> {
// Only write to disk if the database is modified.
if !self.borrow_dirty() {
return Ok(());
}
let bytes = Self::serialize(self.borrow_dirs())?;
util::write(self.borrow_path(), &bytes).context("could not write to database")?;
self.with_dirty_mut(|dirty| *dirty = false);
Ok(())
}
/// Increments the rank of a directory, or creates it if it does not exist.
pub fn increment(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
Some(dir) => dir.rank = (dir.rank + by).max(0.0),
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
});
self.with_dirty_mut(|dirty| *dirty = true);
}
/// Increments the rank and updates the last_accessed of a directory, or
/// creates it if it does not exist.
pub fn increment_update(&mut self, path: impl AsRef<str> + Into<String>, by: Rank, now: Epoch) {
self.with_dirs_mut(|dirs| match dirs.iter_mut().find(|dir| dir.path == path.as_ref()) {
Some(dir) => {
dir.rank = (dir.rank + by).max(0.0);
dir.last_accessed = now;
}
None => dirs.push(Dir { path: path.into().into(), rank: by.max(0.0), last_accessed: now }),
});
self.with_dirty_mut(|dirty| *dirty = true);
}
pub fn remove(&mut self, path: impl AsRef<str>) -> bool {
let deleted = self.with_dirs_mut(|dirs| match dirs.iter().position(|dir| dir.path == path.as_ref()) {
Some(idx) => {
dirs.swap_remove(idx);
true
}
None => false,
});
self.with_dirty_mut(|dirty| *dirty |= deleted);
deleted
}
pub fn age(&mut self, max_age: Rank) {
let mut dirty = false;
self.with_dirs_mut(|dirs| {
let total_age = dirs.iter().map(|dir| dir.rank).sum::<Rank>();
if total_age > max_age {
let factor = 0.9 * max_age / total_age;
for idx in (0..dirs.len()).rev() {
let dir = &mut dirs[idx];
dir.rank *= factor;
if dir.rank < 1.0 {
dirs.swap_remove(idx);
}
}
dirty = true;
}
});
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
}
pub fn dedup(&mut self) {
// Sort by path, so that equal paths are next to each other.
self.sort_by_path();
let mut dirty = false;
self.with_dirs_mut(|dirs| {
for idx in (1..dirs.len()).rev() {
// Check if curr_dir and next_dir have equal paths.
let curr_dir = &dirs[idx];
let next_dir = &dirs[idx - 1];
if next_dir.path != curr_dir.path {
continue;
}
// Merge curr_dir's rank and last_accessed into next_dir.
let rank = curr_dir.rank;
let last_accessed = curr_dir.last_accessed;
let next_dir = &mut dirs[idx - 1];
next_dir.last_accessed = next_dir.last_accessed.max(last_accessed);
next_dir.rank += rank;
// Delete curr_dir.
dirs.swap_remove(idx);
dirty = true;
}
});
self.with_dirty_mut(|dirty_prev| *dirty_prev |= dirty);
}
pub fn sort_by_path(&mut self) {
self.with_dirs_mut(|dirs| dirs.sort_unstable_by(|dir1, dir2| dir1.path.cmp(&dir2.path)));
self.with_dirty_mut(|dirty| *dirty = true);
}
pub fn sort_by_score(&mut self, now: Epoch) {
self.with_dirs_mut(|dirs| {
dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| dir1.score(now).total_cmp(&dir2.score(now)))
});
self.with_dirty_mut(|dirty| *dirty = true);
}
pub fn dirs(&self) -> &[Dir] {
self.borrow_dirs()
}
fn serialize(dirs: &[Dir<'_>]) -> Result<Vec<u8>> {
(|| -> bincode::Result<_> {
// Preallocate buffer with combined size of sections.
let buffer_size = bincode::serialized_size(&Self::VERSION)? + bincode::serialized_size(&dirs)?;
let mut buffer = Vec::with_capacity(buffer_size as usize);
// Serialize sections into buffer.
bincode::serialize_into(&mut buffer, &Self::VERSION)?;
bincode::serialize_into(&mut buffer, &dirs)?;
Ok(buffer)
})()
.context("could not serialize database")
}
fn deserialize(bytes: &[u8]) -> Result<Vec<Dir>> {
// 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);
// Split bytes into sections.
let version_size = deserializer.serialized_size(&Self::VERSION).unwrap() as _;
if bytes.len() < version_size {
bail!("could not deserialize database: corrupted data");
}
let (bytes_version, bytes_dirs) = bytes.split_at(version_size);
// Deserialize sections.
let version = deserializer.deserialize(bytes_version)?;
let dirs = match version {
Self::VERSION => deserializer.deserialize(bytes_dirs).context("could not deserialize database")?,
version => {
bail!("unsupported version (got {version}, supports {})", Self::VERSION)
}
};
Ok(dirs)
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Dir<'a> {
#[serde(borrow)]
pub path: Cow<'a, str>,
pub rank: Rank,
pub last_accessed: Epoch,
}
impl Dir<'_> {
pub fn score(&self, now: Epoch) -> Rank {
const HOUR: Epoch = 60 * 60;
const DAY: Epoch = 24 * HOUR;
const WEEK: Epoch = 7 * DAY;
// The older the entry, the lesser its importance.
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;

View File

@ -161,7 +161,9 @@ fn tmpfile<P: AsRef<Path>>(dir: P) -> Result<(File, PathBuf)> {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => break Ok((file, path)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && attempts < MAX_ATTEMPTS => (),
Err(e) => break Err(e).with_context(|| format!("could not create file: {}", path.display())),
Err(e) => {
break Err(e).with_context(|| format!("could not create file: {}", path.display()));
}
}
}
}