PRE-MERGE #18700 Update command palette search to prioritize "longest substring" match.

This commit is contained in:
Carlos Zamora
2025-05-15 15:59:44 -07:00
17 changed files with 1227 additions and 212 deletions

View File

@@ -133,3 +133,4 @@ Resources/(?!en)
^\Qsrc/terminal/parser/ft_fuzzwrapper/run.bat\E$
^\Qsrc/tools/lnkd/lnkd.bat\E$
^\Qsrc/tools/pixels/pixels.bat\E$
^\Qsrc/cascadia/ut_app/FzfTests.cpp\E$

View File

@@ -651,6 +651,7 @@ FONTSTRING
FONTTYPE
FONTWIDTH
FONTWINDOW
foob
FORCEOFFFEEDBACK
FORCEONFEEDBACK
FRAMECHANGED
@@ -668,9 +669,11 @@ fuzzer
fuzzmain
fuzzmap
fuzzwrapper
fuzzyfinder
fwdecl
fwe
fwlink
fzf
gci
gcx
gdi
@@ -1248,6 +1251,7 @@ onecoreuuid
ONECOREWINDOWS
onehalf
oneseq
oob
openbash
opencode
opencon

View File

@@ -35,7 +35,8 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with no filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 1u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC");
VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted());
@@ -43,8 +44,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with empty filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L""));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 1u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC");
VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted());
@@ -52,8 +54,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with filter equal to the string");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"AAAAAABBBBBBCCC";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"AAAAAABBBBBBCCC"));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 1u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC");
VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted());
@@ -61,8 +64,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with filter with first character matching");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"A";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"A"));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 2u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A");
VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted());
@@ -72,8 +76,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with filter with other case");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"a";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"a"));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 2u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A");
VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted());
@@ -83,8 +88,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with filter matching several characters");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"ab";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"ab"));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 4u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A");
VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted());
@@ -98,8 +104,9 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing command name segmentation with non matching filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"abcd";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"abcd"));
filteredCommand->_update();
auto segments = filteredCommand->HighlightedName().Segments();
VERIFY_ARE_EQUAL(segments.Size(), 1u);
VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC");
VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted());
@@ -116,48 +123,48 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing weight of command with no filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 0);
}
{
Log::Comment(L"Testing weight of command with empty filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L""));
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 0);
}
{
Log::Comment(L"Testing weight of command with filter equal to the string");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"AAAAAABBBBBBCCC";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"AAAAAABBBBBBCCC"));
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 30); // 1 point for the first char and 2 points for the 14 consequent ones + 1 point for the beginning of the word
}
{
Log::Comment(L"Testing weight of command with filter with first character matching");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"A";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"A"));
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 2); // 1 point for the first char match + 1 point for the beginning of the word
}
{
Log::Comment(L"Testing weight of command with filter with other case");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"a";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"a"));
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 2); // 1 point for the first char match + 1 point for the beginning of the word
}
{
Log::Comment(L"Testing weight of command with filter matching several characters");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"ab";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"ab"));
filteredCommand->_update();
auto weight = filteredCommand->Weight();
VERIFY_ARE_EQUAL(weight, 3); // 1 point for the first char match + 1 point for the beginning of the word + 1 point for the match of "b"
}
});
@@ -181,14 +188,12 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing comparison of commands with empty filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
filteredCommand->_Weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L""));
filteredCommand->_update();
const auto filteredCommand2 = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem2);
filteredCommand2->_Filter = L"";
filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName();
filteredCommand2->_Weight = filteredCommand2->_computeWeight();
filteredCommand2->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L""));
filteredCommand->_update();
VERIFY_ARE_EQUAL(filteredCommand->Weight(), filteredCommand2->Weight());
VERIFY_IS_TRUE(winrt::TerminalApp::implementation::FilteredCommand::Compare(*filteredCommand, *filteredCommand2));
@@ -196,14 +201,12 @@ namespace TerminalAppLocalTests
{
Log::Comment(L"Testing comparison of commands with different weights");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"B";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
filteredCommand->_Weight = filteredCommand->_computeWeight();
filteredCommand->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"B"));
filteredCommand->_update();
const auto filteredCommand2 = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem2);
filteredCommand2->_Filter = L"B";
filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName();
filteredCommand2->_Weight = filteredCommand2->_computeWeight();
filteredCommand2->_pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"B"));
filteredCommand->_update();
VERIFY_IS_TRUE(filteredCommand->Weight() < filteredCommand2->Weight()); // Second command gets more points due to the beginning of the word
VERIFY_IS_FALSE(winrt::TerminalApp::implementation::FilteredCommand::Compare(*filteredCommand, *filteredCommand2));

View File

