From 72c40db192716256af04570612fcb2c87db30164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20K=C3=A5gedal=20Reimer?= Date: Mon, 7 Sep 2026 13:12:43 +0200 Subject: [PATCH 1/2] Escape regex metacharacters in over-long search patterns `SPECIAL_CHARS_REGEX` escaped its own leading bracket, so the character class never opened and matched nothing. The replacement string was a JS-ism too: Dart's `replaceAll` does not expand `$&`, so even with a correct class every metacharacter would have been replaced by the literal text `\$&` rather than escaped. The net effect was that no escaping happened at all. Patterns longer than `maxPatternLength` go down the regex path and were compiled raw, so an unbalanced `(`, an unterminated `[` or a trailing `\` threw a FormatException, and other metacharacters were quietly treated as regex syntax rather than matched literally. Fix the character class and switch to `replaceAllMapped` so the match can actually be referenced. Three of the five new tests fail without this change with the FormatExceptions above; the other two pin down the literal-matching behaviour, which the old code happened to get right. Both defects date back to the original Fuse.js port in 01e0f6a1. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 +++++ lib/bitap/bitap_regex_search.dart | 16 +++++++---- test/fuzzy_test.dart | 47 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7d971..4a1a65f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## Unreleased +- Escape regex metacharacters before compiling a search pattern. Patterns + longer than `maxPatternLength` take the regex path, where an unbalanced `(`, + an unterminated `[`, or a trailing `\` previously threw a `FormatException` + and other metacharacters were silently interpreted as regex syntax instead of + being matched literally. Closes #23. + ## 0.5.1 - Range error when using limit argument to fuse.search, closes #25. [Original PR](https://github.com/comigor/fuzzy/pull/26). diff --git a/lib/bitap/bitap_regex_search.dart b/lib/bitap/bitap_regex_search.dart index 995107f..8641ca8 100644 --- a/lib/bitap/bitap_regex_search.dart +++ b/lib/bitap/bitap_regex_search.dart @@ -1,16 +1,20 @@ import 'data/match_index.dart'; import 'data/match_score.dart'; -/// Pattern to exclude special characters -final Pattern SPECIAL_CHARS_REGEX = - RegExp(r'\[-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]'); +/// Pattern matching the regex metacharacters that have to be escaped before a +/// search pattern can safely be compiled as a [RegExp]. +final Pattern SPECIAL_CHARS_REGEX = RegExp(r'[-\[\]/{}()*+?.\\^$|]'); + +/// Escapes every regex metacharacter in [pattern] so that it is matched +/// literally. +String _escapeSpecialChars(String pattern) => + pattern.replaceAllMapped(SPECIAL_CHARS_REGEX, (match) => '\\${match[0]}'); /// Execute a bitap regex search MatchScore bitapRegexSearch( String text, String pattern, Pattern tokenSeparator) { - final regex = RegExp(pattern - .replaceAll(SPECIAL_CHARS_REGEX, r'\$&') - .replaceAll(tokenSeparator, '|')); + final regex = + RegExp(_escapeSpecialChars(pattern).replaceAll(tokenSeparator, '|')); final matches = regex.allMatches(text); final isMatch = matches.isNotEmpty; diff --git a/test/fuzzy_test.dart b/test/fuzzy_test.dart index 3954127..4fd79c2 100644 --- a/test/fuzzy_test.dart +++ b/test/fuzzy_test.dart @@ -718,4 +718,51 @@ void main() { expect(result.length, equals(0)); }); }); + + group('Searching with special characters', () { + const pipeInsulation = 'Rohrisolierung 22 mm, Steinwolle (1 m Stange)'; + const copperPipe = 'Cu-Rohr 22 x 1 mm, Stange 5 m [VPE 10]'; + const quantifiers = 'super+large+much+unique+36+very+wow+'; + final specialCharList = [ + pipeInsulation, + copperPipe, + quantifiers, + 'Apple', + ]; + late Fuzzy fuse; + setUp(() { + fuse = setup(itemList: specialCharList); + }); + + List itemsFor(String pattern) => + fuse.search(pattern).map((r) => r.item).toList(); + + // Every pattern below is longer than maxPatternLength, so it takes the + // regex path rather than the bitap one. Before special characters were + // escaped, these threw a FormatException. + test('an unbalanced parenthesis does not throw', () { + expect(itemsFor('Rohrisolierung 22 mm, Steinwolle ('), + contains(pipeInsulation)); + }); + + test('an unterminated character class does not throw', () { + expect( + itemsFor('Cu-Rohr 22 x 1 mm, Stange 5 m [VP'), contains(copperPipe)); + }); + + test('a trailing backslash does not throw', () { + expect(() => itemsFor(r'Rohrisolierung 22 mm, Steinwolle \'), + returnsNormally); + }); + + // Single-token patterns, so no alternation is introduced and the whole + // pattern has to match literally. + test('a trailing quantifier is matched literally', () { + expect(itemsFor(quantifiers), equals([quantifiers])); + }); + + test('a dot does not match an arbitrary character', () { + expect(itemsFor('super+large+much+unique+36+very.wow+'), isEmpty); + }); + }); } From aaa2670a8d27e5454f3083b866d836cda50cab7b Mon Sep 17 00:00:00 2001 From: Igor Borges Date: Mon, 7 Sep 2026 17:08:08 -0300 Subject: [PATCH 2/2] Bump version to 0.5.2 for changelog check --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1a65f..7753e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # CHANGELOG -## Unreleased +## 0.5.2 - Escape regex metacharacters before compiling a search pattern. Patterns longer than `maxPatternLength` take the regex path, where an unbalanced `(`, an unterminated `[`, or a trailing `\` previously threw a `FormatException` diff --git a/pubspec.yaml b/pubspec.yaml index a2a4fb5..a5f3f70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: fuzzy -version: 0.5.1 +version: 0.5.2 description: > Fuzzy search in Dart. Initially a code conversion, subset of Fuse.js.