quickOpenScorer.ts 19.1 KB
Newer Older
1 2 3 4 5
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

6
import { compareAnything } from 'vs/base/common/comparers';
7
import { matchesPrefix, IMatch, matchesCamelCase, isUpper } from 'vs/base/common/filters';
B
Benjamin Pasero 已提交
8 9 10
import { nativeSep } from 'vs/base/common/paths';
import { isWindows, isLinux } from 'vs/base/common/platform';
import { stripWildcards, equalsIgnoreCase } from 'vs/base/common/strings';
B
Benjamin Pasero 已提交
11
import { CharCode } from 'vs/base/common/charCode';
12 13 14 15

export type Score = [number /* score */, number[] /* match positions */];
export type ScorerCache = { [key: string]: IItemScore };

16 17
const NO_MATCH = 0;
const NO_SCORE: Score = [NO_MATCH, []];
18

19 20 21 22
// const DEBUG = false;
// const DEBUG_MATRIX = false;

export function score(target: string, query: string, queryLower: string, fuzzy: boolean): Score {
23
	if (!target || !query) {
24
		return NO_SCORE; // return early if target or query are undefined
25
	}
26

27 28 29 30
	const targetLength = target.length;
	const queryLength = query.length;

	if (targetLength < queryLength) {
31
		return NO_SCORE; // impossible for query to be contained in target
32
	}
33

34 35 36
	// if (DEBUG) {
	// 	console.group(`Target: ${target}, Query: ${query}`);
	// }
37

38 39
	const targetLower = target.toLowerCase();

40
	// When not searching fuzzy, we require the query to be contained fully
41
	// in the target string contiguously.
42
	if (!fuzzy) {
43
		const indexOfQueryInTarget = targetLower.indexOf(queryLower);
44
		if (indexOfQueryInTarget === -1) {
45 46 47
			// if (DEBUG) {
			// 	console.log(`Characters not matching consecutively ${queryLower} within ${targetLower}`);
			// }
48 49 50

			return NO_SCORE;
		}
51 52
	}

53 54 55 56 57 58
	const res = doScore(query, queryLower, queryLength, target, targetLower, targetLength);

	// if (DEBUG) {
	// 	console.log(`%cFinal Score: ${res[0]}`, 'font-weight: bold');
	// 	console.groupEnd();
	// }
59 60 61 62

	return res;
}

63
function doScore(query: string, queryLower: string, queryLength: number, target: string, targetLower: string, targetLength: number): [number, number[]] {
M
Matt Bierner 已提交
64 65
	const scores: number[] = [];
	const matches: number[] = [];
66 67

	//
68 69
	// Build Scorer Matrix:
	//
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
	// 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++) {
		for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) {
			const currentIndex = queryIndex * targetLength + targetIndex;
			const leftIndex = currentIndex - 1;
			const diagIndex = (queryIndex - 1) * targetLength + targetIndex - 1;

88 89
			const leftScore: number = targetIndex > 0 ? scores[leftIndex] : 0;
			const diagScore: number = queryIndex > 0 && targetIndex > 0 ? scores[diagIndex] : 0;
90

91
			const matchesSequenceLength: number = queryIndex > 0 && targetIndex > 0 ? matches[diagIndex] : 0;
92

93 94 95 96 97 98 99 100 101 102 103
			// 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 && queryIndex > 0) {
				score = 0;
			} else {
				score = computeCharScore(query, queryLower, queryIndex, target, targetLower, targetIndex, matchesSequenceLength);
			}
104 105 106 107 108 109 110 111

			// 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;
			}
112

113 114 115 116 117 118 119 120
			// 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;
			}
		}
121 122
	}

123
	// Restore Positions (starting from bottom right of matrix)
M
Matt Bierner 已提交
124
	const positions: number[] = [];
125 126 127 128 129 130 131 132 133 134 135 136 137 138
	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--;
		}
139 140
	}

141 142
	// Print matrix
	// if (DEBUG_MATRIX) {
143
	// printMatrix(query, target, matches, scores);
144 145 146
	// }

	return [scores[queryLength * targetLength - 1], positions.reverse()];
147 148
}