@@ -1174,12 +1174,15 @@ namespace winrt::TerminalApp::implementation
}
else if (_currentMode == CommandPaletteMode::TabSearchMode || _currentMode == CommandPaletteMode::ActionMode || _currentMode == CommandPaletteMode::CommandlineMode)
{
auto pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(searchText));
for (const auto& action : commandsToFilter)
{
// Update filter for all commands
// This will modify the highlighting but will also lead to re-computation of weight (and consequently sorting).
// Pay attention that it already updates the highlighting in the UI
action.UpdateFilter(searchText);
auto impl = winrt::get_self<implementation::FilteredCommand>(action);
impl->UpdateFilter(pattern);
// if there is active search we skip commands with 0 weight
if (searchText.empty() || action.Weight() > 0)

View File

@@ -5,6 +5,7 @@
#include "CommandPalette.h"
#include "HighlightedText.h"
#include <LibraryResources.h>
#include "fzf/fzf.h"
#include "FilteredCommand.g.cpp"
@@ -35,197 +36,103 @@ namespace winrt::TerminalApp::implementation
void FilteredCommand::_constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item)
{
_Item = item;
_Filter = L"";
_Weight = 0;
_HighlightedName = _computeHighlightedName();
_update();
// Recompute the highlighted name if the item name changes
_itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() }](auto& /*sender*/, auto& e) {
auto filteredCommand{ weakThis.get() };
if (filteredCommand && e.PropertyName() == L"Name")
{
filteredCommand->HighlightedName(filteredCommand->_computeHighlightedName());
filteredCommand->Weight(filteredCommand->_computeWeight());
filteredCommand->_update();
}
});
}
void FilteredCommand::UpdateFilter(const winrt::hstring& filter)
void FilteredCommand::UpdateFilter(std::shared_ptr<fzf::matcher::Pattern> pattern)
{
// If the filter was not changed we want to prevent the re-computation of matching
// that might result in triggering a notification event
if (filter != _Filter)
if (pattern != _pattern)
{
Filter(filter);
HighlightedName(_computeHighlightedName());
Weight(_computeWeight());
_pattern = pattern;
_update();
}
}
// Method Description:
// - Looks up the filter characters within the item name.
// Iterating through the filter and the item name it tries to associate the next filter character
// with the first appearance of this character in the item name suffix.
//
// E.g., for filter="c l t s" and name="close all tabs after this", the match will be "CLose TabS after this".
//
// The item name is then split into segments (groupings of matched and non matched characters).
//
// E.g., the segments were the example above will be "CL", "ose ", "T", "ab", "S", "after this".
//
// The segments matching the filter characters are marked as highlighted.
//
// E.g., ("CL", true) ("ose ", false), ("T", true), ("ab", false), ("S", true), ("after this", false)
//
// TODO: we probably need to merge this logic with _getWeight computation?
//
// Return Value:
// - The HighlightedText object initialized with the segments computed according to the algorithm above.
winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName()
void FilteredCommand::_update()
{
const auto segments = winrt::single_threaded_observable_vector<winrt::TerminalApp::HighlightedTextSegment>();
auto segments = winrt::single_threaded_observable_vector<winrt::TerminalApp::HighlightedTextSegment>();
auto commandName = _Item.Name();
auto isProcessingMatchedSegment = false;
uint32_t nextOffsetToReport = 0;
uint32_t currentOffset = 0;
for (const auto searchChar : _Filter)
auto weight = 0;
if (!_pattern || !_pattern->terms.empty())
{
const WCHAR searchCharAsString[] = { searchChar, L'\0' };
while (true)
{
if (currentOffset == commandName.size())
{
// There are still unmatched filter characters but we finished scanning the name.
// In this case we return the entire item name as unmatched
auto entireNameSegment{ winrt::make<HighlightedTextSegment>(commandName, false) };
segments.Clear();
segments.Append(entireNameSegment);
return winrt::make<HighlightedText>(segments);
}
segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false));
}
else if (auto match = fzf::matcher::Match(commandName, *_pattern.get()); !match)
{
segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false));
}
else
{
auto& matchResult = *match;
weight = matchResult.Score;
auto positions = matchResult.Pos;
// positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8}
// sorting these in ascending order so it is easier to build the text segments
std::ranges::sort(positions);
// a position can be matched in multiple terms, removed duplicates to simplify segments
positions.erase(std::unique(positions.begin(), positions.end()), positions.end());
// GH#9941: search should be locale-aware as well
// We use the same comparison method as upon sorting to guarantee consistent behavior
const WCHAR currentCharAsString[] = { commandName[currentOffset], L'\0' };
auto isCurrentCharMatched = lstrcmpi(searchCharAsString, currentCharAsString) == 0;
if (isProcessingMatchedSegment != isCurrentCharMatched)
std::vector<std::pair<size_t, size_t>> runs;
if (!positions.empty())
{
size_t runStart = positions[0];
size_t runEnd = runStart;
for (size_t i = 1; i < positions.size(); ++i)
{
// We reached the end of the region (matched character came after a series of unmatched or vice versa).
// Conclude the segment and add it to the list.
// Skip segment if it is empty (might happen when the first character of the name is matched)
auto sizeToReport = currentOffset - nextOffsetToReport;
if (sizeToReport > 0)
if (positions[i] == runEnd + 1)
{
winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport };
auto highlightedSegment{ winrt::make<HighlightedTextSegment>(segment, isProcessingMatchedSegment) };
segments.Append(highlightedSegment);
nextOffsetToReport = currentOffset;
runEnd = positions[i];
}
else
{
runs.emplace_back(runStart, runEnd);
runStart = positions[i];
runEnd = runStart;
}
isProcessingMatchedSegment = isCurrentCharMatched;
}
currentOffset++;
if (isCurrentCharMatched)
{
// We have matched this filter character, let's move to matching the next filter char
break;
}
runs.emplace_back(runStart, runEnd);
}
}
// Either the filter or the item name were fully processed.
// If we were in the middle of the matched segment - add it.
if (isProcessingMatchedSegment)
{
auto sizeToReport = currentOffset - nextOffsetToReport;
if (sizeToReport > 0)
size_t lastPos = 0;
for (auto [start, end] : runs)
{
winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport };
auto highlightedSegment{ winrt::make<HighlightedTextSegment>(segment, true) };
segments.Append(highlightedSegment);
nextOffsetToReport = currentOffset;
}
}
// Now create a segment for all remaining characters.
// We will have remaining characters as long as the filter is shorter than the item name.
auto sizeToReport = commandName.size() - nextOffsetToReport;
if (sizeToReport > 0)
{
winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport };
auto highlightedSegment{ winrt::make<HighlightedTextSegment>(segment, false) };
segments.Append(highlightedSegment);
}
return winrt::make<HighlightedText>(segments);
}
// Function Description:
// - Calculates a "weighting" by which should be used to order a item
// name relative to other names, given a specific search string.
// Currently, this is based off of two factors:
// * The weight is incremented once for each matched character of the
// search text.
// * If a matching character from the search text was found at the start
// of a word in the name, then we increment the weight again.
// * For example, for a search string "sp", we want "Split Pane" to
// appear in the list before "Close Pane"
// * Consecutive matches will be weighted higher than matches with
// characters in between the search characters.
// - This will return 0 if the item should not be shown. If all the
// characters of search text appear in order in `name`, then this function
// will return a positive number. There can be any number of characters
// separating consecutive characters in searchText.
// * For example:
// "name": "New Tab"
// "name": "Close Tab"
// "name": "Close Pane"
// "name": "[-] Split Horizontal"
// "name": "[ | ] Split Vertical"
// "name": "Next Tab"
// "name": "Prev Tab"
// "name": "Open Settings"
// "name": "Open Media Controls"
// * "open" should return both "**Open** Settings" and "**Open** Media Controls".
// * "Tab" would return "New **Tab**", "Close **Tab**", "Next **Tab**" and "Prev
// **Tab**".
// * "P" would return "Close **P**ane", "[-] S**p**lit Horizontal", "[ | ]
// S**p**lit Vertical", "**P**rev Tab", "O**p**en Settings" and "O**p**en Media
// Controls".
// * "sv" would return "[ | ] Split Vertical" (by matching the **S** in
// "Split", then the **V** in "Vertical").
// Arguments:
// - searchText: the string of text to search for in `name`
// - name: the name to check
// Return Value:
// - the relative weight of this match
int FilteredCommand::_computeWeight()
{
auto result = 0;
auto isNextSegmentWordBeginning = true;
for (const auto& segment : _HighlightedName.Segments())
{
const auto& segmentText = segment.TextSegment();
const auto segmentSize = segmentText.size();
if (segment.IsHighlighted())
{
// Give extra point for each consecutive match
result += (segmentSize <= 1) ? segmentSize : 1 + 2 * (segmentSize - 1);
// Give extra point if this segment is at the beginning of a word
if (isNextSegmentWordBeginning)
if (start > lastPos)
{
result++;
hstring nonMatch{ commandName.data() + lastPos,
static_cast<unsigned>(start - lastPos) };
segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false));
}
hstring matchSeg{ commandName.data() + start,
static_cast<unsigned>(end - start + 1) };
segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true));
lastPos = end + 1;
}
isNextSegmentWordBeginning = segmentSize > 0 && segmentText[segmentSize - 1] == L' ';
if (lastPos < commandName.size())
{
hstring tail{ commandName.data() + lastPos,
static_cast<unsigned>(commandName.size() - lastPos) };
segments.Append(winrt::TerminalApp::HighlightedTextSegment(tail, false));
}
}
return result;
HighlightedName(winrt::make<HighlightedText>(segments));
Weight(weight);
}
// Function Description:

