Browse Source

Rearchitecture (#151)

* New architecture

* added newer versions of node

* Travis fix

* Remove iojs to make travis happy

* Added copyright info

* Fixed package and config

* Incremeneted version

* Added support for iojs

* Removed newline from copyright

* Version up
Kirollos Risk 2 years ago
parent
commit
42b082fb16

+ 4 - 0
.babelrc

@@ -0,0 +1,4 @@
+{
+  "presets": ["es2015", "stage-2"],
+  "plugins": ["babel-plugin-add-module-exports"]
+}

+ 2 - 3
.gitignore

@@ -1,6 +1,5 @@
 node_modules/
-test/index.html
-latest.zip
+npm-debug.log
 .DS_Store
 .idea
-runner.js
+runner.js

+ 5 - 6
.travis.yml

@@ -1,13 +1,12 @@
 language: node_js
 node_js:
-    - '0.12'
-    - '0.10'
-    - '0.8'
+    - '6'
+    - '7'
     - 'iojs'
     
 before_install:
-    - '[ "${TRAVIS_NODE_VERSION}" != "0.8" ] || npm install -g npm@1.4.28'
-    - npm install -g npm@2.12
+    - '[ "${TRAVIS_NODE_VERSION}" != "0.8" ] || npm install -g npm@3.10'
+    - npm install -g npm@3.10
     
 git:
-    depth: 10
+    depth: 10

+ 10 - 1
CHANGELOG.md

@@ -1,3 +1,12 @@
+# Version 2.7.2
+
+- Removed Bower support
+- Modified library into a more more palatable architecture, where the Bitap portion is now its own separate module.
+
+### BREAKING CHANGES
+
+- Removed `include` option in favor of more explicit booleans: `includeScore` and `includeMatches`. Both are `false` by default.
+
 # Version 2.6.2
 
 - Fix typings based on TypeScript guidelines (#129)
@@ -35,4 +44,4 @@
 - 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)
+- Allowed results with a value of 0 to be returned (#73)

+ 6 - 0
COPYRIGHT.txt

@@ -0,0 +1,6 @@
+Fuse.js {VERSION} - Lightweight fuzzy-search ({HOMEPAGE})
+
+Copyright (c) 2012-2017 Kirollos Risk ({AUTHOR_URL})
+All Rights Reserved. Apache Software License 2.0
+
+http://www.apache.org/licenses/LICENSE-2.0

+ 1 - 199
README.md

@@ -17,10 +17,6 @@ Check out the [demo & usage](http://fusejs.io/)
 **Table of Contents**
 
 - [Where-to-post summary](#where-to-post-summary)
-- [Usage](#usage)
-  - [Options](#options)
-  - [Methods](#methods)
-  - [Weighted Search](#weighted-search)
 - [Contributing](#contributing)
   - [Coding conventions](#coding-conventions)
   - [Testing](#testing)
@@ -36,198 +32,6 @@ Check out the [demo & usage](http://fusejs.io/)
 - You're a horrible human being -- send me an [email](mailto:kirollos+github@gmail.com)
 - You're awesome -- support Fuse.js development with [Patreon](https://www.patreon.com/fusejs)/[PayPal](https://www.paypal.me/kirorisk)
 
----
-
-## Usage
-
-### Options
-
-**keys** (*type*: `Array`)
-
-List of properties that will be searched.  This also supports nested properties:
-
-```javascript
-var books = [{
-  title: "Old Man's War",
-  author: {
-    firstName: "John",
-    lastName: "Scalzi"
-  }
-}];
-var fuse = new Fuse(books, { keys: ["title", "author.firstName"] });
-```
-
--
-
-**id** (*type*: `String`)
-
-The name of the identifier property. If specified, the returned result will be a list of the items' identifiers, otherwise it will be a list of the items.
-
--
-
-**caseSensitive** (*type*: `Boolean`, *default*: `false`)
-
-Indicates whether comparisons should be case sensitive.
-
--
-
-**include** (*type*: `Array`, *default*: `[]`)
-
-An array of values that should be included from the searcher's output. When this array contains elements, each result in the list will be of the form `{ item: ..., include1: ..., include2: ... }`. Values you can include are `score`, `matches`. Ex:
-
-```javascript
-{ include: ['score', 'matches' ] }
-```
-
--
-
-**shouldSort** (*type*: `Boolean`, *default*: `true`)
-
-Whether to sort the result list, by score.
-
--
-
-**searchFn** (*type*: `Function`, *default*: `BitapSearcher`)
-
-The search function to use.  Note that the search function (`[[Function]]`) must conform to the following API:
-
-```javascript
-/*
-@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) { ... }
-```
-
--
-
-**getFn** (*type*: `Function`, *default*: `Utils.deepValue`)
-
-The get function to use when fetching an object's properties.  The default will search nested paths *ie foo.bar.baz*
-
-```javascript
-/*
-@param obj The object being searched
-@param path The path to the target property
-*/
-
-// example using an object with a `getter` method
-getFn: function (obj, path) {
-  return obj.get(path);
-}
-```
--
-
-**sortFn** (*type*: `Function`, *default*: `Array.prototype.sort`)
-
-The function that is used for sorting the result list.
-
--
-
-**location** (*type*: `Integer`, *default*: `0`)
-
-Determines approximately where in the text is the pattern expected to be found.
-
--
-
-**threshold** (*type*: `Decimal`, *default*: `0.6`)
-
-At what point does the match algorithm give up. A threshold of `0.0` requires a perfect match (of both letters and location), a threshold of `1.0` would match anything.
-
--
-
-**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 `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`.
-
--
-
-**maxPatternLength** (*type*: `Integer`, *default*: `32`)
-
-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.
-
--
-
-**tokenize** (*type*: `Boolean`, *default*: `false`)
-
-When true, the search algorithm will search individual words **and** the full string, computing the final score as a function of both. Note that when `tokenize` is `true`, the `threshold`, `distance`, and `location` are inconsequential for individual tokens.
-
--
-
-**tokenSeparator** (*type*: `Regex`, *default*: `/ +/g`)
-
-Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
-
--
-
-**matchAllTokens** (*type*: `Boolean`, *default*: `false`)
-
-When `true`, the result set will only include records that match all tokens. Will only work if `tokenize` is also true.
-
--
-
-**findAllMatches** (*type*: `Boolean`, *default*: `false`)
-
-When `true`, the matching function will continue to the end of a search pattern even if a perfect match has already been located in the string.
-
--
-
-**minMatchCharLength** (*type*: `Integer`, *default*: `1`)
-
-When set to include matches, only those whose length exceeds this value will be returned. (For instance, if you want to ignore single character index returns, set to `2`)
-
-### Methods
-
-**`search(/*pattern*/)`**
-
-```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*/)`**
-
-```javascript
-@param {Array} list
-@return {Array} The newly set list
-```
-
-Sets a new list for Fuse to match against.
-
-### Weighted Search
-
-In some cases you may want certain keys to be weighted differently:
-
-```javascript
-var fuse = new Fuse(books, {
-  keys: [{
-    name: 'title',
-    weight: 0.3
-  }, {
-    name: 'author',
-    weight: 0.7
-  }]
-});
-```
-
-Where `0 < weight <= 1`
-
 ## Contributing
 
 ### Coding conventions
@@ -236,6 +40,4 @@ Code should be run through [Standard Format](https://www.npmjs.com/package/stand
 
 ### Testing
 
-Before submitting a pull request, please add relevant tests in `test/fuse-test.js`, and execute them via `npm test`.
-
-Note that **ALL TESTS MUST PASS**, otherwise the pull request will be automatically rejected.
+Before submitting a pull request, please add relevant tests in `test/index.js`, and execute them via `npm test`.

+ 1 - 1
backers.md

@@ -1,3 +1,3 @@
 # Backers
 
-You can join them in supporting Fuse.js development by [pledging on Patreon](https://www.patreon.com/fusejs)! Backers in the same pledge level appear in the order of pledge date.
+You can join them in supporting Fuse.js development by [pledging on Patreon](https://www.patreon.com/fusejs)! Backers in the same pledge level appear in the order of pledge date.

+ 0 - 8
bower.json

@@ -1,8 +0,0 @@
-{
-  "name": "fuse.js",
-  "version": "2.6.2",
-  "main": "./src/fuse.js",
-  "ignore": [
-    "test"
-  ]
-}

+ 0 - 4
build.sh

@@ -1,4 +0,0 @@
-#!/bin/sh
-
-node_modules/uglify-js/bin/uglifyjs src/fuse.js -c -m --comments -o src/fuse.min.js
-zip latest.zip src/*

File diff suppressed because it is too large
+ 976 - 0
dist/fuse.js


File diff suppressed because it is too large
+ 1 - 0
dist/fuse.js.map


File diff suppressed because it is too large
+ 9 - 0
dist/fuse.min.js


+ 3 - 3
index.d.ts

@@ -13,9 +13,9 @@ declare namespace Fuse {
   export interface FuseOptions {
     id?: string;
     caseSensitive?: boolean;
-    include?: string[];
+    showMatches?: boolean;
+    showScore?: boolean;
     shouldSort?: boolean;
-    searchFn?: any;
     sortFn?: (a: { score: number }, b: { score: number }) => number;
     getFn?: (obj: any, path: string) => any;
     keys?: string[] | { name: string; weight: number }[];
@@ -30,4 +30,4 @@ declare namespace Fuse {
     minMatchCharLength?: number;
     findAllMatches?: boolean;
   }
-}
+}

+ 23 - 8
package.json

@@ -1,19 +1,34 @@
 {
   "name": "fuse.js",
-  "author": "Kirollos Risk",
-  "version": "2.6.2",
+  "author": { 
+    "name" : "Kirollos Risk",
+    "email" : "kirollos@gmail.com",
+    "url" : "http://kiro.me"
+  },
+  "version": "2.7.2",
   "description": "Lightweight fuzzy-search",
   "license": "Apache",
-  "main": "./src/fuse.js",
   "types": "./index.d.ts",
-  "repository": "https://github.com/krisk/Fuse.git",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/krisk/Fuse.git"
+  },
+  "homepage": "http://fusejs.io",
   "dependencies": {},
+  "keywords": ["fuzzy", "bitap"],
+  "main": "dist/fuse.js",
   "scripts": {
-    "test": "vows --spec"
+    "test": "vows test/**.js",
+    "build": "WEBPACK_ENV=build webpack && WEBPACK_ENV=dev webpack",
+    "dev": "WEBPACK_ENV=dev webpack --progress --colors --watch"
   },
   "devDependencies": {
-    "vows": "0.5.x",
-    "grunt-bump": "0.0.11",
-    "uglify-js": "*"
+    "webpack": "^2.4.1",
+    "babel-core": "^6.24.1",
+    "babel-loader": "^6.4.1",
+    "babel-preset-es2015": "^6.24.1",
+    "babel-preset-stage-2": "^6.24.1",
+    "babel-plugin-add-module-exports": "0.2.1",
+    "vows": "0.5.x"
   }
 }

+ 26 - 0
src/bitap/bitap_matched_indices.js

@@ -0,0 +1,26 @@
+module.exports = (matchmask = [], minMatchCharLength = 1) => {
+  let matchedIndices = []
+  let start = -1
+  let end = -1
+  let i = 0
+
+  for (let len = matchmask.length; i < len; i += 1) {
+    let match = matchmask[i]
+    if (match && start === -1) {
+      start = i
+    } else if (!match && start !== -1) {
+      end = i - 1
+      if ((end - start) + 1 >= minMatchCharLength) {
+        matchedIndices.push([start, end])
+      }
+      start = -1
+    }
+  }
+
+  // (i-1 - start) + 1 => i - start
+  if (matchmask[i - 1] && (i - start) >= minMatchCharLength) {
+    matchedIndices.push([start, i - 1])
+  }
+
+  return matchedIndices
+}

+ 14 - 0
src/bitap/bitap_pattern_alphabet.js

@@ -0,0 +1,14 @@
+module.exports = (pattern) => {
+  let mask = {}
+  let len = pattern.length
+
+  for (let i = 0; i < len; i += 1) {
+    mask[pattern.charAt(i)] = 0
+  }
+
+  for (let i = 0; i < len; i += 1) {
+    mask[pattern.charAt(i)] |= 1 << (len - i - 1)
+  }
+
+  return mask
+}

+ 19 - 0
src/bitap/bitap_regex_search.js

@@ -0,0 +1,19 @@
+module.exports = (text, pattern, tokenSeparator = / +/g) => {
+  let matches = text.match(new RegExp(pattern.replace(tokenSeparator, '|')))
+  let isMatch = !!matches
+  let matchedIndices = []
+
+  if (isMatch) {
+    for (i = 0, matchesLen = matches.length; i < matchesLen; i += 1) {
+      match = matches[i]
+      matchedIndices.push([text.indexOf(match), match.length - 1])
+    }
+  }
+  
+  return {
+    // TODO: revisit this score
+    score: isMatched ? 0.5 : 1,
+    isMatch,
+    matchedIndices
+  } 
+}

+ 11 - 0
src/bitap/bitap_score.js

@@ -0,0 +1,11 @@
+module.exports = (pattern, { errors = 0, currentLocation = 0, expectedLocation = 0, distance = 100 }) => {
+  const accuracy = errors / pattern.length
+  const proximity = Math.abs(expectedLocation - currentLocation)
+
+  if (!distance) {
+    // Dodge divide by zero error.
+    return proximity ? 1.0 : accuracy
+  }
+
+  return accuracy + (proximity / distance)
+}

+ 153 - 0
src/bitap/bitap_search.js

@@ -0,0 +1,153 @@
+const bitapScore = require('./bitap_score')
+const matchedIndices = require('./bitap_matched_indices')
+
+module.exports = (text, pattern, patternAlphabet, { location = 0, distance = 100, threshold = 0.6, findAllMatches = false, minMatchCharLength = 1 }) => {
+  const expectedLocation = location
+  // Set starting location at beginning text and initialize the alphabet.
+  const textLen = text.length
+  // Highest score beyond which we give up.
+  let currentThreshold = threshold
+  // Is there a nearby exact match? (speedup)
+  let bestLocation = text.indexOf(pattern, expectedLocation)
+
+  const patternLen = pattern.length
+
+  // a mask of the matches
+  const matchMask = []
+  for (let i = 0; i < textLen; i += 1) {
+    matchMask[i] = 0
+  }
+
+  if (bestLocation != -1) {
+    let score = bitapScore(pattern, { 
+      errors: 0, 
+      currentLocation: bestLocation, 
+      expectedLocation, 
+      distance 
+    })
+    currentThreshold = Math.min(score, currentThreshold)
+
+    // What about in the other direction? (speed up)
+    bestLocation = text.lastIndexOf(pattern, expectedLocation + patternLen)
+
+    if (bestLocation != -1) {
+      let score = bitapScore(pattern, { 
+        errors: 0, 
+        currentLocation: bestLocation, 
+        expectedLocation, 
+        distance
+      })
+      currentThreshold = Math.min(score, currentThreshold)
+    }
+  }
+
+  // Reset the best location
+  bestLocation = -1
+
+  let lastBitArr = []
+  let finalScore = 1
+  let binMax = patternLen + textLen
+
+  const locations = []
+  const mask = 1 << (patternLen - 1)
+
+  for (let i = 0; i < patternLen; i += 1) {
+    // Scan for the best match; each iteration allows for one more error.
+    // Run a binary search to determine how far from the match location we can stray
+    // at this error level.
+    let binMin = 0
+    let binMid = binMax
+
+    while (binMin < binMid) {
+      const score = bitapScore(pattern, { 
+        errors: i, 
+        currentLocation: expectedLocation + binMid, 
+        expectedLocation, 
+        distance 
+      })
+
+      if (score <= currentThreshold) {
+        binMin = binMid
+      } else {
+        binMax = binMid
+      }
+
+      binMid = Math.floor((binMax - binMin) / 2 + binMin)
+    }
+
+    // Use the result from this iteration as the maximum for the next.
+    binMax = binMid
+
+    let start = Math.max(1, expectedLocation - binMid + 1)
+    let finish = findAllMatches ? textLen : Math.min(expectedLocation + binMid, textLen) + patternLen
+
+    // Initialize the bit array
+    let bitArr = Array(finish + 2)
+
+    bitArr[finish + 1] = (1 << i) - 1
+
+    for (let j = finish; j >= start; j -= 1) {
+      let currentLocation = j - 1
+      let charMatch = patternAlphabet[text.charAt(currentLocation)]
+
+      if (charMatch) {
+        matchMask[currentLocation] = 1
+      }
+
+      // First pass: exact match
+      bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch
+
+      // Subsequent passes: fuzzy match
+      if (i !== 0) {
+        bitArr[j] |= (((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1) | lastBitArr[j + 1]
+      }
+      
+      if (bitArr[j] & mask) {
+        finalScore = bitapScore(pattern, { 
+          errors: i, 
+          currentLocation, 
+          expectedLocation, 
+          distance 
+        })
+
+        // This match will almost certainly be better than any existing match.
+        // But check anyway.
+        if (finalScore <= currentThreshold) {
+          // Indeed it is
+          currentThreshold = finalScore
+          bestLocation = currentLocation
+          locations.push(bestLocation)
+
+          // Already passed `loc`, downhill from here on in.
+          if (bestLocation <= expectedLocation) {
+            break
+          }
+
+          // When passing `bestLocation`, don't exceed our current distance from `expectedLocation`.
+          start = Math.max(1, 2 * expectedLocation - bestLocation)
+        }
+      }
+    }
+
+    // No hope for a (better) match at greater error levels.    
+    const score = bitapScore(pattern, { 
+      errors: i + 1, 
+      currentLocation: expectedLocation, 
+      expectedLocation, 
+      distance 
+    })
+  
+    if (score > currentThreshold) {
+      break
+    }
+    
+    lastBitArr = bitArr
+  }
+
+  // Count exact matches (those with a score of 0) to be "almost" exact
+  return {
+    isMatch: bestLocation >= 0,
+    score: finalScore === 0 ? 0.001 : finalScore,
+    matchedIndices: matchedIndices(matchMask, minMatchCharLength)
+  }
+}

+ 80 - 0
src/bitap/index.js

@@ -0,0 +1,80 @@
+const bitapRegexSearch = require('./bitap_regex_search')
+const bitapSearch = require('./bitap_search')
+const patternAlphabet = require('./bitap_pattern_alphabet')
+
+class Bitap {
+  constructor (pattern, { 
+    // Approximately where in the text is the pattern expected to be found?
+    location = 0, 
+    // Determines how close the match must be to the fuzzy location (specified above).
+    // 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 fuzzy location to be found using a 0.8 threshold.
+    distance = 100, 
+    // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
+    // (of both letters and location), a threshold of '1.0' would match anything.
+    threshold = 0.6, 
+    // Machine word size
+    maxPatternLength = 32,
+    // Indicates whether comparisons should be case sensitive.
+    isCaseSensitive = false,
+    // Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
+    tokenSeparator = / +/g,
+    // When true, the algorithm continues searching to the end of the input even if a perfect
+    // match is found before the end of the same input.
+    findAllMatches = false,
+    // Minimum number of characters that must be matched before a result is considered a match
+    minMatchCharLength = 1
+  }) {
+    this.options = {
+      location,
+      distance,
+      threshold,
+      maxPatternLength,
+      isCaseSensitive,
+      tokenSeparator,
+      findAllMatches,
+      minMatchCharLength
+    }
+
+    this.pattern = this.options.isCaseSensitive ? pattern : pattern.toLowerCase()
+
+    if (this.pattern.length <= maxPatternLength) {
+      this.patternAlphabet = patternAlphabet(this.pattern)
+    }
+  }
+
+  search (text) {
+    if (!this.options.isCaseSensitive) {
+      text = text.toLowerCase()
+    }
+
+    // Exact match
+    if (this.pattern === text) {
+      return {
+        isMatch: true,
+        score: 0,
+        matchedIndices: [[0, text.length - 1]]
+      }
+    }
+
+    // When pattern length is greater than the machine word length, just do a a regex comparison
+    const { maxPatternLength, tokenSeparator } = this.options
+    if (this.pattern.length > maxPatternLength) {
+      return bitapRegexSearch(text, this.pattern, tokenSeparator)
+    }
+
+    // Otherwise, use Bitap algorithm
+    const { location, distance, threshold, findAllMatches, minMatchCharLength } = this.options
+    return bitapSearch(text, this.pattern, this.patternAlphabet, {
+      location,
+      distance,
+      threshold,
+      findAllMatches,
+      minMatchCharLength
+    })
+  }
+}
+
+module.exports = Bitap

+ 0 - 825
src/fuse.js

@@ -1,825 +0,0 @@
-/**
- * @license
- * Fuse - Lightweight fuzzy-search
- *
- * Copyright (c) 2012-2016 Kirollos Risk <kirollos@gmail.com>.
- * All Rights Reserved. Apache Software License 2.0
- *
- * 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
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-;(function (global) {
-  'use strict'
-
-  /** @type {function(...*)} */
-  function log () {
-    console.log.apply(console, arguments)
-  }
-
-  var defaultOptions = {
-    // The name of the identifier property. If specified, the returned result will be a list
-    // of the items' dentifiers, otherwise it will be a list of the items.
-    id: null,
-
-    // Indicates whether comparisons should be case sensitive.
-
-    caseSensitive: false,
-
-    // An array of values that should be included from the searcher's output. When this array
-    // contains elements, each result in the list will be of the form `{ item: ..., include1: ..., include2: ... }`.
-    // Values you can include are `score`, `matchedLocations`
-    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
-    },
-
-    // The get function to use when fetching an object's properties.
-    // The default will search nested paths *ie foo.bar.baz*
-    getFn: deepValue,
-
-    // List of properties that will be searched. This also supports nested properties.
-    keys: [],
-
-    // Will print to the console. Useful for debugging.
-    verbose: false,
-
-    // When true, the search algorithm will search individual words **and** the full string,
-    // computing the final score as a function of both. Note that when `tokenize` is `true`,
-    // the `threshold`, `distance`, and `location` are inconsequential for individual tokens.
-    tokenize: false,
-
-    // When true, the result set will only include records that match all tokens. Will only work
-    // if `tokenize` is also true.
-    matchAllTokens: false,
-
-    // Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
-    tokenSeparator: / +/g,
-
-    // Minimum number of characters that must be matched before a result is considered a match
-    minMatchCharLength: 1,
-
-    // When true, the algorithm continues searching to the end of the input even if a perfect
-    // match is found before the end of the same input.
-    findAllMatches: false
-  }
-
-  /**
-   * @constructor
-   * @param {!Array} list
-   * @param {!Object<string, *>} options
-   */
-  function Fuse (list, options) {
-    var key
-
-    this.list = list
-    this.options = options = options || {}
-
-    for (key in defaultOptions) {
-      if (!defaultOptions.hasOwnProperty(key)) {
-        continue;
-      }
-      // Add boolean type options
-      if (typeof defaultOptions[key] === 'boolean') {
-        this.options[key] = key in options ? options[key] : defaultOptions[key];
-      // Add all other options
-      } else {
-        this.options[key] = options[key] || defaultOptions[key]
-      }
-    }
-  }
-
-  Fuse.VERSION = '2.6.2'
-
-  /**
-   * 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('\nSearch term:', pattern, '\n')
-
-    this.pattern = pattern
-    this.results = []
-    this.resultMap = {}
-    this._keyMap = null
-
-    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(options.tokenSeparator)
-    var i = 0
-    var len = tokens.length
-
-    if (this.options.tokenize) {
-      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 key
-    var weight
-    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 {
-      this._keyMap = {}
-      // 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++) {
-          key = keys[j]
-          if (typeof key !== 'string') {
-            weight = (1 - key.weight) || 1
-            this._keyMap[key.name] = {
-              weight: weight
-            }
-            if (key.weight <= 0 || key.weight > 1) {
-              throw new Error('Key weight has to be > 0 and <= 1')
-            }
-            key = key.name
-          } else {
-            this._keyMap[key] = {
-              weight: 1
-            }
-          }
-          this._analyze(key, getFn(item, key, []), item, i)
-        }
-      }
-    }
-  }
-
-  Fuse.prototype._analyze = function (key, text, entity, index) {
-    var options = this.options
-    var words
-    var scores
-    var exists = false
-    var existingResult
-    var averageScore
-    var finalScore
-    var scoresLen
-    var mainSearchResult
-    var tokenSearcher
-    var termScores
-    var word
-    var tokenSearchResult
-    var hasMatchInText
-    var checkTextMatches
-    var i
-    var j
-
-    // Check if the text can be searched
-    if (text === undefined || text === null) {
-      return
-    }
-
-    scores = []
-
-    var numTextMatches = 0
-
-    if (typeof text === 'string') {
-      words = text.split(options.tokenSeparator)
-
-      if (options.verbose) log('---------\nKey:', key)
-
-      if (this.options.tokenize) {
-        for (i = 0; i < this.tokenSearchers.length; i++) {
-          tokenSearcher = this.tokenSearchers[i]
-
-          if (options.verbose) log('Pattern:', tokenSearcher.pattern)
-
-          termScores = []
-          hasMatchInText = false
-
-          for (j = 0; j < words.length; j++) {
-            word = words[j]
-            tokenSearchResult = tokenSearcher.search(word)
-            var obj = {}
-            if (tokenSearchResult.isMatch) {
-              obj[word] = tokenSearchResult.score
-              exists = true
-              hasMatchInText = true
-              scores.push(tokenSearchResult.score)
-            } else {
-              obj[word] = 1
-              if (!this.options.matchAllTokens) {
-                scores.push(1)
-              }
-            }
-            termScores.push(obj)
-          }
-
-          if (hasMatchInText) {
-            numTextMatches++
-          }
-
-          if (options.verbose) log('Token scores:', termScores)
-        }
-
-        averageScore = scores[0]
-        scoresLen = scores.length
-        for (i = 1; i < scoresLen; i++) {
-          averageScore += scores[i]
-        }
-        averageScore = averageScore / scoresLen
-
-        if (options.verbose) log('Token score average:', averageScore)
-      }
-
-      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('Score average:', finalScore)
-
-      checkTextMatches = (this.options.tokenize && this.options.matchAllTokens) ? numTextMatches >= this.tokenSearchers.length : true
-
-      if (options.verbose) log('Check Matches', checkTextMatches)
-
-      // If a match is found, add the item to <rawResults>, including its score
-      if ((exists || mainSearchResult.isMatch) && checkTextMatches) {
-        // Check if the item already exists in our results
-        existingResult = this.resultMap[index]
-
-        if (existingResult) {
-          // Use the lowest score
-          // existingResult.score, bitapResult.score
-          existingResult.output.push({
-            key: key,
-            score: finalScore,
-            matchedIndices: mainSearchResult.matchedIndices
-          })
-        } else {
-          // Add it to the raw result list
-          this.resultMap[index] = {
-            item: entity,
-            output: [{
-              key: key,
-              score: finalScore,
-              matchedIndices: mainSearchResult.matchedIndices
-            }]
-          }
-
-          this.results.push(this.resultMap[index])
-        }
-      }
-    } else if (isArray(text)) {
-      for (i = 0; i < text.length; i++) {
-        this._analyze(key, text[i], entity, index)
-      }
-    }
-  }
-
-  Fuse.prototype._computeScore = function () {
-    var i
-    var j
-    var keyMap = this._keyMap
-    var totalScore
-    var output
-    var scoreLen
-    var score
-    var weight
-    var results = this.results
-    var bestScore
-    var nScore
-
-    if (this.options.verbose) log('\n\nComputing score:\n')
-
-    for (i = 0; i < results.length; i++) {
-      totalScore = 0
-      output = results[i].output
-      scoreLen = output.length
-
-      bestScore = 1
-
-      for (j = 0; j < scoreLen; j++) {
-        score = output[j].score
-        weight = keyMap ? keyMap[output[j].key].weight : 1
-
-        nScore = score * weight
-
-        if (weight !== 1) {
-          bestScore = Math.min(bestScore, nScore)
-        } else {
-          totalScore += nScore
-          output[j].nScore = nScore
-        }
-      }
-
-      if (bestScore === 1) {
-        results[i].score = totalScore / scoreLen
-      } else {
-        results[i].score = bestScore
-      }
-
-      if (this.options.verbose) log(results[i])
-    }
-  }
-
-  Fuse.prototype._sort = function () {
-    var options = this.options
-    if (options.shouldSort) {
-      if (options.verbose) log('\n\nSorting....')
-      this.results.sort(options.sortFn)
-    }
-  }
-
-  Fuse.prototype._format = function () {
-    var options = this.options
-    var getFn = options.getFn
-    var finalOutput = []
-    var item
-    var i
-    var len
-    var results = this.results
-    var replaceValue
-    var getItemAtIndex
-    var include = options.include
-
-    if (options.verbose) log('\n\nOutput:\n\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 record = results[index]
-      var data
-      var j
-      var output
-      var _item
-      var _result
-
-      // If `include` has values, put the item in the result
-      if (include.length > 0) {
-        data = {
-          item: record.item
-        }
-        if (include.indexOf('matches') !== -1) {
-          output = record.output
-          data.matches = []
-          for (j = 0; j < output.length; j++) {
-            _item = output[j]
-            _result = {
-              indices: _item.matchedIndices
-            }
-            if (_item.key) {
-              _result.key = _item.key
-            }
-            data.matches.push(_result)
-          }
-        }
-
-        if (include.indexOf('score') !== -1) {
-          data.score = results[index].score
-        }
-
-      } else {
-        data = record.item
-      }
-
-      return data
-    }
-
-    // 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 <results>,
-    // since it contains other metadata
-    for (i = 0, len = results.length; i < len; i++) {
-      replaceValue(i)
-      item = getItemAtIndex(i)
-      finalOutput.push(item)
-    }
-
-    return finalOutput
-  }
-
-  // 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
-   *
-   *   http://code.google.com/p/google-diff-match-patch/
-   *
-   * Modified by: Kirollos Risk <kirollos@gmail.com>
-   * -----------------------------------------------
-   * Details: the algorithm and structure was modified to allow the creation of
-   * <Searcher> instances with a <search> method which does the actual
-   * bitap search. The <pattern> (the string that is searched for) is only defined
-   * 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")
-   * you may not use this file except in compliance with the License.
-   *
-   * @constructor
-   */
-  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()
-    }
-  }
-
-  BitapSearcher.defaultOptions = {
-    // Approximately where in the text is the pattern expected to be found?
-    location: 0,
-
-    // Determines how close the match must be to the fuzzy location (specified above).
-    // 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 fuzzy location to be found using a 0.8 threshold.
-    distance: 100,
-
-    // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
-    // (of both letters and location), a threshold of '1.0' would match anything.
-    threshold: 0.6,
-
-    // Machine word size
-    maxPatternLength: 32
-  }
-
-  /**
-   * Initialize the alphabet for the Bitap algorithm.
-   * @return {Object} Hash of character locations.
-   * @private
-   */
-  BitapSearcher.prototype._calculatePatternAlphabet = function () {
-    var mask = {},
-      i = 0
-
-    for (i = 0; i < this.patternLen; i++) {
-      mask[this.pattern.charAt(i)] = 0
-    }
-
-    for (i = 0; i < this.patternLen; i++) {
-      mask[this.pattern.charAt(i)] |= 1 << (this.pattern.length - i - 1)
-    }
-
-    return mask
-  }
-
-  /**
-   * Compute and return the score for a match with `e` errors and `x` location.
-   * @param {number} errors Number of errors in match.
-   * @param {number} location Location of match.
-   * @return {number} Overall score for match (0.0 = good, 1.0 = bad).
-   * @private
-   */
-  BitapSearcher.prototype._bitapScore = function (errors, location) {
-    var accuracy = errors / this.patternLen,
-      proximity = Math.abs(this.options.location - location)
-
-    if (!this.options.distance) {
-      // Dodge divide by zero error.
-      return proximity ? 1.0 : accuracy
-    }
-    return accuracy + (proximity / this.options.distance)
-  }
-
-  /**
-   * Compute and return the result of the search
-   * @param {string} text The text to search in
-   * @return {{isMatch: boolean, score: number}} Literal containing:
-   *                          isMatch - Whether the text is a match or not
-   *                          score - Overall score for the match
-   * @public
-   */
-  BitapSearcher.prototype.search = function (text) {
-    var options = this.options
-    var i
-    var j
-    var textLen
-    var findAllMatches
-    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
-    var matchMask
-    var matchedIndices
-    var matchesLen
-    var match
-
-    text = options.caseSensitive ? text : text.toLowerCase()
-
-    if (this.pattern === text) {
-      // Exact match
-      return {
-        isMatch: true,
-        score: 0,
-        matchedIndices: [[0, text.length - 1]]
-      }
-    }
-
-    // When pattern length is greater than the machine word length, just do a a regex comparison
-    if (this.patternLen > options.maxPatternLength) {
-      matches = text.match(new RegExp(this.pattern.replace(options.tokenSeparator, '|')))
-      isMatched = !!matches
-
-      if (isMatched) {
-        matchedIndices = []
-        for (i = 0, matchesLen = matches.length; i < matchesLen; i++) {
-          match = matches[i]
-          matchedIndices.push([text.indexOf(match), match.length - 1])
-        }
-      }
-
-      return {
-        isMatch: isMatched,
-        // TODO: revisit this score
-        score: isMatched ? 0.5 : 1,
-        matchedIndices: matchedIndices
-      }
-    }
-
-    findAllMatches = options.findAllMatches
-
-    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)
-
-    // a mask of the matches
-    matchMask = []
-    for (i = 0; i < textLen; i++) {
-      matchMask[i] = 0
-    }
-
-    if (bestLoc != -1) {
-      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)
-      }
-    }
-
-    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 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
-        } else {
-          binMax = binMid
-        }
-        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)
-      if (findAllMatches) {
-        finish = textLen;
-      } else {
-        finish = Math.min(location + binMid, textLen) + this.patternLen
-      }
-
-      // Initialize the bit array
-      bitArr = Array(finish + 2)
-
-      bitArr[finish + 1] = (1 << i) - 1
-
-      for (j = finish; j >= start; j--) {
-        charMatch = this.patternAlphabet[text.charAt(j - 1)]
-
-        if (charMatch) {
-          matchMask[j - 1] = 1
-        }
-
-        if (i === 0) {
-          // First pass: exact match.
-          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]
-        }
-        if (bitArr[j] & this.matchmask) {
-          score = this._bitapScore(i, j - 1)
-
-          // This match will almost certainly be better than any existing match.
-          // But check anyway.
-          if (score <= threshold) {
-            // Indeed it is
-            threshold = score
-            bestLoc = j - 1
-            locations.push(bestLoc)
-
-            if (bestLoc > location) {
-              // When passing loc, don't exceed our current distance from loc.
-              start = Math.max(1, 2 * location - bestLoc)
-            } else {
-              // Already passed loc, downhill from here on in.
-              break
-            }
-          }
-        }
-      }
-
-      // No hope for a (better) match at greater error levels.
-      if (this._bitapScore(i + 1, location) > threshold) {
-        break
-      }
-      lastBitArr = bitArr
-    }
-
-    matchedIndices = this._getMatchedIndices(matchMask)
-
-    // Count exact matches (those with a score of 0) to be "almost" exact
-    return {
-      isMatch: bestLoc >= 0,
-      score: score === 0 ? 0.001 : score,
-      matchedIndices: matchedIndices
-    }
-  }
-
-  BitapSearcher.prototype._getMatchedIndices = function (matchMask) {
-    var matchedIndices = []
-    var start = -1
-    var end = -1
-    var i = 0
-    var match
-    var len = matchMask.length
-    for (; i < len; i++) {
-      match = matchMask[i]
-      if (match && start === -1) {
-        start = i
-      } else if (!match && start !== -1) {
-        end = i - 1
-        if ((end - start) + 1 >= this.options.minMatchCharLength) {
-            matchedIndices.push([start, end])
-        }
-        start = -1
-      }
-    }
-    if (matchMask[i - 1]) {
-      if ((i-1 - start) + 1 >= this.options.minMatchCharLength) {
-        matchedIndices.push([start, i - 1])
-      }
-    }
-    return matchedIndices
-  }
-
-  // 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
-  } else if (typeof define === 'function' && define.amd) {
-    // AMD. Register as an anonymous module.
-    define(function () {
-      return Fuse
-    })
-  } else {
-    // Browser globals (root is window)
-    global.Fuse = Fuse
-  }
-
-})(this);

File diff suppressed because it is too large
+ 0 - 20
src/fuse.min.js


+ 39 - 0
src/helpers/deep_value.js

@@ -0,0 +1,39 @@
+const isArray = require('./is_array')
+
+const deepValue = (obj, path, list) => {
+  if (!path) {
+    // If there's no path left, we've gotten to the object we care about.
+    list.push(obj)
+  } else {
+    const dotIndex = path.indexOf('.')
+    let firstSegment = path
+    let remaining = null
+
+    if (dotIndex !== -1) {
+      firstSegment = path.slice(0, dotIndex)
+      remaining = path.slice(dotIndex + 1)
+    }
+
+    const 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 (let i = 0, len = value.length; i < len; i += 1) {
+          deepValue(value[i], remaining, list)
+        }
+      } else if (remaining) {
+        // An object. Recurse further.
+        deepValue(value, remaining, list)
+      }
+    }
+  }
+
+  return list
+}
+
+module.exports = (obj, path) => {
+  return deepValue(obj, path, [])
+}

