Files
azuredatastudio/src/vs/base/common/fuzzyScorer.ts
Karl Burtram 867a963882 Merge from vscode bead496a613e475819f89f08e9e882b841bc1fe8 (#14883)
* Merge from vscode bead496a613e475819f89f08e9e882b841bc1fe8

* Bump distro

* Upgrade GCC to 4.9 due to yarn install errors

* Update build image

* Fix bootstrap base url

* Bump distro

* Fix build errors

* Update source map file

* Disable checkbox for blocking migration issues (#15131)

* disable checkbox for blocking issues

* wip

* disable checkbox fixes

* fix strings

* Remove duplicate tsec command

* Default to off for tab color if settings not present

* re-skip failing tests

* Fix mocha error

* Bump sqlite version & fix notebooks search view

* Turn off esbuild warnings

* Update esbuild log level

* Fix overflowactionbar tests

* Fix ts-ignore in dropdown tests

* cleanup/fixes

* Fix hygiene

* Bundle in entire zone.js module

* Remove extra constructor param

* bump distro for web compile break

* bump distro for web compile break v2

* Undo log level change

* New distro

* Fix integration test scripts

* remove the "no yarn.lock changes" workflow

* fix scripts v2

* Update unit test scripts

* Ensure ads-kerberos2 updates in .vscodeignore

* Try fix unit tests

* Upload crash reports

* remove nogpu

* always upload crashes

* Use bash script

* Consolidate data/ext dir names

* Create in tmp directory

Co-authored-by: chlafreniere <hichise@gmail.com>
Co-authored-by: Christopher Suh <chsuh@microsoft.com>
Co-authored-by: chgagnon <chgagnon@microsoft.com>
2021-04-27 14:01:59 -07:00

883 lines
27 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { compareAnything } from 'vs/base/common/comparers';
import { matchesPrefix, IMatch, isUpper, fuzzyScore, createMatches as createFuzzyMatches } from 'vs/base/common/filters';
import { sep } from 'vs/base/common/path';
import { isWindows, isLinux } from 'vs/base/common/platform';
import { stripWildcards, equalsIgnoreCase } from 'vs/base/common/strings';
import { CharCode } from 'vs/base/common/charCode';
//#region Fuzzy scorer
export type FuzzyScore = [number /* score */, number[] /* match positions */];
export type FuzzyScorerCache = { [key: string]: IItemScore };
const NO_MATCH = 0;
const NO_SCORE: FuzzyScore = [NO_MATCH, []];
// const DEBUG = false;
// const DEBUG_MATRIX = false;
export function scoreFuzzy(target: string, query: string, queryLower: string, fuzzy: boolean): FuzzyScore {
if (!target || !query) {
return NO_SCORE; // return early if target or query are undefined
}
const targetLength = target.length;
const queryLength = query.length;
if (targetLength < queryLength) {
return NO_SCORE; // impossible for query to be contained in target
}
// if (DEBUG) {
// console.group(`Target: ${target}, Query: ${query}`);
// }
const targetLower = target.toLowerCase();
// When not searching fuzzy, we require the query to be contained fully
// in the target string contiguously.
if (!fuzzy) {
if (!targetLower.includes(queryLower)) {
// if (DEBUG) {
// console.log(`Characters not matching consecutively ${queryLower} within ${targetLower}`);
// }
return NO_SCORE;
}
}
const res = doScoreFuzzy(query, queryLower, queryLength, target, targetLower, targetLength);
// if (DEBUG) {
// console.log(`%cFinal Score: ${res[0]}`, 'font-weight: bold');
// console.groupEnd();
// }
return res;
}
function doScoreFuzzy(query: string, queryLower: string, queryLength: number, target: string, targetLower: string, targetLength: number): FuzzyScore {
const scores: number[] = [];
const matches: number[] = [];
//
// Build Scorer Matrix:
//
// The matrix is composed of query q and target t. For each index we score
// q[i] with t[i] and compare that with the previous score. If the score is
// equal or larger, we keep the match. In addition to the score, we also keep
// the length of the consecutive matches to use as boost for the score.
//
// t a r g e t
// q
// u
// e
// r
// y
//
for (let queryIndex = 0; queryIndex < queryLength; queryIndex++) {
const queryIndexOffset = queryIndex * targetLength;
const queryIndexPreviousOffset = queryIndexOffset - targetLength;
const queryIndexGtNull = queryIndex > 0;
const queryCharAtIndex = query[queryIndex];
const queryLowerCharAtIndex = queryLower[queryIndex];
for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) {
const targetIndexGtNull = targetIndex > 0;
const currentIndex = queryIndexOffset + targetIndex;
const leftIndex = currentIndex - 1;
const diagIndex = queryIndexPreviousOffset + targetIndex - 1;
const leftScore = targetIndexGtNull ? scores[leftIndex] : 0;
const diagScore = queryIndexGtNull && targetIndexGtNull ? scores[diagIndex] : 0;
const matchesSequenceLength = queryIndexGtNull && targetIndexGtNull ? matches[diagIndex] : 0;
// If we are not matching on the first query character any more, we only produce a
// score if we had a score previously for the last query index (by looking at the diagScore).
// This makes sure that the query always matches in sequence on the target. For example
// given a target of "ede" and a query of "de", we would otherwise produce a wrong high score
// for query[1] ("e") matching on target[0] ("e") because of the "beginning of word" boost.
let score: number;
if (!diagScore && queryIndexGtNull) {
score = 0;
} else {
score = computeCharScore(queryCharAtIndex, queryLowerCharAtIndex, target, targetLower, targetIndex, matchesSequenceLength);
}
// We have a score and its equal or larger than the left score
// Match: sequence continues growing from previous diag value
// Score: increases by diag score value
if (score && diagScore + score >= leftScore) {
matches[currentIndex] = matchesSequenceLength + 1;
scores[currentIndex] = diagScore + score;
}
// We either have no score or the score is lower than the left score
// Match: reset to 0
// Score: pick up from left hand side
else {
matches[currentIndex] = NO_MATCH;
scores[currentIndex] = leftScore;
}
}
}
// Restore Positions (starting from bottom right of matrix)
const positions: number[] = [];
let queryIndex = queryLength - 1;
let targetIndex = targetLength - 1;
while (queryIndex >= 0 && targetIndex >= 0) {
const currentIndex = queryIndex * targetLength + targetIndex;
const match = matches[currentIndex];
if (match === NO_MATCH) {
targetIndex--; // go left
} else {
positions.push(targetIndex);
// go up and left
queryIndex--;
targetIndex--;
}
}
// Print matrix
// if (DEBUG_MATRIX) {
// printMatrix(query, target, matches, scores);
// }
return [scores[queryLength * targetLength - 1], positions.reverse()];
}
function computeCharScore(queryCharAtIndex: string, queryLowerCharAtIndex: string, target: string, targetLower: string, targetIndex: number, matchesSequenceLength: number): number {
let score = 0;
if (!considerAsEqual(queryLowerCharAtIndex, targetLower[targetIndex])) {
return score; // no match of characters
}
// Character match bonus
score += 1;
// if (DEBUG) {
// console.groupCollapsed(`%cCharacter match bonus: +1 (char: ${queryLowerCharAtIndex} at index ${targetIndex}, total score: ${score})`, 'font-weight: normal');
// }
// Consecutive match bonus
if (matchesSequenceLength > 0) {
score += (matchesSequenceLength * 5);
// if (DEBUG) {
// console.log(`Consecutive match bonus: +${matchesSequenceLength * 5}`);
// }
}
// Same case bonus
if (queryCharAtIndex === target[targetIndex]) {
score += 1;
// if (DEBUG) {
// console.log('Same case bonus: +1');
// }
}
// Start of word bonus
if (targetIndex === 0) {
score += 8;
// if (DEBUG) {
// console.log('Start of word bonus: +8');
// }
}
else {
// After separator bonus
const separatorBonus = scoreSeparatorAtPos(target.charCodeAt(targetIndex - 1));
if (separatorBonus) {
score += separatorBonus;
// if (DEBUG) {
// console.log(`After separtor bonus: +${separatorBonus}`);
// }
}
// Inside word upper case bonus (camel case)
else if (isUpper(target.charCodeAt(targetIndex))) {
score += 2;
// if (DEBUG) {
// console.log('Inside word upper case bonus: +2');
// }
}
}
// if (DEBUG) {
// console.groupEnd();
// }
return score;
}
function considerAsEqual(a: string, b: string): boolean {
if (a === b) {
return true;
}
// Special case path spearators: ignore platform differences
if (a === '/' || a === '\\') {
return b === '/' || b === '\\';
}
return false;
}
function scoreSeparatorAtPos(charCode: number): number {
switch (charCode) {
case CharCode.Slash:
case CharCode.Backslash:
return 5; // prefer path separators...
case CharCode.Underline:
case CharCode.Dash:
case CharCode.Period:
case CharCode.Space:
case CharCode.SingleQuote:
case CharCode.DoubleQuote:
case CharCode.Colon:
return 4; // ...over other separators
default:
return 0;
}
}
// function printMatrix(query: string, target: string, matches: number[], scores: number[]): void {
// console.log('\t' + target.split('').join('\t'));
// for (let queryIndex = 0; queryIndex < query.length; queryIndex++) {
// let line = query[queryIndex] + '\t';
// for (let targetIndex = 0; targetIndex < target.length; targetIndex++) {
// const currentIndex = queryIndex * target.length + targetIndex;
// line = line + 'M' + matches[currentIndex] + '/' + 'S' + scores[currentIndex] + '\t';
// }
// console.log(line);
// }
// }
//#endregion
//#region Alternate fuzzy scorer implementation that is e.g. used for symbols
export type FuzzyScore2 = [number | undefined /* score */, IMatch[]];
const NO_SCORE2: FuzzyScore2 = [undefined, []];
export function scoreFuzzy2(target: string, query: IPreparedQuery | IPreparedQueryPiece, patternStart = 0, wordStart = 0): FuzzyScore2 {
// Score: multiple inputs
const preparedQuery = query as IPreparedQuery;
if (preparedQuery.values && preparedQuery.values.length > 1) {
return doScoreFuzzy2Multiple(target, preparedQuery.values, patternStart, wordStart);
}
// Score: single input
return doScoreFuzzy2Single(target, query, patternStart, wordStart);
}
function doScoreFuzzy2Multiple(target: string, query: IPreparedQueryPiece[], patternStart: number, wordStart: number): FuzzyScore2 {
let totalScore = 0;
const totalMatches: IMatch[] = [];
for (const queryPiece of query) {
const [score, matches] = doScoreFuzzy2Single(target, queryPiece, patternStart, wordStart);
if (typeof score !== 'number') {
// if a single query value does not match, return with
// no score entirely, we require all queries to match
return NO_SCORE2;
}
totalScore += score;
totalMatches.push(...matches);
}
// if we have a score, ensure that the positions are
// sorted in ascending order and distinct
return [totalScore, normalizeMatches(totalMatches)];
}
function doScoreFuzzy2Single(target: string, query: IPreparedQueryPiece, patternStart: number, wordStart: number): FuzzyScore2 {
const score = fuzzyScore(query.original, query.originalLowercase, patternStart, target, target.toLowerCase(), wordStart, true);
if (!score) {
return NO_SCORE2;
}
return [score[0], createFuzzyMatches(score)];
}
//#endregion
//#region Item (label, description, path) scorer
/**
* Scoring on structural items that have a label and optional description.
*/
export interface IItemScore {
/**
* Overall score.
*/
score: number;
/**
* Matches within the label.
*/
labelMatch?: IMatch[];
/**
* Matches within the description.
*/
descriptionMatch?: IMatch[];
}
const NO_ITEM_SCORE: IItemScore = Object.freeze({ score: 0 });
export interface IItemAccessor<T> {
/**
* Just the label of the item to score on.
*/
getItemLabel(item: T): string | undefined;
/**
* The optional description of the item to score on.
*/
getItemDescription(item: T): string | undefined;
/**
* If the item is a file, the path of the file to score on.
*/
getItemPath(file: T): string | undefined;
}
const PATH_IDENTITY_SCORE = 1 << 18;
const LABEL_PREFIX_SCORE_THRESHOLD = 1 << 17;
const LABEL_SCORE_THRESHOLD = 1 << 16;
export function scoreItemFuzzy<T>(item: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor<T>, cache: FuzzyScorerCache): IItemScore {
if (!item || !query.normalized) {
return NO_ITEM_SCORE; // we need an item and query to score on at least
}
const label = accessor.getItemLabel(item);
if (!label) {
return NO_ITEM_SCORE; // we need a label at least
}
const description = accessor.getItemDescription(item);
// in order to speed up scoring, we cache the score with a unique hash based on:
// - label
// - description (if provided)
// - query (normalized)
// - number of query pieces (i.e. 'hello world' and 'helloworld' are different)
// - whether fuzzy matching is enabled or not
let cacheHash: string;
if (description) {
cacheHash = `${label}${description}${query.normalized}${Array.isArray(query.values) ? query.values.length : ''}${fuzzy}`;
} else {
cacheHash = `${label}${query.normalized}${Array.isArray(query.values) ? query.values.length : ''}${fuzzy}`;
}
const cached = cache[cacheHash];
if (cached) {
return cached;
}
const itemScore = doScoreItemFuzzy(label, description, accessor.getItemPath(item), query, fuzzy);
cache[cacheHash] = itemScore;
return itemScore;
}
function doScoreItemFuzzy(label: string, description: string | undefined, path: string | undefined, query: IPreparedQuery, fuzzy: boolean): IItemScore {
const preferLabelMatches = !path || !query.containsPathSeparator;
// Treat identity matches on full path highest
if (path && (isLinux ? query.pathNormalized === path : equalsIgnoreCase(query.pathNormalized, path))) {
return { score: PATH_IDENTITY_SCORE, labelMatch: [{ start: 0, end: label.length }], descriptionMatch: description ? [{ start: 0, end: description.length }] : undefined };
}
// Score: multiple inputs
if (query.values && query.values.length > 1) {
return doScoreItemFuzzyMultiple(label, description, path, query.values, preferLabelMatches, fuzzy);
}
// Score: single input
return doScoreItemFuzzySingle(label, description, path, query, preferLabelMatches, fuzzy);
}
function doScoreItemFuzzyMultiple(label: string, description: string | undefined, path: string | undefined, query: IPreparedQueryPiece[], preferLabelMatches: boolean, fuzzy: boolean): IItemScore {
let totalScore = 0;
const totalLabelMatches: IMatch[] = [];
const totalDescriptionMatches: IMatch[] = [];
for (const queryPiece of query) {
const { score, labelMatch, descriptionMatch } = doScoreItemFuzzySingle(label, description, path, queryPiece, preferLabelMatches, fuzzy);
if (score === NO_MATCH) {
// if a single query value does not match, return with
// no score entirely, we require all queries to match
return NO_ITEM_SCORE;
}
totalScore += score;
if (labelMatch) {
totalLabelMatches.push(...labelMatch);
}
if (descriptionMatch) {
totalDescriptionMatches.push(...descriptionMatch);
}
}
// if we have a score, ensure that the positions are
// sorted in ascending order and distinct
return {
score: totalScore,
labelMatch: normalizeMatches(totalLabelMatches),
descriptionMatch: normalizeMatches(totalDescriptionMatches)
};
}
function doScoreItemFuzzySingle(label: string, description: string | undefined, path: string | undefined, query: IPreparedQueryPiece, preferLabelMatches: boolean, fuzzy: boolean): IItemScore {
// Prefer label matches if told so or we have no description
if (preferLabelMatches || !description) {
const [labelScore, labelPositions] = scoreFuzzy(label, query.normalized, query.normalizedLowercase, fuzzy);
if (labelScore) {
// If we have a prefix match on the label, we give a much
// higher baseScore to elevate these matches over others
// This ensures that typing a file name wins over results
// that are present somewhere in the label, but not the
// beginning.
const labelPrefixMatch = matchesPrefix(query.normalized, label);
let baseScore: number;
if (labelPrefixMatch) {
baseScore = LABEL_PREFIX_SCORE_THRESHOLD;
// We give another boost to labels that are short, e.g. given
// files "window.ts" and "windowActions.ts" and a query of
// "window", we want "window.ts" to receive a higher score.
// As such we compute the percentage the query has within the
// label and add that to the baseScore.
const prefixLengthBoost = Math.round((query.normalized.length / label.length) * 100);
baseScore += prefixLengthBoost;
} else {
baseScore = LABEL_SCORE_THRESHOLD;
}
return { score: baseScore + labelScore, labelMatch: labelPrefixMatch || createMatches(labelPositions) };
}
}
// Finally compute description + label scores if we have a description
if (description) {
let descriptionPrefix = description;
if (!!path) {
descriptionPrefix = `${description}${sep}`; // assume this is a file path
}
const descriptionPrefixLength = descriptionPrefix.length;
const descriptionAndLabel = `${descriptionPrefix}${label}`;
const [labelDescriptionScore, labelDescriptionPositions] = scoreFuzzy(descriptionAndLabel, query.normalized, query.normalizedLowercase, fuzzy);
if (labelDescriptionScore) {
const labelDescriptionMatches = createMatches(labelDescriptionPositions);
const labelMatch: IMatch[] = [];
const descriptionMatch: IMatch[] = [];
// We have to split the matches back onto the label and description portions
labelDescriptionMatches.forEach(h => {
// Match overlaps label and description part, we need to split it up
if (h.start < descriptionPrefixLength && h.end > descriptionPrefixLength) {
labelMatch.push({ start: 0, end: h.end - descriptionPrefixLength });
descriptionMatch.push({ start: h.start, end: descriptionPrefixLength });
}
// Match on label part
else if (h.start >= descriptionPrefixLength) {
labelMatch.push({ start: h.start - descriptionPrefixLength, end: h.end - descriptionPrefixLength });
}
// Match on description part
else {
descriptionMatch.push(h);
}
});
return { score: labelDescriptionScore, labelMatch, descriptionMatch };
}
}
return NO_ITEM_SCORE;
}
function createMatches(offsets: number[] | undefined): IMatch[] {
const ret: IMatch[] = [];
if (!offsets) {
return ret;
}
let last: IMatch | undefined;
for (const pos of offsets) {
if (last && last.end === pos) {
last.end += 1;
} else {
last = { start: pos, end: pos + 1 };
ret.push(last);
}
}
return ret;
}
function normalizeMatches(matches: IMatch[]): IMatch[] {
// sort matches by start to be able to normalize
const sortedMatches = matches.sort((matchA, matchB) => {
return matchA.start - matchB.start;
});
// merge matches that overlap
const normalizedMatches: IMatch[] = [];
let currentMatch: IMatch | undefined = undefined;
for (const match of sortedMatches) {
// if we have no current match or the matches
// do not overlap, we take it as is and remember
// it for future merging
if (!currentMatch || !matchOverlaps(currentMatch, match)) {
currentMatch = match;
normalizedMatches.push(match);
}
// otherwise we merge the matches
else {
currentMatch.start = Math.min(currentMatch.start, match.start);
currentMatch.end = Math.max(currentMatch.end, match.end);
}
}
return normalizedMatches;
}
function matchOverlaps(matchA: IMatch, matchB: IMatch): boolean {
if (matchA.end < matchB.start) {
return false; // A ends before B starts
}
if (matchB.end < matchA.start) {
return false; // B ends before A starts
}
return true;
}
//#endregion
//#region Comparers
export function compareItemsByFuzzyScore<T>(itemA: T, itemB: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor<T>, cache: FuzzyScorerCache): number {
const itemScoreA = scoreItemFuzzy(itemA, query, fuzzy, accessor, cache);
const itemScoreB = scoreItemFuzzy(itemB, query, fuzzy, accessor, cache);
const scoreA = itemScoreA.score;
const scoreB = itemScoreB.score;
// 1.) identity matches have highest score
if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) {
if (scoreA !== scoreB) {
return scoreA === PATH_IDENTITY_SCORE ? -1 : 1;
}
}
// 2.) matches on label are considered higher compared to label+description matches
if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) {
if (scoreA !== scoreB) {
return scoreA > scoreB ? -1 : 1;
}
// prefer more compact matches over longer in label (unless this is a prefix match where
// longer prefix matches are actually preferred)
if (scoreA < LABEL_PREFIX_SCORE_THRESHOLD && scoreB < LABEL_PREFIX_SCORE_THRESHOLD) {
const comparedByMatchLength = compareByMatchLength(itemScoreA.labelMatch, itemScoreB.labelMatch);
if (comparedByMatchLength !== 0) {
return comparedByMatchLength;
}
}
// prefer shorter labels over longer labels
const labelA = accessor.getItemLabel(itemA) || '';
const labelB = accessor.getItemLabel(itemB) || '';
if (labelA.length !== labelB.length) {
return labelA.length - labelB.length;
}
}
// 3.) compare by score in label+description
if (scoreA !== scoreB) {
return scoreA > scoreB ? -1 : 1;
}
// 4.) scores are identical: prefer matches in label over non-label matches
const itemAHasLabelMatches = Array.isArray(itemScoreA.labelMatch) && itemScoreA.labelMatch.length > 0;
const itemBHasLabelMatches = Array.isArray(itemScoreB.labelMatch) && itemScoreB.labelMatch.length > 0;
if (itemAHasLabelMatches && !itemBHasLabelMatches) {
return -1;
} else if (itemBHasLabelMatches && !itemAHasLabelMatches) {
return 1;
}
// 5.) scores are identical: prefer more compact matches (label and description)
const itemAMatchDistance = computeLabelAndDescriptionMatchDistance(itemA, itemScoreA, accessor);
const itemBMatchDistance = computeLabelAndDescriptionMatchDistance(itemB, itemScoreB, accessor);
if (itemAMatchDistance && itemBMatchDistance && itemAMatchDistance !== itemBMatchDistance) {
return itemBMatchDistance > itemAMatchDistance ? -1 : 1;
}
// 6.) scores are identical: start to use the fallback compare
return fallbackCompare(itemA, itemB, query, accessor);
}
function computeLabelAndDescriptionMatchDistance<T>(item: T, score: IItemScore, accessor: IItemAccessor<T>): number {
let matchStart: number = -1;
let matchEnd: number = -1;
// If we have description matches, the start is first of description match
if (score.descriptionMatch && score.descriptionMatch.length) {
matchStart = score.descriptionMatch[0].start;
}
// Otherwise, the start is the first label match
else if (score.labelMatch && score.labelMatch.length) {
matchStart = score.labelMatch[0].start;
}
// If we have label match, the end is the last label match
// If we had a description match, we add the length of the description
// as offset to the end to indicate this.
if (score.labelMatch && score.labelMatch.length) {
matchEnd = score.labelMatch[score.labelMatch.length - 1].end;
if (score.descriptionMatch && score.descriptionMatch.length) {
const itemDescription = accessor.getItemDescription(item);
if (itemDescription) {
matchEnd += itemDescription.length;
}
}
}
// If we have just a description match, the end is the last description match
else if (score.descriptionMatch && score.descriptionMatch.length) {
matchEnd = score.descriptionMatch[score.descriptionMatch.length - 1].end;
}
return matchEnd - matchStart;
}
function compareByMatchLength(matchesA?: IMatch[], matchesB?: IMatch[]): number {
if ((!matchesA && !matchesB) || ((!matchesA || !matchesA.length) && (!matchesB || !matchesB.length))) {
return 0; // make sure to not cause bad comparing when matches are not provided
}
if (!matchesB || !matchesB.length) {
return -1;
}
if (!matchesA || !matchesA.length) {
return 1;
}
// Compute match length of A (first to last match)
const matchStartA = matchesA[0].start;
const matchEndA = matchesA[matchesA.length - 1].end;
const matchLengthA = matchEndA - matchStartA;
// Compute match length of B (first to last match)
const matchStartB = matchesB[0].start;
const matchEndB = matchesB[matchesB.length - 1].end;
const matchLengthB = matchEndB - matchStartB;
// Prefer shorter match length
return matchLengthA === matchLengthB ? 0 : matchLengthB < matchLengthA ? 1 : -1;
}
function fallbackCompare<T>(itemA: T, itemB: T, query: IPreparedQuery, accessor: IItemAccessor<T>): number {
// check for label + description length and prefer shorter
const labelA = accessor.getItemLabel(itemA) || '';
const labelB = accessor.getItemLabel(itemB) || '';
const descriptionA = accessor.getItemDescription(itemA);
const descriptionB = accessor.getItemDescription(itemB);
const labelDescriptionALength = labelA.length + (descriptionA ? descriptionA.length : 0);
const labelDescriptionBLength = labelB.length + (descriptionB ? descriptionB.length : 0);
if (labelDescriptionALength !== labelDescriptionBLength) {
return labelDescriptionALength - labelDescriptionBLength;
}
// check for path length and prefer shorter
const pathA = accessor.getItemPath(itemA);
const pathB = accessor.getItemPath(itemB);
if (pathA && pathB && pathA.length !== pathB.length) {
return pathA.length - pathB.length;
}
// 7.) finally we have equal scores and equal length, we fallback to comparer
// compare by label
if (labelA !== labelB) {
return compareAnything(labelA, labelB, query.normalized);
}
// compare by description
if (descriptionA && descriptionB && descriptionA !== descriptionB) {
return compareAnything(descriptionA, descriptionB, query.normalized);
}
// compare by path
if (pathA && pathB && pathA !== pathB) {
return compareAnything(pathA, pathB, query.normalized);
}
// equal
return 0;
}
//#endregion
//#region Query Normalizer
export interface IPreparedQueryPiece {
/**
* The original query as provided as input.
*/
original: string;
originalLowercase: string;
/**
* Original normalized to platform separators:
* - Windows: \
* - Posix: /
*/
pathNormalized: string;
/**
* In addition to the normalized path, will have
* whitespace and wildcards removed.
*/
normalized: string;
normalizedLowercase: string;
}
export interface IPreparedQuery extends IPreparedQueryPiece {
/**
* Query split by spaces into pieces.
*/
values: IPreparedQueryPiece[] | undefined;
/**
* Whether the query contains path separator(s) or not.
*/
containsPathSeparator: boolean;
}
/**
* Helper function to prepare a search value for scoring by removing unwanted characters
* and allowing to score on multiple pieces separated by whitespace character.
*/
const MULTIPLE_QUERY_VALUES_SEPARATOR = ' ';
export function prepareQuery(original: string): IPreparedQuery {
if (typeof original !== 'string') {
original = '';
}
const originalLowercase = original.toLowerCase();
const { pathNormalized, normalized, normalizedLowercase } = normalizeQuery(original);
const containsPathSeparator = pathNormalized.indexOf(sep) >= 0;
let values: IPreparedQueryPiece[] | undefined = undefined;
const originalSplit = original.split(MULTIPLE_QUERY_VALUES_SEPARATOR);
if (originalSplit.length > 1) {
for (const originalPiece of originalSplit) {
const {
pathNormalized: pathNormalizedPiece,
normalized: normalizedPiece,
normalizedLowercase: normalizedLowercasePiece
} = normalizeQuery(originalPiece);
if (normalizedPiece) {
if (!values) {
values = [];
}
values.push({
original: originalPiece,
originalLowercase: originalPiece.toLowerCase(),
pathNormalized: pathNormalizedPiece,
normalized: normalizedPiece,
normalizedLowercase: normalizedLowercasePiece
});
}
}
}
return { original, originalLowercase, pathNormalized, normalized, normalizedLowercase, values, containsPathSeparator };
}
function normalizeQuery(original: string): { pathNormalized: string, normalized: string, normalizedLowercase: string } {
let pathNormalized: string;
if (isWindows) {
pathNormalized = original.replace(/\//g, sep); // Help Windows users to search for paths when using slash
} else {
pathNormalized = original.replace(/\\/g, sep); // Help macOS/Linux users to search for paths when using backslash
}
const normalized = stripWildcards(pathNormalized).replace(/\s/g, '');
return {
pathNormalized,
normalized,
normalizedLowercase: normalized.toLowerCase()
};
}
export function pieceToQuery(piece: IPreparedQueryPiece): IPreparedQuery;
export function pieceToQuery(pieces: IPreparedQueryPiece[]): IPreparedQuery;
export function pieceToQuery(arg1: IPreparedQueryPiece | IPreparedQueryPiece[]): IPreparedQuery {
if (Array.isArray(arg1)) {
return prepareQuery(arg1.map(piece => piece.original).join(MULTIPLE_QUERY_VALUES_SEPARATOR));
}
return prepareQuery(arg1.original);
}
//#endregion