View File

@@ -5,6 +5,7 @@
#include "HighlightedTextControl.h"
#include "FilteredCommand.g.h"
#include "fzf/fzf.h"
// fwdecl unittest classes
namespace TerminalAppLocalTests
@@ -19,13 +20,12 @@ namespace winrt::TerminalApp::implementation
FilteredCommand() = default;
FilteredCommand(const winrt::TerminalApp::PaletteItem& item);
virtual void UpdateFilter(const winrt::hstring& filter);
virtual void UpdateFilter(std::shared_ptr<fzf::matcher::Pattern> pattern);
static int Compare(const winrt::TerminalApp::FilteredCommand& first, const winrt::TerminalApp::FilteredCommand& second);
til::property_changed_event PropertyChanged;
WINRT_OBSERVABLE_PROPERTY(winrt::TerminalApp::PaletteItem, Item, PropertyChanged.raise, nullptr);
WINRT_OBSERVABLE_PROPERTY(winrt::hstring, Filter, PropertyChanged.raise);
WINRT_OBSERVABLE_PROPERTY(winrt::TerminalApp::HighlightedText, HighlightedName, PropertyChanged.raise);
WINRT_OBSERVABLE_PROPERTY(int, Weight, PropertyChanged.raise);
@@ -33,8 +33,8 @@ namespace winrt::TerminalApp::implementation
void _constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item);
private:
winrt::TerminalApp::HighlightedText _computeHighlightedName();
int _computeWeight();
std::shared_ptr<fzf::matcher::Pattern> _pattern;
void _update();
Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker _itemChangedRevoker;
friend class TerminalAppLocalTests::FilteredCommandTests;

View File

@@ -12,10 +12,7 @@ namespace TerminalApp
FilteredCommand(PaletteItem item);
PaletteItem Item { get; };
String Filter;
HighlightedText HighlightedName { get; };
Int32 Weight;
void UpdateFilter(String filter);
}
}

View File

@@ -32,6 +32,7 @@ namespace winrt::TerminalApp::implementation
void SnippetsPaneContent::_updateFilteredCommands()
{
const auto& queryString = _filterBox().Text();
auto pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(queryString));
// DON'T replace the itemSource here. If you do, it'll un-expand all the
// nested items the user has expanded. Instead, just update the filter.
@@ -39,7 +40,7 @@ namespace winrt::TerminalApp::implementation
for (const auto& t : _allTasks)
{
auto impl = winrt::get_self<implementation::FilteredTask>(t);
impl->UpdateFilter(queryString);
impl->UpdateFilter(pattern);
}
}

View File

