Skip to content

Commit ec739b3

Browse files
committed
feat: Support search terms in 'memy list', deprecate 'memy z'
1 parent ebb47d2 commit ec739b3

11 files changed

Lines changed: 882 additions & 882 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ memy was originally inspired by [fasd](https://github.com/whjvenyl/fasd), but th
5151
# or use the `memy-cd` convenience command if the memy hook is installed for your shell (see below)
5252
```
5353

54-
- Change to the most frecent directory containing the string 'download' (case-insensitive):
54+
- Search using ordered keywords (case-insensitive; the last keyword must match the last path component):
5555

5656
```sh
57-
cd $(memy list -d -s --output-filter-command 'grep -i download | head -1')
57+
memy list project notes
5858
```
5959

6060
- Open a recently used file with the platform default application, selecting it using `fzf` or other selector:

hooks/bash

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,13 @@ if ! declare -f z &>/dev/null && ! command -v z &>/dev/null; then
7979
return
8080
fi
8181
local result
82-
result="$(memy z -- "$@")" && cd "${result/#\~/$HOME}"
82+
result="$(memy list -d --head 1 --zoxide-compatible -- "$@")" && cd "${result/#\~/$HOME}"
8383
}
8484
fi
8585

8686
if ! declare -f zi &>/dev/null && ! command -v zi &>/dev/null; then
8787
function zi() {
8888
local result
89-
result="$(memy z -i -- "$@")" && cd "$result"
89+
result="$(memy list -d --output-filter --zoxide-compatible -- "$@")" && cd "$result"
9090
}
9191
fi

hooks/fish.fish

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,14 @@ if not functions -q z; and not command -q z
5050
cd $OLDPWD
5151
return
5252
end
53-
set result (memy z -- $argv)
53+
set result (memy list -d --head 1 --zoxide-compatible -- $argv)
5454
and cd $result
5555
end
5656
end
5757

5858
if not functions -q zi; and not command -q zi
5959
function zi
60-
set result (memy z -i -- $argv)
60+
set result (memy list -d --output-filter --zoxide-compatible -- $argv)
6161
and cd $result
6262
end
6363
end

hooks/zsh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,13 @@ if ! (( $+functions[z] )) && ! (( $+commands[z] )); then
5959
return
6060
fi
6161
local result
62-
result="$(memy z -- "$@")" && cd "${result/#\~/$HOME}"
62+
result="$(memy list -d --head 1 --zoxide-compatible -- "$@")" && cd "${result/#\~/$HOME}"
6363
}
6464
fi
6565

6666
if ! (( $+functions[zi] )) && ! (( $+commands[zi] )); then
6767
function zi() {
6868
local result
69-
result="$(memy z -i -- "$@")" && cd "$result"
69+
result="$(memy list -d --output-filter --zoxide-compatible -- "$@")" && cd "$result"
7070
}
7171
fi

src/list.rs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@ use core::error::Error;
33
use rusqlite::Connection;
44
use std::fs::FileType;
55
use std::io::{Write as _, stdout};
6+
use tracing::debug;
67
use tracing::instrument;
78

89
use crate::utils;
910
use crate::utils::db;
11+
use crate::utils::path;
1012
use crate::utils::query;
13+
use crate::utils::search::matches_zoxide_algo;
1114
use crate::utils::types::Frecency;
1215
use crate::utils::types::NotedCount;
1316

@@ -41,10 +44,14 @@ fn calculate(conn: &Connection, args: &ListArgs) -> Result<Vec<PathFrecency>, Bo
4144
return query::FilterResult::Exclude;
4245
}
4346

47+
if !args.keywords.is_empty() && !matches_zoxide_algo(&row.path, &args.keywords) {
48+
return query::FilterResult::Exclude;
49+
}
50+
4451
query::FilterResult::Include
4552
})?;
4653

47-
let to_output = matches
54+
let mut to_output: Vec<PathFrecency> = matches
4855
.into_iter()
4956
.map(|m| PathFrecency {
5057
path: m.table_paths_entry.path,
@@ -55,6 +62,13 @@ fn calculate(conn: &Connection, args: &ListArgs) -> Result<Vec<PathFrecency>, Bo
5562
})
5663
.collect();
5764

65+
if let Some(n) = args.head {
66+
let len = to_output.len();
67+
if n < len {
68+
to_output.drain(..len - n);
69+
}
70+
}
71+
5872
Ok(to_output)
5973
}
6074

@@ -84,10 +98,43 @@ fn format_results(results: &[PathFrecency], args: &ListArgs) -> Result<String, B
8498

8599
#[instrument(level = "trace")]
86100
pub fn command(args: &ListArgs) -> Result<(), Box<dyn Error>> {
101+
if args.zoxide_compatible && !args.output_filter {
102+
if args.keywords.is_empty() {
103+
let home = std::env::home_dir().ok_or("Cannot determine home directory")?;
104+
let normalized_home = path::normalize_path(&home);
105+
let mut stdout_handle = stdout().lock();
106+
debug!(
107+
"Returning home directory as in zoxide_compatible mode and no keywords provided"
108+
);
109+
writeln!(stdout_handle, "{}", normalized_home.to_string_lossy())?;
110+
return Ok(());
111+
}
112+
113+
if args.keywords.len() == 1 && args.keywords[0] == "-" {
114+
return Err("z -: cannot determine previous directory from within memy; use 'cd -' directly in your shell".into());
115+
}
116+
117+
if args.keywords.len() == 1
118+
&& let Some(resolved) = path::resolve_existing_dir(&args.keywords[0])
119+
{
120+
debug!(
121+
"Returning directory as specified on command line: {}",
122+
resolved.to_string_lossy()
123+
);
124+
let mut stdout_handle = stdout().lock();
125+
writeln!(stdout_handle, "{}", resolved.to_string_lossy())?;
126+
return Ok(());
127+
}
128+
}
129+
87130
let db_connection = db::open()?;
88131
let results: Vec<PathFrecency> = calculate(&db_connection, args)?;
89132
db::close(db_connection)?;
90133

134+
if results.is_empty() && (!args.keywords.is_empty() || args.zoxide_compatible) {
135+
return Err("no match found".into());
136+
}
137+
91138
let output = format_results(&results, args)?;
92139

93140
if args.output_filter {

src/utils/cli.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ pub struct NoteArgs {
9595
}
9696

9797
#[derive(Args, Debug)]
98+
#[allow(
99+
clippy::struct_excessive_bools,
100+
reason = "CLI flags are naturally boolean"
101+
)]
98102
pub struct ListArgs {
99103
/// Show only files in the list
100104
#[arg(short, long, conflicts_with = "directories_only")]
@@ -113,6 +117,10 @@ pub struct ListArgs {
113117
#[arg(long, value_name = "TIME")]
114118
pub newer_than: Option<String>,
115119

120+
/// Return only the top N most frecent results
121+
#[arg(long, value_name = "N")]
122+
pub head: Option<usize>,
123+
116124
/// Pipe output through a command, defaulting to an interactive filter like `fzf`
117125
#[arg(short = 's', long = "output-filter", visible_alias = "select-filter")]
118126
pub output_filter: bool,
@@ -125,6 +133,16 @@ pub struct ListArgs {
125133
requires = "output_filter"
126134
)]
127135
pub output_filter_command: Option<String>,
136+
137+
/// Enable zoxide-compatible shortcut behaviours (home dir on no keywords, existing dir
138+
/// passthrough, '-' error); intended for use by shell hooks only
139+
#[arg(long, hide = true)]
140+
pub zoxide_compatible: bool,
141+
142+
/// Search keywords (case-insensitive, must appear in path in the order provided, last keyword must appear in
143+
/// the last path component)
144+
#[arg(value_name = "KEYWORDS", num_args = 0..)]
145+
pub keywords: Vec<String>,
128146
}
129147

130148
#[derive(Args, Debug)]

src/utils/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod logging;
88
pub mod output;
99
pub mod path;
1010
pub mod query;
11+
pub mod search;
1112
pub mod time;
1213
pub mod types;
1314

src/utils/search.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/// Returns true if `path` matches all `keywords` using the zoxide matching algorithm:
2+
/// * All terms must be present within the path, in order.
3+
/// * The last component of the last keyword must be contained in the last component of the path.
4+
#[must_use]
5+
pub fn matches_zoxide_algo(path: &str, keywords: &[String]) -> bool {
6+
if keywords.is_empty() {
7+
return true;
8+
}
9+
10+
let path_lower = path.to_lowercase();
11+
let mut search_start = 0;
12+
13+
for keyword in keywords {
14+
let kw_lower = keyword.to_lowercase();
15+
if let Some(found_offset) = path_lower[search_start..].find(&kw_lower) {
16+
search_start += found_offset + kw_lower.len();
17+
} else {
18+
return false;
19+
}
20+
}
21+
22+
let last_keyword = keywords.last().expect("keywords is non-empty");
23+
let last_kw_lower = last_keyword.to_lowercase();
24+
let kw_last_component = last_kw_lower
25+
.split('/')
26+
.next_back()
27+
.unwrap_or(&last_kw_lower);
28+
let path_last_component = path_lower.split('/').next_back().unwrap_or(&path_lower);
29+
30+
path_last_component.contains(kw_last_component)
31+
}
32+
33+
#[allow(clippy::unwrap_used, reason = "unwrap() OK inside tests")]
34+
#[cfg(test)]
35+
mod tests {
36+
use super::*;
37+
use proptest::prelude::*;
38+
39+
#[test]
40+
fn test_matches_zoxide_empty_keywords() {
41+
assert!(matches_zoxide_algo("/foo/bar", &[]));
42+
}
43+
44+
#[test]
45+
fn test_matches_zoxide_basic() {
46+
assert!(matches_zoxide_algo("/foo/bar", &["bar".to_owned()]));
47+
}
48+
49+
#[test]
50+
fn test_matches_zoxide_last_component_rule() {
51+
// "bar" must appear in last component of path
52+
assert!(!matches_zoxide_algo("/bar/foo", &["bar".to_owned()]));
53+
}
54+
55+
#[test]
56+
fn test_matches_zoxide_case_insensitive() {
57+
assert!(matches_zoxide_algo("/FOO/BAR", &["bar".to_owned()]));
58+
assert!(matches_zoxide_algo("/foo/bar", &["BAR".to_owned()]));
59+
}
60+
61+
#[test]
62+
fn test_matches_zoxide_multiple_keywords_ordered() {
63+
assert!(matches_zoxide_algo(
64+
"/foo/bar",
65+
&["fo".to_owned(), "ba".to_owned()]
66+
));
67+
// reversed order should not match
68+
assert!(!matches_zoxide_algo(
69+
"/foo/bar",
70+
&["ba".to_owned(), "fo".to_owned()]
71+
));
72+
}
73+
74+
#[test]
75+
fn test_matches_zoxide_slash_in_keyword() {
76+
// z foo/bar matches /foo/bar but not /foo/bar/baz
77+
assert!(matches_zoxide_algo("/foo/bar", &["foo/bar".to_owned()]));
78+
assert!(!matches_zoxide_algo(
79+
"/foo/bar/baz",
80+
&["foo/bar".to_owned()]
81+
));
82+
}
83+
84+
#[test]
85+
fn test_matches_zoxide_slash_separated_keywords() {
86+
// z fo / ba matches /foo/bar but not /foobar
87+
assert!(matches_zoxide_algo(
88+
"/foo/bar",
89+
&["fo".to_owned(), "/".to_owned(), "ba".to_owned()]
90+
));
91+
assert!(!matches_zoxide_algo(
92+
"/foobar",
93+
&["fo".to_owned(), "/".to_owned(), "ba".to_owned()]
94+
));
95+
}
96+
97+
#[test]
98+
fn test_matches_zoxide_file_last_component() {
99+
// Keyword matching against a file path — last component is the filename
100+
assert!(matches_zoxide_algo(
101+
"/home/user/docs/report.pdf",
102+
&["docs".to_owned(), "rep".to_owned()]
103+
));
104+
assert!(matches_zoxide_algo(
105+
"/home/user/docs/report.pdf",
106+
&["rep".to_owned()]
107+
));
108+
// "docs" must be in last component (filename), not a directory
109+
assert!(!matches_zoxide_algo(
110+
"/home/user/docs/report.pdf",
111+
&["docs".to_owned()]
112+
));
113+
}
114+
115+
proptest! {
116+
#[test]
117+
fn prop_keyword_in_last_component_matches(
118+
prefix in "[a-z]{1,8}",
119+
keyword in "[a-z]{2,8}",
120+
suffix in "[a-z]{0,5}",
121+
) {
122+
// Construct a path where the keyword is embedded in the last component.
123+
let path = format!("/{prefix}/{keyword}{suffix}");
124+
prop_assert!(matches_zoxide_algo(&path, core::slice::from_ref(&keyword)),
125+
"path={path} should match keyword={keyword}");
126+
}
127+
128+
#[test]
129+
fn prop_keyword_only_in_non_last_component_doesnt_match(
130+
keyword in "[a-z]{4,8}",
131+
last_component in "[a-z]{4,8}",
132+
) {
133+
// The last component must not contain the keyword.
134+
prop_assume!(!last_component.contains(keyword.as_str()));
135+
let path = format!("/{keyword}/{last_component}");
136+
prop_assert!(!matches_zoxide_algo(&path, core::slice::from_ref(&keyword)),
137+
"keyword={keyword} should not match when absent from last component of path={path}");
138+
}
139+
}
140+
}

0 commit comments

Comments
 (0)