149
function computeCharScore(query: string, queryLower: string, queryIndex: number, target: string, targetLower: string, targetIndex: number, matchesSequenceLength: number): number {
150
	let score = 0;
151

152 153 154
	if (queryLower[queryIndex] !== targetLower[targetIndex]) {
		return score; // no match of characters
	}
155

156 157
	// Character match bonus
	score += 1;
158

159 160 161
	// if (DEBUG) {
	// 	console.groupCollapsed(`%cCharacter match bonus: +1 (char: ${queryLower[queryIndex]} at index ${targetIndex}, total score: ${score})`, 'font-weight: normal');
	// }
162

163 164 165
	// Consecutive match bonus
	if (matchesSequenceLength > 0) {
		score += (matchesSequenceLength * 5);
166

167 168 169 170
		// if (DEBUG) {
		// 	console.log('Consecutive match bonus: ' + (matchesSequenceLength * 5));
		// }
	}
171

172 173 174
	// Same case bonus
	if (query[queryIndex] === target[targetIndex]) {
		score += 1;
175

176 177 178 179
		// if (DEBUG) {
		// 	console.log('Same case bonus: +1');
		// }
	}
180

181 182 183
	// Start of word bonus
	if (targetIndex === 0) {
		score += 8;
184

185 186 187 188
		// if (DEBUG) {
		// 	console.log('Start of word bonus: +8');
		// }
	}
189

B
Benjamin Pasero 已提交
190
	else {
191

B
Benjamin Pasero 已提交
192 193 194 195
		// After separator bonus
		const separatorBonus = scoreSeparatorAtPos(target.charCodeAt(targetIndex - 1));
		if (separatorBonus) {
			score += separatorBonus;
196

B
Benjamin Pasero 已提交
197 198 199 200
			// if (DEBUG) {
			// 	console.log('After separtor bonus: +4');
			// }
		}
201

B
Benjamin Pasero 已提交
202 203 204 205 206 207 208 209
		// Inside word upper case bonus (camel case)
		else if (isUpper(target.charCodeAt(targetIndex))) {
			score += 1;

			// if (DEBUG) {
			// 	console.log('Inside word upper case bonus: +1');
			// }
		}
210 211
	}

212 213 214
	// if (DEBUG) {
	// 	console.groupEnd();
	// }
215

216
	return score;
217 218
}

B
Benjamin Pasero 已提交
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236
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;
	}
}

237 238 239 240 241 242 243 244 245 246 247 248
// 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);
// 	}
// }
B
Benjamin Pasero 已提交
249

250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
/**
 * 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;

	/**
	 * The optional description of the item to score on. Can be null.
	 */
	getItemDescription(item: T): string;

	/**
	 * If the item is a file, the path of the file to score on. Can be null.
	 */
	getItemPath(file: T): string;
}

const PATH_IDENTITY_SCORE = 1 << 18;
const LABEL_PREFIX_SCORE = 1 << 17;
const LABEL_CAMELCASE_SCORE = 1 << 16;
const LABEL_SCORE_THRESHOLD = 1 << 15;

296
export interface IPreparedQuery {
297
	original: string;
298 299 300 301 302 303 304 305
	value: string;
	lowercase: string;
	containsPathSeparator: boolean;
}

/**
 * Helper function to prepare a search value for scoring in quick open by removing unwanted characters.
 */
