diff --git a/.gitignore b/.gitignore index 617106b0a..be2bb811f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ test/index.html latest.zip .DS_Store .idea +runner.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..008638cf4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Version 2.0.0 + +- Modified search algorithm to search individual words AND the full string, computing the final score as a function of both. This yields better scoring accuracy (#41) +- Changed exact substrings to not have a score of zero. That is searching for "hell" in "hello" will not yield a score of zero, while searching for "hello" will (#63) +- Added `verbose` option, which will print to the console useful information, mostly for debugging +- Improved code structure. +- Added version information within Fuse itself +- Added this Changelog (#64) +- Added fallback when pattern length is greater than machine word length (i.e, > 32 characters) (#38) +- Allowed results with a value of 0 to be returned (#73) diff --git a/README.md b/README.md index f536eb0a5..a9e338f48 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,4 @@ -*Update: There are plenty of enhancements that need to be done to this library. There's more work than available time for me; so if you're interested in being one of the core contributors and helping out, let's talk!* - -Fuse -==== +# Fuse [![NPM](https://nodei.co/npm/fuse.js.png?downloads=true)](https://nodei.co/npm/fuse.js/) @@ -98,7 +95,7 @@ getFn: function (obj, path) { The function that is used for sorting the result list. -### Bitap specific options +--- **location** (*type*: `Integer`, *default*: `0`) @@ -114,7 +111,7 @@ At what point does the match algorithm give up. A threshold of `0.0` requires a **distance** (*type*: `Integer`, *default*: `100`) -Determines how close the match must be to the fuzzy location (specified by `location`). An exact letter match which is `distance` characters away from the fuzzy location would score as a complete mismatch. A `distance` of `0` requires the match be at the exact `location` specified, a `threshold` of `1000` would require a perfect match to be within 800 characters of the `location` to be found using a `threshold` of `0.8`. +Determines how close the match must be to the fuzzy location (specified by `location`). An exact letter match which is `distance` characters away from the fuzzy location would score as a complete mismatch. A `distance` of `0` requires the match be at the exact `location` specified, a `distance` of `1000` would require a perfect match to be within 800 characters of the `location` to be found using a `threshold` of `0.8`. --- @@ -122,22 +119,36 @@ Determines how close the match must be to the fuzzy location (specified by `loca The maximum length of the pattern. The longer the pattern, the more intensive the search operation will be. Whenever the pattern exceeds the `maxPatternLength`, an error will be thrown. Why is this important? Read [this](http://en.wikipedia.org/wiki/Word_(computer_architecture)#Word_size_choice). +--- + +**verbose** (*type*: `Boolean`, *default*: `false`) + +Will print to the console. Useful for debugging. + ## Methods -**search(pattern)** +**`search(/*pattern*/)`** -@param {String} pattern The pattern string to fuzzy search on. -@return {Array} A list of all serch matches. +```javascript +@param {String} pattern The pattern string to fuzzy search on. +@return {Array} A list of all search matches. +``` Searches for all the items whose keys (fuzzy) match the pattern. -**set(list)** +**`set(/*list*/)`** -@param {Array} list -@return {Array} The newly set list +```javascript +@param {Array} list +@return {Array} The newly set list +``` Sets a new list for Fuse to match against. +## Coding conventions + +Code should be run through [Standard Format](https://www.npmjs.com/package/standard-format). + ## Contributing to Fuse Before submitting a pull request, please add relevant tests in `test/fuse-test.js`, and execute them via `npm test`. diff --git a/bower.json b/bower.json index 1c4720b62..3238ea9e4 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "fuse.js", - "version": "1.3.1", + "version": "2.0.0", "main": "./src/fuse.js", "ignore": [ "test" diff --git a/package.json b/package.json index 36bbc820b..74941d3f7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fuse.js", "author": "Kirollos Risk", - "version": "1.3.1", + "version": "2.0.0", "description": "Lightweight fuzzy-search", "license": "Apache", "main": "./src/fuse.js", @@ -15,4 +15,4 @@ "grunt-bump": "0.0.11", "uglify-js": "*" } -} \ No newline at end of file +} diff --git a/runner.js b/runner.js new file mode 100644 index 000000000..69ac5f87b --- /dev/null +++ b/runner.js @@ -0,0 +1,20 @@ +Fuse = require('./src/fuse') + +var items = [ + // 'Borwaila hamlet', + // 'Bobe hamlet', + 'Boma', + 'Bo'] +var fuse = new Fuse(items, { + include: ['score'], + verbose: true +}) +var result = fuse.search('Bosdflkj sdlkfjs dlkfjsdlkfjsldkfj sldkfj slkdjflksdjflksdjf lkdsjf lksjdf lksjdflkjsd lkfj ') + +// var items = ['FH Mannheim', 'University Mannheim'] +// var fuse = new Fuse(items, { +// verbose: true +// }) +// var result = fuse.search('Uni Mannheim') + +return diff --git a/src/fuse.js b/src/fuse.js index da433fce3..40db00363 100644 --- a/src/fuse.js +++ b/src/fuse.js @@ -2,10 +2,10 @@ * @license * Fuse - Lightweight fuzzy-search * - * Copyright (c) 2012 Kirollos Risk . + * Copyright (c) 2012-2016 Kirollos Risk . * All Rights Reserved. Apache Software License 2.0 * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -17,7 +17,370 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -(function(global) { +;(function (global) { + 'use strict' + + function log () { + console.log.apply(null, arguments) + } + + var MULTI_CHAR_REGEX = / +/g + + var defaultOptions = { + id: null, + caseSensitive: false, + // A list of values to be passed from the searcher to the result set. + // If include is set to ['score', 'highlight'], each result + // in the list will be of the form: `{ item: ..., score: ..., highlight: ... }` + + include: [], + + // Whether to sort the result list, by score + shouldSort: true, + + // The search function to use + // Note that the default search function ([[Function]]) must conform to the following API: + // + // @param pattern The pattern string to search + // @param options The search option + // [[Function]].constructor = function(pattern, options) + // + // @param text: the string to search in for the pattern + // @return Object in the form of: + // - isMatch: boolean + // - score: Int + // [[Function]].prototype.search = function(text) + searchFn: BitapSearcher, + + // Default sort function + sortFn: function (a, b) { + return a.score - b.score + }, + + // Default get function + getFn: deepValue, + + keys: [], + + verbose: false + } + + function Fuse (list, options) { + var i + var len + var key + var keys + + this.list = list + this.options = options = options || {} + + // Add boolean type options + for (i = 0, keys = ['sort', 'shouldSort', 'verbose'], len = keys.length; i < len; i++) { + key = keys[i] + this.options[key] = key in options ? options[key] : defaultOptions[key] + } + // Add all other options + for (i = 0, keys = ['searchFn', 'sortFn', 'keys', 'getFn', 'include'], len = keys.length; i < len; i++) { + key = keys[i] + this.options[key] = options[key] || defaultOptions[key] + } + } + + Fuse.VERSION = '2.0.0' + + /** + * Sets a new list for Fuse to match against. + * @param {Array} list + * @return {Array} The newly set list + * @public + */ + Fuse.prototype.set = function (list) { + this.list = list + return list + } + + Fuse.prototype.search = function (pattern) { + if (this.options.verbose) log('=====================\n', 'Search term:', pattern) + + this.pattern = pattern + this.results = [] + this.resultMap = {} + + this._prepareSearchers() + this._startSearch() + this._computeScore() + this._sort() + + var output = this._format() + return output + } + + Fuse.prototype._prepareSearchers = function () { + var options = this.options + var pattern = this.pattern + var searchFn = options.searchFn + var tokens = pattern.split(MULTI_CHAR_REGEX) + var i = 0 + var len = tokens.length + + this.tokenSearchers = [] + + for (; i < len; i++) { + this.tokenSearchers.push(new searchFn(tokens[i], options)) + } + this.fullSeacher = new searchFn(pattern, options) + } + + Fuse.prototype._startSearch = function () { + var options = this.options + var getFn = options.getFn + var list = this.list + var listLen = list.length + var keys = this.options.keys + var keysLen = keys.length + var item = null + var i + var j + + // Check the first item in the list, if it's a string, then we assume + // that every item in the list is also a string, and thus it's a flattened array. + if (typeof list[0] === 'string') { + // Iterate over every item + for (i = 0; i < listLen; i++) { + this._analyze(list[i], i, i) + } + } else { + // Otherwise, the first item is an Object (hopefully), and thus the searching + // is done on the values of the keys of each item. + // Iterate over every item + for (i = 0; i < listLen; i++) { + item = list[i] + // Iterate over every key + for (j = 0; j < keysLen; j++) { + this._analyze(getFn(item, keys[j], []), item, i) + } + } + } + } + + Fuse.prototype._analyze = function (text, entity, index) { + var options = this.options + var words + var scores + var exists = false + var tokenSearchers = this.tokenSearchers + var tokenSearchersLen = tokenSearchers.length + var existingResult + var averageScore + var finalScore + var scoresLen + var mainSearchResult + var tokenSearcher + var termScores + var word + var tokenSearchResult + var i + var j + + // Check if the text can be searched + if (text === undefined || text === null) { + return + } + + scores = [] + + if (typeof text === 'string') { + words = text.split(MULTI_CHAR_REGEX) + + if (options.verbose) log('---------\n', 'Record:', words) + + for (i = 0; i < this.tokenSearchers.length; i++) { + tokenSearcher = this.tokenSearchers[i] + termScores = [] + for (j = 0; j < words.length; j++) { + word = words[j] + tokenSearchResult = tokenSearcher.search(word) + if (tokenSearchResult.isMatch) { + exists = true + termScores.push(tokenSearchResult.score) + scores.push(tokenSearchResult.score) + } else { + termScores.push(1) + scores.push(1) + } + } + if (options.verbose) log('Score for "' + tokenSearcher.pattern + '":', termScores) + } + + averageScore = scores[0] + scoresLen = scores.length + for (i = 1; i < scoresLen; i++) { + averageScore += scores[i] + } + averageScore = averageScore / scoresLen + + if (options.verbose) log('Individual word score average:', averageScore) + + // Get the result + mainSearchResult = this.fullSeacher.search(text) + if (options.verbose) log('Full text score:', mainSearchResult.score) + + finalScore = mainSearchResult.score + if (averageScore !== undefined) { + finalScore = (finalScore + averageScore) / 2 + } + + if (options.verbose) log('Average', finalScore) + + // If a match is found, add the item to , including its score + if (exists || mainSearchResult.isMatch) { + // Check if the item already exists in our results + existingResult = this.resultMap[index] + if (existingResult) { + // Use the lowest score + // existingResult.score, bitapResult.score + existingResult.scores.push(finalScore) + } else { + // Add it to the raw result list + this.resultMap[index] = { + item: entity, + scores: [finalScore] + } + this.results.push(this.resultMap[index]) + } + } + } else if (isArray(text)) { + for (i = 0; i < text.length; i++) { + this._analyze(text[i], entity, index) + } + } + } + + Fuse.prototype._computeScore = function () { + var i + var j + var totalScore + var currScore + var scoreLen + var results = this.results + + for (i = 0; i < results.length; i++) { + totalScore = 0 + currScore = results[i].scores + scoreLen = currScore.length + for (j = 0; j < scoreLen; j++) { + totalScore += currScore[j] + } + results[i].score = totalScore / scoreLen + } + } + + Fuse.prototype._sort = function () { + var options = this.options + if (options.shouldSort) { + if (options.verbose) log('Sorting....') + this.results.sort(options.sortFn) + } + } + + Fuse.prototype._format = function () { + var options = this.options + var getFn = options.getFn + var output = [] + var item + var i + var len + var results = this.results + var replaceValue + var getItemAtIndex + + if (options.verbose) log('------------\n', 'Output:\n', results) + + // Helper function, here for speed-up, which replaces the item with its value, + // if the options specifies it, + replaceValue = options.id ? function (index) { + results[index].item = getFn(results[index].item, options.id, [])[0] + } : function () {} + + getItemAtIndex = function (index) { + var resultItem + var includeVal + var j + + // If `include` has values, put the item under result.item + if (options.include.length > 0) { + resultItem = { + item: results[index].item, + } + // Then include the `includes` + for (j = 0; j < options.include.length; j++) { + includeVal = options.include[j] + resultItem[includeVal] = results[index][includeVal] + } + } else { + resultItem = results[index].item + } + + return resultItem + } + + // From the results, push into a new array only the item identifier (if specified) + // of the entire item. This is because we don't want to return the , + // since it contains other metadata + for (i = 0, len = results.length; i < len; i++) { + replaceValue(i) + item = getItemAtIndex(i) + output.push(item) + } + + return output + } + + // Helpers + + function deepValue (obj, path, list) { + var firstSegment + var remaining + var dotIndex + var value + var i + var len + + if (!path) { + // If there's no path left, we've gotten to the object we care about. + list.push(obj) + } else { + dotIndex = path.indexOf('.') + + if (dotIndex !== -1) { + firstSegment = path.slice(0, dotIndex) + remaining = path.slice(dotIndex + 1) + } else { + firstSegment = path + } + + value = obj[firstSegment] + if (value !== null && value !== undefined) { + if (!remaining && (typeof value === 'string' || typeof value === 'number')) { + list.push(value) + } else if (isArray(value)) { + // Search each item in the array. + for (i = 0, len = value.length; i < len; i++) { + deepValue(value[i], remaining, list) + } + } else if (remaining) { + // An object. Recurse further. + deepValue(value, remaining, list) + } + } + } + + return list + } + + function isArray (obj) { + return Object.prototype.toString.call(obj) === '[object Array]' + } /** * Adapted from "Diff, Match and Patch", by Google @@ -32,27 +395,25 @@ * once per instance and thus it eliminates redundant re-creation when searching * over a list of strings. * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. */ - var BitapSearcher = function(pattern, options) { - options = options || {}; - this.options = options; - this.options.location = options.location || BitapSearcher.defaultOptions.location; - this.options.distance = 'distance' in options ? options.distance : BitapSearcher.defaultOptions.distance; - this.options.threshold = 'threshold' in options ? options.threshold : BitapSearcher.defaultOptions.threshold; - this.options.maxPatternLength = options.maxPatternLength || BitapSearcher.defaultOptions.maxPatternLength; - - this.pattern = options.caseSensitive ? pattern : pattern.toLowerCase(); - this.patternLen = pattern.length; - - if (this.patternLen > this.options.maxPatternLength) { - throw new Error('Pattern length is too long'); + function BitapSearcher (pattern, options) { + options = options || {} + this.options = options + this.options.location = options.location || BitapSearcher.defaultOptions.location + this.options.distance = 'distance' in options ? options.distance : BitapSearcher.defaultOptions.distance + this.options.threshold = 'threshold' in options ? options.threshold : BitapSearcher.defaultOptions.threshold + this.options.maxPatternLength = options.maxPatternLength || BitapSearcher.defaultOptions.maxPatternLength + + this.pattern = options.caseSensitive ? pattern : pattern.toLowerCase() + this.patternLen = pattern.length + + if (this.patternLen <= this.options.maxPatternLength) { + this.matchmask = 1 << (this.patternLen - 1) + this.patternAlphabet = this._calculatePatternAlphabet() } - - this.matchmask = 1 << (this.patternLen - 1); - this.patternAlphabet = this._calculatePatternAlphabet(); - }; + } BitapSearcher.defaultOptions = { // Approximately where in the text is the pattern expected to be found? @@ -71,27 +432,27 @@ // Machine word size maxPatternLength: 32 - }; + } /** * Initialize the alphabet for the Bitap algorithm. * @return {Object} Hash of character locations. * @private */ - BitapSearcher.prototype._calculatePatternAlphabet = function() { + BitapSearcher.prototype._calculatePatternAlphabet = function () { var mask = {}, - i = 0; + i = 0 for (i = 0; i < this.patternLen; i++) { - mask[this.pattern.charAt(i)] = 0; + mask[this.pattern.charAt(i)] = 0 } for (i = 0; i < this.patternLen; i++) { - mask[this.pattern.charAt(i)] |= 1 << (this.pattern.length - i - 1); + mask[this.pattern.charAt(i)] |= 1 << (this.pattern.length - i - 1) } - return mask; - }; + return mask + } /** * Compute and return the score for a match with `e` errors and `x` location. @@ -100,16 +461,16 @@ * @return {number} Overall score for match (0.0 = good, 1.0 = bad). * @private */ - BitapSearcher.prototype._bitapScore = function(errors, location) { + BitapSearcher.prototype._bitapScore = function (errors, location) { var accuracy = errors / this.patternLen, - proximity = Math.abs(this.options.location - location); + proximity = Math.abs(this.options.location - location) if (!this.options.distance) { // Dodge divide by zero error. - return proximity ? 1.0 : accuracy; + return proximity ? 1.0 : accuracy } - return accuracy + (proximity / this.options.distance); - }; + return accuracy + (proximity / this.options.distance) + } /** * Compute and return the result of the search @@ -119,383 +480,158 @@ * {Decimal} score Overall score for the match * @public */ - BitapSearcher.prototype.search = function(text) { - text = this.options.caseSensitive ? text : text.toLowerCase(); + BitapSearcher.prototype.search = function (text) { + var options = this.options + var i + var j + var textLen + var location + var threshold + var bestLoc + var binMin + var binMid + var binMax + var start, finish + var bitArr + var lastBitArr + var charMatch + var score + var locations + var matches + var isMatched + + text = options.caseSensitive ? text : text.toLowerCase() if (this.pattern === text) { + // console.log("EXACT") // Exact match return { isMatch: true, score: 0 - }; + } } - var i, j, - // Set starting location at beginning text and initialize the alphabet. - textLen = text.length, - LOCATION = this.options.location, - // Highest score beyond which we give up. - THRESHOLD = this.options.threshold, - // Is there a nearby exact match? (speedup) - bestLoc = text.indexOf(this.pattern, LOCATION), - binMin, binMid, - binMax = this.patternLen + textLen, - start, finish, - bitArr, lastBitArr, - charMatch, - score = 1, - locations = []; + // When pattern length is greater than the machine word length, just do a + // a reject comparison + if (this.patternLen > options.maxPatternLength) { + matches = text.match(new RegExp(this.pattern.replace(MULTI_CHAR_REGEX, '|'))) + isMatched = !!matches + return { + isMatch: isMatched, + // TODO: revisit this score + score: isMatched ? 0.5 : 1 + } + } + + location = options.location + // Set starting location at beginning text and initialize the alphabet. + textLen = text.length + // Highest score beyond which we give up. + threshold = options.threshold + // Is there a nearby exact match? (speedup) + bestLoc = text.indexOf(this.pattern, location) if (bestLoc != -1) { - THRESHOLD = Math.min(this._bitapScore(0, bestLoc), THRESHOLD); - // What about in the other direction? (speedup) - bestLoc = text.lastIndexOf(this.pattern, LOCATION + this.patternLen); + threshold = Math.min(this._bitapScore(0, bestLoc), threshold) + // What about in the other direction? (speed up) + bestLoc = text.lastIndexOf(this.pattern, location + this.patternLen) if (bestLoc != -1) { - THRESHOLD = Math.min(this._bitapScore(0, bestLoc), THRESHOLD); + threshold = Math.min(this._bitapScore(0, bestLoc), threshold) } } - bestLoc = -1; + bestLoc = -1 + score = 1 + locations = [] + binMax = this.patternLen + textLen for (i = 0; i < this.patternLen; i++) { // Scan for the best match; each iteration allows for one more error. - // Run a binary search to determine how far from 'MATCH_LOCATION' we can stray at this - // error level. - binMin = 0; - binMid = binMax; + // Run a binary search to determine how far from the match location we can stray + // at this error level. + binMin = 0 + binMid = binMax while (binMin < binMid) { - if (this._bitapScore(i, LOCATION + binMid) <= THRESHOLD) { - binMin = binMid; + if (this._bitapScore(i, location + binMid) <= threshold) { + binMin = binMid } else { - binMax = binMid; + binMax = binMid } - binMid = Math.floor((binMax - binMin) / 2 + binMin); + binMid = Math.floor((binMax - binMin) / 2 + binMin) } // Use the result from this iteration as the maximum for the next. - binMax = binMid; - start = Math.max(1, LOCATION - binMid + 1); - finish = Math.min(LOCATION + binMid, textLen) + this.patternLen; + binMax = binMid + start = Math.max(1, location - binMid + 1) + finish = Math.min(location + binMid, textLen) + this.patternLen // Initialize the bit array - bitArr = Array(finish + 2); + bitArr = Array(finish + 2) - bitArr[finish + 1] = (1 << i) - 1; + bitArr[finish + 1] = (1 << i) - 1 for (j = finish; j >= start; j--) { - // The alphabet is a sparse hash, so the following line generates warnings. - charMatch = this.patternAlphabet[text.charAt(j - 1)]; + charMatch = this.patternAlphabet[text.charAt(j - 1)] + // console.log('charMatch', charMatch, text.charAt(j - 1)) if (i === 0) { // First pass: exact match. - bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch; + bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch } else { // Subsequent passes: fuzzy match. - bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch | (((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1) | lastBitArr[j + 1]; + bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch | (((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1) | lastBitArr[j + 1] } if (bitArr[j] & this.matchmask) { - score = this._bitapScore(i, j - 1); + // Count exact matches (those with a score of 0) to be "almost" exact + score = this._bitapScore(i, j - 1) || 0.001 + // This match will almost certainly be better than any existing match. // But check anyway. - if (score <= THRESHOLD) { - // Told you so. - THRESHOLD = score; - bestLoc = j - 1; - locations.push(bestLoc); + if (score <= threshold) { + // Indeed it is + threshold = score + bestLoc = j - 1 + locations.push(bestLoc) - if (bestLoc > LOCATION) { + if (bestLoc > location) { // When passing loc, don't exceed our current distance from loc. - start = Math.max(1, 2 * LOCATION - bestLoc); + start = Math.max(1, 2 * location - bestLoc) } else { // Already passed loc, downhill from here on in. - break; + break } } } } // No hope for a (better) match at greater error levels. - if (this._bitapScore(i + 1, LOCATION) > THRESHOLD) { - break; + if (this._bitapScore(i + 1, location) > threshold) { + break } - lastBitArr = bitArr; + lastBitArr = bitArr } return { isMatch: bestLoc >= 0, score: score - }; - }; - - var deepValueHelper = function(obj, path, list) { - var firstSegment, remaining, dotIndex; - - if (!path) { - // If there's no path left, we've gotten to the object we care about. - list.push(obj); - } else { - dotIndex = path.indexOf('.'); - if (dotIndex !== -1) { - firstSegment = path.slice(0, dotIndex); - remaining = path.slice(dotIndex + 1); - } else { - firstSegment = path; - } - - var value = obj[firstSegment]; - if (value) { - if (!remaining && (typeof value === 'string' || typeof value === 'number')) { - list.push(value); - } else if (Utils.isArray(value)) { - // Search each item in the array. - for (var i = 0, len = value.length; i < len; i++) { - deepValueHelper(value[i], remaining, list); - } - } else if (remaining) { - // An object. Recurse further. - deepValueHelper(value, remaining, list); - } - } - } - - return list; - }; - - var Utils = { - /** - * Traverse an object - * @param {Object} obj The object to traverse - * @param {String} path A . separated path to a key in the object. Example 'Data.Object.Somevalue' - * @return {Object} - */ - deepValue: function(obj, path) { - return deepValueHelper(obj, path, []); - }, - isArray: function(obj) { - return Object.prototype.toString.call(obj) === '[object Array]'; - } - }; - - /** - * @param {Array} list - * @param {Object} options - * @public - */ - function Fuse(list, options) { - this.list = list; - this.options = options = options || {}; - - var i, len, key, keys; - // Add boolean type options - for (i = 0, keys = ['sort', 'shouldSort'], len = keys.length; i < len; i++) { - key = keys[i]; - this.options[key] = key in options ? options[key] : Fuse.defaultOptions[key]; - } - // Add all other options - for (i = 0, keys = ['searchFn', 'sortFn', 'keys', 'getFn', 'include'], len = keys.length; i < len; i++) { - key = keys[i]; - this.options[key] = options[key] || Fuse.defaultOptions[key]; - } - }; - - Fuse.defaultOptions = { - id: null, - - caseSensitive: false, - - // A list of values to be passed from the searcher to the result set. - // If include is set to ['score', 'highlight'], each result - // in the list will be of the form: `{ item: ..., score: ..., highlight: ... }` - include: [], - - // Whether to sort the result list, by score - shouldSort: true, - - // The search function to use - // Note that the default search function ([[Function]]) must conform to the following API: - // - // @param pattern The pattern string to search - // @param options The search option - // [[Function]].constructor = function(pattern, options) - // - // @param text: the string to search in for the pattern - // @return Object in the form of: - // - isMatch: boolean - // - score: Int - // [[Function]].prototype.search = function(text) - searchFn: BitapSearcher, - - // Default sort function - sortFn: function(a, b) { - return a.score - b.score; - }, - - // Default get function - getFn: Utils.deepValue, - - keys: [] - }; - - /** - * Sets a new list for Fuse to match against. - * @param {Array} list - * @return {Array} The newly set list - * @public - */ - Fuse.prototype.set = function(list) { - this.list = list; - - return list; - }; - - /** - * Searches for all the items whose keys (fuzzy) match the pattern. - * @param {String} pattern The pattern string to fuzzy search on. - * @return {Array} A list of all serch matches. - * @public - */ - Fuse.prototype.search = function(pattern) { - var searcher = new(this.options.searchFn)(pattern, this.options), - j, item, - list = this.list, - dataLen = list.length, - options = this.options, - searchKeys = this.options.keys, - searchKeysLen = searchKeys.length, - bitapResult, - rawResults = [], - resultMap = {}, - existingResult, - results = []; - - /** - * Calls for bitap analysis. Builds the raw result list. - * @param {String} text The pattern string to fuzzy search on. - * @param {String|Number} entity If the is an Array, then entity will be an index, - * otherwise it's the item object. - * @param {Number} index - * @private - */ - var analyzeText = function(text, entity, index) { - // Check if the text can be searched - if (text === undefined || text === null) { - return; - } - - if (typeof text === 'string') { - - // Get the result - bitapResult = searcher.search(text); - - // If a match is found, add the item to , including its score - if (bitapResult.isMatch) { - - // Check if the item already exists in our results - existingResult = resultMap[index]; - if (existingResult) { - // Use the lowest score - existingResult.score = Math.min(existingResult.score, bitapResult.score); - } else { - // Add it to the raw result list - resultMap[index] = { - item: entity, - score: bitapResult.score - }; - rawResults.push(resultMap[index]); - } - } - } else if (Utils.isArray(text)) { - for (var i = 0; i < text.length; i++) { - analyzeText(text[i], entity, index); - } - } - }; - - // Check the first item in the list, if it's a string, then we assume - // that every item in the list is also a string, and thus it's a flattened array. - if (typeof list[0] === 'string') { - // Iterate over every item - for (var i = 0; i < dataLen; i++) { - analyzeText(list[i], i, i); - } - } else { - // Otherwise, the first item is an Object (hopefully), and thus the searching - // is done on the values of the keys of each item. - - // Iterate over every item - for (var i = 0; i < dataLen; i++) { - item = list[i]; - // Iterate over every key - for (j = 0; j < searchKeysLen; j++) { - analyzeText(options.getFn(item, searchKeys[j]), item, i); - } - } } - - if (options.shouldSort) { - rawResults.sort(options.sortFn); - } - - // Helper function, here for speed-up, which replaces the item with its value, - // if the options specifies it, - var replaceValue = options.id ? function(i) { - rawResults[i].item = options.getFn(rawResults[i].item, options.id)[0]; - } : function() { - return; // no-op - }; - - // Helper function, here for speed-up, which returns the - // item formatted based on the options. - var getItem = function(i) { - var resultItem; - - if(options.include.length > 0) // If `include` has values, put the item under result.item - { - resultItem = { - item: rawResults[i].item, - }; - - // Then include the includes - for(var j = 0; j < options.include.length; j++) - { - var includeVal = options.include[j]; - resultItem[includeVal] = rawResults[i][includeVal]; - } - } - else - { - resultItem = rawResults[i].item; - } - - return resultItem; - }; - - // From the results, push into a new array only the item identifier (if specified) - // of the entire item. This is because we don't want to return the , - // since it contains other metadata; - for (var i = 0, len = rawResults.length; i < len; i++) { - replaceValue(i); - results.push(getItem(i)); - } - - return results; - }; + } // Export to Common JS Loader if (typeof exports === 'object') { // Node. Does not work with strict CommonJS, but // only CommonJS-like environments that support module.exports, // like Node. - module.exports = Fuse; + module.exports = Fuse } else if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. - define(function() { - return Fuse; - }); + define(function () { + return Fuse + }) } else { // Browser globals (root is window) - global.Fuse = Fuse; + global.Fuse = Fuse } -})(this); +})(this) diff --git a/src/fuse.min.js b/src/fuse.min.js index 276681342..080225cf8 100644 --- a/src/fuse.min.js +++ b/src/fuse.min.js @@ -2,10 +2,10 @@ * @license * Fuse - Lightweight fuzzy-search * - * Copyright (c) 2012 Kirollos Risk . + * Copyright (c) 2012-2016 Kirollos Risk . * All Rights Reserved. Apache Software License 2.0 * - * Licensed under the Apache License, Version 2.0 (the "License"); + * Licensed under the Apache License, Version 2.0 (the "License") * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * @@ -17,4 +17,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -!function(t){function e(t,n){this.list=t,this.options=n=n||{};var i,o,s,r;for(i=0,r=["sort","shouldSort"],o=r.length;o>i;i++)s=r[i],this.options[s]=s in n?n[s]:e.defaultOptions[s];for(i=0,r=["searchFn","sortFn","keys","getFn","include"],o=r.length;o>i;i++)s=r[i],this.options[s]=n[s]||e.defaultOptions[s]}var n=function(t,e){if(e=e||{},this.options=e,this.options.location=e.location||n.defaultOptions.location,this.options.distance="distance"in e?e.distance:n.defaultOptions.distance,this.options.threshold="threshold"in e?e.threshold:n.defaultOptions.threshold,this.options.maxPatternLength=e.maxPatternLength||n.defaultOptions.maxPatternLength,this.pattern=e.caseSensitive?t:t.toLowerCase(),this.patternLen=t.length,this.patternLen>this.options.maxPatternLength)throw new Error("Pattern length is too long");this.matchmask=1<i;)this._bitapScore(e,l+o)<=f?i=o:d=o,o=Math.floor((d-i)/2+i);for(d=o,s=Math.max(1,l-o+1),r=Math.min(l+o,c)+this.patternLen,a=Array(r+2),a[r+1]=(1<=s;n--)if(p=this.patternAlphabet[t.charAt(n-1)],a[n]=0===e?(a[n+1]<<1|1)&p:(a[n+1]<<1|1)&p|((h[n+1]|h[n])<<1|1)|h[n+1],a[n]&this.matchmask&&(g=this._bitapScore(e,n-1),f>=g)){if(f=g,u=n-1,m.push(u),!(u>l))break;s=Math.max(1,2*l-u)}if(this._bitapScore(e+1,l)>f)break;h=a}return{isMatch:u>=0,score:g}};var i=function(t,e,n){var s,r,a;if(e){a=e.indexOf("."),-1!==a?(s=e.slice(0,a),r=e.slice(a+1)):s=e;var h=t[s];if(h)if(r||"string"!=typeof h&&"number"!=typeof h)if(o.isArray(h))for(var p=0,c=h.length;c>p;p++)i(h[p],r,n);else r&&i(h,r,n);else n.push(h)}else n.push(t);return n},o={deepValue:function(t,e){return i(t,e,[])},isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)}};e.defaultOptions={id:null,caseSensitive:!1,include:[],shouldSort:!0,searchFn:n,sortFn:function(t,e){return t.score-e.score},getFn:o.deepValue,keys:[]},e.prototype.set=function(t){return this.list=t,t},e.prototype.search=function(t){var e,n,i,s,r=new this.options.searchFn(t,this.options),a=this.list,h=a.length,p=this.options,c=this.options.keys,l=c.length,f=[],u={},d=[],g=function(t,e,n){if(void 0!==t&&null!==t)if("string"==typeof t)i=r.search(t),i.isMatch&&(s=u[n],s?s.score=Math.min(s.score,i.score):(u[n]={item:e,score:i.score},f.push(u[n])));else if(o.isArray(t))for(var a=0;am;m++)g(a[m],m,m);else for(var m=0;h>m;m++)for(n=a[m],e=0;l>e;e++)g(p.getFn(n,c[e]),n,m);p.shouldSort&&f.sort(p.sortFn);for(var v=p.id?function(t){f[t].item=p.getFn(f[t].item,p.id)[0]}:function(){},y=function(t){var e;if(p.include.length>0){e={item:f[t].item};for(var t=0;tm;m++)v(m),d.push(y(m));return d},"object"==typeof exports?module.exports=e:"function"==typeof define&&define.amd?define(function(){return e}):t.Fuse=e}(this); \ No newline at end of file +!function(t){"use strict";function e(){console.log.apply(null,arguments)}function s(t,e){var s,r,n,i;for(this.list=t,this.options=e=e||{},s=0,i=["sort","shouldSort","verbose"],r=i.length;r>s;s++)n=i[s],this.options[n]=n in e?e[n]:h[n];for(s=0,i=["searchFn","sortFn","keys","getFn","include"],r=i.length;r>s;s++)n=i[s],this.options[n]=e[n]||h[n]}function r(t,e,s){var i,o,h,a,p,c;if(e){if(h=e.indexOf("."),-1!==h?(i=e.slice(0,h),o=e.slice(h+1)):i=e,a=t[i],null!==a&&void 0!==a)if(o||"string"!=typeof a&&"number"!=typeof a)if(n(a))for(p=0,c=a.length;c>p;p++)r(a[p],o,s);else o&&r(a,o,s);else s.push(a)}else s.push(t);return s}function n(t){return"[object Array]"===Object.prototype.toString.call(t)}function i(t,e){e=e||{},this.options=e,this.options.location=e.location||i.defaultOptions.location,this.options.distance="distance"in e?e.distance:i.defaultOptions.distance,this.options.threshold="threshold"in e?e.threshold:i.defaultOptions.threshold,this.options.maxPatternLength=e.maxPatternLength||i.defaultOptions.maxPatternLength,this.pattern=e.caseSensitive?t:t.toLowerCase(),this.patternLen=t.length,this.patternLen<=this.options.maxPatternLength&&(this.matchmask=1<n;n++)this.tokenSearchers.push(new s(r[n],t));this.fullSeacher=new s(e,t)},s.prototype._startSearch=function(){var t,e,s=this.options,r=s.getFn,n=this.list,i=n.length,o=this.options.keys,h=o.length,a=null;if("string"==typeof n[0])for(t=0;i>t;t++)this._analyze(n[t],t,t);else for(t=0;i>t;t++)for(a=n[t],e=0;h>e;e++)this._analyze(r(a,o[e],[]),a,t)},s.prototype._analyze=function(t,s,r){{var i,h,a,p,c,l,u,f,d,g,v,m,S,y=this.options,b=!1,_=this.tokenSearchers;_.length}if(void 0!==t&&null!==t)if(h=[],"string"==typeof t){for(i=t.split(o),y.verbose&&e("---------\n","Record:",i),m=0;mm;m++)p+=h[m];p/=l,y.verbose&&e("Individual word score average:",p),u=this.fullSeacher.search(t),y.verbose&&e("Full text score:",u.score),c=u.score,void 0!==p&&(c=(c+p)/2),y.verbose&&e("Average",c),(b||u.isMatch)&&(a=this.resultMap[r],a?a.scores.push(c):(this.resultMap[r]={item:s,scores:[c]},this.results.push(this.resultMap[r])))}else if(n(t))for(m=0;me;e++)s+=r[e];i[t].score=s/n}},s.prototype._sort=function(){var t=this.options;t.shouldSort&&(t.verbose&&e("Sorting...."),this.results.sort(t.sortFn))},s.prototype._format=function(){var t,s,r,n,i,o=this.options,h=o.getFn,a=[],p=this.results;for(o.verbose&&e("------------\n","Output:\n",p),n=o.id?function(t){p[t].item=h(p[t].item,o.id,[])[0]}:function(){},i=function(t){var e,s,r;if(o.include.length>0)for(e={item:p[t].item},r=0;rs;s++)n(s),t=i(s),a.push(t);return a},i.defaultOptions={location:0,distance:100,threshold:.6,maxPatternLength:32},i.prototype._calculatePatternAlphabet=function(){var t={},e=0;for(e=0;eb.maxPatternLength)return S=t.match(new RegExp(this.pattern.replace(o,"|"))),y=!!S,{isMatch:y,score:y?.5:1};for(n=b.location,r=t.length,i=b.threshold,h=t.indexOf(this.pattern,n),-1!=h&&(i=Math.min(this._bitapScore(0,h),i),h=t.lastIndexOf(this.pattern,n+this.patternLen),-1!=h&&(i=Math.min(this._bitapScore(0,h),i))),h=-1,v=1,m=[],c=this.patternLen+r,e=0;ea;)this._bitapScore(e,n+p)<=i?a=p:c=p,p=Math.floor((c-a)/2+a);for(c=p,l=Math.max(1,n-p+1),u=Math.min(n+p,r)+this.patternLen,f=Array(u+2),f[u+1]=(1<=l;s--)if(g=this.patternAlphabet[t.charAt(s-1)],f[s]=0===e?(f[s+1]<<1|1)&g:(f[s+1]<<1|1)&g|((d[s+1]|d[s])<<1|1)|d[s+1],f[s]&this.matchmask&&(v=this._bitapScore(e,s-1)||.001,i>=v)){if(i=v,h=s-1,m.push(h),!(h>n))break;l=Math.max(1,2*n-h)}if(this._bitapScore(e+1,n)>i)break;d=f}return{isMatch:h>=0,score:v}},"object"==typeof exports?module.exports=s:"function"==typeof define&&define.amd?define(function(){return s}):t.Fuse=s}(this); \ No newline at end of file diff --git a/test/books.json b/test/books.json index 8b78fd40f..e2115e60f 100644 --- a/test/books.json +++ b/test/books.json @@ -1,4 +1,16 @@ [ +{ + "title": "The code of the wooster", + "author": "aa" +}, +{ + "title": "the wooster code", + "author": "aa" +}, +{ + "title": "The code", + "author": "aa" +}, { "title": "Old Man's War", "author": "John Scalzi" diff --git a/test/fuse-test.js b/test/fuse-test.js index 561e4d916..e41bd59bf 100644 --- a/test/fuse-test.js +++ b/test/fuse-test.js @@ -1,577 +1,665 @@ var assert = require('assert'), vows = require('vows'), - Fuse = require('../src/fuse'); + Fuse = require('../src/fuse') + +var verbose = false vows.describe('Flat list of strings: ["Apple", "Orange", "Banana"]').addBatch({ 'Flat:': { - topic: function() { - var fruits = ["Apple", "Orange", "Banana"]; - var fuse = new Fuse(fruits) - return fuse; + topic: function () { + var fruits = ['Apple', 'Orange', 'Banana'] + var fuse = new Fuse(fruits, { + verbose: verbose + }) + return fuse }, 'When searching for the term "Apple"': { - topic: function(fuse) { - var result = fuse.search("Apple"); - return result; + topic: function (fuse) { + var result = fuse.search('Apple') + return result }, - 'we get a list of containing 1 item, which is an exact match': function(result) { - assert.equal(result.length, 1); + 'we get a list of containing 1 item, which is an exact match': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the index 0, representing ["Apple"]': function(result) { - assert.equal(result[0], 0); + 'whose value is the index 0, representing ["Apple"]': function (result) { + assert.equal(result[0], 0) }, }, 'When performing a fuzzy search for the term "ran"': { - topic: function(fuse) { - var result = fuse.search("ran"); - return result; + topic: function (fuse) { + var result = fuse.search('ran') + return result }, - 'we get a list of containing 2 items: [1, 2]': function(result) { - assert.equal(result.length, 2); + 'we get a list of containing 2 items: [1, 2]': function (result) { + assert.equal(result.length, 2) }, - 'whose values represent the indices of ["Orange", "Banana"]': function(result) { - assert.equal(result[0], 1); - assert.equal(result[1], 2); + 'whose values represent the indices of ["Orange", "Banana"]': function (result) { + assert.equal(result[0], 1) + assert.equal(result[1], 2) }, }, 'When performing a fuzzy search for the term "nan"': { - topic: function(fuse) { - var result = fuse.search("nan"); - return result; + topic: function (fuse) { + var result = fuse.search('nan') + return result }, - 'we get a list of containing 2 items: [2, 1]': function(result) { - assert.equal(result.length, 2); + 'we get a list of containing 2 items: [2, 1]': function (result) { + assert.equal(result.length, 2) }, - 'whose values represent the indices of ["Banana", "Orange"]': function(result) { - assert.equal(result[0], 2); - assert.equal(result[1], 1); + 'whose values represent the indices of ["Banana", "Orange"]': function (result) { + assert.equal(result[0], 2) + assert.equal(result[1], 1) }, } } -}).export(module); +}).export(module) vows.describe('List of books - searching "title" and "author"').addBatch({ 'Books:': { - topic: function() { - var books = require('./books.json'); + topic: function () { + var books = require('./books.json') var options = { - keys: ["title", "author"] + keys: ['title', 'author'], + verbose: verbose } - var fuse = new Fuse(books, options); - return fuse; + var fuse = new Fuse(books, options) + return fuse }, 'When searching for the term "HTML5"': { - topic: function(fuse) { - var result = fuse.search("HTML5"); - return result; + topic: function (fuse) { + var result = fuse.search('HTML5') + return result }, - 'we get a list of containing 1 item, which is an exact match': function(result) { - assert.equal(result.length, 1); + 'we get a list of containing 3 items': function (result) { + assert.equal(result.length, 3) }, - 'whose value is { title: "HTML5", author: "Remy Sharp" }': function(result) { + 'whose value is { title: "HTML5", author: "Remy Sharp" }': function (result) { assert.deepEqual(result[0], { title: 'HTML5', author: 'Remy Sharp' - }); + }) }, }, 'When searching for the term "Woodhouse"': { - topic: function(fuse) { - var result = fuse.search("Woodhouse"); - return result; - }, - 'we get a list of containing 3 items': function(result) { - assert.equal(result.length, 3); - }, - 'which are all the books written by "P.D. Woodhouse"': function(result) { - assert.deepEqual(result[0], { - title: 'Right Ho Jeeves', - author: 'P.D. Woodhouse' - }); - assert.deepEqual(result[1], { - title: 'The Code of the Wooster', - author: 'P.D. Woodhouse' - }); - assert.deepEqual(result[2], { - title: 'Thank You Jeeves', - author: 'P.D. Woodhouse' - }); - }, + topic: function (fuse) { + var result = fuse.search('Jeeves Woodhouse') + return result + }, + 'we get a list of containing 5 items': function (result) { + assert.equal(result.length, 6) + }, + 'which are all the books written by "P.D. Woodhouse"': function (result) { + var output = [ + { title: 'Right Ho Jeeves', author: 'P.D. Woodhouse' }, + { title: 'Thank You Jeeves', author: 'P.D. Woodhouse' }, + { title: 'The Code of the Wooster', author: 'P.D. Woodhouse' }, + { title: 'The Lock Artist', author: 'Steve Hamilton' }, + { title: 'the wooster code', author: 'aa' }, + { title: 'The code of the wooster', author: 'aa' } + ] + assert.deepEqual(result, output) + } }, 'When searching for the term "brwn"': { - topic: function(fuse) { - var result = fuse.search("brwn"); - return result; + topic: function (fuse) { + var result = fuse.search('brwn') + return result }, - 'we get a list of containing at least 3 items': function(result) { - assert.isTrue(result.length > 3); + 'we get a list of containing at least 3 items': function (result) { + assert.isTrue(result.length > 3) }, - 'and the first 3 items should be all the books written by Dan Brown': function(result) { + 'and the first 3 items should be all the books written by Dan Brown': function (result) { assert.deepEqual(result[0], { - "title": "The DaVinci Code", - "author": "Dan Brown" - }); + 'title': 'The DaVinci Code', + 'author': 'Dan Brown' + }) assert.deepEqual(result[1], { - "title": "Angels & Demons", - "author": "Dan Brown" - }); + 'title': 'Angels & Demons', + 'author': 'Dan Brown' + }) assert.deepEqual(result[2], { - "title": "The Lost Symbol", - "author": "Dan Brown" - }); + 'title': 'The Lost Symbol', + 'author': 'Dan Brown' + }) }, } } -}).export(module); +}).export(module) vows.describe('Deep key search, with ["title", "author.firstName"]').addBatch({ 'Deep:': { - topic: function() { + topic: function () { var books = [{ - "title": "Old Man's War", - "author": { - "firstName": "John", - "lastName": "Scalzi" + 'title': "Old Man's War", + 'author': { + 'firstName': 'John', + 'lastName': 'Scalzi' } }, { - "title": "The Lock Artist", - "author": { - "firstName": "Steve", - "lastName": "Hamilton" + 'title': 'The Lock Artist', + 'author': { + 'firstName': 'Steve', + 'lastName': 'Hamilton' } }, { - "title": "HTML5", - }]; + 'title': 'HTML5', + }] var options = { - keys: ["title", "author.firstName"] + keys: ['title', 'author.firstName'], + verbose: verbose } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list of containing at least 1 item': function(result) { - assert.isTrue(result.length > 0); + 'we get a list of containing at least 1 item': function (result) { + assert.isTrue(result.length > 0) }, - 'whose first value is found': function(result) { + 'whose first value is found': function (result) { assert.deepEqual(result[0], { - "title": "The Lock Artist", - "author": { - "firstName": "Steve", - "lastName": "Hamilton" + 'title': 'The Lock Artist', + 'author': { + 'firstName': 'Steve', + 'lastName': 'Hamilton' } - }); + }) }, } } -}).export(module); +}).export(module) vows.describe('Custom search function, with ["title", "author.firstName"]').addBatch({ 'Deep:': { - topic: function() { + topic: function () { var books = [{ - "title": "Old Man's War", - "author": { - "firstName": "John", - "lastName": "Scalzi" + 'title': "Old Man's War", + 'author': { + 'firstName': 'John', + 'lastName': 'Scalzi' } }, { - "title": "The Lock Artist", - "author": { - "firstName": "Steve", - "lastName": "Hamilton" + 'title': 'The Lock Artist', + 'author': { + 'firstName': 'Steve', + 'lastName': 'Hamilton' } - }]; + }] var options = { - keys: ["title", "author.firstName"], - getFn: function(obj, path) { + keys: ['title', 'author.firstName'], + getFn: function (obj, path) { if (!obj) { - return null; + return null } - obj = obj.author.lastName; - return obj; + obj = obj.author.lastName + return obj } - }; + } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Hmlt"': { - topic: function(fuse) { - var result = fuse.search("Hmlt"); - return result; + topic: function (fuse) { + var result = fuse.search('Hmlt') + return result }, - 'we get a list of containing at least 1 item': function(result) { - assert.isTrue(result.length > 0); + 'we get a list of containing at least 1 item': function (result) { + assert.isTrue(result.length > 0) }, - 'whose first value is found': function(result) { + 'whose first value is found': function (result) { assert.deepEqual(result[0], { - "title": "The Lock Artist", - "author": { - "firstName": "Steve", - "lastName": "Hamilton" + 'title': 'The Lock Artist', + 'author': { + 'firstName': 'Steve', + 'lastName': 'Hamilton' } - }); + }) }, }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list of containing at least no items': function(result) { - // assert.isTrue(result.length > 0); - assert.equal(result.length, 0); + 'we get a list of containing at least no items': function (result) { + // assert.isTrue(result.length > 0) + assert.equal(result.length, 0) }, } } -}).export(module); +}).export(module) vows.describe('Include score in result list: ["Apple", "Orange", "Banana"]').addBatch({ 'Options:': { - topic: function() { - var fruits = ["Apple", "Orange", "Banana"]; + topic: function () { + var fruits = ['Apple', 'Orange', 'Banana'] var fuse = new Fuse(fruits, { - include: ['score'] - }); - return fuse; + include: ['score'], + verbose: verbose + }) + return fuse }, 'When searching for the term "Apple"': { - topic: function(fuse) { - var result = fuse.search("Apple"); - return result; + topic: function (fuse) { + var result = fuse.search('Apple') + return result }, - 'we get a list of containing 1 item, which is an exact match': function(result) { - assert.equal(result.length, 1); + 'we get a list of containing 1 item, which is an exact match': function (result) { + assert.equal(result.length, 1) }, - 'whose value and score exist': function(result) { - assert.equal(result[0].item, 0); - assert.equal(result[0].score, 0); + 'whose value and score exist': function (result) { + assert.equal(result[0].item, 0) + assert.equal(result[0].score, 0) }, }, 'When performing a fuzzy search for the term "ran"': { - topic: function(fuse) { - var result = fuse.search("ran"); - return result; + topic: function (fuse) { + var result = fuse.search('ran') + return result }, - 'we get a list of containing 2 items': function(result) { - assert.equal(result.length, 2); + 'we get a list of containing 2 items': function (result) { + assert.equal(result.length, 2) }, - 'whose items represent the indices, and have non-zero scores': function(result) { - assert.equal(result[0].item, 1); - assert.equal(result[1].item, 2); - assert.isNotZero(result[0].score); - assert.isNotZero(result[1].score); + 'whose items represent the indices, and have non-zero scores': function (result) { + assert.equal(result[0].item, 1) + assert.equal(result[1].item, 2) + assert.isNotZero(result[0].score) + assert.isNotZero(result[1].score) }, } } -}).export(module); +}).export(module) vows.describe('Only include ID in results list, with "ISBN"').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": "0765348276", - "title": "Old Man's War", - "author": "John Scalzi" + 'ISBN': '0765348276', + 'title': "Old Man's War", + 'author': 'John Scalzi' }, { - "ISBN": "0312696957", - "title": "The Lock Artist", - "author": "Steve Hamilton" - }]; + 'ISBN': '0312696957', + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton' + }] var options = { - keys: ["title", "author"], - id: "ISBN" + keys: ['title', 'author'], + id: 'ISBN' } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.equal(result, '0312696957'); + 'whose value is the ISBN of the book': function (result) { + assert.equal(result, '0312696957') }, } } -}).export(module); +}).export(module) vows.describe('Include both ID and score in results list').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": "0765348276", - "title": "Old Man's War", - "author": "John Scalzi" + 'ISBN': '0765348276', + 'title': "Old Man's War", + 'author': 'John Scalzi' }, { - "ISBN": "0312696957", - "title": "The Lock Artist", - "author": "Steve Hamilton" - }]; + 'ISBN': '0312696957', + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton' + }] var options = { - keys: ["title", "author"], - id: "ISBN", - include: ['score'] + keys: ['title', 'author'], + id: 'ISBN', + include: ['score'], + verbose: verbose } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.equal(result[0].item, '0312696957'); + 'whose value is the ISBN of the book': function (result) { + assert.equal(result[0].item, '0312696957') }, - 'and has a score different than zero': function(result) { - assert.isNotZero(result[0].score); + 'and has a score different than zero': function (result) { + assert.isNotZero(result[0].score) } } } -}).export(module); +}).export(module) vows.describe('Search when IDs are numbers').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": 1111, - "title": "Old Man's War", - "author": "John Scalzi" + 'ISBN': 1111, + 'title': "Old Man's War", + 'author': 'John Scalzi' }, { - "ISBN": 2222, - "title": "The Lock Artist", - "author": "Steve Hamilton" - }]; + 'ISBN': 2222, + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton' + }] var options = { - keys: ["title", "author"], - id: "ISBN", - include: ['score'] + keys: ['title', 'author'], + id: 'ISBN', + include: ['score'], + verbose: verbose } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.equal(result[0].item, 2222); + 'whose value is the ISBN of the book': function (result) { + assert.equal(result[0].item, 2222) }, - 'and has a score different than zero': function(result) { - assert.isNotZero(result[0].score); + 'and has a score different than zero': function (result) { + assert.isNotZero(result[0].score) } } } -}).export(module); +}).export(module) vows.describe('Recurse into arrays').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": "0765348276", - "title": "Old Man's War", - "author": "John Scalzi", - "tags": ["fiction"] + 'ISBN': '0765348276', + 'title': "Old Man's War", + 'author': 'John Scalzi', + 'tags': ['fiction'] }, { - "ISBN": "0312696957", - "title": "The Lock Artist", - "author": "Steve Hamilton", - "tags": ["fiction"] + 'ISBN': '0312696957', + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton', + 'tags': ['fiction'] }, { - "ISBN": "0321784421", - "title": "HTML5", - "author": "Remy Sharp", - "tags": ["nonfiction"] - }]; + 'ISBN': '0321784421', + 'title': 'HTML5', + 'author': 'Remy Sharp', + 'tags': ['nonfiction'] + }] var options = { - keys: ["tags"], - id: "ISBN", - threshold: 0 + keys: ['tags'], + id: 'ISBN', + threshold: 0, + verbose: verbose } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the tag "nonfiction"': { - topic: function(fuse) { - var result = fuse.search("nonfiction"); - return result; + topic: function (fuse) { + var result = fuse.search('nonfiction') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.equal(result[0], '0321784421'); + 'whose value is the ISBN of the book': function (result) { + assert.equal(result[0], '0321784421') } } } -}).export(module); +}).export(module) vows.describe('Recurse into objects in arrays').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": "0765348276", - "title": "Old Man's War", - "author": { - "name": "John Scalzi", - "tags": [{ - value: "American" + 'ISBN': '0765348276', + 'title': "Old Man's War", + 'author': { + 'name': 'John Scalzi', + 'tags': [{ + value: 'American' }] } }, { - "ISBN": "0312696957", - "title": "The Lock Artist", - "author": { - "name": "Steve Hamilton", - "tags": [{ - value: "American" + 'ISBN': '0312696957', + 'title': 'The Lock Artist', + 'author': { + 'name': 'Steve Hamilton', + 'tags': [{ + value: 'American' }] } }, { - "ISBN": "0321784421", - "title": "HTML5", - "author": { - "name": "Remy Sharp", - "tags": [{ - value: "British" + 'ISBN': '0321784421', + 'title': 'HTML5', + 'author': { + 'name': 'Remy Sharp', + 'tags': [{ + value: 'British' }] } - }]; + }] var options = { - keys: ["author.tags.value"], - id: "ISBN", - threshold: 0 + keys: ['author.tags.value'], + id: 'ISBN', + threshold: 0, + verbose: verbose } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the author tag "British"': { - topic: function(fuse) { - var result = fuse.search("British"); - return result; + topic: function (fuse) { + var result = fuse.search('British') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.equal(result[0], '0321784421'); + 'whose value is the ISBN of the book': function (result) { + assert.equal(result[0], '0321784421') } } } -}).export(module); - - +}).export(module) vows.describe('Searching by ID').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": "A", - "title": "Old Man's War", - "author": "John Scalzi" + 'ISBN': 'A', + 'title': "Old Man's War", + 'author': 'John Scalzi' }, { - "ISBN": "B", - "title": "The Lock Artist", - "author": "Steve Hamilton" - }]; + 'ISBN': 'B', + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton' + }] var options = { - keys: ["title", "author"], - id: "ISBN" + keys: ['title', 'author'], + id: 'ISBN' } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { - assert.isString(result[0]); - assert.equal(result[0], "B"); + 'whose value is the ISBN of the book': function (result) { + assert.isString(result[0]) + assert.equal(result[0], 'B') }, } } -}).export(module); +}).export(module) vows.describe('Set new list on Fuse').addBatch({ 'Options:': { - topic: function() { - var fruits = ["Apple", "Orange", "Banana"]; - var vegetables = ["Onion", "Lettuce", "Broccoli"]; + topic: function () { + var fruits = ['Apple', 'Orange', 'Banana'] + var vegetables = ['Onion', 'Lettuce', 'Broccoli'] - var fuse = new Fuse(fruits); - fuse.set(vegetables); - return fuse; + var fuse = new Fuse(fruits, { + verbose: verbose + }) + fuse.set(vegetables) + return fuse }, 'When searching for the term "Apple"': { - topic: function(fuse) { - var result = fuse.search("Lettuce"); - return result; + topic: function (fuse) { + var result = fuse.search('Lettuce') + return result }, - 'we get a list of containing 1 item, which is an exact match': function(result) { - assert.equal(result.length, 1); + 'we get a list of containing 1 item, which is an exact match': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the index 0, representing ["Lettuce"]': function(result) { - assert.equal(result[0], 1); + 'whose value is the index 0, representing ["Lettuce"]': function (result) { + assert.equal(result[0], 1) }, } } -}).export(module); +}).export(module) vows.describe('Searching by nested ID').addBatch({ 'Options:': { - topic: function() { + topic: function () { var books = [{ - "ISBN": { - "name": "A" + 'ISBN': { + 'name': 'A' }, - "title": "Old Man's War", - "author": "John Scalzi" + 'title': "Old Man's War", + 'author': 'John Scalzi' }, { - "ISBN": { - "name": "B" + 'ISBN': { + 'name': 'B' }, - "title": "The Lock Artist", - "author": "Steve Hamilton" - }]; + 'title': 'The Lock Artist', + 'author': 'Steve Hamilton' + }] var options = { - keys: ["title", "author"], - id: "ISBN.name" + keys: ['title', 'author'], + id: 'ISBN.name' } var fuse = new Fuse(books, options) - return fuse; + return fuse }, 'When searching for the term "Stve"': { - topic: function(fuse) { - var result = fuse.search("Stve"); - return result; + topic: function (fuse) { + var result = fuse.search('Stve') + return result }, - 'we get a list containing 1 item': function(result) { - assert.equal(result.length, 1); + 'we get a list containing 1 item': function (result) { + assert.equal(result.length, 1) }, - 'whose value is the ISBN of the book': function(result) { + 'whose value is the ISBN of the book': function (result) { assert.isString(result[0]) - assert.equal(result[0], "B"); + assert.equal(result[0], 'B') + }, + } + } +}).export(module) + +vows.describe('Searching list').addBatch({ + 'Options:': { + topic: function () { + var items = ['FH Mannheim', 'University Mannheim'] + var fuse = new Fuse(items) + return fuse + }, + 'When searching for the term "Uni Mannheim"': { + topic: function (fuse) { + var result = fuse.search('Uni Mannheim') + return result + }, + 'we get a list containing 2 items': function (result) { + assert.equal(result.length, 2) + }, + 'whose first value is the index of "University Mannheim"': function (result) { + assert.equal(result[0], 1) + } + } + } +}).export(module) + +vows.describe('Searching list').addBatch({ + 'Options:': { + topic: function () { + var items = [ + 'Borwaila hamlet', + 'Bobe hamlet', + 'Bo hamlet', + 'Boma hamlet'] + var fuse = new Fuse(items, { + include: ['score'], + verbose: verbose + }) + return fuse + }, + 'When searching for the term "Bo hamet"': { + topic: function (fuse) { + var result = fuse.search('Bo hamet') + return result + }, + 'we get a list containing 4 items': function (result) { + assert.equal(result.length, 4) + }, + 'whose first value is the index of "Bo hamlet"': function (result) { + assert.equal(result[0].item, 2) + } + } + } +}).export(module) + +vows.describe('List of books - searching for long pattern length > 32').addBatch({ + 'Books:': { + topic: function () { + var books = require('./books.json') + var options = { + keys: ['title'], + verbose: verbose + } + var fuse = new Fuse(books, options) + return fuse + }, + 'When searching for the term "HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5 HTML5"': { + topic: function (fuse) { + var result = fuse.search('HTML5 ') + return result + }, + 'we get a a non empty list': function (result) { + assert.isTrue(!!result.length) + }, + 'whose first value is { title: "HTML5 ", author: "Remy Sharp" }': function (result) { + assert.deepEqual(result[0], { + title: 'HTML5', + author: 'Remy Sharp' + }) }, } } -}).export(module); +}).export(module)