Repository: dathere/qsv (language: rust)

Issue:
Exact search?
I fairly regularly find myself wanting to run `qsv search` for an exact string, and then get caught out by`qsv search -s id 179` also returning 1790, 1791, etc.

So then I remember to anchor it as `qsv search -s id "^179$"`, and everything is fine until I'm looking for something with special characters (`qsv search -s url "^https://example.com/?id=179$"`). So then I have to dig even deeper into my memory to remember the syntax for `qsv luau filter "url = …"` (often forgetting at first that I need `==` there)`. 

At this point I return to the docs sure that I've overlooked something much simpler for what has to be a common usage pattern.

Am I actually missing something really obvious? Or, might it be possible to add an 'exact match' flag to `qsv search` (or possibly a separate `qsv find` or the like)?



Discussion / hints:
That's a good idea @tmtmtmtm !

I'll add a new `--literal` option to both `search` and `searchset`. If the `--literal` option is set, you can search even with the regex special characters.

Fix patch:
diff --git a/src/cmd/search.rs b/src/cmd/search.rs
index 21069a48f..fd0c91de5 100644
--- a/src/cmd/search.rs
+++ b/src/cmd/search.rs
@@ -22,6 +22,9 @@ Usage:
 search options:
     -i, --ignore-case      Case insensitive search. This is equivalent to
                            prefixing the regex with '(?i)'.
+    --literal              Treat the regex as a literal string. This allows
+                           you to search for exact matches that even contain special
+                           regex characters without escaping them.
     -s, --select <arg>     Select the columns to search. See 'qsv select -h'
                            for the full syntax.
     -v, --invert-match     Select only rows that did not match