@@ -77,13 +77,14 @@ namespace winrt::TerminalApp::implementation
}
}
void UpdateFilter(const winrt::hstring& filter)
void UpdateFilter(std::shared_ptr<fzf::matcher::Pattern> pattern)
{
_filteredCommand->UpdateFilter(filter);
_pattern = pattern;
_filteredCommand->UpdateFilter(pattern);
for (const auto& c : _children)
{
auto impl = winrt::get_self<implementation::FilteredTask>(c);
impl->UpdateFilter(filter);
impl->UpdateFilter(pattern);
}
PropertyChanged.raise(*this, Windows::UI::Xaml::Data::PropertyChangedEventArgs{ L"Visibility" });
@@ -108,6 +109,7 @@ namespace winrt::TerminalApp::implementation
bool HasChildren() { return _children.Size() > 0; }
winrt::Microsoft::Terminal::Settings::Model::Command Command() { return _command; }
winrt::TerminalApp::FilteredCommand FilteredCommand() { return *_filteredCommand; }
std::shared_ptr<fzf::matcher::Pattern> _pattern;
int32_t Row() { return HasChildren() ? 2 : 1; } // See the BODGY comment in the .XAML for explanation
@@ -117,7 +119,7 @@ namespace winrt::TerminalApp::implementation
winrt::Windows::UI::Xaml::Visibility Visibility()
{
// Is there no filter, or do we match it?
if (_filteredCommand->Filter().empty() || _filteredCommand->Weight() > 0)
if ((!_pattern || _pattern->terms.empty() || _filteredCommand->Weight() > 0))
{
return winrt::Windows::UI::Xaml::Visibility::Visible;
}

View File

@@ -936,12 +936,15 @@ namespace winrt::TerminalApp::implementation
auto commandsToFilter = _commandsToFilter();
{
auto pattern = std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(searchText));
for (const auto& action : commandsToFilter)
{
// Update filter for all commands
// This will modify the highlighting but will also lead to re-computation of weight (and consequently sorting).
// Pay attention that it already updates the highlighting in the UI
action.UpdateFilter(searchText);
auto impl = winrt::get_self<implementation::FilteredCommand>(action);
impl->UpdateFilter(pattern);
// if there is active search we skip commands with 0 weight
if (searchText.empty() || action.Weight() > 0)

View File

@@ -138,6 +138,8 @@
</ClInclude>
<ClInclude Include="FilteredCommand.h" />
<ClInclude Include="Pane.h" />
<ClInclude Include="fzf/fzf.h" />
<ClInclude Include="fzf/LICENSE" />
<ClInclude Include="ColorHelper.h" />
<ClInclude Include="pch.h" />
<ClInclude Include="ShortcutActionDispatch.h">
@@ -212,6 +214,7 @@
<ClCompile Include="TabBase.cpp">
<DependentUpon>TabBase.idl</DependentUpon>
</ClCompile>
<ClCompile Include="fzf/fzf.cpp" />
<ClCompile Include="TabPaletteItem.cpp" />
<ClCompile Include="TaskbarState.cpp">
<DependentUpon>TaskbarState.idl</DependentUpon>

View File

@@ -41,6 +41,9 @@
<ClCompile Include="HighlightedText.cpp">
<Filter>highlightedText</Filter>
</ClCompile>
<ClCompile Include="fzf/fzf.cpp">
<Filter>fzf</Filter>
</ClCompile>
<ClCompile Include="Toast.cpp" />
<ClCompile Include="LanguageProfileNotifier.cpp" />
<ClCompile Include="Monarch.cpp" />
@@ -77,6 +80,12 @@
<ClInclude Include="HighlightedText.h">
<Filter>highlightedText</Filter>
</ClInclude>
<ClInclude Include="fzf/fzf.h">
<Filter>fzf</Filter>
</ClInclude>
<ClInclude Include="fzf/LICENSE">
<Filter>fzf</Filter>
</ClInclude>
<ClInclude Include="Toast.h" />
<ClInclude Include="LanguageProfileNotifier.h" />
<ClInclude Include="WindowsPackageManagerFactory.h" />
@@ -176,6 +185,9 @@
<Filter Include="highlightedText">
<UniqueIdentifier>{e490f626-547d-4b5b-b22d-c6d33c9e3210}</UniqueIdentifier>
</Filter>
<Filter Include="fzf">
<UniqueIdentifier>{e4588ff4-c80a-40f7-be57-3e81f570a93d}</UniqueIdentifier>
</Filter>
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="App.xaml">

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2013-2024 Junegunn Choi
Copyright (c) 2021-2025 Simon Hauser
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,490 @@
#include "pch.h"
#include "fzf.h"
#include <algorithm>
namespace fzf
{
namespace matcher
{
constexpr int16_t ScoreMatch = 16;
constexpr int16_t ScoreGapStart = -3;
constexpr int16_t ScoreGapExtension = -1;
constexpr int16_t BoundaryBonus = ScoreMatch / 2;
constexpr int16_t NonWordBonus = ScoreMatch / 2;
constexpr int16_t CamelCaseBonus = BoundaryBonus + ScoreGapExtension;
constexpr int16_t BonusConsecutive = -(ScoreGapStart + ScoreGapExtension);
constexpr int16_t BonusFirstCharMultiplier = 2;
enum CharClass : uint8_t
{
NonWord = 0,
CharLower = 1,
CharUpper = 2,
Digit = 3,
};
std::wstring_view TrimStart(const std::wstring_view str)
{
const auto off = str.find_first_not_of(L' ');
return str.substr(std::min(off, str.size()));
}
std::wstring_view TrimSuffixSpaces(std::wstring_view input)
{
size_t end = input.size();
while (end > 0 && input[end - 1] == L' ')
{
--end;
}
return input.substr(0, end);
}
UChar32 FoldCase(UChar32 c) noexcept
{
return u_foldCase(c, U_FOLD_CASE_DEFAULT);
}
int32_t IndexOfChar(const std::vector<UChar32>& input, const UChar32 searchChar, int32_t startIndex)
{
for (int32_t i = startIndex; i < static_cast<int32_t>(input.size()); ++i)
{
if (input[i] == searchChar)
{
return i;
}
}
return -1;
}
int32_t FuzzyIndexOf(const std::vector<UChar32>& input, const std::vector<UChar32>& pattern)
{
int32_t idx = 0;
int32_t firstIdx = 0;
for (int32_t pi = 0; pi < pattern.size(); ++pi)
{
idx = IndexOfChar(input, pattern[pi], idx);
if (idx < 0)
{
return -1;
}
if (pi == 0 && idx > 0)
{
firstIdx = idx - 1;
}
idx++;
}
return firstIdx;
}
int16_t CalculateBonus(CharClass prevClass, CharClass currentClass)
{
if (prevClass == NonWord && currentClass != NonWord)
{
return BoundaryBonus;
}
if ((prevClass == CharLower && currentClass == CharUpper) ||
(prevClass != Digit && currentClass == Digit))
{
return CamelCaseBonus;
}
if (currentClass == NonWord)
{
return NonWordBonus;
}
return 0;
}
static constexpr auto s_charClassLut = []() {
std::array<CharClass, U_CHAR_CATEGORY_COUNT> lut{};
lut.fill(CharClass::NonWord);
lut[U_UPPERCASE_LETTER] = CharUpper;
lut[U_LOWERCASE_LETTER] = CharLower;
lut[U_MODIFIER_LETTER] = CharLower;
lut[U_OTHER_LETTER] = CharLower;
lut[U_DECIMAL_DIGIT_NUMBER] = Digit;
return lut;
}();
CharClass ClassOf(UChar32 ch)
{
return s_charClassLut[u_charType(ch)];
}
FzfResult FzfFuzzyMatchV2(const std::vector<UChar32>& text, const std::vector<UChar32>& pattern, std::vector<int32_t>* pos)
{
int32_t patternSize = static_cast<int32_t>(pattern.size());
int32_t textSize = static_cast<int32_t>(text.size());
if (patternSize == 0)
{
return { 0, 0, 0 };
}
std::vector<UChar32> foldedText;
foldedText.reserve(text.size());
for (auto cp : text)
{
auto foldedCp = u_foldCase(cp, U_FOLD_CASE_DEFAULT);
foldedText.push_back(foldedCp);
}
int32_t firstIndexOf = FuzzyIndexOf(foldedText, pattern);
if (firstIndexOf < 0)
{
return { -1, -1, 0 };
}
auto initialScores = std::vector<int16_t>(textSize);
auto consecutiveScores = std::vector<int16_t>(textSize);
auto firstOccurrenceOfEachChar = std::vector<int32_t>(patternSize);
auto bonusesSpan = std::vector<int16_t>(textSize);
int16_t maxScore = 0;
int32_t maxScorePos = 0;
int32_t patternIndex = 0;
int32_t lastIndex = 0;
UChar32 firstPatternChar = pattern[0];
UChar32 currentPatternChar = pattern[0];
int16_t previousInitialScore = 0;
CharClass previousClass = NonWord;
bool inGap = false;
std::span<const UChar32> lowerText(foldedText);
auto lowerTextSlice = lowerText.subspan(firstIndexOf);
auto initialScoresSlice = std::span(initialScores).subspan(firstIndexOf);
auto consecutiveScoresSlice = std::span(consecutiveScores).subspan(firstIndexOf);
auto bonusesSlice = std::span(bonusesSpan).subspan(firstIndexOf, textSize - firstIndexOf);
for (int32_t i = 0; i < lowerTextSlice.size(); i++)
{
UChar32 currentChar = lowerTextSlice[i];
CharClass currentClass = ClassOf(currentChar);
int16_t bonus = CalculateBonus(previousClass, currentClass);
bonusesSlice[i] = bonus;
previousClass = currentClass;
//currentPatternChar was already folded in ParsePattern
if (currentChar == currentPatternChar)
{
if (patternIndex < pattern.size())
{
firstOccurrenceOfEachChar[patternIndex] = firstIndexOf + static_cast<int32_t>(i);
patternIndex++;
if (patternIndex < patternSize)
{
currentPatternChar = pattern[patternIndex];
}
}
lastIndex = firstIndexOf + static_cast<int32_t>(i);
}
if (currentChar == firstPatternChar)
{
int16_t score = ScoreMatch + bonus * BonusFirstCharMultiplier;
initialScoresSlice[i] = score;
consecutiveScoresSlice[i] = 1;
if (patternSize == 1 && (score > maxScore))
{
maxScore = score;
maxScorePos = firstIndexOf + static_cast<int32_t>(i);
if (bonus == BoundaryBonus)
{
break;
}
}
inGap = false;
}
else
{
initialScoresSlice[i] = inGap ? std::max<int16_t>(previousInitialScore + ScoreGapExtension, 0) : std::max<int16_t>(previousInitialScore + ScoreGapStart, 0);
consecutiveScoresSlice[i] = 0;
inGap = true;
}
previousInitialScore = initialScoresSlice[i];
}
if (patternIndex != pattern.size())
{
return { -1, -1, 0 };
}
if (pattern.size() == 1)
{
if (pos)
{
pos->push_back(maxScorePos);
}
int32_t end = maxScorePos + 1;
return { maxScorePos, end, maxScore };
}
int32_t firstOccurrenceOfFirstChar = firstOccurrenceOfEachChar[0];
int32_t width = lastIndex - firstOccurrenceOfFirstChar + 1;
int32_t rows = static_cast<int32_t>(pattern.size());
auto consecutiveCharMatrixSize = width * patternSize;
std::vector<int16_t> scoreMatrix(width * rows);
std::copy_n(
initialScores.begin() + firstOccurrenceOfFirstChar,
width,
scoreMatrix.begin());
auto scoreSpan = std::span<int16_t>(scoreMatrix);
std::vector<int16_t> consecutiveCharMatrix(width * rows);
std::copy_n(
consecutiveScores.begin() + firstOccurrenceOfFirstChar,
width,
consecutiveCharMatrix.begin());
auto consecutiveCharMatrixSpan = std::span(consecutiveCharMatrix);
auto patternSliceStr = std::span(pattern).subspan(1);
for (int32_t off = 0; off < patternSize - 1; off++)
{
auto patternCharOffset = firstOccurrenceOfEachChar[off + 1];
auto sliceLen = lastIndex - patternCharOffset + 1;
currentPatternChar = patternSliceStr[off];
patternIndex = off + 1;
int32_t row = patternIndex * width;
inGap = false;
auto tmp = lowerText.subspan(patternCharOffset, sliceLen);
std::span<const UChar32> textSlice = lowerText.subspan(patternCharOffset, sliceLen);
std::span<int16_t> bonusSlice(bonusesSpan.begin() + patternCharOffset, textSlice.size());
std::span<int16_t> consecutiveCharMatrixSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size());
std::span<int16_t> consecutiveCharMatrixDiagonalSlice = consecutiveCharMatrixSpan.subspan(
+row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size());
std::span<int16_t> scoreMatrixSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size());
std::span<int16_t> scoreMatrixDiagonalSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size());
std::span<int16_t> scoreMatrixLeftSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1, textSlice.size());
if (!scoreMatrixLeftSlice.empty())
{
scoreMatrixLeftSlice[0] = 0;
}
for (int32_t j = 0; j < textSlice.size(); j++)
{
UChar32 currentChar = textSlice[j];
int32_t column = patternCharOffset + static_cast<int32_t>(j);
int16_t score = inGap ? scoreMatrixLeftSlice[j] + ScoreGapExtension : scoreMatrixLeftSlice[j] + ScoreGapStart;
int16_t diagonalScore = 0;
int16_t consecutive = 0;
if (currentChar == currentPatternChar)
{
diagonalScore = scoreMatrixDiagonalSlice[j] + ScoreMatch;
int16_t bonus = bonusSlice[j];
consecutive = consecutiveCharMatrixDiagonalSlice[j] + 1;
if (bonus == BoundaryBonus)
{
consecutive = 1;
}
else if (consecutive > 1)
{
bonus = std::max({ bonus, BonusConsecutive, (bonusesSpan[column - consecutive + 1]) });
}
if (diagonalScore + bonus < score)
{
diagonalScore += bonusSlice[j];
consecutive = 0;
}
else
{
diagonalScore += bonus;
}
}
consecutiveCharMatrixSlice[j] = consecutive;
inGap = (diagonalScore < score);
int16_t cellScore = std::max(int16_t{0}, std::max(diagonalScore, score));
if (off == patternSize - 2 && cellScore > maxScore)
{
maxScore = cellScore;
maxScorePos = column;
}
scoreMatrixSlice[j] = cellScore;
}
}
int32_t currentColIndex = maxScorePos;
if (pos)
{
patternIndex = patternSize - 1;
bool preferCurrentMatch = true;
while (true)
{
int32_t rowStartIndex = patternIndex * width;
int colOffset = currentColIndex - firstOccurrenceOfFirstChar;
int cellScore = scoreMatrix[rowStartIndex + colOffset];
int diagonalCellScore = 0, leftCellScore = 0;
if (patternIndex > 0 && currentColIndex >= firstOccurrenceOfEachChar[patternIndex])
{
diagonalCellScore = scoreMatrix[rowStartIndex - width + colOffset - 1];
}
if (currentColIndex > firstOccurrenceOfEachChar[patternIndex])
{
leftCellScore = scoreMatrix[rowStartIndex + colOffset - 1];
}
if (cellScore > diagonalCellScore &&
(cellScore > leftCellScore || (cellScore == leftCellScore && preferCurrentMatch)))
{
pos->push_back(currentColIndex);
if (patternIndex == 0)
{
break;
}
patternIndex--;
}
currentColIndex--;
if (rowStartIndex + colOffset >= consecutiveCharMatrixSize)
{
break;
}
preferCurrentMatch = (consecutiveCharMatrix[rowStartIndex + colOffset] > 1) ||
((rowStartIndex + width + colOffset + 1 <
consecutiveCharMatrixSize) &&
(consecutiveCharMatrix[rowStartIndex + width + colOffset + 1] > 0));
}
}
int32_t end = maxScorePos + 1;
return { currentColIndex, end, maxScore };
}
int BonusAt(std::wstring_view input, int idx)
{
if (idx == 0)
{
return BoundaryBonus;
}
return CalculateBonus(ClassOf(input[idx - 1]), ClassOf(input[idx]));
}
int32_t utf32Length(std::wstring_view str)
{
return u_countChar32(reinterpret_cast<const UChar*>(str.data()), static_cast<int32_t>(str.size()));
}
static std::vector<UChar32> ConvertUtf16ToCodePoints(
std::wstring_view text,
bool fold,
std::vector<int32_t>* utf16OffsetsOut = nullptr)
{
const UChar* data = reinterpret_cast<const UChar*>(text.data());
int32_t dataLen = static_cast<int32_t>(text.size());
int32_t cpCount = utf32Length(text);
std::vector<UChar32> out;
out.reserve(cpCount);
if (utf16OffsetsOut)
{
utf16OffsetsOut->clear();
utf16OffsetsOut->reserve(cpCount);
}
int32_t src = 0;
while (src < dataLen)
{
auto startUnit = src;
if (utf16OffsetsOut)
{
utf16OffsetsOut->push_back(startUnit);
}
UChar32 cp;
U16_NEXT(data, src, dataLen, cp);
if (fold)
{
cp = u_foldCase(cp, U_FOLD_CASE_DEFAULT);
}
out.push_back(cp);
}
return out;
}
Pattern ParsePattern(const std::wstring_view patternStr)
{
Pattern patObj;
if (patternStr.empty())
{
return patObj;
}
auto trimmed = TrimStart(patternStr);
trimmed = TrimSuffixSpaces(trimmed);
size_t pos = 0;
while (pos < trimmed.size())
{
size_t found = trimmed.find(L' ', pos);
auto slice = (found == std::wstring_view::npos) ? trimmed.substr(pos) : trimmed.substr(pos, found - pos);
patObj.terms.push_back(ConvertUtf16ToCodePoints(slice, true));
if (found == std::wstring_view::npos)
{
break;
}
pos = found + 1;
}
return patObj;
}
static std::vector<int32_t> MapCodepointsToUtf16(
std::vector<int32_t> const& cpPos,
std::vector<int32_t> const& cpMap,
size_t dataLen)
{
std::vector<int32_t> utf16pos;
utf16pos.reserve(cpPos.size() * 2);
for (int32_t cpIndex : cpPos)
{
int32_t start = cpMap[cpIndex];
int32_t end = cpIndex + int32_t{ 1 } < static_cast<int32_t>(cpMap.size()) ? cpMap[cpIndex + 1] : static_cast<int32_t>(dataLen);
for (int32_t cu = end - 1; cu >= start; --cu)
{
utf16pos.push_back(cu);
}
}
return utf16pos;
}
std::optional<MatchResult> Match(std::wstring_view text, const Pattern& pattern)
{
if (pattern.terms.empty())
{
return MatchResult{};;
}
int16_t totalScore = 0;
std::vector<int32_t> pos;
for (const auto& term : pattern.terms)
{
std::vector<int32_t> utf16map;
std::vector<int32_t> codePointPos;
auto textCodePoints = ConvertUtf16ToCodePoints(text, false, &utf16map);
FzfResult res = FzfFuzzyMatchV2(textCodePoints, term, &codePointPos);
if (res.Score <= 0)
{
return std::nullopt;
}
auto termUtf16Pos = MapCodepointsToUtf16(codePointPos, utf16map, text.size());
for (auto t : termUtf16Pos)
{
pos.push_back(t);
}
totalScore += res.Score;
}
return MatchResult{totalScore, pos};
}
}
}