+ 3 - 0
src/helpers/is_array.js

@@ -0,0 +1,3 @@
+module.exports = (obj) => {
+  return Object.prototype.toString.call(obj) === '[object Array]'
+}

+ 399 - 0
src/index.js

@@ -0,0 +1,399 @@
+const Bitap = require('./bitap')
+const deepValue = require('./helpers/deep_value')
+const isArray = require('./helpers/is_array')
+
+class Fuse {
+  constructor (list, { 
+    // Approximately where in the text is the pattern expected to be found?
+    location = 0, 
+    // Determines how close the match must be to the fuzzy location (specified above).
+    // 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 fuzzy location to be found using a 0.8 threshold.
+    distance = 100, 
+    // At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
+    // (of both letters and location), a threshold of '1.0' would match anything.
+    threshold = 0.6, 
+    // Machine word size
+    maxPatternLength = 32,
+    // Indicates whether comparisons should be case sensitive.
+    caseSensitive = false,
+    // Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
+    tokenSeparator = / +/g,
+    // When true, the algorithm continues searching to the end of the input even if a perfect
+    // match is found before the end of the same input.
+    findAllMatches = false,
+    // Minimum number of characters that must be matched before a result is considered a match
+    minMatchCharLength = 1,
+    // The name of the identifier property. If specified, the returned result will be a list
+    // of the items' dentifiers, otherwise it will be a list of the items.
+    id = null,
+    // List of properties that will be searched. This also supports nested properties.
+    keys = [],
+    // Whether to sort the result list, by score
+    shouldSort = true,
+    // The get function to use when fetching an object's properties.
+    // The default will search nested paths *ie foo.bar.baz*
+    getFn = deepValue,
+    // Default sort function
+    sortFn = (a, b) => (a.score - b.score),
+    // When true, the search algorithm will search individual words **and** the full string,
+    // computing the final score as a function of both. Note that when `tokenize` is `true`,
+    // the `threshold`, `distance`, and `location` are inconsequential for individual tokens.
+    tokenize = false,
+    // When true, the result set will only include records that match all tokens. Will only work
+    // if `tokenize` is also true.
+    matchAllTokens = false,
+    // Will print to the console. Useful for debugging.
+
+    includeMatches = false,
+    includeScore = false,
+
+    verbose = false
+  }) {
+    this.options = {
+      location,
+      distance,
+      threshold,
+      maxPatternLength,
+      isCaseSensitive: caseSensitive,
+      tokenSeparator,
+      findAllMatches,
+      minMatchCharLength,
+      id,
+      keys,
+      includeMatches,
+      includeScore,
+      shouldSort,
+      getFn,
+      sortFn,
+      verbose,
+      tokenize,
+      matchAllTokens
+    }
+
+    this.set(list)
+  }
+
+  set (list) {
+    this.list = list
+    return list
+  }
+
+  search (pattern) {
+    this._log(`---------\nSearch pattern: "${pattern}"`)
+
+    const {
+      tokenSearchers,
+      fullSearcher
+    } = this._prepareSearchers(pattern)
+
+    let { weights, results } = this._search(tokenSearchers, fullSearcher)
+
+    this._computeScore(weights, results)
+
+    if (this.options.shouldSort) {
+      this._sort(results)
+    }
+    
+    return this._format(results)
+  }
+
+  _prepareSearchers (pattern = '') {
+    const tokenSearchers = []
+
+    if (this.options.tokenize) {
+      // Tokenize on the separator
+      const tokens = pattern.split(this.options.tokenSeparator)
+      for (let i = 0, len = tokens.length; i < len; i += 1) {
+        tokenSearchers.push(new Bitap(tokens[i], this.options))
+      }
+    }
+    
+    let fullSearcher = new Bitap(pattern, this.options)
+    
+    return { tokenSearchers, fullSearcher }
+  }
+
+  _search (tokenSearchers = [], fullSearcher) {
+    const list = this.list
+    const resultMap = {}
+    const results = []
+
+    // 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 (let i = 0, len = list.length; i < len; i += 1) {
+        this._analyze({
+          key: '', 
+          value: list[i], 
+          record: i, 
+          index: i
+        }, {
+          resultMap, 
+          results,
+          tokenSearchers,
+          fullSearcher
+        })
+      }
+
+      return { weights: null, results }
+    }
+
+    // Otherwise, the first item is an Object (hopefully), and thus the searching
+    // is done on the values of the keys of each item.
+    const weights = {}
+    for (let i = 0, len = list.length; i < len; i += 1) {
+      let item = list[i]
+      // Iterate over every key
+      for (let j = 0, keysLen = this.options.keys.length; j < keysLen; j += 1) {
+        let key = this.options.keys[j]
+        if (typeof key !== 'string') {
+          weights[key.name] = {
+            weight: (1 - key.weight) || 1
+          }
+          if (key.weight <= 0 || key.weight > 1) {
+            throw new Error('Key weight has to be > 0 and <= 1')
+          }
+          key = key.name
+        } else {
+          weights[key] = {
+            weight: 1
+          }
+        }
+
+        this._analyze({
+          key, 
+          value: this.options.getFn(item, key), 
+          record: item, 
+          index: i
+        }, {
+          resultMap, 
+          results,
+          tokenSearchers, 
+          fullSearcher
+        })
+      }
+    }
+
+    return { weights, results }
+  }
+
+  _analyze ({ key, value, record, index }, { tokenSearchers = [], fullSearcher = [], resultMap = {}, results = [] }) {
+    // Check if the texvaluet can be searched
+    if (value === undefined || value === null) {
+      return
+    }
+
+    let exists = false
+    let averageScore = -1
+    let numTextMatches = 0
+
+    if (typeof value === 'string') {
+      this._log(`\nKey: ${key === '' ? '-': key}`)
+
+      let mainSearchResult = fullSearcher.search(value)
+      this._log(`Full text: "${value}", score: ${mainSearchResult.score}`)
+      
+      if (this.options.tokenize) {
+        let words = value.split(this.options.tokenSeparator)
+        let scores = []
+
+        for (let i = 0; i < tokenSearchers.length; i += 1) {
+          let tokenSearcher = tokenSearchers[i]
+
+          this._log(`\nPattern: "${tokenSearcher.pattern}"`)
+
+          // let tokenScores = []
+          let hasMatchInText = false
+
+          for (let j = 0; j < words.length; j += 1) {
+            let word = words[j]
+            let tokenSearchResult = tokenSearcher.search(word)
+            let obj = {}
+            if (tokenSearchResult.isMatch) {
+              obj[word] = tokenSearchResult.score
+              exists = true
+              hasMatchInText = true
+              scores.push(tokenSearchResult.score)
+            } else {
+              obj[word] = 1
+              if (!this.options.matchAllTokens) {
+                scores.push(1)
+              }
+            }
+            this._log(`Token: "${word}", score: ${obj[word]}`)
+            // tokenScores.push(obj)
+          }
+
+          if (hasMatchInText) {
+            numTextMatches += 1
+          }
+        }
+
+        averageScore = scores[0]
+        let scoresLen = scores.length
+        for (let i = 1; i < scoresLen; i += 1) {
+          averageScore += scores[i]
+        }
+        averageScore = averageScore / scoresLen
+
+        this._log('Token score average:', averageScore)
+      }
+
+      let finalScore = mainSearchResult.score
+      if (averageScore > -1) {
+        finalScore = (finalScore + averageScore) / 2
+      }
+
+      this._log('Score average:', finalScore)
+
+      let checkTextMatches = (this.options.tokenize && this.options.matchAllTokens) ? numTextMatches >= tokenSearchers.length : true
+
+      this._log(`\nCheck Matches: ${checkTextMatches}`)
+
+      // If a match is found, add the item to <rawResults>, including its score
+      if ((exists || mainSearchResult.isMatch) && checkTextMatches) {
+        // Check if the item already exists in our results
+        let existingResult = resultMap[index]
+
+        if (existingResult) {
+          // Use the lowest score
+          // existingResult.score, bitapResult.score
+          existingResult.output.push({
+            key: key,
+            score: finalScore,
+            matchedIndices: mainSearchResult.matchedIndices
+          })
+        } else {
+          // Add it to the raw result list
+          resultMap[index] = {
+            item: record,
+            output: [{
+              key: key,
+              score: finalScore,
+              matchedIndices: mainSearchResult.matchedIndices
+            }]
+          }
+
+          results.push(resultMap[index])
+        }
+      }
+    } else if (isArray(value)) {
+      for (let i = 0, len = value.length; i < len; i += 1) {
+        this._analyze({
+          key, 
+          value: value[i], 
+          record, 
+          index
+        }, {
+          resultMap, 
+          results,
+          tokenSearchers,
+          fullSearcher
+        })
+      }
+    }
+  }
+
+  _computeScore (weights, results) {
+    this._log('\n\nComputing score:\n')
+
+    for (let i = 0, len = results.length; i < len; i += 1) {
+      const output = results[i].output
+      const scoreLen = output.length
+
+      let totalScore = 0
+      let bestScore = 1
+
+      for (let j = 0; j < scoreLen; j += 1) {
+        let score = output[j].score
+        let weight = weights ? weights[output[j].key].weight : 1
+        let nScore = score * weight
+
+        if (weight !== 1) {
+          bestScore = Math.min(bestScore, nScore)
+        } else {
+          output[j].nScore = nScore
+          totalScore += nScore
+        }
+      }
+      
+      results[i].score = bestScore === 1 ? totalScore / scoreLen : bestScore
+
+      this._log(results[i])
+    }
+  }
+
+  _sort (results) {
+    this._log('\n\nSorting....')
+    results.sort(this.options.sortFn)
+  }
+
+  _format (results) {
+    const finalOutput = []
+
+    this._log('\n\nOutput:\n\n', results)
+
+    let transformers = []
+
+    if (this.options.includeMatches) {
+      transformers.push((result, data) => {
+        const output = result.output
+        data.matches = []
+
+        for (let i = 0, len = output.length; i < len; i += 1) {
+          let item = output[i]
+          let obj = {
+            indices: item.matchedIndices
+          }
+          if (item.key) {
+            obj.key = item.key
+          }
+          data.matches.push(obj)
+        }
+      })
+    }
+    
+    if (this.options.includeScore) {
+      transformers.push((result, data) => {
+        data.score = result.score
+      })
+    }
+
+    for (let i = 0, len = results.length; i < len; i += 1) {
+      const result = results[i]
+
+      if (this.options.id) {
+        result.item = this.options.getFn(result.item, this.options.id)[0]
+      }
+
+      if (!transformers.length) {
+        finalOutput.push(result.item)
+        continue
+      }
+
+      const data = {
+        item: result.item
+      }
+
+      for (let j = 0, len = transformers.length; j < len; j += 1) {
+        transformers[j](result, data)
+      }
+
+      finalOutput.push(data)
+    }
+
+    return finalOutput
+  }
+
+  _log () {
+    if (this.options.verbose) {
+      console.log(...arguments)
+    }
+  }
+}
+
+module.exports = Fuse

