Repository: yshavit/mdq (rust)

Issue:
change regex library
Negative lookaheads would be nice to have, and we don't need the performance benefits that `regex` gets from removing them.

Pull request description:
change to fancy-regex
- change the library to use fancy-regex (this uses the old regex under the hood)
- update the error handling: fancy-regex's default error handling wasn't very nice as an inner error, so I unwrapped it for nicer display
- add a unit test to smoke test the new available functionality. I only picked one new bit of functionality (lookahead) because I'm interested in verifying that fancy-regex is being used at all, not in testing the library

This is not breaking, because the regex library that mdq uses is intentionally obscured in the public API.

resolves #121

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 3fd9d72..3f11a68 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -61,6 +61,21 @@ dependencies = [
  "windows-sys",
 ]
 
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
 [[package]]
 name = "block-buffer"
 version = "0.10.4"
@@ -223,6 +238,17 @@ version = "1.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
 
+[[package]]
+name = "fancy-regex"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
 [[package]]
 name = "fnv"
 version = "1.0.7"
@@ -312,6 +338,7 @@ version = "0.7.3-dev"
 dependencies = [
  "clap",
  "derive_builder",
+ "fancy-regex",
  "indoc",
  "lazy_static",
  "markdown",
@@ -319,7 +346,6 @@ dependencies = [
  "paste",
  "pest",
  "pest_derive",
- "regex",
  "serde",
  "serde_json",
  "toml",
@@ -406,18 +432,6 @@ dependencies = [
  "proc-macro2",
 ]
 
-[[package]]
-name = "regex"
-version = "1.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
 [[package]]
 name = "regex-automata"
 version = "0.4.9"
diff --git a/Cargo.toml b/Cargo.toml
index e468d45..4212e50 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ memchr = "2.7.4"
 paste = "1.0"
 pest = "2.8"
 pest_derive = { version = "2.8", features = ["grammar-extras"] }
-regex = "1.10.4"
+fancy-regex = "0.14"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1.0"
 
diff --git a/src/query/matcher_try_from.rs b/src/query/matcher_try_from.rs
index c8baded..5276134 100644
--- a/src/query/matcher_try_from.rs
+++ b/src/query/matcher_try_from.rs
@@ -1,6 +1,7 @@
 use crate::query::strings::{ParsedString, ParsedStringMode};
 use crate::query::{DetachedSpan, InnerParseError, Pair};
 use crate::select::{Matcher, Regex};
+use fancy_regex::Error;
 
 impl Matcher {
     pub(crate) fn try_from(pair: Option<Pair>) -> Result<Self, InnerParseError> {
@@ -28,8 +29,20 @@ impl Matcher {
                 anchor_end: parsed_string.anchor_end,
             },
             ParsedStringMode::Regex => {
-                let re =
-                    regex::Regex::new(&parsed_string.text).map_err(|e| InnerParseError::Other(span, e.to_string()))?;
+                let re = fancy_regex::Regex::new(&parsed_string.text).map_err(|e| {
+                    match e {
+                        Error::ParseError(pos, err) => {
+                            let mut re_span = span;
+                            re_span.start += pos + 1; // +1 for the regex's opening slash
+                            re_span.end = re_span.start;
+                            InnerParseError::Other(re_span, format!("regex parse error: {err}"))
+                        }
+                        err => {
+                            // not expected, but we'll handle it anyway
+                            InnerParseError::Other(span, err.to_string())
+                        }
+                    }
+                })?;
                 Self::Regex(Regex { re })
             }
         };
diff --git a/src/query/selector_try_from.rs b/src/query/selector_try_from.rs
index fc44d75..fdce3a0 100644
--- a/src/query/selector_try_from.rs
+++ b/src/query/selector_try_from.rs
@@ -646,7 +646,7 @@ mod tests {
                 "</> /<div.*>/",
                 Selector::Html(HtmlMatcher {
                     html: Matcher::Regex(Regex {
-                        re: regex::Regex::new("<div.*>").unwrap(),
+                        re: fancy_regex::Regex::new("<div.*>").unwrap(),
                     }),
                 }),
             )
diff --git a/src/select/matcher.rs b/src/select/matcher.rs
index 6c26ac0..94e84ca 100644
--- a/src/select/matcher.rs
+++ b/src/select/matcher.rs
@@ -28,7 +28,7 @@ pub enum Matcher {
 /// The actual regex library is intentionally obscured so that it can change in the future without breaking the API.
 #[derive(Debug, Clone)]
 pub struct Regex {
-    pub(crate) re: regex::Regex,
+    pub(crate) re: fancy_regex::Regex,
 }
 
 impl PartialEq for Regex {
diff --git a/src/select/string_matcher.rs b/src/select/string_matcher.rs
index bc12414..543ae2c 100644
--- a/src/select/string_matcher.rs
+++ b/src/select/string_matcher.rs
@@ -2,7 +2,7 @@ use crate::md_elem::elem::*;
 use crate::md_elem::*;
 use crate::output::inlines_to_plain_string;
 use crate::select::Matcher;
-use regex::Regex;
+use fancy_regex::Regex;
 use std::borrow::Borrow;
 
 #[derive(Debug)]
@@ -18,7 +18,12 @@ impl PartialEq for StringMatcher {
 
 impl StringMatcher {
     pub fn matches(&self, haystack: &str) -> bool {
-        self.re.is_match(haystack)
+        match self.re.is_match(haystack) {
+            Ok(m) => m,
+            Err(e) => {
+                panic!("failed to evaluate regular expression: {e}");
+            }
+        }
     }
 
     pub fn matches_inlines<I: Borrow<Inline>>(&self, haystack: &[I]) -> bool {
@@ -106,7 +111,7 @@ impl SubstringToRegex {
         if self.anchor_start {
             pattern.push('^');
         }
-        pattern.push_str(&regex::escape(&self.look_for));
+        pattern.push_str(&fancy_regex::escape(&self.look_for));
         if self.anchor_end {
             pattern.push('$');
         }
@@ -315,6 +320,15 @@ mod test {
         parse_and_check_with(StringVariant::AngleBracket, "> rest", StringMatcher::any(), "> rest");
     }
 
+    /// Test for fancy_regex specific feature (lookaround)
+    #[test]
+    fn fancy_regex_lookahead() {
+        let matcher = re(r#"foo(?=bar)"#); // Positive lookahead: matches "foo" only if followed by "bar"
+        assert!(matcher.matches("foobar"));
+        assert!(!matcher.matches("foo"));
+        assert!(!matcher.matches("foobaz"));
+    }
+
     fn parse_and_check_with(
         string_variant: StringVariant,
         text: &str,


Test patch:
diff --git a/tests/md_cases/bad_queries.toml b/tests/md_cases/bad_queries.toml
index 2fb9792..731dc96 100644
--- a/tests/md_cases/bad_queries.toml
+++ b/tests/md_cases/bad_queries.toml
@@ -61,15 +61,12 @@ cli_args = ['# /\P{/']
 expect_success = false
 output = ''
 output_err = '''Syntax error in select specifier:
- --> 1:3
+ --> 1:4
   |
 1 | # /\P{/
-  |   ^---^
+  |    ^
   |
-  = regex parse error:
-    \P{
-       ^
-error: incomplete escape sequence, reached end of pattern prematurely
+  = regex parse error: Unicode escape not closed
 '''
 
 [expect."bareword isn't closed"]