Skip to content

Commit

Permalink
Update version to 0.0.12
Browse files Browse the repository at this point in the history
Fix #8.
Handle long, nearly-matching queries better.
Add config.maxIterations to limit the number of loops that can be generated by long near matches.
Use the location param of indexOf to avoid creating a new string every time we look for the query.
Use default params in the Range constructor.
Add test of worst-case matching query that fits within maxIterations.
Add test of quickScore() with no params.
  • Loading branch information
fwextensions committed Apr 25, 2021
1 parent 0cf26a7 commit 2bd2fcb
Show file tree
Hide file tree
Showing 10 changed files with 296 additions and 252 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## [0.0.12](https://github.com/fwextensions/quick-score/releases/tag/v0.0.12) - 2021-04-24

### Fixed

- Limit the number of loops inside the `quickScore()` function so that long, nearly-matching queries don't take too long before returning a 0 score. Added `config.maxIterations` to control the number of loops.
- Update `devDependencies` to latest packages.


## [0.0.11](https://github.com/fwextensions/quick-score/releases/tag/v0.0.11) - 2021-03-26

### Added
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

QuickScore improves on the original Quicksilver algorithm by tuning the scoring for long strings, such as webpage titles or URLs, so that the order of the search results makes more sense. It's used by the [QuicKey extension for Chrome](https://chrome.google.com/webstore/detail/quickey-%E2%80%93-the-quick-tab-s/ldlghkoiihaelfnggonhjnfiabmaficg) to enable users to easily find an open tab via search.

QuickScore is fast, dependency-free, and is less than 2KB when minified and gzipped.
QuickScore is fast, dependency-free, and is just 2KB when minified and gzipped.


## Demo
Expand Down Expand Up @@ -142,10 +142,11 @@ Each item in the results array has a few more properties when matching against o
* `item`: the object that was scored
* `score`: the highest score from among the individual key scores
* `scoreKey`: the name of the key with the highest score, which will be an empty string if they're all zero
* `scoreValue`: the value of the key with the highest score, which makes it easier to access if it's a nested string
* `scores`: a hash of the individual scores for each key
* `matches`: a hash of arrays that specify the character ranges of the query match for each key

When two items have the same score, they're sorted alphabetically and case-insensitively on the first key in the keys array. In the example above, that would be `title`.
When two items have the same score, they're sorted alphabetically and case-insensitively on the key specified by the `sortKey` option, which defaults to the first item in the keys array. In the example above, that would be `title`.

Each result item also has a `_` property, which caches transformed versions of the item's strings, and might contain additional internal metadata in the future. It can be ignored.