@@ -88,6 +91,7 @@ use crate::{
 struct Args {
     arg_input:           Option<String>,
     arg_regex:           String,
+    flag_literal:        bool,
     flag_select:         SelectColumns,
     flag_output:         Option<String>,
     flag_no_headers:     bool,
@@ -115,7 +119,13 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
         args.flag_unicode
     };
 
-    let pattern = RegexBuilder::new(&args.arg_regex)
+    let arg_regex = if args.flag_literal {
+        regex::escape(&args.arg_regex)
+    } else {
+        args.arg_regex.clone()
+    };
+
+    let pattern = RegexBuilder::new(&arg_regex)
         .case_insensitive(args.flag_ignore_case)
         .unicode(regex_unicode)
         .size_limit(args.flag_size_limit * (1 << 20))
diff --git a/src/cmd/searchset.rs b/src/cmd/searchset.rs
index 5f508a093..28726d4db 100644
--- a/src/cmd/searchset.rs
+++ b/src/cmd/searchset.rs
@@ -28,6 +28,9 @@ Usage:
 search options:
     -i, --ignore-case          Case insensitive search. This is equivalent to
                                prefixing the regex with '(?i)'.
+    --literal                  Treat the regex as a literal string. This allows
+                               you to search for exact matches that even contain special
+                               regex characters without escaping them.
     -s, --select <arg>         Select the columns to search. See 'qsv select -h'
                                for the full syntax.
     -v, --invert-match         Select only rows that did not match
@@ -99,6 +102,7 @@ use crate::{
 struct Args {
     arg_input:              Option<String>,
     arg_regexset_file:      String,
+    flag_literal:           bool,
     flag_select:            SelectColumns,
     flag_output:            Option<String>,
     flag_no_headers:        bool,
@@ -119,9 +123,18 @@ struct Args {
     flag_quiet:             bool,
 }
 
-fn read_regexset(filename: &String) -> io::Result<Vec<String>> {
+fn read_regexset(filename: &String, literal: bool) -> io::Result<Vec<String>> {
     match File::open(filename) {
-        Ok(f) => BufReader::new(f).lines().collect(),
+        Ok(f) => {
+            if literal {
+                BufReader::new(f)
+                    .lines()
+                    .map(|l| l.map(|s| regex::escape(&s)))
+                    .collect()
+            } else {
+                BufReader::new(f).lines().collect()
+            }
+        },
         Err(e) => Err(io::Error::new(
             io::ErrorKind::NotFound,
             format!("Cannot open regexset file {filename}: {e}"),
@@ -142,7 +155,7 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
         );
     }
 
-    let regexset = read_regexset(&args.arg_regexset_file)?;
+    let regexset = read_regexset(&args.arg_regexset_file, args.flag_literal)?;
 
     let mut regex_labels: Vec<String> = Vec::with_capacity(regexset.len());
     let labels_re = Regex::new(r".?#(?P<label>.*)$").unwrap();


Test patch:
diff --git a/tests/test_search.rs b/tests/test_search.rs
index 05d58560e..15a40c7fe 100644
--- a/tests/test_search.rs
+++ b/tests/test_search.rs
@@ -13,6 +13,19 @@ fn data(headers: bool) -> Vec<Vec<String>> {
     rows
 }
 
+fn data_with_regex_chars(headers: bool) -> Vec<Vec<String>> {
+    let mut rows = vec![
+        svec!["foo^bar", "barfoo"],
+        svec!["a", "b"],
+        svec!["^barfoo", "foobar"],
+        svec!["Ḟooƀar", "ḃarḟoo"],
+    ];
+    if headers {
+        rows.insert(0, svec!["h1", "h2"]);
+    }
+    rows
+}
+
 #[test]
 fn search() {
     let wrk = Workdir::new("search");
@@ -625,3 +638,17 @@ fn search_preview_json() {
     assert_eq!(got, expected);
     wrk.assert_success(&mut cmd);
 }
+
+#[test]
+fn search_literal() {
+    let wrk = Workdir::new("search_literal");
+    wrk.create("data.csv", data_with_regex_chars(false));
+    let mut cmd = wrk.command("search");
+    cmd.arg("^bar").arg("data.csv").arg("--literal");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![svec!["foo^bar", "barfoo"], svec!["^barfoo", "foobar"]];
+    assert_eq!(got, expected);
+
+    wrk.assert_success(&mut cmd);
+}
diff --git a/tests/test_searchset.rs b/tests/test_searchset.rs
index 273aee7bc..35184b715 100644
--- a/tests/test_searchset.rs
+++ b/tests/test_searchset.rs
@@ -17,11 +17,31 @@ fn data(headers: bool) -> Vec<Vec<String>> {
     rows
 }
 
+fn data_with_regex_chars(headers: bool) -> Vec<Vec<String>> {
+    let mut rows = vec![
+        svec!["foo$bar^", "barfoo"],
+        svec!["a", "b"],
+        svec!["$bar^foo", "foobar"],
+        svec!["is wal[do] here", "spot"],
+        svec!["Ḟooƀar", "$ḃar^ḟoo"],
+        svec!["bleh", "no, Wal[do] is there"],
+    ];
+    if headers {
+        rows.insert(0, svec!["h1", "h2"]);
+    }
+    rows
+}
+
 fn regexset_file() -> Vec<Vec<String>> {
     let rows = vec![svec!["^foo"], svec!["bar$"], svec!["waldo"]];
     rows
 }
 
+fn regexset_literal_file() -> Vec<Vec<String>> {
+    let rows = vec![svec!["$bar^"], svec!["[do]"]];
+    rows
+}
+
 fn regexset_no_match_file() -> Vec<Vec<String>> {
     let rows = vec![svec!["^blah"], svec!["bloop$"], svec!["joel"]];
     rows
@@ -427,3 +447,23 @@ fn searchset_flag_complex_unmatched_output() {
 
     wrk.assert_success(&mut cmd);
 }
+
+#[test]
+fn searchset_literal() {
+    let wrk = Workdir::new("searchset_literal");
+    wrk.create("data.csv", data_with_regex_chars(true));
+    wrk.create("regexset.txt", regexset_literal_file());
+    let mut cmd = wrk.command("searchset");
+    cmd.arg("regexset.txt").arg("data.csv").arg("--literal");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["h1", "h2"],
+        svec!["foo$bar^", "barfoo"],
+        svec!["$bar^foo", "foobar"],
+        svec!["is wal[do] here", "spot"],
+        svec!["bleh", "no, Wal[do] is there"],
+    ];
+    assert_eq!(got, expected);
+    wrk.assert_success(&mut cmd);
+}