From afab2beac8c9374db9996118d1892e2bf074275d Mon Sep 17 00:00:00 2001 From: Ryan C Date: Fri, 6 Apr 2018 10:54:21 +0100 Subject: [PATCH] Add VSCode QuickOpen Scoring/Sorting (#2009) * Add VSCode files for now. * Hook up to current regex method. * Fix or ignore lint issues. * Swap to using scoreItem and add Oni wrapper. * Hook up highlighting to VSCode results. Had to remove test for now. * Fix lint. * Split across multiple files. * Changes for linting. * Run lint tool. * Potential fix for filtering issue. * Add back pinned option. * Removed extra info so tests pass again. * Test with using scorer only. If the score is 0, we can just exclude it, so seems pointless to do the regex and then that. Will need to implement the filtering some other way though. * Enable sorting with fuzzy finder. * Added back fallback comparer. * Fix lint. * Make naming consistent. * Bring in updated oni-api. * Swap to a separate filter. Bring back tests for RegEx filter. * Fix bug with filtering. * Bring over all the regex tests. Converted to check highlights and score now too. * Add score test. * Added additional test. * Added two tests for length. * Remove replace and fallback. Convert string was needed to deal with the regex, but since this is its own scorer it isn't needed. Swapping to the default fallback fixes file length sorting when scores match. * Tidy up files. * Add test for search with file extension. * Swap default. --- .../Configuration/DefaultConfiguration.ts | 2 +- browser/src/Services/QuickOpen/QuickOpen.ts | 17 +- browser/src/Services/QuickOpen/RegExFilter.ts | 4 +- .../src/Services/QuickOpen/Scorer/CharCode.ts | 422 +++++++++++ .../Services/QuickOpen/Scorer/Comparers.ts | 196 ++++++ .../QuickOpen/Scorer/OniQuickOpenScorer.ts | 78 ++ .../QuickOpen/Scorer/QuickOpenScorer.ts | 665 ++++++++++++++++++ .../Services/QuickOpen/Scorer/Utilities.ts | 41 ++ .../src/Services/QuickOpen/Scorer/filters.ts | 224 ++++++ .../src/Services/QuickOpen/Scorer/strings.ts | 71 ++ .../src/Services/QuickOpen/VSCodeFilter.ts | 83 +++ .../Services/QuickOpen/RegExFilterTests.ts | 3 + .../Services/QuickOpen/VSCodeFilterTests.ts | 287 ++++++++ package.json | 2 +- 14 files changed, 2087 insertions(+), 8 deletions(-) create mode 100644 browser/src/Services/QuickOpen/Scorer/CharCode.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/Comparers.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/OniQuickOpenScorer.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/QuickOpenScorer.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/Utilities.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/filters.ts create mode 100644 browser/src/Services/QuickOpen/Scorer/strings.ts create mode 100644 browser/src/Services/QuickOpen/VSCodeFilter.ts create mode 100644 browser/test/Services/QuickOpen/VSCodeFilterTests.ts diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index 42f745c7dc..0f64338aa4 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -104,7 +104,7 @@ const BaseConfiguration: IConfigurationValues = { "editor.linePadding": 2, "editor.quickOpen.execCommand": null, - "editor.quickOpen.filterStrategy": "regex", + "editor.quickOpen.filterStrategy": "vscode", "editor.split.mode": "native", diff --git a/browser/src/Services/QuickOpen/QuickOpen.ts b/browser/src/Services/QuickOpen/QuickOpen.ts index 20c2a3f386..fd5895295d 100644 --- a/browser/src/Services/QuickOpen/QuickOpen.ts +++ b/browser/src/Services/QuickOpen/QuickOpen.ts @@ -22,6 +22,7 @@ import { render as renderPinnedIcon } from "./PinnedIconView" import { QuickOpenItem, QuickOpenType } from "./QuickOpenItem" import { regexFilter } from "./RegExFilter" import * as RipGrep from "./RipGrep" +import { vsCodeFilter } from "./VSCodeFilter" import { getFileIcon } from "./../FileIcon" @@ -77,10 +78,18 @@ export class QuickOpen { const filterStrategy = configuration.getValue("editor.quickOpen.filterStrategy") - const useRegExFilter = filterStrategy === "regex" - - const filterFunction = useRegExFilter ? regexFilter : fuseFilter - this._menu.setFilterFunction(filterFunction) + switch (filterStrategy) { + case "fuse": + this._menu.setFilterFunction(fuseFilter) + break + case "regex": + this._menu.setFilterFunction(regexFilter) + break + case "vscode": + default: + this._menu.setFilterFunction(vsCodeFilter) + break + } // If in exec directory or home, show bookmarks to change cwd to if (this._isInstallDirectoryOrHome()) { diff --git a/browser/src/Services/QuickOpen/RegExFilter.ts b/browser/src/Services/QuickOpen/RegExFilter.ts index 3fa7f0c475..d11a5d1907 100644 --- a/browser/src/Services/QuickOpen/RegExFilter.ts +++ b/browser/src/Services/QuickOpen/RegExFilter.ts @@ -1,7 +1,7 @@ /** - * MenuFilter.ts + * RegExFilter.ts * - * Implements filtering logic for the menu + * Implements RegEx filtering logic for the menu */ import * as sortBy from "lodash/sortBy" diff --git a/browser/src/Services/QuickOpen/Scorer/CharCode.ts b/browser/src/Services/QuickOpen/Scorer/CharCode.ts new file mode 100644 index 0000000000..22080f8c8d --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/CharCode.ts @@ -0,0 +1,422 @@ +/* tslint:disable */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +"use strict" + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export const enum CharCode { + Null = 0, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, + + U_Combining_Grave_Accent = 0x0300, // U+0300 Combining Grave Accent + U_Combining_Acute_Accent = 0x0301, // U+0301 Combining Acute Accent + U_Combining_Circumflex_Accent = 0x0302, // U+0302 Combining Circumflex Accent + U_Combining_Tilde = 0x0303, // U+0303 Combining Tilde + U_Combining_Macron = 0x0304, // U+0304 Combining Macron + U_Combining_Overline = 0x0305, // U+0305 Combining Overline + U_Combining_Breve = 0x0306, // U+0306 Combining Breve + U_Combining_Dot_Above = 0x0307, // U+0307 Combining Dot Above + U_Combining_Diaeresis = 0x0308, // U+0308 Combining Diaeresis + U_Combining_Hook_Above = 0x0309, // U+0309 Combining Hook Above + U_Combining_Ring_Above = 0x030a, // U+030A Combining Ring Above + U_Combining_Double_Acute_Accent = 0x030b, // U+030B Combining Double Acute Accent + U_Combining_Caron = 0x030c, // U+030C Combining Caron + U_Combining_Vertical_Line_Above = 0x030d, // U+030D Combining Vertical Line Above + U_Combining_Double_Vertical_Line_Above = 0x030e, // U+030E Combining Double Vertical Line Above + U_Combining_Double_Grave_Accent = 0x030f, // U+030F Combining Double Grave Accent + U_Combining_Candrabindu = 0x0310, // U+0310 Combining Candrabindu + U_Combining_Inverted_Breve = 0x0311, // U+0311 Combining Inverted Breve + U_Combining_Turned_Comma_Above = 0x0312, // U+0312 Combining Turned Comma Above + U_Combining_Comma_Above = 0x0313, // U+0313 Combining Comma Above + U_Combining_Reversed_Comma_Above = 0x0314, // U+0314 Combining Reversed Comma Above + U_Combining_Comma_Above_Right = 0x0315, // U+0315 Combining Comma Above Right + U_Combining_Grave_Accent_Below = 0x0316, // U+0316 Combining Grave Accent Below + U_Combining_Acute_Accent_Below = 0x0317, // U+0317 Combining Acute Accent Below + U_Combining_Left_Tack_Below = 0x0318, // U+0318 Combining Left Tack Below + U_Combining_Right_Tack_Below = 0x0319, // U+0319 Combining Right Tack Below + U_Combining_Left_Angle_Above = 0x031a, // U+031A Combining Left Angle Above + U_Combining_Horn = 0x031b, // U+031B Combining Horn + U_Combining_Left_Half_Ring_Below = 0x031c, // U+031C Combining Left Half Ring Below + U_Combining_Up_Tack_Below = 0x031d, // U+031D Combining Up Tack Below + U_Combining_Down_Tack_Below = 0x031e, // U+031E Combining Down Tack Below + U_Combining_Plus_Sign_Below = 0x031f, // U+031F Combining Plus Sign Below + U_Combining_Minus_Sign_Below = 0x0320, // U+0320 Combining Minus Sign Below + U_Combining_Palatalized_Hook_Below = 0x0321, // U+0321 Combining Palatalized Hook Below + U_Combining_Retroflex_Hook_Below = 0x0322, // U+0322 Combining Retroflex Hook Below + U_Combining_Dot_Below = 0x0323, // U+0323 Combining Dot Below + U_Combining_Diaeresis_Below = 0x0324, // U+0324 Combining Diaeresis Below + U_Combining_Ring_Below = 0x0325, // U+0325 Combining Ring Below + U_Combining_Comma_Below = 0x0326, // U+0326 Combining Comma Below + U_Combining_Cedilla = 0x0327, // U+0327 Combining Cedilla + U_Combining_Ogonek = 0x0328, // U+0328 Combining Ogonek + U_Combining_Vertical_Line_Below = 0x0329, // U+0329 Combining Vertical Line Below + U_Combining_Bridge_Below = 0x032a, // U+032A Combining Bridge Below + U_Combining_Inverted_Double_Arch_Below = 0x032b, // U+032B Combining Inverted Double Arch Below + U_Combining_Caron_Below = 0x032c, // U+032C Combining Caron Below + U_Combining_Circumflex_Accent_Below = 0x032d, // U+032D Combining Circumflex Accent Below + U_Combining_Breve_Below = 0x032e, // U+032E Combining Breve Below + U_Combining_Inverted_Breve_Below = 0x032f, // U+032F Combining Inverted Breve Below + U_Combining_Tilde_Below = 0x0330, // U+0330 Combining Tilde Below + U_Combining_Macron_Below = 0x0331, // U+0331 Combining Macron Below + U_Combining_Low_Line = 0x0332, // U+0332 Combining Low Line + U_Combining_Double_Low_Line = 0x0333, // U+0333 Combining Double Low Line + U_Combining_Tilde_Overlay = 0x0334, // U+0334 Combining Tilde Overlay + U_Combining_Short_Stroke_Overlay = 0x0335, // U+0335 Combining Short Stroke Overlay + U_Combining_Long_Stroke_Overlay = 0x0336, // U+0336 Combining Long Stroke Overlay + U_Combining_Short_Solidus_Overlay = 0x0337, // U+0337 Combining Short Solidus Overlay + U_Combining_Long_Solidus_Overlay = 0x0338, // U+0338 Combining Long Solidus Overlay + U_Combining_Right_Half_Ring_Below = 0x0339, // U+0339 Combining Right Half Ring Below + U_Combining_Inverted_Bridge_Below = 0x033a, // U+033A Combining Inverted Bridge Below + U_Combining_Square_Below = 0x033b, // U+033B Combining Square Below + U_Combining_Seagull_Below = 0x033c, // U+033C Combining Seagull Below + U_Combining_X_Above = 0x033d, // U+033D Combining X Above + U_Combining_Vertical_Tilde = 0x033e, // U+033E Combining Vertical Tilde + U_Combining_Double_Overline = 0x033f, // U+033F Combining Double Overline + U_Combining_Grave_Tone_Mark = 0x0340, // U+0340 Combining Grave Tone Mark + U_Combining_Acute_Tone_Mark = 0x0341, // U+0341 Combining Acute Tone Mark + U_Combining_Greek_Perispomeni = 0x0342, // U+0342 Combining Greek Perispomeni + U_Combining_Greek_Koronis = 0x0343, // U+0343 Combining Greek Koronis + U_Combining_Greek_Dialytika_Tonos = 0x0344, // U+0344 Combining Greek Dialytika Tonos + U_Combining_Greek_Ypogegrammeni = 0x0345, // U+0345 Combining Greek Ypogegrammeni + U_Combining_Bridge_Above = 0x0346, // U+0346 Combining Bridge Above + U_Combining_Equals_Sign_Below = 0x0347, // U+0347 Combining Equals Sign Below + U_Combining_Double_Vertical_Line_Below = 0x0348, // U+0348 Combining Double Vertical Line Below + U_Combining_Left_Angle_Below = 0x0349, // U+0349 Combining Left Angle Below + U_Combining_Not_Tilde_Above = 0x034a, // U+034A Combining Not Tilde Above + U_Combining_Homothetic_Above = 0x034b, // U+034B Combining Homothetic Above + U_Combining_Almost_Equal_To_Above = 0x034c, // U+034C Combining Almost Equal To Above + U_Combining_Left_Right_Arrow_Below = 0x034d, // U+034D Combining Left Right Arrow Below + U_Combining_Upwards_Arrow_Below = 0x034e, // U+034E Combining Upwards Arrow Below + U_Combining_Grapheme_Joiner = 0x034f, // U+034F Combining Grapheme Joiner + U_Combining_Right_Arrowhead_Above = 0x0350, // U+0350 Combining Right Arrowhead Above + U_Combining_Left_Half_Ring_Above = 0x0351, // U+0351 Combining Left Half Ring Above + U_Combining_Fermata = 0x0352, // U+0352 Combining Fermata + U_Combining_X_Below = 0x0353, // U+0353 Combining X Below + U_Combining_Left_Arrowhead_Below = 0x0354, // U+0354 Combining Left Arrowhead Below + U_Combining_Right_Arrowhead_Below = 0x0355, // U+0355 Combining Right Arrowhead Below + U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, // U+0356 Combining Right Arrowhead And Up Arrowhead Below + U_Combining_Right_Half_Ring_Above = 0x0357, // U+0357 Combining Right Half Ring Above + U_Combining_Dot_Above_Right = 0x0358, // U+0358 Combining Dot Above Right + U_Combining_Asterisk_Below = 0x0359, // U+0359 Combining Asterisk Below + U_Combining_Double_Ring_Below = 0x035a, // U+035A Combining Double Ring Below + U_Combining_Zigzag_Above = 0x035b, // U+035B Combining Zigzag Above + U_Combining_Double_Breve_Below = 0x035c, // U+035C Combining Double Breve Below + U_Combining_Double_Breve = 0x035d, // U+035D Combining Double Breve + U_Combining_Double_Macron = 0x035e, // U+035E Combining Double Macron + U_Combining_Double_Macron_Below = 0x035f, // U+035F Combining Double Macron Below + U_Combining_Double_Tilde = 0x0360, // U+0360 Combining Double Tilde + U_Combining_Double_Inverted_Breve = 0x0361, // U+0361 Combining Double Inverted Breve + U_Combining_Double_Rightwards_Arrow_Below = 0x0362, // U+0362 Combining Double Rightwards Arrow Below + U_Combining_Latin_Small_Letter_A = 0x0363, // U+0363 Combining Latin Small Letter A + U_Combining_Latin_Small_Letter_E = 0x0364, // U+0364 Combining Latin Small Letter E + U_Combining_Latin_Small_Letter_I = 0x0365, // U+0365 Combining Latin Small Letter I + U_Combining_Latin_Small_Letter_O = 0x0366, // U+0366 Combining Latin Small Letter O + U_Combining_Latin_Small_Letter_U = 0x0367, // U+0367 Combining Latin Small Letter U + U_Combining_Latin_Small_Letter_C = 0x0368, // U+0368 Combining Latin Small Letter C + U_Combining_Latin_Small_Letter_D = 0x0369, // U+0369 Combining Latin Small Letter D + U_Combining_Latin_Small_Letter_H = 0x036a, // U+036A Combining Latin Small Letter H + U_Combining_Latin_Small_Letter_M = 0x036b, // U+036B Combining Latin Small Letter M + U_Combining_Latin_Small_Letter_R = 0x036c, // U+036C Combining Latin Small Letter R + U_Combining_Latin_Small_Letter_T = 0x036d, // U+036D Combining Latin Small Letter T + U_Combining_Latin_Small_Letter_V = 0x036e, // U+036E Combining Latin Small Letter V + U_Combining_Latin_Small_Letter_X = 0x036f, // U+036F Combining Latin Small Letter X + + /** + * Unicode Character 'LINE SEPARATOR' (U+2028) + * http://www.fileformat.info/info/unicode/char/2028/index.htm + */ + LINE_SEPARATOR_2028 = 8232, + + // http://www.fileformat.info/info/unicode/category/Sk/list.htm + U_CIRCUMFLEX = 0x005e, // U+005E CIRCUMFLEX + U_GRAVE_ACCENT = 0x0060, // U+0060 GRAVE ACCENT + U_DIAERESIS = 0x00a8, // U+00A8 DIAERESIS + U_MACRON = 0x00af, // U+00AF MACRON + U_ACUTE_ACCENT = 0x00b4, // U+00B4 ACUTE ACCENT + U_CEDILLA = 0x00b8, // U+00B8 CEDILLA + U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02c2, // U+02C2 MODIFIER LETTER LEFT ARROWHEAD + U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02c3, // U+02C3 MODIFIER LETTER RIGHT ARROWHEAD + U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02c4, // U+02C4 MODIFIER LETTER UP ARROWHEAD + U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02c5, // U+02C5 MODIFIER LETTER DOWN ARROWHEAD + U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02d2, // U+02D2 MODIFIER LETTER CENTRED RIGHT HALF RING + U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02d3, // U+02D3 MODIFIER LETTER CENTRED LEFT HALF RING + U_MODIFIER_LETTER_UP_TACK = 0x02d4, // U+02D4 MODIFIER LETTER UP TACK + U_MODIFIER_LETTER_DOWN_TACK = 0x02d5, // U+02D5 MODIFIER LETTER DOWN TACK + U_MODIFIER_LETTER_PLUS_SIGN = 0x02d6, // U+02D6 MODIFIER LETTER PLUS SIGN + U_MODIFIER_LETTER_MINUS_SIGN = 0x02d7, // U+02D7 MODIFIER LETTER MINUS SIGN + U_BREVE = 0x02d8, // U+02D8 BREVE + U_DOT_ABOVE = 0x02d9, // U+02D9 DOT ABOVE + U_RING_ABOVE = 0x02da, // U+02DA RING ABOVE + U_OGONEK = 0x02db, // U+02DB OGONEK + U_SMALL_TILDE = 0x02dc, // U+02DC SMALL TILDE + U_DOUBLE_ACUTE_ACCENT = 0x02dd, // U+02DD DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02de, // U+02DE MODIFIER LETTER RHOTIC HOOK + U_MODIFIER_LETTER_CROSS_ACCENT = 0x02df, // U+02DF MODIFIER LETTER CROSS ACCENT + U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02e5, // U+02E5 MODIFIER LETTER EXTRA-HIGH TONE BAR + U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02e6, // U+02E6 MODIFIER LETTER HIGH TONE BAR + U_MODIFIER_LETTER_MID_TONE_BAR = 0x02e7, // U+02E7 MODIFIER LETTER MID TONE BAR + U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02e8, // U+02E8 MODIFIER LETTER LOW TONE BAR + U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02e9, // U+02E9 MODIFIER LETTER EXTRA-LOW TONE BAR + U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02ea, // U+02EA MODIFIER LETTER YIN DEPARTING TONE MARK + U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02eb, // U+02EB MODIFIER LETTER YANG DEPARTING TONE MARK + U_MODIFIER_LETTER_UNASPIRATED = 0x02ed, // U+02ED MODIFIER LETTER UNASPIRATED + U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02ef, // U+02EF MODIFIER LETTER LOW DOWN ARROWHEAD + U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02f0, // U+02F0 MODIFIER LETTER LOW UP ARROWHEAD + U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02f1, // U+02F1 MODIFIER LETTER LOW LEFT ARROWHEAD + U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02f2, // U+02F2 MODIFIER LETTER LOW RIGHT ARROWHEAD + U_MODIFIER_LETTER_LOW_RING = 0x02f3, // U+02F3 MODIFIER LETTER LOW RING + U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02f4, // U+02F4 MODIFIER LETTER MIDDLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02f5, // U+02F5 MODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT + U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02f6, // U+02F6 MODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT + U_MODIFIER_LETTER_LOW_TILDE = 0x02f7, // U+02F7 MODIFIER LETTER LOW TILDE + U_MODIFIER_LETTER_RAISED_COLON = 0x02f8, // U+02F8 MODIFIER LETTER RAISED COLON + U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02f9, // U+02F9 MODIFIER LETTER BEGIN HIGH TONE + U_MODIFIER_LETTER_END_HIGH_TONE = 0x02fa, // U+02FA MODIFIER LETTER END HIGH TONE + U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02fb, // U+02FB MODIFIER LETTER BEGIN LOW TONE + U_MODIFIER_LETTER_END_LOW_TONE = 0x02fc, // U+02FC MODIFIER LETTER END LOW TONE + U_MODIFIER_LETTER_SHELF = 0x02fd, // U+02FD MODIFIER LETTER SHELF + U_MODIFIER_LETTER_OPEN_SHELF = 0x02fe, // U+02FE MODIFIER LETTER OPEN SHELF + U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02ff, // U+02FF MODIFIER LETTER LOW LEFT ARROW + U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375 GREEK LOWER NUMERAL SIGN + U_GREEK_TONOS = 0x0384, // U+0384 GREEK TONOS + U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385 GREEK DIALYTIKA TONOS + U_GREEK_KORONIS = 0x1fbd, // U+1FBD GREEK KORONIS + U_GREEK_PSILI = 0x1fbf, // U+1FBF GREEK PSILI + U_GREEK_PERISPOMENI = 0x1fc0, // U+1FC0 GREEK PERISPOMENI + U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1fc1, // U+1FC1 GREEK DIALYTIKA AND PERISPOMENI + U_GREEK_PSILI_AND_VARIA = 0x1fcd, // U+1FCD GREEK PSILI AND VARIA + U_GREEK_PSILI_AND_OXIA = 0x1fce, // U+1FCE GREEK PSILI AND OXIA + U_GREEK_PSILI_AND_PERISPOMENI = 0x1fcf, // U+1FCF GREEK PSILI AND PERISPOMENI + U_GREEK_DASIA_AND_VARIA = 0x1fdd, // U+1FDD GREEK DASIA AND VARIA + U_GREEK_DASIA_AND_OXIA = 0x1fde, // U+1FDE GREEK DASIA AND OXIA + U_GREEK_DASIA_AND_PERISPOMENI = 0x1fdf, // U+1FDF GREEK DASIA AND PERISPOMENI + U_GREEK_DIALYTIKA_AND_VARIA = 0x1fed, // U+1FED GREEK DIALYTIKA AND VARIA + U_GREEK_DIALYTIKA_AND_OXIA = 0x1fee, // U+1FEE GREEK DIALYTIKA AND OXIA + U_GREEK_VARIA = 0x1fef, // U+1FEF GREEK VARIA + U_GREEK_OXIA = 0x1ffd, // U+1FFD GREEK OXIA + U_GREEK_DASIA = 0x1ffe, // U+1FFE GREEK DASIA + + U_OVERLINE = 0x203e, // Unicode Character 'OVERLINE' + + /** + * UTF-8 BOM + * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF) + * http://www.fileformat.info/info/unicode/char/feff/index.htm + */ + UTF8_BOM = 65279, +} diff --git a/browser/src/Services/QuickOpen/Scorer/Comparers.ts b/browser/src/Services/QuickOpen/Scorer/Comparers.ts new file mode 100644 index 0000000000..39ce1264e7 --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/Comparers.ts @@ -0,0 +1,196 @@ +/* tslint:disable */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +"use strict" + +import { nativeSep } from "./Utilities" + +let intlFileNameCollator: Intl.Collator +let intlFileNameCollatorIsNumeric: boolean + +export function setFileNameComparer(collator: Intl.Collator): void { + intlFileNameCollator = collator + intlFileNameCollatorIsNumeric = collator.resolvedOptions().numeric +} + +export function compareFileNames(one: string, other: string, caseSensitive = false): number { + if (intlFileNameCollator) { + const a = one || "" + const b = other || "" + const result = intlFileNameCollator.compare(a, b) + + // Using the numeric option in the collator will + // make compare(`foo1`, `foo01`) === 0. We must disambiguate. + if (intlFileNameCollatorIsNumeric && result === 0 && a !== b) { + return a < b ? -1 : 1 + } + + return result + } + + return noIntlCompareFileNames(one, other, caseSensitive) +} + +const FileNameMatch = /^(.*?)(\.([^.]*))?$/ + +export function noIntlCompareFileNames(one: string, other: string, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase() + other = other && other.toLowerCase() + } + + const [oneName, oneExtension] = extractNameAndExtension(one) + const [otherName, otherExtension] = extractNameAndExtension(other) + + if (oneName !== otherName) { + return oneName < otherName ? -1 : 1 + } + + if (oneExtension === otherExtension) { + return 0 + } + + return oneExtension < otherExtension ? -1 : 1 +} + +export function compareFileExtensions(one: string, other: string): number { + if (intlFileNameCollator) { + const [oneName, oneExtension] = extractNameAndExtension(one) + const [otherName, otherExtension] = extractNameAndExtension(other) + + let result = intlFileNameCollator.compare(oneExtension, otherExtension) + + if (result === 0) { + // Using the numeric option in the collator will + // make compare(`foo1`, `foo01`) === 0. We must disambiguate. + if (intlFileNameCollatorIsNumeric && oneExtension !== otherExtension) { + return oneExtension < otherExtension ? -1 : 1 + } + + // Extensions are equal, compare filenames + result = intlFileNameCollator.compare(oneName, otherName) + + if (intlFileNameCollatorIsNumeric && result === 0 && oneName !== otherName) { + return oneName < otherName ? -1 : 1 + } + } + + return result + } + + return noIntlCompareFileExtensions(one, other) +} + +function noIntlCompareFileExtensions(one: string, other: string): number { + const [oneName, oneExtension] = extractNameAndExtension(one && one.toLowerCase()) + const [otherName, otherExtension] = extractNameAndExtension(other && other.toLowerCase()) + + if (oneExtension !== otherExtension) { + return oneExtension < otherExtension ? -1 : 1 + } + + if (oneName === otherName) { + return 0 + } + + return oneName < otherName ? -1 : 1 +} + +function extractNameAndExtension(str?: string): [string, string] { + const match = str ? FileNameMatch.exec(str) : ([] as RegExpExecArray) + + return [(match && match[1]) || "", (match && match[3]) || ""] +} + +function comparePathComponents(one: string, other: string, caseSensitive = false): number { + if (!caseSensitive) { + one = one && one.toLowerCase() + other = other && other.toLowerCase() + } + + if (one === other) { + return 0 + } + + return one < other ? -1 : 1 +} + +export function comparePaths(one: string, other: string, caseSensitive = false): number { + const oneParts = one.split(nativeSep) + const otherParts = other.split(nativeSep) + + const lastOne = oneParts.length - 1 + const lastOther = otherParts.length - 1 + let endOne: boolean, endOther: boolean + + for (let i = 0; ; i++) { + endOne = lastOne === i + endOther = lastOther === i + + if (endOne && endOther) { + return compareFileNames(oneParts[i], otherParts[i], caseSensitive) + } else if (endOne) { + return -1 + } else if (endOther) { + return 1 + } + + const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive) + + if (result !== 0) { + return result + } + } +} + +export function compareAnything(one: string, other: string, lookFor: string): number { + let elementAName = one.toLowerCase() + let elementBName = other.toLowerCase() + + // Sort prefix matches over non prefix matches + const prefixCompare = compareByPrefix(one, other, lookFor) + if (prefixCompare) { + return prefixCompare + } + + // Sort suffix matches over non suffix matches + let elementASuffixMatch = elementAName.endsWith(lookFor) + let elementBSuffixMatch = elementBName.endsWith(lookFor) + if (elementASuffixMatch !== elementBSuffixMatch) { + return elementASuffixMatch ? -1 : 1 + } + + // Understand file names + let r = compareFileNames(elementAName, elementBName) + if (r !== 0) { + return r + } + + // Compare by name + return elementAName.localeCompare(elementBName) +} + +export function compareByPrefix(one: string, other: string, lookFor: string): number { + let elementAName = one.toLowerCase() + let elementBName = other.toLowerCase() + + // Sort prefix matches over non prefix matches + let elementAPrefixMatch = elementAName.startsWith(lookFor) + let elementBPrefixMatch = elementBName.startsWith(lookFor) + if (elementAPrefixMatch !== elementBPrefixMatch) { + return elementAPrefixMatch ? -1 : 1 + } else if (elementAPrefixMatch && elementBPrefixMatch) { + // Same prefix: Sort shorter matches to the top to have those on top that match more precisely + if (elementAName.length < elementBName.length) { + return -1 + } + + if (elementAName.length > elementBName.length) { + return 1 + } + } + + return 0 +} diff --git a/browser/src/Services/QuickOpen/Scorer/OniQuickOpenScorer.ts b/browser/src/Services/QuickOpen/Scorer/OniQuickOpenScorer.ts new file mode 100644 index 0000000000..39a162c138 --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/OniQuickOpenScorer.ts @@ -0,0 +1,78 @@ +import { IMatch } from "./filters" +import { + compareItemsByScore, + IItemAccessor, + IItemScore, + prepareQuery, + scoreItem, +} from "./QuickOpenScorer" +import { nativeSep } from "./Utilities" + +export const NO_ITEM_SCORE: IItemScore = Object.freeze({ score: 0 }) + +class OniAccessor implements IItemAccessor { + public getItemLabel(result: any): string { + return result.label ? result.label : "" + } + + public getItemDescription(result: any): string { + return result.detail ? result.detail : "" + } + + public getItemPath(result: any): string { + return result.detail + nativeSep + result.label + } +} + +export function scoreItemOni(resultObject: any, searchString: string, fuzzy: boolean): IItemScore { + if (!searchString) { + return NO_ITEM_SCORE + } + + const query = prepareQuery(searchString) + + if (!resultObject || !query.value) { + return NO_ITEM_SCORE + } + + const accessor = new OniAccessor() + + return scoreItem(resultObject, query, fuzzy, accessor) +} + +export function compareItemsByScoreOni( + resultObjectA: any, + resultObjectB: any, + searchString: string, + fuzzy: boolean, +): number { + if (!searchString) { + return 0 + } + + const query = prepareQuery(searchString) + + if (!resultObjectA || !resultObjectB || !query.value) { + return 0 + } + + const accessor = new OniAccessor() + + return compareItemsByScore(resultObjectA, resultObjectB, query, fuzzy, accessor) +} + +export const getHighlightsFromResult = (result: IMatch[]): number[] => { + if (!result) { + return [] + } + + const highlights: number[] = [] + + result.forEach(r => { + for (let i = r.start; i < r.end; i++) { + highlights.push(i) + } + }) + + return highlights +} diff --git a/browser/src/Services/QuickOpen/Scorer/QuickOpenScorer.ts b/browser/src/Services/QuickOpen/Scorer/QuickOpenScorer.ts new file mode 100644 index 0000000000..965f5df118 --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/QuickOpenScorer.ts @@ -0,0 +1,665 @@ +/* tslint:disable */ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +"use strict" + +import { CharCode } from "./CharCode" +import { compareAnything } from "./Comparers" +import { createMatches, IMatch, matchesCamelCase, matchesPrefix } from "./filters" +import { equalsIgnoreCase } from "./strings" +import { isLinux, isWindows, stripWildcards, nativeSep, isUpper } from "./Utilities" + +export type Score = [number /* score */, number[] /* match positions */] +export type ScorerCache = { [key: string]: IItemScore } + +const NO_MATCH = 0 +const NO_SCORE: Score = [NO_MATCH, []] + +// const DEBUG = false; +// const DEBUG_MATRIX = false; + +export function score(target: string, query: string, queryLower: string, fuzzy: boolean): Score { + 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) { + const indexOfQueryInTarget = targetLower.indexOf(queryLower) + if (indexOfQueryInTarget === -1) { + // if (DEBUG) { + // console.log(`Characters not matching consecutively ${queryLower} within ${targetLower}`); + // } + + return NO_SCORE + } + } + + const res = doScore(query, queryLower, queryLength, target, targetLower, targetLength) + + // if (DEBUG) { + // console.log(`%cFinal Score: ${res[0]}`, 'font-weight: bold'); + // console.groupEnd(); + // } + + return res +} + +function doScore( + query: string, + queryLower: string, + queryLength: number, + target: string, + targetLower: string, + targetLength: number, +): [number, number[]] { + const scores = [] + const matches = [] + + // + // 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++) { + for (let targetIndex = 0; targetIndex < targetLength; targetIndex++) { + const currentIndex = queryIndex * targetLength + targetIndex + const leftIndex = currentIndex - 1 + const diagIndex = (queryIndex - 1) * targetLength + targetIndex - 1 + + const leftScore: number = targetIndex > 0 ? scores[leftIndex] : 0 + const diagScore: number = queryIndex > 0 && targetIndex > 0 ? scores[diagIndex] : 0 + + const matchesSequenceLength: number = + queryIndex > 0 && targetIndex > 0 ? 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 && queryIndex > 0) { + score = 0 + } else { + score = computeCharScore( + query, + queryLower, + queryIndex, + 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 + } else { + // 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 + matches[currentIndex] = NO_MATCH + scores[currentIndex] = leftScore + } + } + } + + // Restore Positions (starting from bottom right of matrix) + const positions = [] + 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( + query: string, + queryLower: string, + queryIndex: number, + target: string, + targetLower: string, + targetIndex: number, + matchesSequenceLength: number, +): number { + let score = 0 + + if (queryLower[queryIndex] !== targetLower[targetIndex]) { + return score // no match of characters + } + + // Character match bonus + score += 1 + + // if (DEBUG) { + // console.groupCollapsed(`%cCharacter match bonus: +1 (char: ${queryLower[queryIndex]} 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 (query[queryIndex] === 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: +4'); + // } + } else if (isUpper(target.charCodeAt(targetIndex))) { + // Inside word upper case bonus (camel case) + score += 1 + + // if (DEBUG) { + // console.log('Inside word upper case bonus: +1'); + // } + } + } + + // if (DEBUG) { + // console.groupEnd(); + // } + + return score +} + +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); +// } +// } + +/** + * 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 { + /** + * 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 + +export interface IPreparedQuery { + original: string + value: string + lowercase: string + containsPathSeparator: boolean +} + +/** + * Helper function to prepare a search value for scoring in quick open by removing unwanted characters. + */ +export function prepareQuery(original: string): IPreparedQuery { + let lowercase: string + let containsPathSeparator: boolean + let value: string + + if (original) { + 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 + } + + lowercase = value.toLowerCase() + containsPathSeparator = value.indexOf(nativeSep) >= 0 + } + + return { original, value, lowercase, containsPathSeparator } +} + +export function scoreItem( + item: T, + query: IPreparedQuery, + fuzzy: boolean, + accessor: IItemAccessor, + // cache: ScorerCache, +): IItemScore { + if (!item || !query.value) { + 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) + + // let cacheHash: string + // if (description) { + // cacheHash = `${label}${description}${query.value}${fuzzy}` + // } else { + // cacheHash = `${label}${query.value}${fuzzy}` + // } + + // const cached = cache[cacheHash] + // if (cached) { + // return cached + // } + + const itemScore = doScoreItem(label, description, accessor.getItemPath(item), query, fuzzy) + // cache[cacheHash] = itemScore + + return itemScore +} + +function doScoreItem( + label: string, + description: string, + path: string, + query: IPreparedQuery, + fuzzy: boolean, +): IItemScore { + // 1.) treat identity matches on full path highest + if (path && isLinux ? query.original === path : equalsIgnoreCase(query.original, path)) { + return { + score: PATH_IDENTITY_SCORE, + labelMatch: [{ start: 0, end: label.length }], + descriptionMatch: description ? [{ start: 0, end: description.length }] : void 0, + } + } + + // We only consider label matches if the query is not including file path separators + const preferLabelMatches = !path || !query.containsPathSeparator + if (preferLabelMatches) { + // 2.) treat prefix matches on the label second highest + const prefixLabelMatch = matchesPrefix(query.value, label) + if (prefixLabelMatch) { + return { score: LABEL_PREFIX_SCORE, labelMatch: prefixLabelMatch } + } + + // 3.) treat camelcase matches on the label third highest + const camelcaseLabelMatch = matchesCamelCase(query.value, label) + if (camelcaseLabelMatch) { + return { score: LABEL_CAMELCASE_SCORE, labelMatch: camelcaseLabelMatch } + } + + // 4.) prefer scores on the label if any + const [labelScore, labelPositions] = score(label, query.value, query.lowercase, fuzzy) + if (labelScore) { + return { + score: labelScore + LABEL_SCORE_THRESHOLD, + labelMatch: createMatches(labelPositions), + } + } + } + + // 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}` + + const [labelDescriptionScore, labelDescriptionPositions] = score( + descriptionAndLabel, + query.value, + query.lowercase, + 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 }) + } else if (h.start >= descriptionPrefixLength) { + // Match on label part + labelMatch.push({ + start: h.start - descriptionPrefixLength, + end: h.end - descriptionPrefixLength, + }) + } else { + // Match on description part + descriptionMatch.push(h) + } + }) + + return { score: labelDescriptionScore, labelMatch, descriptionMatch } + } + } + + return NO_ITEM_SCORE +} + +export function compareItemsByScore( + itemA: T, + itemB: T, + query: IPreparedQuery, + fuzzy: boolean, + accessor: IItemAccessor, + // cache: ScorerCache, + fallbackComparer = fallbackCompare, +): number { + const itemScoreA = scoreItem(itemA, query, fuzzy, accessor) + const itemScoreB = scoreItem(itemB, query, fuzzy, accessor) + + const scoreA = itemScoreA.score + const scoreB = itemScoreB.score + + // 1.) prefer identity matches + if (scoreA === PATH_IDENTITY_SCORE || scoreB === PATH_IDENTITY_SCORE) { + if (scoreA !== scoreB) { + return scoreA === PATH_IDENTITY_SCORE ? -1 : 1 + } + } + + // 2.) prefer label prefix matches + 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 + } + } + + // 3.) prefer camelcase matches + 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) + + // prefer more compact camel case matches over longer + const comparedByMatchLength = compareByMatchLength( + itemScoreA.labelMatch, + itemScoreB.labelMatch, + ) + if (comparedByMatchLength !== 0) { + return comparedByMatchLength + } + + // prefer shorter names when both match on label camelcase + if (labelA.length !== labelB.length) { + return labelA.length - labelB.length + } + } + + // 4.) prefer label scores + if (scoreA > LABEL_SCORE_THRESHOLD || scoreB > LABEL_SCORE_THRESHOLD) { + if (scoreB < LABEL_SCORE_THRESHOLD) { + return -1 + } + + if (scoreA < LABEL_SCORE_THRESHOLD) { + return 1 + } + } + + // 5.) compare by score + if (scoreA !== scoreB) { + return scoreA > scoreB ? -1 : 1 + } + + // 6.) 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 + } + + // 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( + item: T, + score: IItemScore, + accessor: IItemAccessor, +): number { + const hasLabelMatches = score.labelMatch && score.labelMatch.length + const hasDescriptionMatches = score.descriptionMatch && score.descriptionMatch.length + + let matchStart: number = -1 + let matchEnd: number = -1 + + // If we have description matches, the start is first of description match + if (hasDescriptionMatches) { + matchStart = score.descriptionMatch[0].start + } else if (hasLabelMatches) { + // Otherwise, the start is the first label match + 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 (hasLabelMatches) { + matchEnd = score.labelMatch[score.labelMatch.length - 1].end + if (hasDescriptionMatches) { + const itemDescription = accessor.getItemDescription(item) + if (itemDescription) { + matchEnd += itemDescription.length + } + } + } else if (hasDescriptionMatches) { + // If we have just a description match, the end is the last description match + matchEnd = score.descriptionMatch[score.descriptionMatch.length - 1].end + } + + return matchEnd - matchStart +} + +function compareByMatchLength(matchesA?: IMatch[], matchesB?: IMatch[]): number { + if ((!matchesA && !matchesB) || (!matchesA.length && !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 +} + +export function fallbackCompare( + itemA: T, + itemB: T, + query: IPreparedQuery, + accessor: IItemAccessor, +): 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.value) + } + + // compare by description + if (descriptionA && descriptionB && descriptionA !== descriptionB) { + return compareAnything(descriptionA, descriptionB, query.value) + } + + // compare by path + if (pathA && pathB && pathA !== pathB) { + return compareAnything(pathA, pathB, query.value) + } + + // equal + return 0 +} diff --git a/browser/src/Services/QuickOpen/Scorer/Utilities.ts b/browser/src/Services/QuickOpen/Scorer/Utilities.ts new file mode 100644 index 0000000000..65d19a3610 --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/Utilities.ts @@ -0,0 +1,41 @@ +/** + * Assortment of imported Utility functions from VSCode + */ + +import { CharCode } from "./CharCode" + +export const isWindows = process.platform === "win32" +export const isMacintosh = process.platform === "darwin" +export const isLinux = process.platform === "linux" + +// The native path separator depending on the OS. +export const nativeSep = isWindows ? "\\" : "/" + +export function isLower(code: number): boolean { + return CharCode.a <= code && code <= CharCode.z +} + +export function isUpper(code: number): boolean { + return CharCode.A <= code && code <= CharCode.Z +} + +export function isNumber(code: number): boolean { + return CharCode.Digit0 <= code && code <= CharCode.Digit9 +} + +export function isWhitespace(code: number): boolean { + return ( + code === CharCode.Space || + code === CharCode.Tab || + code === CharCode.LineFeed || + code === CharCode.CarriageReturn + ) +} + +export function isAlphanumeric(code: number): boolean { + return isLower(code) || isUpper(code) || isNumber(code) +} + +export function stripWildcards(pattern: string): string { + return pattern.replace(/\*/g, "") +} diff --git a/browser/src/Services/QuickOpen/Scorer/filters.ts b/browser/src/Services/QuickOpen/Scorer/filters.ts new file mode 100644 index 0000000000..2fd972288d --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/filters.ts @@ -0,0 +1,224 @@ +/* tslint:disable */ +/** + * Imported functions from VSCode's filters.ts + */ + +import { startsWithIgnoreCase } from "./strings" +import { isAlphanumeric, isLower, isNumber, isUpper, isWhitespace } from "./Utilities" + +export interface IFilter { + // Returns null if word doesn't match. + (word: string, wordToMatchAgainst: string): IMatch[] +} + +export interface IMatch { + start: number + end: number +} + +export const matchesPrefix: IFilter = _matchesPrefix.bind(undefined, true) + +function _matchesPrefix(ignoreCase: boolean, word: string, wordToMatchAgainst: string): IMatch[] { + if (!wordToMatchAgainst || wordToMatchAgainst.length < word.length) { + return null + } + + let matches: boolean + if (ignoreCase) { + matches = startsWithIgnoreCase(wordToMatchAgainst, word) + } else { + matches = wordToMatchAgainst.indexOf(word) === 0 + } + + if (!matches) { + return null + } + + return word.length > 0 ? [{ start: 0, end: word.length }] : [] +} + +export function createMatches(position: number[]): IMatch[] { + let ret: IMatch[] = [] + if (!position) { + return ret + } + let last: IMatch + for (const pos of position) { + if (last && last.end === pos) { + last.end += 1 + } else { + last = { start: pos, end: pos + 1 } + ret.push(last) + } + } + return ret +} + +function nextAnchor(camelCaseWord: string, start: number): number { + for (let i = start; i < camelCaseWord.length; i++) { + let c = camelCaseWord.charCodeAt(i) + if ( + isUpper(c) || + isNumber(c) || + (i > 0 && !isAlphanumeric(camelCaseWord.charCodeAt(i - 1))) + ) { + return i + } + } + return camelCaseWord.length +} + +function _matchesCamelCase(word: string, camelCaseWord: string, i: number, j: number): IMatch[] { + if (i === word.length) { + return [] + } else if (j === camelCaseWord.length) { + return null + } else if (word[i] !== camelCaseWord[j].toLowerCase()) { + return null + } else { + let result: IMatch[] = null + let nextUpperIndex = j + 1 + result = _matchesCamelCase(word, camelCaseWord, i + 1, j + 1) + while ( + !result && + (nextUpperIndex = nextAnchor(camelCaseWord, nextUpperIndex)) < camelCaseWord.length + ) { + result = _matchesCamelCase(word, camelCaseWord, i + 1, nextUpperIndex) + nextUpperIndex++ + } + return result === null ? null : join({ start: j, end: j + 1 }, result) + } +} + +interface ICamelCaseAnalysis { + upperPercent: number + lowerPercent: number + alphaPercent: number + numericPercent: number +} + +// Heuristic to avoid computing camel case matcher for words that don't +// look like camelCaseWords. +function analyzeCamelCaseWord(word: string): ICamelCaseAnalysis { + let upper = 0, + lower = 0, + alpha = 0, + numeric = 0, + code = 0 + + for (let i = 0; i < word.length; i++) { + code = word.charCodeAt(i) + + if (isUpper(code)) { + upper++ + } + if (isLower(code)) { + lower++ + } + if (isAlphanumeric(code)) { + alpha++ + } + if (isNumber(code)) { + numeric++ + } + } + + let upperPercent = upper / word.length + let lowerPercent = lower / word.length + let alphaPercent = alpha / word.length + let numericPercent = numeric / word.length + + return { upperPercent, lowerPercent, alphaPercent, numericPercent } +} + +function isUpperCaseWord(analysis: ICamelCaseAnalysis): boolean { + const { upperPercent, lowerPercent } = analysis + return lowerPercent === 0 && upperPercent > 0.6 +} + +function isCamelCaseWord(analysis: ICamelCaseAnalysis): boolean { + const { upperPercent, lowerPercent, alphaPercent, numericPercent } = analysis + return lowerPercent > 0.2 && upperPercent < 0.8 && alphaPercent > 0.6 && numericPercent < 0.2 +} + +// Heuristic to avoid computing camel case matcher for words that don't +// look like camel case patterns. +function isCamelCasePattern(word: string): boolean { + let upper = 0, + lower = 0, + code = 0, + whitespace = 0 + + for (let i = 0; i < word.length; i++) { + code = word.charCodeAt(i) + + if (isUpper(code)) { + upper++ + } + if (isLower(code)) { + lower++ + } + if (isWhitespace(code)) { + whitespace++ + } + } + + if ((upper === 0 || lower === 0) && whitespace === 0) { + return word.length <= 30 + } else { + return upper <= 5 + } +} + +export function matchesCamelCase(word: string, camelCaseWord: string): IMatch[] { + if (!camelCaseWord) { + return null + } + + camelCaseWord = camelCaseWord.trim() + + if (camelCaseWord.length === 0) { + return null + } + + if (!isCamelCasePattern(word)) { + return null + } + + if (camelCaseWord.length > 60) { + return null + } + + const analysis = analyzeCamelCaseWord(camelCaseWord) + + if (!isCamelCaseWord(analysis)) { + if (!isUpperCaseWord(analysis)) { + return null + } + + camelCaseWord = camelCaseWord.toLowerCase() + } + + let result: IMatch[] = null + let i = 0 + + while ( + i < camelCaseWord.length && + (result = _matchesCamelCase(word.toLowerCase(), camelCaseWord, 0, i)) === null + ) { + i = nextAnchor(camelCaseWord, i + 1) + } + + return result +} + +function join(head: IMatch, tail: IMatch[]): IMatch[] { + if (tail.length === 0) { + tail = [head] + } else if (head.end === tail[0].start) { + tail[0].start = head.start + } else { + tail.unshift(head) + } + return tail +} diff --git a/browser/src/Services/QuickOpen/Scorer/strings.ts b/browser/src/Services/QuickOpen/Scorer/strings.ts new file mode 100644 index 0000000000..1f40ca34fe --- /dev/null +++ b/browser/src/Services/QuickOpen/Scorer/strings.ts @@ -0,0 +1,71 @@ +/* tslint:disable */ +/** + * Imported functions from VSCode's strings.ts + */ + +import { CharCode } from "./CharCode" + +function isLowerAsciiLetter(code: number): boolean { + return code >= CharCode.a && code <= CharCode.z +} + +function isUpperAsciiLetter(code: number): boolean { + return code >= CharCode.A && code <= CharCode.Z +} + +function isAsciiLetter(code: number): boolean { + return isLowerAsciiLetter(code) || isUpperAsciiLetter(code) +} + +export function equalsIgnoreCase(a: string, b: string): boolean { + const len1 = a ? a.length : 0 + const len2 = b ? b.length : 0 + + if (len1 !== len2) { + return false + } + + return doEqualsIgnoreCase(a, b) +} + +function doEqualsIgnoreCase(a: string, b: string, stopAt = a.length): boolean { + if (typeof a !== "string" || typeof b !== "string") { + return false + } + + for (let i = 0; i < stopAt; i++) { + const codeA = a.charCodeAt(i) + const codeB = b.charCodeAt(i) + + if (codeA === codeB) { + continue + } + + // a-z A-Z + if (isAsciiLetter(codeA) && isAsciiLetter(codeB)) { + let diff = Math.abs(codeA - codeB) + if (diff !== 0 && diff !== 32) { + return false + } + } else { + // Any other charcode + if ( + String.fromCharCode(codeA).toLowerCase() !== + String.fromCharCode(codeB).toLowerCase() + ) { + return false + } + } + } + + return true +} + +export function startsWithIgnoreCase(str: string, candidate: string): boolean { + const candidateLength = candidate.length + if (candidate.length > str.length) { + return false + } + + return doEqualsIgnoreCase(str, candidate, candidateLength) +} diff --git a/browser/src/Services/QuickOpen/VSCodeFilter.ts b/browser/src/Services/QuickOpen/VSCodeFilter.ts new file mode 100644 index 0000000000..56444293fd --- /dev/null +++ b/browser/src/Services/QuickOpen/VSCodeFilter.ts @@ -0,0 +1,83 @@ +/** + * VSCodeFilter.ts + * + * Implements filtering logic for the menu using the scores module from VSCode. + */ + +import * as sortBy from "lodash/sortBy" + +import * as Oni from "oni-api" + +import { + compareItemsByScoreOni, + getHighlightsFromResult, + scoreItemOni, +} from "./Scorer/OniQuickOpenScorer" + +import { IMenuOptionWithHighlights, shouldFilterbeCaseSensitive } from "./../Menu" + +export const vsCodeFilter = ( + options: Oni.Menu.MenuOption[], + searchString: string, +): IMenuOptionWithHighlights[] => { + if (!searchString) { + const opt = options.map(o => { + return { + ...o, + detailHighlights: [], + labelHighlights: [], + } + }) + + return sortBy(opt, o => (o.pinned ? 0 : 1)) + } + + const isCaseSensitive = shouldFilterbeCaseSensitive(searchString) + + if (!isCaseSensitive) { + searchString = searchString.toLowerCase() + } + + const listOfSearchTerms = searchString.split(" ").filter(x => x) + + // Since the VSCode scorer doesn't deal so well with the spaces, + // instead rebuild the term in reverse order. + // ie `index browser editor` becomes `browsereditorindex` + // This allows the scoring and highlighting to work better. + const vsCodeSearchString = + listOfSearchTerms.length > 1 + ? listOfSearchTerms.slice(1).join("") + listOfSearchTerms[0] + : listOfSearchTerms[0] + + const filteredOptions = processSearchTerm(vsCodeSearchString, options) + + const ret = filteredOptions.filter(fo => { + if (fo.score === 0) { + return false + } else { + return true + } + }) + + return ret.sort((e1, e2) => compareItemsByScoreOni(e1, e2, vsCodeSearchString, true)) +} + +export const processSearchTerm = ( + searchString: string, + options: Oni.Menu.MenuOption[], +): Oni.Menu.IMenuOptionWithHighlights[] => { + const result: Oni.Menu.IMenuOptionWithHighlights[] = options.map(f => { + const itemScore = scoreItemOni(f, searchString, true) + const detailHighlights = getHighlightsFromResult(itemScore.descriptionMatch) + const labelHighlights = getHighlightsFromResult(itemScore.labelMatch) + + return { + ...f, + detailHighlights, + labelHighlights, + score: f.pinned ? Number.MAX_SAFE_INTEGER : itemScore.score, + } + }) + + return result +} diff --git a/browser/test/Services/QuickOpen/RegExFilterTests.ts b/browser/test/Services/QuickOpen/RegExFilterTests.ts index c548e1d0ae..d4c6301c1f 100644 --- a/browser/test/Services/QuickOpen/RegExFilterTests.ts +++ b/browser/test/Services/QuickOpen/RegExFilterTests.ts @@ -66,6 +66,7 @@ describe("regexFilter", () => { result.forEach(r => { delete r.detailHighlights delete r.labelHighlights + delete r.score }) const expectedResult = [ @@ -88,6 +89,7 @@ describe("regexFilter", () => { // elsewhere. delete result[0].detailHighlights delete result[0].labelHighlights + delete result[0].score const expectedResult = [{ label: "index.ts", detail: "browser/src/index.ts" }] @@ -106,6 +108,7 @@ describe("regexFilter", () => { // elsewhere. delete result[0].detailHighlights delete result[0].labelHighlights + delete result[0].score const expectedResult = [ { label: "index.ts", detail: "browser/src/services/quickopen/index.ts" }, diff --git a/browser/test/Services/QuickOpen/VSCodeFilterTests.ts b/browser/test/Services/QuickOpen/VSCodeFilterTests.ts new file mode 100644 index 0000000000..a1f36c3233 --- /dev/null +++ b/browser/test/Services/QuickOpen/VSCodeFilterTests.ts @@ -0,0 +1,287 @@ +/** + * VSCodeFilterTests.ts + */ + +import * as assert from "assert" +import { processSearchTerm, vsCodeFilter } from "./../../../src/Services/QuickOpen/VSCodeFilter" + +describe("processSearchTerm", () => { + it("Correctly matches word.", async () => { + const testString = "src" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/test" }, + ] + + const result = processSearchTerm(testString, testList) + const filteredResult = result.filter(r => r.score !== 0) + + // Remove the score since it can change if we updated the + // module. As long as its not 0 that is enough here. + assert.equal(result[0].score > 0, true) + delete result[0].score + + const expectedResult = [ + { + label: "index.ts", + labelHighlights: [] as number[], + detail: "browser/src", + detailHighlights: [8, 9, 10], + }, + ] + + assert.deepEqual(filteredResult, expectedResult) + }) + it("Correctly score case-match higher", async () => { + const testString = "SRC" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/SRC" }, + ] + + const result = processSearchTerm(testString, testList) + + // Check the exact case match scores higher + const lowercase = result.find(r => r.detail === "browser/src") + const uppercase = result.find(r => r.detail === "browser/SRC") + assert.equal(uppercase.score > lowercase.score, true) + + // Both should be highlighted though + assert.deepEqual(uppercase.detailHighlights, [8, 9, 10]) + assert.deepEqual(lowercase.detailHighlights, [8, 9, 10]) + }) + it("Correctly returns no matches.", async () => { + const testString = "zzz" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/test" }, + ] + + const result = processSearchTerm(testString, testList) + const filteredResult = result.filter(r => r.score !== 0) + + assert.deepEqual(filteredResult, []) + }) +}) + +describe("vsCodeFilter", () => { + it("Correctly matches string.", async () => { + const testString = "index" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "main.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/test" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Remove the score since it can change if we updated the + // module. + // However, the score should be equal due to an exact match on both. + assert.equal(result[0].score === result[1].score, true) + delete result[0].score + delete result[1].score + + const expectedResult = [ + { + label: "index.ts", + labelHighlights: [0, 1, 2, 3, 4], + detail: "browser/src", + detailHighlights: [] as number[], + }, + { + label: "index.ts", + labelHighlights: [0, 1, 2, 3, 4], + detail: "browser/test", + detailHighlights: [] as number[], + }, + ] + + assert.deepEqual(result, expectedResult) + }) + it("Correctly matches string with extension.", async () => { + const testString = "index.ts" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "main.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/test" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Remove the score since it can change if we updated the + // module. + // However, the score should be equal due to an exact match on both. + assert.equal(result[0].score === result[1].score, true) + delete result[0].score + delete result[1].score + + const expectedResult = [ + { + label: "index.ts", + labelHighlights: [0, 1, 2, 3, 4, 5, 6, 7], + detail: "browser/src", + detailHighlights: [] as number[], + }, + { + label: "index.ts", + labelHighlights: [0, 1, 2, 3, 4, 5, 6, 7], + detail: "browser/test", + detailHighlights: [] as number[], + }, + ] + + assert.deepEqual(result, expectedResult) + }) + it("Correctly splits and matches string.", async () => { + const testString = "index src" + const testList = [ + { label: "index.ts", detail: "browser/src" }, + { label: "index.ts", detail: "browser/test" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Remove the score since it can change if we updated the + // module. As long as its not 0 that is enough here. + assert.equal(result[0].score > 0, true) + delete result[0].score + + const expectedResult = [ + { + label: "index.ts", + labelHighlights: [0, 1, 2, 3, 4], + detail: "browser/src", + detailHighlights: [8, 9, 10], + }, + ] + + assert.deepEqual(result, expectedResult) + }) + it("Correctly matches long split string.", async () => { + const testString = "index src service quickopen" + const testList = [ + { label: "index.ts", detail: "browser/src/services/menu" }, + { label: "index.ts", detail: "browser/src/services/quickopen" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Remove the score since it can change if we updated the + // module. As long as its not 0 that is enough here. + // Similarly, the highlights has been tested elsewhere, + // and its long here, so just check lengths. + assert.equal(result[0].score > 0, true) + assert.equal(result[0].labelHighlights.length === 5, true) + assert.equal(result[0].detailHighlights.length === 19, true) + delete result[0].score + delete result[0].labelHighlights + delete result[0].detailHighlights + + const expectedResult = [{ label: "index.ts", detail: "browser/src/services/quickopen" }] + + assert.deepEqual(result, expectedResult) + }) + it("Correctly doesn't match.", async () => { + const testString = "zzz" + const testList = [ + { label: "index.ts", detail: "browser/src/services/menu" }, + { label: "index.ts", detail: "browser/src/services/quickopen" }, + ] + + const result = vsCodeFilter(testList, testString) + + assert.deepEqual(result, []) + }) + it("Correctly matches split string in turn.", async () => { + const testString = "index main" + const testList = [ + { label: "index.ts", detail: "browser/src/services/config" }, + { label: "index.ts", detail: "browser/src/services/quickopen" }, + { label: "main.ts", detail: "browser/src/services/menu" }, + ] + + // Should return no results, since the first term should restrict the second + // search to return no results. + const result = vsCodeFilter(testList, testString) + + assert.deepEqual(result, []) + }) + it("Correctly sorts results for fuzzy match.", async () => { + const testString = "aBE" + const testList = [ + { label: "BufferEditor.ts", detail: "packages/demo/src" }, + { label: "BufferEditorContainer.ts", detail: "packages/demo/src" }, + { label: "astBackedEditing.ts", detail: "packages/core/src" }, + ] + + // All results match, but only the last has an exact match on aBE inside the file name. + const result = vsCodeFilter(testList, testString) + + const be = result.find(r => r.label === "BufferEditor.ts") + const bec = result.find(r => r.label === "BufferEditorContainer.ts") + const abe = result.find(r => r.label === "astBackedEditing.ts") + + // Therefore it should score the highest. + assert.equal(abe.score > be.score, true) + assert.equal(abe.score > bec.score, true) + + // It should also be the first in the list + assert.deepEqual(result[0], abe) + }) + it("Correctly sorts results for filtered search.", async () => { + const testString = "buffer test oni" + const testList = [ + { label: "BufferEditor.ts", detail: "packages/demo/src" }, + { label: "BufferEditorContainer.ts", detail: "packages/demo/src" }, + { label: "BufferEditor.ts", detail: "packages/core/src" }, + { label: "BufferEditor.ts", detail: "packages/core/test" }, + { label: "BufferEditor.ts", detail: "packages/core/test/oni" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Should only match the last term + const best = result.find(r => r.detail === "packages/core/test/oni") + assert.deepEqual(result[0], best) + assert.equal(result.length, 1) + }) + it("Correctly sorts results for shortest result on file name.", async () => { + const testString = "main" + const testList = [ + { label: "main.tex", detail: "packages/core/src" }, + { label: "main.tex", detail: "packages/core/test" }, + { label: "main.tex", detail: "packages/core/test/oni" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Should prefer the short path + const best = result.find(r => r.detail === "packages/core/src") + const second = result.find(r => r.detail === "packages/core/test") + const third = result.find(r => r.detail === "packages/core/test/oni") + + // Order should be as follows + assert.deepEqual(result[0], best) + assert.deepEqual(result[1], second) + assert.deepEqual(result[2], third) + }) + it("Correctly sorts results for shortest result on path.", async () => { + const testString = "somepath" + const testList = [ + { label: "fileA.ts", detail: "/some/path" }, + { label: "fileB.ts", detail: "/some/path/longer" }, + { label: "fileC.ts", detail: "packages/core/oni" }, + ] + + const result = vsCodeFilter(testList, testString) + + // Should prefer the short path + const best = result.find(r => r.label === "fileA.ts") + const second = result.find(r => r.label === "fileB.ts") + + // Order should be as follows + assert.deepEqual(result[0], best) + assert.deepEqual(result[1], second) + }) +}) diff --git a/package.json b/package.json index fb7e402460..069996729b 100644 --- a/package.json +++ b/package.json @@ -858,7 +858,7 @@ "minimist": "1.2.0", "msgpack-lite": "0.1.26", "ocaml-language-server": "^1.0.27", - "oni-api": "^0.0.41", + "oni-api": "^0.0.42", "oni-neovim-binaries": "0.1.1", "oni-ripgrep": "0.0.4", "oni-types": "0.0.4",