Expand Down Expand Up @@ -185,7 +186,7 @@ Many search interfaces highlight the letters in each item that match what the us
This function is an example of how an item could be highlighted using React. It surrounds each sequence of matching letters in a `<mark>` tag and then returns the full string in a `<span>`. You could then style the `<mark>` tag to be bold or a different color to highlight the matches. (Something similar could be done by concatenating plain strings of HTML tags, though you'll need to be careful to escape the substrings.)

```jsx
function highglight(string, matches) {
function highlight(string, matches) {
const substrings = [];
let previousEnd = 0;

Expand Down
434 changes: 214 additions & 220 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "quick-score",
"version": "0.0.11",
"version": "0.0.12",
"description": "A JavaScript string-scoring and fuzzy-matching library based on the Quicksilver algorithm, designed for smart auto-complete.",
"keywords": [
"string",
Expand Down Expand Up @@ -58,14 +58,14 @@
]
},
"devDependencies": {
"@babel/core": "^7.13.10",
"@babel/preset-env": "^7.13.12",
"@babel/core": "^7.13.16",
"@babel/preset-env": "^7.13.15",
"benchmark": "^2.1.4",
"eslint": "^7.22.0",
"eslint": "^7.25.0",
"jest": "^26.6.3",
"jsdoc": "^3.6.6",
"rimraf": "^3.0.2",
"rollup": "^2.42.4",
"rollup": "^2.45.2",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-babel-minify": "^10.0.0",
"rollup-plugin-node-resolve": "^5.2.0"
Expand Down
3 changes: 2 additions & 1 deletion src/QuickScore.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ export class QuickScore {
* - `score`: the highest score from among the individual key scores
* - `scoreKey`: the name of the key with the highest score, which will be
* an empty string if they're all zero
* - `scoreValue`: the value of the key with the highest score
* - `scoreValue`: the value of the key with the highest score, which makes
* it easier to access if it's a nested string
* - `scores`: a hash of the individual scores for each key
* - `matches`: a hash of arrays that specify the character ranges of the
* query match for each key
Expand Down
6 changes: 5 additions & 1 deletion src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ const BaseConfigDefaults = {
})(),
ignoredScore: 0.9,
skippedScore: 0.15,
emptyQueryScore: 0
emptyQueryScore: 0,
// long, nearly-matching queries can generate up to 2^queryLength loops,
// so support worst-case queries up to 16 characters and then give up
// and return 0 for longer queries that may or may not actually match
maxIterations: Math.pow(2, 16)
};
const QSConfigDefaults = {
longStringLength: 150,
Expand Down
35 changes: 23 additions & 12 deletions src/quick-score.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ export function quickScore(
config = DefaultConfig,
stringRange = new Range(0, string.length))
{
if (!query) {
let iterations = 0;

if (query) {
return calcScore(stringRange, new Range(0, query.length), new Range());
} else {
return config.emptyQueryScore;
}

return calcScore(stringRange, new Range(0, query.length), new Range());


function calcScore(
searchRange,
Expand All @@ -58,15 +60,25 @@ export function quickScore(
if (!queryRange.length) {
// deduct some points for all remaining characters
return config.ignoredScore;
}

if (queryRange.length > searchRange.length) {
} else if (queryRange.length > searchRange.length) {
return 0;
}

const initialMatchesLength = matches && matches.length;

for (let i = queryRange.length; i > 0; i--) {
if (iterations > config.maxIterations) {
// a long query that matches the string except for the last
// character can generate 2^queryLength iterations of this
// loop before returning 0, so short-circuit that when we've
// seen too many iterations (bit of an ugly kludge, but it
// avoids locking up the UI if the user somehow types an
// edge-case query)
return 0;
}

iterations++;

const querySubstring = transformedQuery.substring(queryRange.location, queryRange.location + i);
// reduce the length of the search range by the number of chars
// we're skipping in the query, to make sure there's enough string
Expand Down Expand Up @@ -163,16 +175,15 @@ quickScore.createConfig = createConfig;

function getRangeOfSubstring(
string,
substring,
query,
searchRange)
{
const stringToSearch = string.substring(searchRange.location, searchRange.max());
const subStringIndex = stringToSearch.indexOf(substring);
const index = string.indexOf(query, searchRange.location);
const result = new Range();

if (subStringIndex > -1) {
result.location = subStringIndex + searchRange.location;
result.length = substring.length;
if (index > -1 && index < searchRange.max()) {
result.location = index;
result.length = query.length;
}

return result;
Expand Down
13 changes: 4 additions & 9 deletions src/range.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,11 @@ export class Range {
* @param {number} [length=0] - Number of characters in the range.
*/
constructor(
location,
length)
location = -1,
length = 0)
{
if (typeof location == "number") {
this.location = location;
this.length = length;
} else {
this.location = -1;
this.length = 0;
}
this.location = location;
this.length = length;
}


Expand Down
2 changes: 1 addition & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ describe("Exported functions test", function() {
test.each([
["QuickScore", 0, QuickScore],
["quickScore", 0, quickScore],
["Range", 2, Range]
["Range", 0, Range]
])("%s() should have %i arguments", (name, arity, fn) => {
expect(typeof fn).toBe("function");
expect(fn.length).toBe(arity);
Expand Down
30 changes: 30 additions & 0 deletions test/quick-score.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ describe("Search ranges", function() {
});


describe("Edge cases", () => {
test("16-character edge-case query should return a score", () => {
const maxQueryLength = 16;
const alphabet = "abcdefghijklmnopqrstuvwxyz";
const goodQuery = alphabet.slice(0, maxQueryLength);
const goodString = goodQuery.split("").join("|") + goodQuery.slice(0, -1) + "@";
const tooLongQuery = alphabet.slice(0, maxQueryLength + 1);
const tooLongString = tooLongQuery.split("").join("|") + tooLongQuery.slice(0, -1) + "@";
const unlimitedIterationsConfig = quickScore.createConfig({ maxIterations: Infinity });

// both strings include almost all of the query as a sequence at the
// end of the string, and the entire query separated by | at the
// beginning. this will generate a worst-case 2^queryLength number
// of loops before a match is found and a score returned. the
// goodQuery will fit within config.maxIterations, but tooLongQuery
// will hit the limit and return 0, even though a match would be
// found if it was allowed to continue looping.
expect(quickScore(goodString, goodQuery)).toBeNearly(.06526);
expect(quickScore(tooLongString, tooLongQuery)).toBe(0);
expect(quickScore(tooLongString, tooLongQuery, undefined, undefined,
undefined, unlimitedIterationsConfig)).toBeNearly(.06126);
});

test("quickScore() returns 0", () => {
// all the parameters have default values
expect(quickScore()).toBe(0);
});
})


// these older scores, from not dividing the reduction of the remaining score
// by half, match what the old NS Quicksilver code returns. the scores were
// changed in TestQSSense.m in this commit:
Expand Down

0 comments on commit 2bd2fcb

Please sign in to comment.