@pulsar-edit/fuzzy-native 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Hanson Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # fuzzy-native
2
+
3
+ [![Build Status](https://travis-ci.org/hansonw/fuzzy-native.svg?branch=master)](https://travis-ci.org/hansonw/fuzzy-native)
4
+
5
+ Fuzzy string matching library package for Node. Implemented natively in C++ for speed with support for multithreading.
6
+
7
+ The scoring algorithm is heavily tuned for file paths, but should work for general strings - it also supports the same algorithm that was implemented in Fuzzaldrin library, the one that powers command-pallette and other fuzzy-finders in Pulsar
8
+
9
+ ## API
10
+
11
+ ```ts
12
+ export type MatcherOptions = {
13
+ // Default: false
14
+ caseSensitive?: boolean,
15
+
16
+ // Default: infinite
17
+ maxResults?: number,
18
+
19
+ // Maximum gap to allow between consecutive letters in a match.
20
+ // Provide a smaller maxGap to speed up query results.
21
+ // Default: unlimited
22
+ maxGap?: number;
23
+
24
+ // Default: 1
25
+ numThreads?: number,
26
+
27
+ // Default: false
28
+ recordMatchIndexes?: boolean,
29
+
30
+ // can be either "fuzzaldrin" or anything else to use the file-based option
31
+ algorithm?: string
32
+ }
33
+
34
+ export type MatchResult = {
35
+ id: number,
36
+ value: string,
37
+
38
+ // A number in the range (0-1]. Higher scores are more relevant.
39
+ // 0 denotes "no match" and will never be returned.
40
+ score: number,
41
+
42
+ // Matching character index in `value` for each character in `query`.
43
+ // This can be costly, so this is only returned if `recordMatchIndexes` was set in `options`.
44
+ matchIndexes?: Array<number>,
45
+ }
46
+
47
+ export class Matcher {
48
+ constructor(candidates: Array<string>) {}
49
+
50
+ // Returns all matching candidates (subject to `options`).
51
+ // Will be ordered by score, descending.
52
+ match: (query: string, options?: MatcherOptions) => Array<MatchResult>;
53
+
54
+ addCandidates: (ids: Array<number>, candidates: Array<string>) => void;
55
+ removeCandidates: (ids: Array<number>) => void;
56
+ setCandidates: (ids: Array<number>, candidates: Array<string>) => void;
57
+ }
58
+ ```
59
+
60
+ See also the [spec](spec/fuzzy-native-spec.js) for basic usage.
61
+
62
+ ## Scoring algorithm
63
+
64
+ ### Default
65
+ The _default scoring_ algorithm is mostly borrowed from @wincent's excellent [command-t](https://github.com/wincent/command-t) vim plugin; most of the code is from [his implementation in match.c](https://github.com/wincent/command-t/blob/master/ruby/command-t/match.c).
66
+
67
+ Read [the source code](src/score_match.cpp) for a quick overview of how it works (the function `recursive_match`).
68
+
69
+ NB: [score_match.cpp](src/score_match.cpp) and [score_match.h](src/score_match.h) have no dependencies besides the C/C++ stdlib and can easily be reused for other purposes.
70
+
71
+ There are a few notable additional optimizations:
72
+
73
+ - Before running the recursive matcher, we first do a backwards scan through the haystack to see if the needle exists at all. At the same time, we compute the right-most match for each character in the needle to prune the search space.
74
+ - For each candidate string, we pre-compute and store a bitmask of its letters in `MatcherBase`. We then compare this the "letter bitmask" of the query to quickly prune out non-matches.
75
+
76
+ ### Fuzzaldrin
77
+
78
+ Ported from Atom's Fuzzaldrin app - it's easier to read the original version than to read the C++ one, they are basically identic: see [scorer.coffee](https://github.com/atom/fuzzaldrin/blob/master/src/scorer.coffee) from Atom's archived repository.
package/binding.gyp ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ 'targets': [
3
+ {
4
+ 'target_name': 'fuzzy-native',
5
+ 'include_dirs': [ '<!(node -e "require(\'nan\')")' ],
6
+ 'cflags': [
7
+ '-std=c++17',
8
+ '-O3',
9
+ ],
10
+ 'xcode_settings': {
11
+ 'OTHER_CPLUSPLUSFLAGS': [
12
+ '-std=c++17',
13
+ '-O3',
14
+ '-stdlib=libc++',
15
+ ],
16
+ 'MACOSX_DEPLOYMENT_TARGET': '10.7',
17
+ },
18
+ 'sources': [
19
+ 'src/binding.cpp',
20
+ 'src/fuzzaldrin_score.cpp',
21
+ 'src/score_match.cpp',
22
+ 'src/MatcherBase.cpp',
23
+ ],
24
+ 'conditions': [
25
+ ['OS == "win"', {
26
+ 'defines': ['_HAS_EXCEPTIONS'],
27
+ 'msvs_disabled_warnings': [
28
+ 4267, # conversion from 'size_t' to 'int', possible loss of data
29
+ 4530, # exception unwinding
30
+ ],
31
+ }],
32
+ ['OS == "linux"', {
33
+ 'ldflags': [
34
+ '-s',
35
+ ],
36
+ }],
37
+ ],
38
+ }
39
+ ]
40
+ }
package/lib/main.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require('../build/Release/fuzzy-native.node')
@@ -0,0 +1,45 @@
1
+ /* @flow */
2
+
3
+ export type MatcherOptions = {
4
+ // Default: false
5
+ caseSensitive?: boolean,
6
+
7
+ // Default: infinite
8
+ maxResults?: number,
9
+
10
+ // Maximum gap to allow between consecutive letters in a match.
11
+ // Provide a smaller maxGap to speed up query results.
12
+ // Default: unlimited
13
+ maxGap?: number;
14
+
15
+ // Default: 1
16
+ numThreads?: number,
17
+
18
+ // Default: false
19
+ recordMatchIndexes?: boolean,
20
+ }
21
+
22
+ export type MatchResult = {
23
+ id: number,
24
+ value: string,
25
+
26
+ // A number in the range (0-1]. Higher scores are more relevant.
27
+ // 0 denotes "no match" and will never be returned.
28
+ score: number,
29
+
30
+ // Matching character index in `value` for each character in `query`.
31
+ // This can be costly, so this is only returned if `recordMatchIndexes` was set in `options`.
32
+ matchIndexes?: Array<number>,
33
+ }
34
+
35
+ export class Matcher {
36
+ constructor(candidates: Array<string>) {}
37
+
38
+ // Returns all matching candidates (subject to `options`).
39
+ // Will be ordered by score, descending.
40
+ match: (query: string, options?: MatcherOptions) => Array<MatchResult>;
41
+
42
+ addCandidates: (ids: Array<number>, candidates: Array<string>) => void;
43
+ removeCandidates: (ids: Array<number>) => void;
44
+ setCandidates: (ids: Array<number>, candidates: Array<string>) => void;
45
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@pulsar-edit/fuzzy-native",
3
+ "version": "1.2.4",
4
+ "description": "Native C++ implementation of a fuzzy string matcher.",
5
+ "main": "lib/main.js",
6
+ "scripts": {
7
+ "test": "jasmine-node --captureExceptions spec"
8
+ },
9
+ "files": [
10
+ "binding.gyp",
11
+ "lib",
12
+ "src"
13
+ ],
14
+ "keywords": [
15
+ "fuzzy",
16
+ "native",
17
+ "matcher",
18
+ "string"
19
+ ],
20
+ "author": "Hanson Wang (hanson.wng@gmail.com)",
21
+ "repository": "https://github.com/pulsar-edit/fuzzy-native",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "nan": "2.17.0"
25
+ },
26
+ "devDependencies": {
27
+ "jasmine-node": "^1.14.5",
28
+ "rimraf": "^2.5.2"
29
+ }
30
+ }
@@ -0,0 +1,329 @@
1
+ #include "MatcherBase.h"
2
+ #include "score_match.h"
3
+ #include "fuzzaldrin_score.h"
4
+
5
+ #include <algorithm>
6
+ #include <atomic>
7
+ #include <queue>
8
+ #include <thread>
9
+
10
+ using namespace std;
11
+
12
+ typedef priority_queue<MatchResult> ResultHeap;
13
+
14
+ inline uint64_t letter_bitmask(const std::string &str) {
15
+ uint64_t result = 0;
16
+ for (char c : str) {
17
+ if (c >= 'a' && c <= 'z') {
18
+ int index = c - 'a';
19
+ uint64_t count_bit = (result >> (index * 2));
20
+ // "Increment" the count_bit:
21
+ // 00 -> 01
22
+ // 01 -> 11
23
+ // 11 -> 11
24
+ count_bit = ((count_bit << 1) | 1) & 3;
25
+ result |= count_bit << (index * 2);
26
+ } else if (c == '-') {
27
+ result |= (1UL << 52);
28
+ } else if (c >= '0' && c <= '9') {
29
+ result |= (1UL << (c - '0' + 54));
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+
35
+ inline string str_to_lower(const std::string &s) {
36
+ string lower(s);
37
+ for (auto& c : lower) {
38
+ if (c >= 'A' && c <= 'Z') {
39
+ c += 'a' - 'A';
40
+ }
41
+ }
42
+ return lower;
43
+ }
44
+
45
+ bool is_slash(char c) {
46
+ return c == '/' || c == '\\';
47
+ }
48
+
49
+ int num_dirs(const std::string &path) {
50
+ int num = 0;
51
+ for (size_t i = 0; i < path.length(); i++) {
52
+ if (is_slash(path[i])) {
53
+ num++;
54
+ }
55
+ }
56
+ return num;
57
+ }
58
+
59
+ int score_based_root_path(const MatchOptions &options,
60
+ const MatcherBase::CandidateData &candidate) {
61
+ const std::string &root = options.root_path;
62
+ if (root.length() == 0) {
63
+ return 0;
64
+ }
65
+
66
+ const std::string &value = candidate.value;
67
+
68
+ size_t num_common_dirs = 0;
69
+ size_t i = 0;
70
+
71
+ // Count number of common directories
72
+ for (; i < root.length() && i < value.length(); i++) {
73
+ if (root[i] != value[i]) {
74
+ break;
75
+ }
76
+ if (is_slash(root[i])) {
77
+ num_common_dirs++;
78
+ }
79
+ }
80
+
81
+ if (i == root.length() && i < value.length() && is_slash(value[i])) {
82
+ num_common_dirs++;
83
+ }
84
+
85
+ return 1000 * num_common_dirs - candidate.num_dirs;
86
+ }
87
+
88
+ // Push a new entry on the heap while ensuring size <= max_results.
89
+ void push_heap(ResultHeap &heap,
90
+ float score,
91
+ int score_based_root_path,
92
+ uint32_t id,
93
+ const std::string *value,
94
+ size_t max_results) {
95
+ MatchResult result(score, score_based_root_path, id, value);
96
+ if (heap.size() < max_results || result < heap.top()) {
97
+ heap.push(std::move(result));
98
+ if (heap.size() > max_results) {
99
+ heap.pop();
100
+ }
101
+ }
102
+ }
103
+
104
+ vector<MatchResult> finalize(const string &query,
105
+ const string &query_case,
106
+ const MatchOptions &options,
107
+ bool record_match_indexes,
108
+ ResultHeap &&heap) {
109
+ vector<MatchResult> vec;
110
+ while (heap.size()) {
111
+ const MatchResult &result = heap.top();
112
+ if (record_match_indexes) {
113
+ result.matchIndexes.reset(new vector<int>(query.size()));
114
+ string lower = str_to_lower(*result.value);
115
+ score_match(
116
+ result.value->c_str(),
117
+ lower.c_str(),
118
+ query.c_str(),
119
+ query_case.c_str(),
120
+ options,
121
+ 0.0,
122
+ result.matchIndexes.get()
123
+ );
124
+ }
125
+ vec.push_back(result);
126
+ heap.pop();
127
+ }
128
+ reverse(vec.begin(), vec.end());
129
+ return vec;
130
+ }
131
+
132
+ void thread_worker(
133
+ const string &query,
134
+ const string &query_case,
135
+ const MatchOptions &options,
136
+ bool use_last_match,
137
+ std::atomic<float>* min_score,
138
+ size_t max_results,
139
+ vector<MatcherBase::CandidateData> &candidates,
140
+ size_t start,
141
+ size_t end,
142
+ ResultHeap &result
143
+ ) {
144
+ uint64_t bitmask = letter_bitmask(query_case.c_str());
145
+ for (size_t i = start; i < end; i++) {
146
+ auto &candidate = candidates[i];
147
+ if (use_last_match && !candidate.last_match) {
148
+ continue;
149
+ }
150
+ if ((bitmask & candidate.bitmask) == bitmask) {
151
+ float score;
152
+ if(query == "") {
153
+ score = 1;
154
+ } else if(options.fuzzaldrin) {
155
+ score = fuzzaldrin_score(candidate.value, query);
156
+ score = fuzzaldrin_basename_score(candidate.value, query, score);
157
+ } else {
158
+ score = score_match(
159
+ candidate.value.c_str(),
160
+ candidate.lowercase.c_str(),
161
+ query.c_str(),
162
+ query_case.c_str(),
163
+ options,
164
+ min_score->load()
165
+ );
166
+ }
167
+ if (score > 0) {
168
+ push_heap(
169
+ result,
170
+ score,
171
+ score_based_root_path(options, candidate),
172
+ candidate.id,
173
+ &candidate.value,
174
+ max_results
175
+ );
176
+ if (result.size() == max_results) {
177
+ float current_max = result.top().score;
178
+ float min_score_value = min_score->load();
179
+ // Unfortunately there's no thread-safe "max"...
180
+ // When running compare_exchange_weak it's possible that another
181
+ // thread wrote to it in the meantime, in which case we have to check
182
+ // again. Since it's always increasing this is guaranteed to converge.
183
+ while (current_max > min_score_value) {
184
+ min_score->compare_exchange_weak(min_score_value, current_max);
185
+ }
186
+ }
187
+ candidate.last_match = true;
188
+ } else {
189
+ candidate.last_match = false;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ vector<MatchResult> MatcherBase::findMatches(const std::string &query,
196
+ const MatcherOptions &options) {
197
+ size_t max_results = options.max_results;
198
+ size_t num_threads = options.num_threads;
199
+ if (max_results == 0) {
200
+ max_results = numeric_limits<size_t>::max();
201
+ }
202
+ MatchOptions matchOptions;
203
+ matchOptions.case_sensitive = options.case_sensitive;
204
+ matchOptions.smart_case = false;
205
+ matchOptions.max_gap = options.max_gap;
206
+ matchOptions.root_path = options.root_path;
207
+ matchOptions.fuzzaldrin = options.fuzzaldrin;
208
+
209
+ string new_query;
210
+ // Ignore all whitespace in the query.
211
+ for (auto c : query) {
212
+ if (!isspace(c)) {
213
+ new_query += c;
214
+ }
215
+ if (options.smart_case && isupper(c) && !matchOptions.case_sensitive) {
216
+ matchOptions.smart_case = true;
217
+ }
218
+ }
219
+
220
+ string query_case;
221
+ if (!options.case_sensitive) {
222
+ query_case = str_to_lower(new_query);
223
+ } else {
224
+ query_case = query;
225
+ }
226
+
227
+ // If our current query is just an extension of the last query,
228
+ // quickly ignore all previous non-matches as an optimization.
229
+ bool use_last_match = query_case.substr(0, lastQuery_.size()) == lastQuery_;
230
+ lastQuery_ = query_case;
231
+
232
+ ResultHeap combined;
233
+ std::atomic<float> min_score(0);
234
+ if (num_threads == 0 || candidates_.size() < 10000) {
235
+ thread_worker(new_query, query_case, matchOptions, use_last_match, &min_score,
236
+ max_results, candidates_, 0, candidates_.size(), combined);
237
+ } else {
238
+ vector<ResultHeap> thread_results(num_threads);
239
+ vector<thread> threads;
240
+ size_t cur_start = 0;
241
+ for (size_t i = 0; i < num_threads; i++) {
242
+ size_t chunk_size = candidates_.size() / num_threads;
243
+ // Distribute remainder among the chunks.
244
+ if (i < candidates_.size() % num_threads) {
245
+ chunk_size++;
246
+ }
247
+ threads.emplace_back(
248
+ thread_worker,
249
+ ref(new_query),
250
+ ref(query_case),
251
+ ref(matchOptions),
252
+ use_last_match,
253
+ &min_score,
254
+ max_results,
255
+ ref(candidates_),
256
+ cur_start,
257
+ cur_start + chunk_size,
258
+ ref(thread_results[i])
259
+ );
260
+ cur_start += chunk_size;
261
+ }
262
+
263
+ for (size_t i = 0; i < num_threads; i++) {
264
+ threads[i].join();
265
+ while (thread_results[i].size()) {
266
+ auto &top = thread_results[i].top();
267
+ push_heap(
268
+ combined,
269
+ top.score,
270
+ top.score_based_root_path,
271
+ top.id,
272
+ top.value,
273
+ max_results
274
+ );
275
+ thread_results[i].pop();
276
+ }
277
+ }
278
+ }
279
+
280
+ return finalize(
281
+ new_query,
282
+ query_case,
283
+ matchOptions,
284
+ options.record_match_indexes,
285
+ move(combined)
286
+ );
287
+ }
288
+
289
+ void MatcherBase::addCandidate(uint32_t id, const string &candidate) {
290
+ auto it = lookup_.find(id);
291
+ if (it == lookup_.end()) {
292
+ string lowercase = str_to_lower(candidate);
293
+ lookup_[id] = candidates_.size();
294
+ CandidateData data;
295
+ data.id = id;
296
+ data.value = candidate;
297
+ data.bitmask = letter_bitmask(lowercase.c_str());
298
+ data.lowercase = move(lowercase);
299
+ data.last_match = true;
300
+ data.num_dirs = num_dirs(candidate);
301
+ candidates_.emplace_back(move(data));
302
+ }
303
+ }
304
+
305
+ void MatcherBase::removeCandidate(uint32_t id) {
306
+ auto it = lookup_.find(id);
307
+ if (it != lookup_.end()) {
308
+ if (it->second + 1 != candidates_.size()) {
309
+ swap(candidates_[it->second], candidates_.back());
310
+ lookup_[candidates_[it->second].id] = it->second;
311
+ }
312
+ candidates_.pop_back();
313
+ lookup_.erase(id);
314
+ }
315
+ }
316
+
317
+ void MatcherBase::clear() {
318
+ candidates_.clear();
319
+ lookup_.clear();
320
+ }
321
+
322
+ void MatcherBase::reserve(size_t n) {
323
+ candidates_.reserve(n);
324
+ lookup_.reserve(n);
325
+ }
326
+
327
+ size_t MatcherBase::size() const {
328
+ return candidates_.size();
329
+ }
@@ -0,0 +1,93 @@
1
+ #pragma once
2
+
3
+ #include <memory>
4
+ #include <string>
5
+ #include <unordered_map>
6
+ #include <vector>
7
+
8
+ struct MatcherOptions {
9
+ bool case_sensitive = false;
10
+ bool smart_case = false;
11
+ bool fuzzaldrin = false;
12
+ size_t num_threads = 0;
13
+ size_t max_results = 0;
14
+ size_t max_gap = 0;
15
+ bool record_match_indexes = false;
16
+ std::string root_path;
17
+ };
18
+
19
+ struct MatchResult {
20
+ float score;
21
+ uint32_t id;
22
+ // We can't afford to copy strings around while we're ranking them.
23
+ // These are not guaranteed to last very long and should be copied out ASAP.
24
+ const std::string *value;
25
+ // Only computed if `record_match_indexes` was set to true.
26
+ mutable std::shared_ptr<std::vector<int>> matchIndexes = nullptr;
27
+ int score_based_root_path;
28
+
29
+ MatchResult(float score,
30
+ int score_based_root_path,
31
+ uint32_t id,
32
+ const std::string *value)
33
+ : score(score),
34
+ id(id),
35
+ value(value),
36
+ score_based_root_path(score_based_root_path) {}
37
+
38
+ // Order small scores to the top of any priority queue.
39
+ // We need a min-heap to maintain the top-N results.
40
+ bool operator<(const MatchResult& other) const {
41
+ if (score == other.score) {
42
+ // In case of a tie, favour shorter strings.
43
+ if (score_based_root_path == other.score_based_root_path) {
44
+ return value->length() < other.value->length();
45
+ }
46
+ return score_based_root_path > other.score_based_root_path;
47
+ }
48
+ return score > other.score;
49
+ }
50
+ };
51
+
52
+ class MatcherBase {
53
+ public:
54
+ struct CandidateData {
55
+ uint32_t id;
56
+ std::string value;
57
+ std::string lowercase;
58
+ int num_dirs;
59
+ /**
60
+ * A bitmask representing the counts of letters a-z contained in the string.
61
+ * Bits i*2 and i*2 + 1 represent a count of the i-th letter:
62
+ * 00 = 0
63
+ * 01 = 1
64
+ * 11 = 2
65
+ * With this scheme, if (bitmask(X) & bitmask(Y)) == bitmask(X) then we
66
+ * instantly know that Y contains at least all the letters in X.
67
+ */
68
+ uint64_t bitmask;
69
+ /**
70
+ * True if this was a match against lastQuery_.
71
+ * Since the most common use case for this library is for typeaheads,
72
+ * we can often avoid a ton of work by skiping past negatives.
73
+ * We'll use this only if the new query strictly extends lastQuery_.
74
+ */
75
+ bool last_match;
76
+ };
77
+
78
+ std::vector<MatchResult> findMatches(const std::string &query,
79
+ const MatcherOptions &options);
80
+ void addCandidate(uint32_t id, const std::string &candidate);
81
+ void removeCandidate(uint32_t id);
82
+ void clear();
83
+ void reserve(size_t n);
84
+ size_t size() const;
85
+
86
+ private:
87
+ // Storing candidate data in an array makes table scans significantly faster.
88
+ // This makes add/remove slightly more expensive, but in our case queries
89
+ // are significantly more frequent.
90
+ std::vector<CandidateData> candidates_;
91
+ std::unordered_map<uint32_t, size_t> lookup_;
92
+ std::string lastQuery_;
93
+ };