Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).

Expand Down
16 changes: 10 additions & 6 deletions lib/bitap/bitap_regex_search.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
47 changes: 47 additions & 0 deletions test/fuzzy_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> fuse;
setUp(() {
fuse = setup(itemList: specialCharList);
});

List<String> 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);
});
});
}
Loading