test/books.json → test/fixtures/books.json


+ 36 - 36
test/fuse-test.js

@@ -1,12 +1,13 @@
-var assert = require('assert'),
-  vows = require('vows'),
-  Fuse = require('../src/fuse')
+const assert = require('assert')
+const vows = require('vows')
+const Fuse = require('../dist/fuse')
+const books = require('./fixtures/books.json')
 
-var verbose = false
+const verbose = false
 
 vows.describe('Flat list of strings: ["Apple", "Orange", "Banana"]').addBatch({
   'Flat:': {
-    topic: function () {
+    topic: function() {
       var fruits = ['Apple', 'Orange', 'Banana']
       var fuse = new Fuse(fruits, {
         verbose: verbose
@@ -56,8 +57,7 @@ vows.describe('Flat list of strings: ["Apple", "Orange", "Banana"]').addBatch({
 
 vows.describe('List of books - searching "title" and "author"').addBatch({
   'Books:': {
-    topic: function () {
-      var books = require('./books.json')
+    topic: function() {
       var options = {
         keys: ['title', 'author'],
         verbose: verbose,
@@ -129,7 +129,7 @@ vows.describe('List of books - searching "title" and "author"').addBatch({
 
 vows.describe('Deep key search, with ["title", "author.firstName"]').addBatch({
   'Deep:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'title': "Old Man's War",
         'author': {
@@ -175,7 +175,7 @@ vows.describe('Deep key search, with ["title", "author.firstName"]').addBatch({
 
 vows.describe('Custom search function, with ["title", "author.firstName"]').addBatch({
   'Deep:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'title': "Old Man's War",
         'author': {
@@ -235,10 +235,10 @@ vows.describe('Custom search function, with ["title", "author.firstName"]').addB
 
 vows.describe('Include score in result list: ["Apple", "Orange", "Banana"]').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var fruits = ['Apple', 'Orange', 'Banana']
       var fuse = new Fuse(fruits, {
-        include: ['score'],
+        includeScore: true,
         verbose: verbose
       })
       return fuse
@@ -276,7 +276,7 @@ vows.describe('Include score in result list: ["Apple", "Orange", "Banana"]').add
 
 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",
@@ -310,7 +310,7 @@ vows.describe('Only include ID in results list, with "ISBN"').addBatch({
 
 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",
@@ -323,7 +323,7 @@ vows.describe('Include both ID and score in results list').addBatch({
       var options = {
         keys: ['title', 'author'],
         id: 'ISBN',
-        include: ['score'],
+        includeScore: true,
         verbose: verbose
       }
       var fuse = new Fuse(books, options)
@@ -349,7 +349,7 @@ vows.describe('Include both ID and score in results list').addBatch({
 
 vows.describe('Search when IDs are numbers').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'ISBN': 1111,
         'title': "Old Man's War",
@@ -362,7 +362,7 @@ vows.describe('Search when IDs are numbers').addBatch({
       var options = {
         keys: ['title', 'author'],
         id: 'ISBN',
-        include: ['score'],
+        includeScore: true,
         verbose: verbose
       }
       var fuse = new Fuse(books, options)
@@ -388,7 +388,7 @@ vows.describe('Search when IDs are numbers').addBatch({
 
 vows.describe('Recurse into arrays').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'ISBN': '0765348276',
         'title': "Old Man's War",
@@ -431,7 +431,7 @@ vows.describe('Recurse into arrays').addBatch({
 
 vows.describe('Recurse into objects in arrays').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'ISBN': '0765348276',
         'title': "Old Man's War",
@@ -486,7 +486,7 @@ vows.describe('Recurse into objects in arrays').addBatch({
 
 vows.describe('Searching by ID').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'ISBN': 'A',
         'title': "Old Man's War",
@@ -521,7 +521,7 @@ vows.describe('Searching by ID').addBatch({
 
 vows.describe('Set new list on Fuse').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var fruits = ['Apple', 'Orange', 'Banana']
       var vegetables = ['Onion', 'Lettuce', 'Broccoli']
 
@@ -548,7 +548,7 @@ vows.describe('Set new list on Fuse').addBatch({
 
 vows.describe('Searching by nested ID').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var books = [{
         'ISBN': {
           'name': 'A'
@@ -587,7 +587,7 @@ vows.describe('Searching by nested ID').addBatch({
 
 vows.describe('Searching list').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var items = ['FH Mannheim', 'University Mannheim']
       var fuse = new Fuse(items)
       return fuse
@@ -609,7 +609,7 @@ vows.describe('Searching list').addBatch({
 
 vows.describe('Searching list').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var items = [
         'Borwaila hamlet',
         'Bobe hamlet',
@@ -617,7 +617,7 @@ vows.describe('Searching list').addBatch({
         'Boma hamlet']
 
       var fuse = new Fuse(items, {
-        include: ['score'],
+        includeScore: true,
         verbose: verbose
       })
       return fuse
@@ -639,8 +639,7 @@ vows.describe('Searching list').addBatch({
 
 vows.describe('List of books - searching for long pattern length > 32').addBatch({
   'Books:': {
-    topic: function () {
-      var books = require('./books.json')
+    topic: function() {
       var options = {
         keys: ['title'],
         verbose: verbose
@@ -668,7 +667,7 @@ vows.describe('List of books - searching for long pattern length > 32').addBatch
 
 vows.describe('Weighted search').addBatch({
   'Books:': {
-    topic: function () {
+    topic: function() {
       var items = [{
         title: "Old Man's War fiction",
         author: 'John X',
@@ -748,14 +747,15 @@ vows.describe('Weighted search').addBatch({
 
 vows.describe('Search location').addBatch({
   'Books:': {
-    topic: function () {
+    topic: function() {
       var items = [{
         name: 'Hello World'
       }]
       var options = {
         keys: ['name'],
         verbose: verbose,
-        include: ['score', 'matches']
+        includeScore: true,
+        includeMatches: true
       }
       var fuse = new Fuse(items, options)
       return fuse
@@ -781,7 +781,7 @@ vows.describe('Search location').addBatch({
 
 vows.describe('Search with match all tokens: ["AustralianSuper - Corporate Division", "Aon Master Trust - Corporate Super", "Promina Corporate Superannuation Fund", "Workforce Superannuation Corporate", "IGT (Australia) Pty Ltd Superannuation Fund"]').addBatch({
   'Flat:': {
-    topic: function () {
+    topic: function() {
       var items = [
         'AustralianSuper - Corporate Division',
         'Aon Master Trust - Corporate Super',
@@ -870,11 +870,11 @@ vows.describe('Search with match all tokens: ["AustralianSuper - Corporate Divis
 
 vows.describe('Searching with default options').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var items = ['t te tes test tes te t'];
 
       var fuse = new Fuse(items, {
-        include: ['matches'],
+        includeMatches: true,
         verbose: verbose
       })
       return fuse
@@ -897,11 +897,11 @@ vows.describe('Searching with default options').addBatch({
 
 vows.describe('Searching with findallmatches options').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var items = ['t te tes test tes te t'];
 
       var fuse = new Fuse(items, {
-        include: ['matches'],
+        includeMatches: true,
         findAllMatches: true,
         verbose: verbose
       })
@@ -925,11 +925,11 @@ vows.describe('Searching with findallmatches options').addBatch({
 
 vows.describe('Searching with minMatchCharLength options').addBatch({
   'Options:': {
-    topic: function () {
+    topic: function() {
       var items = ['t te tes test tes te t'];
 
       var fuse = new Fuse(items, {
-        include: ['matches'],
+        includeMatches: true,
         minMatchCharLength: 2,
         verbose: verbose
       })

File diff suppressed because it is too large
+ 0 - 87326
test/users.json


+ 54 - 0
webpack.config.js

@@ -0,0 +1,54 @@
+const webpack = require('webpack')
+const path = require('path')
+const fs = require('fs')
+const package = require('./package.json')
+
+const LIBRARY_NAME = 'fuse'
+const VERSION = package.version
+const AUTHOR = package.author
+const HOMEPAGE = package.homepage
+
+const UglifyJsPlugin = webpack.optimize.UglifyJsPlugin
+const env = process.env.WEBPACK_ENV
+
+let copyright = fs.readFileSync('COPYRIGHT.txt', 'UTF8')
+let outputFile
+let plugins = [
+  new webpack.BannerPlugin({
+    banner: copyright
+      .replace('{VERSION}', `v${VERSION}`)
+      .replace('{AUTHOR_URL}', `${AUTHOR.url}`)
+      .replace('{HOMEPAGE}', `${HOMEPAGE}`),
+    entryOnly: true
+  })
+]
+
+if (env === 'build') {
+  plugins.push(new UglifyJsPlugin({ minimize: true }))
+  outputFile = `${LIBRARY_NAME}.min.js`
+} else {
+  outputFile = `${LIBRARY_NAME}.js`
+}
+
+const config = {
+  entry: __dirname + './src/index.js',
+  devtool: 'source-map',
+  entry: './src',
+  output: {
+    path: __dirname + '/dist',
+    filename: outputFile,
+    library: 'Fuse',
+    libraryTarget: 'umd',
+    umdNamedDefine: true
+  },
+  module: {
+    loaders: [{
+      test: /(\.js)$/,
+      loader: 'babel-loader',
+      exclude: /(node_modules)/
+    }]
+  },
+  plugins: plugins
+}
+
+module.exports = config