View File

@@ -0,0 +1,32 @@
#pragma once
#include <vector>
#include <icu.h>
namespace fzf
{
namespace matcher
{
struct FzfResult
{
int32_t Start;
int32_t End;
int16_t Score;
};
struct MatchResult
{
int16_t Score = 0;
std::vector<int32_t> Pos;
};
class Pattern
{
public:
std::vector<std::vector<UChar32>> terms;
};
Pattern ParsePattern(const std::wstring_view patternStr);
std::optional<MatchResult> Match(std::wstring_view text, const Pattern& pattern);
}
}

View File

@@ -0,0 +1,534 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#include "precomp.h"
#include "..\TerminalApp\fzf\fzf.h"
using namespace Microsoft::Console;
using namespace WEX::Logging;
using namespace WEX::TestExecution;
using namespace WEX::Common;
namespace TerminalAppUnitTests
{
typedef enum
{
ScoreMatch = 16,
ScoreGapStart = -3,
ScoreGapExtension = -1,
BonusBoundary = ScoreMatch / 2,
BonusNonWord = ScoreMatch / 2,
BonusCamel123 = BonusBoundary + ScoreGapExtension,
BonusConsecutive = -(ScoreGapStart + ScoreGapExtension),
BonusFirstCharMultiplier = 2,
} score_t;
class FzfTests
{
BEGIN_TEST_CLASS(FzfTests)
END_TEST_CLASS()
TEST_METHOD(AllPatternCharsDoNotMatch);
TEST_METHOD(ConsecutiveChars);
TEST_METHOD(ConsecutiveChars_FirstCharBonus);
TEST_METHOD(NonWordBonusBoundary_ConsecutiveChars);
TEST_METHOD(MatchOnNonWordChars_CaseInSensitive);
TEST_METHOD(MatchOnNonWordCharsWithGap);
TEST_METHOD(BonusBoundaryAndFirstCharMultiplier);
TEST_METHOD(MatchesAreCaseInSensitive);
TEST_METHOD(MultipleTerms);
TEST_METHOD(MultipleTerms_AllCharsMatch);
TEST_METHOD(MultipleTerms_NotAllTermsMatch);
TEST_METHOD(MatchesAreCaseInSensitive_BonusBoundary);
TEST_METHOD(TraceBackWillPickTheFirstMatchIfBothHaveTheSameScore);
TEST_METHOD(TraceBackWillPickTheMatchWithTheHighestScore);
TEST_METHOD(TraceBackWillPickTheMatchWithTheHighestScore_Gaps);
TEST_METHOD(TraceBackWillPickEarlierCharsWhenNoBonus);
TEST_METHOD(MatchWithGapCanAHaveHigherScoreThanConsecutiveWhenGapMatchHasBoundaryBonus);
TEST_METHOD(ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothHaveFirstCharBonus);
TEST_METHOD(ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothDontHaveBonus);
TEST_METHOD(MatchWithGapCanHaveHigherScoreThanConsecutiveWhenGapHasFirstCharBonus);
TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerScoreHigherThanConsecutiveCharsWhenTheGapIs3_NoConsecutiveChar_4CharPattern);
TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern);
TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_3CharPattern_1ConsecutiveChar);
TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs5_NoConsecutiveChars_3CharPattern);
TEST_METHOD(Russian_CaseMisMatch);
TEST_METHOD(Russian_CaseMatch);
TEST_METHOD(English_CaseMatch);
TEST_METHOD(English_CaseMisMatch);
TEST_METHOD(SurrogatePair);
TEST_METHOD(French_CaseMatch);
TEST_METHOD(French_CaseMisMatch);
TEST_METHOD(German_CaseMatch);
TEST_METHOD(German_CaseMisMatch_FoldResultsInMultipleCodePoints);
TEST_METHOD(Greek_CaseMisMatch);
TEST_METHOD(Greek_CaseMatch);
TEST_METHOD(SurrogatePair_ToUtf16Pos_ConsecutiveChars);
TEST_METHOD(SurrogatePair_ToUtf16Pos_PreferConsecutiveChars);
TEST_METHOD(SurrogatePair_ToUtf16Pos_GapAndBoundary);
};
void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector<int16_t> expectedPositions)
{
auto pattern = fzf::matcher::ParsePattern(patternText);
auto match = fzf::matcher::Match(text, pattern);
if (expectedScore == 0 && expectedPositions.size() == 0)
{
VERIFY_ARE_EQUAL(std::nullopt, match);
return;
}
auto score = match->Score;
auto positions = match->Pos;
VERIFY_ARE_EQUAL(expectedScore, score);
VERIFY_ARE_EQUAL(expectedPositions.size(), positions.size());
for (auto i = 0; i < expectedPositions.size(); i++)
{
VERIFY_ARE_EQUAL(expectedPositions[i], positions[i]);
}
}
void FzfTests::AllPatternCharsDoNotMatch()
{
AssertScoreAndPositions(
L"fbb",
L"foo bar",
0,
{});
}
void FzfTests::ConsecutiveChars()
{
AssertScoreAndPositions(
L"oba",
L"foobar",
ScoreMatch * 3 + BonusConsecutive * 2,
{ 4, 3, 2 });
}
void FzfTests::ConsecutiveChars_FirstCharBonus()
{
AssertScoreAndPositions(
L"foo",
L"foobar",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2,
{ 2, 1, 0 });
}
void FzfTests::NonWordBonusBoundary_ConsecutiveChars()
{
AssertScoreAndPositions(
L"zshc",
L"/man1/zshcompctl.1",
ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive * 3,
{ 9, 8, 7, 6 });
}
void FzfTests::Russian_CaseMisMatch()
{
AssertScoreAndPositions(
L"новая",
L"Новая вкладка",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::Russian_CaseMatch()
{
AssertScoreAndPositions(
L"Новая",
L"Новая вкладка",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::German_CaseMatch()
{
AssertScoreAndPositions(
L"fuß",
L"Fußball",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2,
{ 2, 1, 0 });
}
void FzfTests::German_CaseMisMatch_FoldResultsInMultipleCodePoints()
{
//This doesn't currently pass, I think ucase_toFullFolding would give the number of code points that resulted from the fold.
//I wasn't sure how to reference that
BEGIN_TEST_METHOD_PROPERTIES()
TEST_METHOD_PROPERTY(L"Ignore", L"true")
END_TEST_METHOD_PROPERTIES()
AssertScoreAndPositions(
L"fuss",
L"Fußball",
//I think ScoreMatch * 4 is correct in this case since it matches 4 codepoints pattern??? fuss
ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 3,
//Only 3 positions in the text were matched
{ 2, 1, 0 });
}
void FzfTests::French_CaseMatch()
{
AssertScoreAndPositions(
L"Éco",
L"École",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2,
{ 2, 1, 0 });
}
void FzfTests::French_CaseMisMatch()
{
AssertScoreAndPositions(
L"Éco",
L"école",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2,
{ 2, 1, 0 });
}
void FzfTests::Greek_CaseMatch()
{
AssertScoreAndPositions(
L"λόγος",
L"λόγος",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::Greek_CaseMisMatch()
{
//I think this tests validates folding (σ, ς)
AssertScoreAndPositions(
L"λόγοσ",
L"λόγος",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::English_CaseMatch()
{
AssertScoreAndPositions(
L"Newer",
L"Newer tab",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::English_CaseMisMatch()
{
AssertScoreAndPositions(
L"newer",
L"Newer tab",
ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4,
{ 4, 3, 2, 1, 0 });
}
void FzfTests::SurrogatePair()
{
AssertScoreAndPositions(
L"N😀ewer",
L"N😀ewer tab",
ScoreMatch * 6 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 5,
{ 6, 5, 4, 3, 2, 1, 0 });
}
void FzfTests::SurrogatePair_ToUtf16Pos_ConsecutiveChars()
{
AssertScoreAndPositions(
L"N𠀋N😀𝄞e𐐷",
L"N𠀋N😀𝄞e𐐷 tab",
ScoreMatch * 7 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 6,
{ 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 });
}
void FzfTests::SurrogatePair_ToUtf16Pos_PreferConsecutiveChars()
{
AssertScoreAndPositions(
L"𠀋😀",
L"N𠀋😀wer 😀b𐐷 ",
ScoreMatch * 2 + BonusConsecutive * 2,
{4, 3, 2, 1});
}
void FzfTests::SurrogatePair_ToUtf16Pos_GapAndBoundary()
{
AssertScoreAndPositions(
L"𠀋😀",
L"N𠀋wer 😀b𐐷 ",
ScoreMatch * 2 + ScoreGapStart + ScoreGapExtension * 3 + BonusBoundary,
{8, 7, 2, 1});
}
void FzfTests::MatchOnNonWordChars_CaseInSensitive()
{
AssertScoreAndPositions(
L"foo-b",
L"xFoo-Bar Baz",
ScoreMatch * 5 + BonusConsecutive * 4 + BonusBoundary,
{5,4,3,2,1});
}
void FzfTests::MatchOnNonWordCharsWithGap()
{
AssertScoreAndPositions(
L"12356",
L"abc123 456",
ScoreMatch * 5 + BonusCamel123 * BonusFirstCharMultiplier + BonusCamel123 * 2 + BonusConsecutive + ScoreGapStart + ScoreGapExtension,
{ 9, 8, 5, 4, 3 });
}
void FzfTests::BonusBoundaryAndFirstCharMultiplier()
{
AssertScoreAndPositions(
L"fbb",
L"foo bar baz",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension,
{ 8, 4, 0 });
}
void FzfTests::MatchesAreCaseInSensitive()
{
AssertScoreAndPositions(
L"FBB",
L"foo bar baz",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension,
{ 8, 4, 0 });
}
void FzfTests::MultipleTerms()
{
auto term1Score = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive);
auto term2Score = ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive) * 3;
AssertScoreAndPositions(
L"sp anta",
L"Split Pane, split: horizontal, profile: SSH: Antares",
term1Score + term2Score,
{1, 0, 48, 47, 46, 45 });
}
void FzfTests::MultipleTerms_AllCharsMatch()
{
auto term1Score = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive * 2);
auto term2Score = term1Score;
AssertScoreAndPositions(
L"foo bar",
L"foo bar",
term1Score + term2Score,
{2,1,0,6,5,4});
}
void FzfTests::MultipleTerms_NotAllTermsMatch()
{
AssertScoreAndPositions(
L"sp anta zz",
L"Split Pane, split: horizontal, profile: SSH: Antares",
0,
{});
}
void FzfTests::MatchesAreCaseInSensitive_BonusBoundary()
{
AssertScoreAndPositions(
L"fbb",
L"Foo Bar Baz",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension,
{ 8, 4, 0 });
}
void FzfTests::TraceBackWillPickTheFirstMatchIfBothHaveTheSameScore()
{
AssertScoreAndPositions(
L"bar",
L"Foo Bar Bar",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2,
{ 6, 5, 4 });
}
void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore()
{
AssertScoreAndPositions(
L"bar",
L"Foo aBar Bar",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2,
{ 11, 10, 9 });
}
void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore_Gaps()
{
AssertScoreAndPositions(
L"bar",
L"Boo Author Raz Bar",
ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2,
{17,16,15});
}
void FzfTests::TraceBackWillPickEarlierCharsWhenNoBonus()
{
AssertScoreAndPositions(
L"clts",
L"close all tabs after this",
ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive + ScoreGapStart + ScoreGapExtension * 7 + BonusBoundary + ScoreGapStart + ScoreGapExtension,
{13,10,1,0});
}
void FzfTests::ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothDontHaveBonus()
{
auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2;
auto gapScore = (ScoreMatch * 3) + ScoreGapStart + ScoreGapStart;
AssertScoreAndPositions(
L"oob",
L"aoobar",
consecutiveScore,
{3,2,1});
AssertScoreAndPositions(
L"oob",
L"aoaoabound",
gapScore,
{5,3,1});
VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore);
}
void FzfTests::ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothHaveFirstCharBonus()
{
auto consecutiveScore = ScoreMatch * 3 + BonusFirstCharMultiplier * BonusBoundary + BonusFirstCharMultiplier * BonusConsecutive * 2;
auto gapScore = (ScoreMatch * 3) + (BonusBoundary * BonusFirstCharMultiplier) + ScoreGapStart + ScoreGapStart;
AssertScoreAndPositions(
L"oob",
L"oobar",
consecutiveScore,
{2,1,0});
AssertScoreAndPositions(
L"oob",
L"oaoabound",
gapScore,
{4,2,0});
VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore);
}
void FzfTests::MatchWithGapCanAHaveHigherScoreThanConsecutiveWhenGapMatchHasBoundaryBonus()
{
auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2;
auto gapScore = (ScoreMatch * 3) + (BonusBoundary * BonusFirstCharMultiplier) + (BonusBoundary * 2) + ScoreGapStart + (ScoreGapExtension * 2) + ScoreGapStart + ScoreGapExtension;
AssertScoreAndPositions(
L"oob",
L"foobar",
consecutiveScore,
{3,2,1});
AssertScoreAndPositions(
L"oob",
L"out-of-bound",
gapScore,
{7,4,0});
VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore);
}
void FzfTests::MatchWithGapCanHaveHigherScoreThanConsecutiveWhenGapHasFirstCharBonus()
{
auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive;
auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart;
AssertScoreAndPositions(
L"ob",
L"aobar",
consecutiveScore,
{2,1});
AssertScoreAndPositions(
L"ob",
L"oabar",
gapScore,
{2,0});
VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore);
}
void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern()
{
auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive;
auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart + ScoreGapExtension * 10;
AssertScoreAndPositions(
L"ob",
L"aobar",
consecutiveScore,
{2,1});
AssertScoreAndPositions(
L"ob",
L"oaaaaaaaaaaabar",
gapScore,
{12,0});
VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore);
}
void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_3CharPattern_1ConsecutiveChar()
{
auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2;
auto gapScore = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive + ScoreGapStart + ScoreGapExtension * 10;
AssertScoreAndPositions(
L"oba",
L"aobar",
consecutiveScore,
{3,2,1});
AssertScoreAndPositions(
L"oba",
L"oaaaaaaaaaaabar",
gapScore,
{13,12,0});
VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore);
}
void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs5_NoConsecutiveChars_3CharPattern()
{
auto allConsecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2;
auto allBoundaryWithGapScore = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart + ScoreGapExtension + ScoreGapExtension + ScoreGapStart + ScoreGapExtension;
AssertScoreAndPositions(
L"oba",
L"aobar",
allConsecutiveScore,
{3,2,1});
AssertScoreAndPositions(
L"oba",
L"oaaabzzar",
allBoundaryWithGapScore,
{7,4,0});
VERIFY_IS_GREATER_THAN(allConsecutiveScore, allBoundaryWithGapScore);
}
void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerScoreHigherThanConsecutiveCharsWhenTheGapIs3_NoConsecutiveChar_4CharPattern()
{
auto consecutiveScore = ScoreMatch * 4 + BonusConsecutive * 3;
auto gapScore = ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart * 3;
AssertScoreAndPositions(
L"obar",
L"aobar",
consecutiveScore,
{4, 3,2,1});
AssertScoreAndPositions(
L"obar",
L"oabzazr",
gapScore,
{6, 4,2,0});
VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore);
}
}

View File

@@ -24,6 +24,7 @@
<ClCompile Include="ColorHelperTests.cpp" />
<ClCompile Include="JsonUtilsTests.cpp" />
<ClCompile Include="FzfTests.cpp" />
<ClCompile Include="precomp.cpp">
<PrecompiledHeader>Create</PrecompiledHeader>