diff --git a/CHANGELOG.md b/CHANGELOG.md
index 071a7c6..64ba9e0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Unreleased
+
+### Added
+
+- `$_ZO_CASE_SENSITIVITY` to support case-sensitive querying in addition to the
+ default case-insensitive querying.
+
## [0.9.8] - 2025-05-27
### Added
diff --git a/README.md b/README.md
index ad42889..4d33277 100644
--- a/README.md
+++ b/README.md
@@ -426,6 +426,9 @@ When calling `zoxide init`, the following flags are available:
Environment variables[^2] can be used for configuration. They must be set before
`zoxide init` is called.
+- `_ZO_CASE_SENSITIVITY`
+ - Defaults to case-insensitive searches. Set to `case-sensitive` for
+ case-sensitive searching.
- `_ZO_DATA_DIR`
- Specifies the directory in which the database is stored.
- The default value varies across OSes:
diff --git a/src/cmd/cmd.rs b/src/cmd/cmd.rs
index d25cda3..1e40da9 100644
--- a/src/cmd/cmd.rs
+++ b/src/cmd/cmd.rs
@@ -22,6 +22,7 @@ https://github.com/ajeetdsouza/zoxide
{all-args}{after-help}
Environment variables:
+{tab}_ZO_CASE_SENSITIVITY{tab}Set case-sensitivity: case-sensitive or case-insensitive (default)
{tab}_ZO_DATA_DIR {tab}Path for zoxide data files
{tab}_ZO_ECHO {tab}Print the matched directory before navigating to it when set to 1
{tab}_ZO_EXCLUDE_DIRS {tab}List of directory globs to be excluded
diff --git a/src/cmd/query.rs b/src/cmd/query.rs
index 362d80a..91545c2 100644
--- a/src/cmd/query.rs
+++ b/src/cmd/query.rs
@@ -79,6 +79,7 @@ impl Query {
fn get_stream<'a>(&self, db: &'a mut Database, now: Epoch) -> Result> {
let mut options = StreamOptions::new(now)
.with_keywords(self.keywords.iter().map(|s| s.as_str()))
+ .with_case_sensitivity(config::case_sensitivity())
.with_exclude(config::exclude_dirs()?);
if !self.all {
let resolve_symlinks = config::resolve_symlinks();
diff --git a/src/config.rs b/src/config.rs
index 0aeda5c..4ecce1d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -6,6 +6,33 @@ use anyhow::{Context, Result, ensure};
use glob::Pattern;
use crate::db::Rank;
+use crate::util;
+
+pub enum CaseSensitivity {
+ CaseInsensitive,
+ CaseSensitive,
+}
+
+impl CaseSensitivity {
+ pub fn convert_case(&self, s: &str) -> String {
+ match self {
+ CaseSensitivity::CaseInsensitive => util::to_lowercase(s),
+ CaseSensitivity::CaseSensitive => s.into(),
+ }
+ }
+}
+
+pub fn case_sensitivity() -> CaseSensitivity {
+ env::var_os("_ZO_CASE_SENSITIVITY")
+ .map_or(CaseSensitivity::CaseInsensitive, map_case_sensitivity)
+}
+
+fn map_case_sensitivity(s: OsString) -> CaseSensitivity {
+ match s.to_str() {
+ Some("case-sensitive") => CaseSensitivity::CaseSensitive,
+ _ => CaseSensitivity::CaseInsensitive,
+ }
+}
pub fn data_dir() -> Result {
let dir = match env::var_os("_ZO_DATA_DIR") {
diff --git a/src/db/stream.rs b/src/db/stream.rs
index 4af7d7a..e91f683 100644
--- a/src/db/stream.rs
+++ b/src/db/stream.rs
@@ -4,8 +4,9 @@ use std::{fs, path};
use glob::Pattern;
+use crate::config::CaseSensitivity;
use crate::db::{Database, Dir, Epoch};
-use crate::util::{self, MONTH};
+use crate::util::MONTH;
pub struct Stream<'a> {
db: &'a mut Database,
@@ -48,13 +49,18 @@ impl<'a> Stream<'a> {
}
fn filter_by_keywords(&self, path: &str) -> bool {
- let (keywords_last, keywords) = match self.options.keywords.split_last() {
+ let keywords: Vec = self
+ .options
+ .keywords
+ .iter()
+ .map(|s| self.options.case_sensitivity.convert_case(s))
+ .collect();
+
+ let (keywords_last, keywords) = match keywords.split_last() {
Some(split) => split,
None => return true,
};
-
- let path = util::to_lowercase(path);
- let mut path = path.as_str();
+ let mut path = &self.options.case_sensitivity.convert_case(path)[..];
match path.rfind(keywords_last) {
Some(idx) => {
if path[idx + keywords_last.len()..].contains(path::is_separator) {
@@ -112,6 +118,9 @@ pub struct StreamOptions {
/// Directories that do not exist and haven't been accessed since TTL will
/// be lazily removed.
ttl: Epoch,
+
+ /// Whether searching should be perform case sensitively.
+ case_sensitivity: CaseSensitivity,
}
impl StreamOptions {
@@ -123,6 +132,7 @@ impl StreamOptions {
exists: false,
resolve_symlinks: false,
ttl: now.saturating_sub(3 * MONTH),
+ case_sensitivity: CaseSensitivity::CaseInsensitive,
}
}
@@ -131,7 +141,12 @@ impl StreamOptions {
I: IntoIterator,
I::Item: AsRef,
{
- self.keywords = keywords.into_iter().map(util::to_lowercase).collect();
+ self.keywords = keywords.into_iter().map(|s| s.as_ref().into()).collect();
+ self
+ }
+
+ pub fn with_case_sensitivity(mut self, case_sensitivity: CaseSensitivity) -> Self {
+ self.case_sensitivity = case_sensitivity;
self
}
@@ -185,4 +200,16 @@ mod tests {
let stream = Stream::new(db, options);
assert_eq!(is_match, stream.filter_by_keywords(path));
}
+
+ #[rstest]
+ // Case normalization
+ #[case(&["fOo", "bAr"], "/foo/bar", false)]
+ fn query_case_sensitive(#[case] keywords: &[&str], #[case] path: &str, #[case] is_match: bool) {
+ let db = &mut Database::new(PathBuf::new(), Vec::new(), |_| Vec::new(), false);
+ let options = StreamOptions::new(0)
+ .with_keywords(keywords.iter())
+ .with_case_sensitivity(CaseSensitivity::CaseSensitive);
+ let stream = Stream::new(db, options);
+ assert_eq!(is_match, stream.filter_by_keywords(path));
+ }
}