306
export function prepareQuery(original: string): IPreparedQuery {
M
Matt Bierner 已提交
307 308 309
	if (!original) {
		original = '';
	}
310

M
Matt Bierner 已提交
311 312 313
	let value = stripWildcards(original).replace(/\s/g, ''); // get rid of all wildcards and whitespace
	if (isWindows) {
		value = value.replace(/\//g, nativeSep); // Help Windows users to search for paths when using slash
314 315
	}

M
Matt Bierner 已提交
316 317 318
	const lowercase = value.toLowerCase();
	const containsPathSeparator = value.indexOf(nativeSep) >= 0;

319
	return { original, value, lowercase, containsPathSeparator };
320 321 322 323
}

export function scoreItem<T>(item: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor<T>, cache: ScorerCache): IItemScore {
	if (!item || !query.value) {
324 325 326 327 328 329 330 331 332 333
		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);

334
	let cacheHash: string;
335
	if (description) {
336
		cacheHash = `${label}${description}${query.value}${fuzzy}`;
337
	} else {
338
		cacheHash = `${label}${query.value}${fuzzy}`;
339 340 341 342 343 344 345
	}

	const cached = cache[cacheHash];
	if (cached) {
		return cached;
	}

B
Benjamin Pasero 已提交
346
	const itemScore = doScoreItem(label, description, accessor.getItemPath(item), query, fuzzy);
347 348 349
	cache[cacheHash] = itemScore;

	return itemScore;
B
Benjamin Pasero 已提交
350 351
}

352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368
function createMatches(offsets: undefined | number[]): IMatch[] {
	let 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;
}

B
Benjamin Pasero 已提交
369
function doScoreItem(label: string, description: string, path: string, query: IPreparedQuery, fuzzy: boolean): IItemScore {
B
Benjamin Pasero 已提交
370

371
	// 1.) treat identity matches on full path highest
B
Benjamin Pasero 已提交
372
	if (path && isLinux ? query.original === path : equalsIgnoreCase(query.original, path)) {
373
		return { score: PATH_IDENTITY_SCORE, labelMatch: [{ start: 0, end: label.length }], descriptionMatch: description ? [{ start: 0, end: description.length }] : void 0 };
B
Benjamin Pasero 已提交
374 375
	}

376
	// We only consider label matches if the query is not including file path separators
377
	const preferLabelMatches = !path || !query.containsPathSeparator;
378
	if (preferLabelMatches) {
B
Benjamin Pasero 已提交
379

380
		// 2.) treat prefix matches on the label second highest
381
		const prefixLabelMatch = matchesPrefix(query.value, label);
382 383 384
		if (prefixLabelMatch) {
			return { score: LABEL_PREFIX_SCORE, labelMatch: prefixLabelMatch };
		}
B
Benjamin Pasero 已提交
385

386
		// 3.) treat camelcase matches on the label third highest
387
		const camelcaseLabelMatch = matchesCamelCase(query.value, label);
388 389 390 391 392
		if (camelcaseLabelMatch) {
			return { score: LABEL_CAMELCASE_SCORE, labelMatch: camelcaseLabelMatch };
		}

		// 4.) prefer scores on the label if any
393
		const [labelScore, labelPositions] = score(label, query.value, query.lowercase, fuzzy);
394 395 396
		if (labelScore) {
			return { score: labelScore + LABEL_SCORE_THRESHOLD, labelMatch: createMatches(labelPositions) };
		}
397
	}
B
Benjamin Pasero 已提交
398

399 400 401 402 403 404 405 406 407 408
	// 5.) finally compute description + label scores if we have a description
	if (description) {
		let descriptionPrefix = description;
		if (!!path) {
			descriptionPrefix = `${description}${nativeSep}`; // assume this is a file path
		}

		const descriptionPrefixLength = descriptionPrefix.length;
		const descriptionAndLabel = `${descriptionPrefix}${label}`;

409
		const [labelDescriptionScore, labelDescriptionPositions] = score(descriptionAndLabel, query.value, query.lowercase, fuzzy);
410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441
		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;
}

442
export function compareItemsByScore<T>(itemA: T, itemB: T, query: IPreparedQuery, fuzzy: boolean, accessor: IItemAccessor<T>, cache: ScorerCache, fallbackComparer = fallbackCompare): number {
443 444 445 446 447
	const itemScoreA = scoreItem(itemA, query, fuzzy, accessor, cache);
	const itemScoreB = scoreItem(itemB, query, fuzzy, accessor, cache);

	const scoreA = itemScoreA.score;
	const scoreB = itemScoreB.score;
448

449
	// 1.) prefer identity matches
450 451 452
	if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) {
		if (scoreA !== scoreB) {
			return scoreA === PATH_IDENTITY_SCORE ? -1 : 1;
B
Benjamin Pasero 已提交
453 454 455
		}
	}

456
	// 2.) prefer label prefix matches
457 458 459 460 461 462 463 464 465 466 467 468
	if (scoreA === LABEL_PREFIX_SCORE || scoreB === LABEL_PREFIX_SCORE) {
		if (scoreA !== scoreB) {
			return scoreA === LABEL_PREFIX_SCORE ? -1 : 1;
		}

		const labelA = accessor.getItemLabel(itemA);
		const labelB = accessor.getItemLabel(itemB);

		// prefer shorter names when both match on label prefix
		if (labelA.length !== labelB.length) {
			return labelA.length - labelB.length;
		}
B
Benjamin Pasero 已提交
469 470
	}

471
	// 3.) prefer camelcase matches
472 473 474 475 476 477 478 479
	if (scoreA === LABEL_CAMELCASE_SCORE || scoreB === LABEL_CAMELCASE_SCORE) {
		if (scoreA !== scoreB) {
			return scoreA === LABEL_CAMELCASE_SCORE ? -1 : 1;
		}

		const labelA = accessor.getItemLabel(itemA);
		const labelB = accessor.getItemLabel(itemB);

480 481 482 483 484 485
		// prefer more compact camel case matches over longer
		const comparedByMatchLength = compareByMatchLength(itemScoreA.labelMatch, itemScoreB.labelMatch);
		if (comparedByMatchLength !== 0) {
			return comparedByMatchLength;
		}

486 487 488 489 490 491
		// prefer shorter names when both match on label camelcase
		if (labelA.length !== labelB.length) {
			return labelA.length - labelB.length;
		}
	}

492
	// 4.) prefer label scores
493 494 495 496 497 498 499 500 501 502
	if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) {
		if (scoreB < LABEL_SCORE_THRESHOLD) {
			return -1;
		}

		if (scoreA < LABEL_SCORE_THRESHOLD) {
			return 1;
		}
	}

503
	// 5.) compare by score
504 505 506 507
	if (scoreA !== scoreB) {
		return scoreA > scoreB ? -1 : 1;
	}

508
	// 6.) scores are identical, prefer more compact matches (label and description)
B
Benjamin Pasero 已提交
509 510 511 512
	const itemAMatchDistance = computeLabelAndDescriptionMatchDistance(itemA, itemScoreA, accessor);
	const itemBMatchDistance = computeLabelAndDescriptionMatchDistance(itemB, itemScoreB, accessor);
	if (itemAMatchDistance && itemBMatchDistance && itemAMatchDistance !== itemBMatchDistance) {
		return itemBMatchDistance > itemAMatchDistance ? -1 : 1;
513 514
	}

B
Benjamin Pasero 已提交
515 516 517 518 519 520 521 522 523 524
	// 7.) at this point, scores are identical and match compactness as well
	// for both items so we start to use the fallback compare
	return fallbackComparer(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
M
Matt Bierner 已提交
525
	if (score.descriptionMatch && score.descriptionMatch.length) {
B
Benjamin Pasero 已提交
526
		matchStart = score.descriptionMatch[0].start;
527 528
	}

B
Benjamin Pasero 已提交
529
	// Otherwise, the start is the first label match
M
Matt Bierner 已提交
530
	else if (score.labelMatch && score.labelMatch.length) {
B
Benjamin Pasero 已提交
531
		matchStart = score.labelMatch[0].start;
532 533
	}

B
Benjamin Pasero 已提交
534 535 536
	// 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.
M
Matt Bierner 已提交
537
	if (score.labelMatch && score.labelMatch.length) {
B
Benjamin Pasero 已提交
538
		matchEnd = score.labelMatch[score.labelMatch.length - 1].end;
M
Matt Bierner 已提交
539
		if (score.descriptionMatch && score.descriptionMatch.length) {
B
Benjamin Pasero 已提交
540 541 542 543 544 545 546 547
			const itemDescription = accessor.getItemDescription(item);
			if (itemDescription) {
				matchEnd += itemDescription.length;
			}
		}
	}

	// If we have just a description match, the end is the last description match
M
Matt Bierner 已提交
548
	else if (score.descriptionMatch && score.descriptionMatch.length) {
B
Benjamin Pasero 已提交
549 550 551 552
		matchEnd = score.descriptionMatch[score.descriptionMatch.length - 1].end;
	}

	return matchEnd - matchStart;
B
Benjamin Pasero 已提交
553 554
}

555
function compareByMatchLength(matchesA?: IMatch[], matchesB?: IMatch[]): number {
M
Matt Bierner 已提交
556
	if ((!matchesA && !matchesB) || ((!matchesA || !matchesA.length) && (!matchesB || !matchesB.length))) {
557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581
		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;
}

582
export function fallbackCompare<T>(itemA: T, itemB: T, query: IPreparedQuery, accessor: IItemAccessor<T>): number {
583

B
Benjamin Pasero 已提交
584
	// check for label + description length and prefer shorter
585 586 587
	const labelA = accessor.getItemLabel(itemA);
	const labelB = accessor.getItemLabel(itemB);

B
Benjamin Pasero 已提交
588 589 590 591 592 593 594 595
	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;
596 597
	}

B
Benjamin Pasero 已提交
598
	// check for path length and prefer shorter
599 600 601 602
	const pathA = accessor.getItemPath(itemA);
	const pathB = accessor.getItemPath(itemB);

	if (pathA && pathB && pathA.length !== pathB.length) {
B
Benjamin Pasero 已提交
603 604 605 606 607 608 609
		return pathA.length - pathB.length;
	}

	// 7.) finally we have equal scores and equal length, we fallback to comparer

	// compare by label
	if (labelA !== labelB) {
610
		return compareAnything(labelA, labelB, query.value);
B
Benjamin Pasero 已提交
611 612 613 614
	}

	// compare by description
	if (descriptionA && descriptionB && descriptionA !== descriptionB) {
615
		return compareAnything(descriptionA, descriptionB, query.value);
B
Benjamin Pasero 已提交
616 617
	}

B
Benjamin Pasero 已提交
618 619
	// compare by path
	if (pathA && pathB && pathA !== pathB) {
620
		return compareAnything(pathA, pathB, query.value);
B
Benjamin Pasero 已提交
621 622
	}

B
Benjamin Pasero 已提交
623 624
	// equal
	return 0;
625
}