diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7d971..7753e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # CHANGELOG +## 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` + 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/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. 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); + }); + }); }