2025-06-19 11:06:31 +00:00
using CommunityToolkit.Mvvm.DependencyInjection ;
2025-01-12 11:45:36 +00:00
using Flow.Launcher.Plugin.SharedModels ;
2019-12-09 19:57:59 +00:00
using System ;
2019-12-03 14:02:59 +00:00
using System.Collections.Generic ;
2016-04-23 23:37:25 +00:00
using System.Linq ;
2025-02-23 13:54:08 +00:00
using Flow.Launcher.Infrastructure.UserSettings ;
2016-04-22 22:29:38 +00:00
2020-04-21 09:12:17 +00:00
namespace Flow.Launcher.Infrastructure
2015-02-07 08:53:33 +00:00
{
2020-01-19 23:06:16 +00:00
public class StringMatcher
2015-02-07 08:53:33 +00:00
{
2025-01-13 01:36:14 +00:00
private readonly MatchOption _defaultMatchOption = new ( ) ;
2019-10-17 10:37:09 +00:00
2020-01-19 23:06:16 +00:00
public SearchPrecisionScore UserSettingSearchPrecision { get ; set ; }
2019-12-29 23:13:33 +00:00
2020-01-19 23:06:16 +00:00
private readonly IAlphabet _alphabet ;
2025-02-23 13:54:08 +00:00
public StringMatcher ( IAlphabet alphabet , Settings settings )
2020-01-19 23:06:16 +00:00
{
_alphabet = alphabet ;
2025-02-23 13:54:08 +00:00
UserSettingSearchPrecision = settings . QuerySearchPrecision ;
2020-01-19 23:06:16 +00:00
}
2025-02-23 13:59:35 +00:00
// This is a workaround to allow unit tests to set the instance
public StringMatcher ( IAlphabet alphabet )
{
_alphabet = alphabet ;
}
2019-09-29 04:24:38 +00:00
2019-09-29 05:03:30 +00:00
public static MatchResult FuzzySearch ( string query , string stringToCompare )
2019-09-29 04:24:38 +00:00
{
2025-02-23 05:40:10 +00:00
return Ioc . Default . GetRequiredService < StringMatcher > ( ) . FuzzyMatch ( query , stringToCompare ) ;
2020-01-19 23:06:16 +00:00
}
public MatchResult FuzzyMatch ( string query , string stringToCompare )
{
return FuzzyMatch ( query , stringToCompare , _defaultMatchOption ) ;
2019-09-29 04:24:38 +00:00
}
/// <summary>
2021-02-02 09:28:52 +00:00
/// Current method has two parts, Acronym Match and Fuzzy Search:
2021-01-29 09:31:52 +00:00
///
/// Acronym Match:
2021-02-02 09:28:52 +00:00
/// Charater listed below will be considered as acronym
2021-01-29 09:31:52 +00:00
/// 1. Character on index 0
/// 2. Character appears after a space
/// 3. Character that is UpperCase
/// 4. Character that is number
///
2021-02-02 09:28:52 +00:00
/// Acronym Match will succeed when all query characters match with acronyms in stringToCompare.
/// If any of the characters in the query isn't matched with stringToCompare, Acronym Match will fail.
/// Score will be calculated based the percentage of all query characters matched with total acronyms in stringToCompare.
2021-01-29 09:31:52 +00:00
///
/// Fuzzy Search:
2020-01-06 10:06:41 +00:00
/// Character matching + substring matching;
2020-01-06 10:38:07 +00:00
/// 1. Query search string is split into substrings, separator is whitespace.
/// 2. Check each query substring's characters against full compare string,
/// 3. if a character in the substring is matched, loop back to verify the previous character.
/// 4. If previous character also matches, and is the start of the substring, update list.
/// 5. Once the previous character is verified, move on to the next character in the query substring.
/// 6. Move onto the next substring's characters until all substrings are checked.
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
2025-06-19 11:06:31 +00:00
/// <summary>
/// Performs fuzzy and acronym-based matching between a query and a target string, returning match details and a relevance score.
2019-09-29 04:24:38 +00:00
/// </summary>
2025-06-19 11:06:31 +00:00
/// <param name="query">The search query to match against the target string.</param>
/// <param name="stringToCompare">The string to be compared with the query.</param>
/// <param name="opt">Options that control matching behavior, such as case sensitivity.</param>
/// <returns>A <see cref="MatchResult"/> indicating whether a match was found, the indices of matched characters, and the computed match score.</returns>
2020-01-19 23:06:16 +00:00
public MatchResult FuzzyMatch ( string query , string stringToCompare , MatchOption opt )
2019-09-29 04:24:38 +00:00
{
2020-12-22 13:53:59 +00:00
if ( string . IsNullOrEmpty ( stringToCompare ) | | string . IsNullOrEmpty ( query ) )
return new MatchResult ( false , UserSettingSearchPrecision ) ;
2020-10-24 09:45:31 +00:00
2019-10-17 10:37:09 +00:00
query = query . Trim ( ) ;
2022-11-16 15:32:27 +00:00
TranslationMapping translationMapping = null ;
2024-05-25 07:02:37 +00:00
if ( _alphabet is not null & & _alphabet . ShouldTranslate ( query ) )
2022-11-16 15:32:27 +00:00
{
// We assume that if a query can be translated (containing characters of a language, like Chinese)
// it actually means user doesn't want it to be translated to English letters.
( stringToCompare , translationMapping ) = _alphabet . Translate ( stringToCompare ) ;
}
2019-12-29 23:13:33 +00:00
2020-12-27 12:16:20 +00:00
var currentAcronymQueryIndex = 0 ;
2020-10-18 13:17:29 +00:00
var acronymMatchData = new List < int > ( ) ;
2021-02-02 04:42:55 +00:00
int acronymsTotalCount = 0 ;
int acronymsMatched = 0 ;
2020-10-18 13:17:29 +00:00
var fullStringToCompareWithoutCase = opt . IgnoreCase ? stringToCompare . ToLower ( ) : stringToCompare ;
2021-01-28 09:41:33 +00:00
var queryWithoutCase = opt . IgnoreCase ? query . ToLower ( ) : query ;
2020-10-24 09:45:31 +00:00
2021-02-01 20:41:36 +00:00
var querySubstrings = queryWithoutCase . Split ( new [ ] { ' ' } , StringSplitOptions . RemoveEmptyEntries ) ;
2020-01-02 20:58:20 +00:00
int currentQuerySubstringIndex = 0 ;
var currentQuerySubstring = querySubstrings [ currentQuerySubstringIndex ] ;
var currentQuerySubstringCharacterIndex = 0 ;
2019-09-29 04:24:38 +00:00
var firstMatchIndex = - 1 ;
2019-12-29 23:13:33 +00:00
var firstMatchIndexInWord = - 1 ;
2019-09-29 04:24:38 +00:00
var lastMatchIndex = 0 ;
2020-01-02 20:58:20 +00:00
bool allQuerySubstringsMatched = false ;
bool matchFoundInPreviousLoop = false ;
2020-01-06 20:22:00 +00:00
bool allSubstringsContainedInCompareString = true ;
2019-12-03 14:02:59 +00:00
var indexList = new List < int > ( ) ;
2020-06-01 22:25:14 +00:00
List < int > spaceIndices = new List < int > ( ) ;
2019-12-03 14:02:59 +00:00
2021-01-28 03:05:09 +00:00
for ( var compareStringIndex = 0 ; compareStringIndex < fullStringToCompareWithoutCase . Length ; compareStringIndex + + )
2019-09-29 04:24:38 +00:00
{
2021-02-01 10:34:35 +00:00
// If acronyms matching successfully finished, this gets the remaining not matched acronyms for score calculation
if ( currentAcronymQueryIndex > = query . Length & & acronymsMatched = = query . Length )
2021-01-31 19:50:53 +00:00
{
2021-02-01 20:41:36 +00:00
if ( IsAcronymCount ( stringToCompare , compareStringIndex ) )
2021-02-01 10:34:35 +00:00
acronymsTotalCount + + ;
2021-01-31 19:50:53 +00:00
continue ;
}
2021-02-01 10:34:35 +00:00
if ( currentAcronymQueryIndex > = query . Length | |
currentAcronymQueryIndex > = query . Length & & allQuerySubstringsMatched )
2020-12-27 12:16:20 +00:00
break ;
2020-06-01 22:25:14 +00:00
// To maintain a list of indices which correspond to spaces in the string to compare
// To populate the list only for the first query substring
2020-12-27 12:16:20 +00:00
if ( fullStringToCompareWithoutCase [ compareStringIndex ] = = ' ' & & currentQuerySubstringIndex = = 0 )
2020-06-01 22:25:14 +00:00
spaceIndices . Add ( compareStringIndex ) ;
2021-02-02 09:28:52 +00:00
// Acronym Match
2021-02-01 10:34:35 +00:00
if ( IsAcronym ( stringToCompare , compareStringIndex ) )
2020-12-27 12:16:20 +00:00
{
if ( fullStringToCompareWithoutCase [ compareStringIndex ] = =
queryWithoutCase [ currentAcronymQueryIndex ] )
{
2021-02-01 10:34:35 +00:00
acronymMatchData . Add ( compareStringIndex ) ;
acronymsMatched + + ;
2021-01-31 19:08:05 +00:00
currentAcronymQueryIndex + + ;
2020-12-27 12:16:20 +00:00
}
2021-02-01 20:41:36 +00:00
}
2021-02-01 10:34:35 +00:00
2021-02-01 20:41:36 +00:00
if ( IsAcronymCount ( stringToCompare , compareStringIndex ) )
2021-02-01 10:34:35 +00:00
acronymsTotalCount + + ;
2021-02-01 20:41:36 +00:00
2020-12-27 12:16:20 +00:00
if ( allQuerySubstringsMatched | | fullStringToCompareWithoutCase [ compareStringIndex ] ! =
2020-12-22 13:53:59 +00:00
currentQuerySubstring [ currentQuerySubstringCharacterIndex ] )
2019-09-29 04:24:38 +00:00
{
2020-01-06 08:15:05 +00:00
matchFoundInPreviousLoop = false ;
2020-12-27 12:16:20 +00:00
2020-01-06 08:15:05 +00:00
continue ;
}
if ( firstMatchIndex < 0 )
{
// first matched char will become the start of the compared string
firstMatchIndex = compareStringIndex ;
}
if ( currentQuerySubstringCharacterIndex = = 0 )
{
// first letter of current word
matchFoundInPreviousLoop = true ;
firstMatchIndexInWord = compareStringIndex ;
}
else if ( ! matchFoundInPreviousLoop )
{
// we want to verify that there is not a better match if this is not a full word
// in order to do so we need to verify all previous chars are part of the pattern
var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex ;
2019-12-29 23:13:33 +00:00
2020-12-22 13:53:59 +00:00
if ( AllPreviousCharsMatched ( startIndexToVerify , currentQuerySubstringCharacterIndex ,
fullStringToCompareWithoutCase , currentQuerySubstring ) )
2020-01-02 20:58:20 +00:00
{
matchFoundInPreviousLoop = true ;
2020-01-06 08:15:05 +00:00
2020-01-13 20:30:40 +00:00
// if it's the beginning character of the first query substring that is matched then we need to update start index
2020-01-06 08:15:05 +00:00
firstMatchIndex = currentQuerySubstringIndex = = 0 ? startIndexToVerify : firstMatchIndex ;
2020-12-22 13:53:59 +00:00
indexList = GetUpdatedIndexList ( startIndexToVerify , currentQuerySubstringCharacterIndex ,
firstMatchIndexInWord , indexList ) ;
2019-12-29 23:13:33 +00:00
}
2020-01-06 08:15:05 +00:00
}
2020-01-02 20:58:20 +00:00
2020-01-06 08:15:05 +00:00
lastMatchIndex = compareStringIndex + 1 ;
indexList . Add ( compareStringIndex ) ;
2019-12-29 23:13:33 +00:00
2020-01-06 08:15:05 +00:00
currentQuerySubstringCharacterIndex + + ;
2020-01-02 20:58:20 +00:00
2020-01-06 09:51:27 +00:00
// if finished looping through every character in the current substring
2020-01-06 08:15:05 +00:00
if ( currentQuerySubstringCharacterIndex = = currentQuerySubstring . Length )
{
2020-01-06 20:25:13 +00:00
// if any of the substrings was not matched then consider as all are not matched
2020-12-22 13:53:59 +00:00
allSubstringsContainedInCompareString =
matchFoundInPreviousLoop & & allSubstringsContainedInCompareString ;
2020-01-06 20:25:13 +00:00
2020-01-06 08:15:05 +00:00
currentQuerySubstringIndex + + ;
2019-12-29 23:13:33 +00:00
2020-12-22 13:53:59 +00:00
allQuerySubstringsMatched =
AllQuerySubstringsMatched ( currentQuerySubstringIndex , querySubstrings . Length ) ;
2020-12-27 12:16:20 +00:00
2020-01-06 09:51:27 +00:00
if ( allQuerySubstringsMatched )
2020-12-27 12:16:20 +00:00
continue ;
2019-12-29 23:13:33 +00:00
2020-01-06 08:15:05 +00:00
// otherwise move to the next query substring
currentQuerySubstring = querySubstrings [ currentQuerySubstringIndex ] ;
currentQuerySubstringCharacterIndex = 0 ;
2019-09-29 04:24:38 +00:00
}
}
2020-10-24 09:45:31 +00:00
2021-02-01 10:34:35 +00:00
// return acronym match if all query char matched
if ( acronymsMatched > 0 & & acronymsMatched = = query . Length )
2020-12-27 12:16:20 +00:00
{
2021-02-02 04:42:55 +00:00
int acronymScore = acronymsMatched * 100 / acronymsTotalCount ;
2021-02-01 10:34:35 +00:00
if ( acronymScore > = ( int ) UserSettingSearchPrecision )
{
acronymMatchData = acronymMatchData . Select ( x = > translationMapping ? . MapToOriginalIndex ( x ) ? ? x ) . Distinct ( ) . ToList ( ) ;
return new MatchResult ( true , UserSettingSearchPrecision , acronymMatchData , acronymScore ) ;
}
2020-12-27 12:16:20 +00:00
}
2020-12-22 13:53:59 +00:00
2020-01-06 10:19:15 +00:00
// proceed to calculate score if every char or substring without whitespaces matched
2020-01-02 20:58:20 +00:00
if ( allQuerySubstringsMatched )
2019-09-29 04:24:38 +00:00
{
2020-06-01 22:25:14 +00:00
var nearestSpaceIndex = CalculateClosestSpaceIndex ( spaceIndices , firstMatchIndex ) ;
2022-11-04 11:03:41 +00:00
// firstMatchIndex - nearestSpaceIndex - 1 is to set the firstIndex as the index of the first matched char
// preceded by a space e.g. 'world' matching 'hello world' firstIndex would be 0 not 6
// giving more weight than 'we or donald' by allowing the distance calculation to treat the starting position at after the space.
var score = CalculateSearchScore ( query , stringToCompare , firstMatchIndex - nearestSpaceIndex - 1 , spaceIndices ,
2020-12-22 13:53:59 +00:00
lastMatchIndex - firstMatchIndex , allSubstringsContainedInCompareString ) ;
2019-12-03 14:02:59 +00:00
2021-01-28 09:41:33 +00:00
var resultList = indexList . Select ( x = > translationMapping ? . MapToOriginalIndex ( x ) ? ? x ) . Distinct ( ) . ToList ( ) ;
2020-12-22 13:53:59 +00:00
return new MatchResult ( true , UserSettingSearchPrecision , resultList , score ) ;
2019-09-29 04:24:38 +00:00
}
2020-06-23 10:46:36 +00:00
return new MatchResult ( false , UserSettingSearchPrecision ) ;
2019-09-29 04:24:38 +00:00
}
2025-06-19 11:06:31 +00:00
/// <summary>
/// Determines whether the character at the specified index in the string is considered part of an acronym, either by being an acronym character or a digit.
/// </summary>
/// <param name="stringToCompare">The string to evaluate.</param>
/// <param name="compareStringIndex">The index of the character to check.</param>
/// <returns>True if the character is an acronym character or a digit; otherwise, false.</returns>
2025-04-10 01:36:47 +00:00
private static bool IsAcronym ( string stringToCompare , int compareStringIndex )
2021-02-01 20:41:36 +00:00
{
if ( IsAcronymChar ( stringToCompare , compareStringIndex ) | | IsAcronymNumber ( stringToCompare , compareStringIndex ) )
return true ;
return false ;
}
2025-06-19 11:06:31 +00:00
/// <summary>
/// Determines whether the character at the specified index should be counted as a distinct acronym character, treating consecutive numbers as a single acronym.
/// </summary>
/// <param name="stringToCompare">The string being analyzed for acronym characters.</param>
/// <param name="compareStringIndex">The index of the character to evaluate.</param>
/// <returns>True if the character is an acronym character or the start of a numeric sequence; otherwise, false.</returns>
2025-04-10 01:36:47 +00:00
private static bool IsAcronymCount ( string stringToCompare , int compareStringIndex )
2021-02-01 20:41:36 +00:00
{
if ( IsAcronymChar ( stringToCompare , compareStringIndex ) )
return true ;
2021-02-02 04:38:41 +00:00
if ( IsAcronymNumber ( stringToCompare , compareStringIndex ) )
return compareStringIndex = = 0 | | char . IsWhiteSpace ( stringToCompare [ compareStringIndex - 1 ] ) ;
2021-02-01 20:41:36 +00:00
return false ;
}
2025-01-13 01:36:14 +00:00
private static bool IsAcronymChar ( string stringToCompare , int compareStringIndex )
2021-02-02 04:38:41 +00:00
= > char . IsUpper ( stringToCompare [ compareStringIndex ] ) | |
compareStringIndex = = 0 | | // 0 index means char is the start of the compare string, which is an acronym
char . IsWhiteSpace ( stringToCompare [ compareStringIndex - 1 ] ) ;
2021-02-01 10:34:35 +00:00
2025-01-13 01:36:14 +00:00
private static bool IsAcronymNumber ( string stringToCompare , int compareStringIndex )
2021-02-02 09:28:52 +00:00
= > stringToCompare [ compareStringIndex ] > = 0 & & stringToCompare [ compareStringIndex ] < = 9 ;
2021-02-01 10:34:35 +00:00
2020-06-01 22:25:14 +00:00
// To get the index of the closest space which preceeds the first matching index
2025-01-13 01:36:14 +00:00
private static int CalculateClosestSpaceIndex ( List < int > spaceIndices , int firstMatchIndex )
2020-06-01 22:25:14 +00:00
{
2021-02-02 04:38:41 +00:00
var closestSpaceIndex = - 1 ;
// spaceIndices should be ordered asc
foreach ( var index in spaceIndices )
2020-06-01 22:25:14 +00:00
{
2021-02-02 04:38:41 +00:00
if ( index < firstMatchIndex )
closestSpaceIndex = index ;
else
break ;
2020-06-01 22:25:14 +00:00
}
2021-02-02 04:38:41 +00:00
return closestSpaceIndex ;
2020-06-01 22:25:14 +00:00
}
2020-06-23 10:46:36 +00:00
private static bool AllPreviousCharsMatched ( int startIndexToVerify , int currentQuerySubstringCharacterIndex ,
2020-12-22 13:53:59 +00:00
string fullStringToCompareWithoutCase , string currentQuerySubstring )
2020-01-02 21:02:02 +00:00
{
var allMatch = true ;
for ( int indexToCheck = 0 ; indexToCheck < currentQuerySubstringCharacterIndex ; indexToCheck + + )
{
if ( fullStringToCompareWithoutCase [ startIndexToVerify + indexToCheck ] ! =
currentQuerySubstring [ indexToCheck ] )
{
allMatch = false ;
}
}
return allMatch ;
}
2020-10-24 09:45:31 +00:00
2020-12-22 13:53:59 +00:00
private static List < int > GetUpdatedIndexList ( int startIndexToVerify , int currentQuerySubstringCharacterIndex ,
int firstMatchIndexInWord , List < int > indexList )
2020-01-02 21:02:02 +00:00
{
var updatedList = new List < int > ( ) ;
indexList . RemoveAll ( x = > x > = firstMatchIndexInWord ) ;
updatedList . AddRange ( indexList ) ;
for ( int indexToCheck = 0 ; indexToCheck < currentQuerySubstringCharacterIndex ; indexToCheck + + )
{
updatedList . Add ( startIndexToVerify + indexToCheck ) ;
}
return updatedList ;
}
2020-01-06 09:51:27 +00:00
private static bool AllQuerySubstringsMatched ( int currentQuerySubstringIndex , int querySubstringsLength )
{
2020-12-27 12:16:20 +00:00
// Acronym won't utilize the substring to match
2020-01-06 09:51:27 +00:00
return currentQuerySubstringIndex > = querySubstringsLength ;
}
2022-11-04 11:03:41 +00:00
private static int CalculateSearchScore ( string query , string stringToCompare , int firstIndex , List < int > spaceIndices , int matchLen ,
2020-12-22 13:53:59 +00:00
bool allSubstringsContainedInCompareString )
2019-09-29 04:24:38 +00:00
{
2019-11-28 11:44:01 +00:00
// A match found near the beginning of a string is scored more than a match found near the end
// A match is scored more if the characters in the patterns are closer to each other,
// while the score is lower if they are more spread out
2019-09-29 04:24:38 +00:00
var score = 100 * ( query . Length + 1 ) / ( ( 1 + firstIndex ) + ( matchLen + 1 ) ) ;
2019-11-28 11:44:01 +00:00
2022-11-04 11:03:41 +00:00
// Give more weight to a match that is closer to the start of the string.
// if the first matched char is immediately before space and all strings are contained in the compare string e.g. 'world' matching 'hello world'
// and 'world hello', because both have 'world' immediately preceded by space, their firstIndex will be 0 when distance is calculated,
// to prevent them scoring the same, we adjust the score by deducting the number of spaces it has from the start of the string, so 'world hello'
// will score slightly higher than 'hello world' because 'hello world' has one additional space.
if ( firstIndex = = 0 & & allSubstringsContainedInCompareString )
score - = spaceIndices . Count ;
2019-11-28 11:44:01 +00:00
// A match with less characters assigning more weights
2019-09-29 04:24:38 +00:00
if ( stringToCompare . Length - query . Length < 5 )
2019-11-28 11:44:01 +00:00
{
2019-10-17 10:37:09 +00:00
score + = 20 ;
2019-11-28 11:44:01 +00:00
}
2019-09-29 04:24:38 +00:00
else if ( stringToCompare . Length - query . Length < 10 )
2019-11-28 11:44:01 +00:00
{
2019-10-17 10:37:09 +00:00
score + = 10 ;
2019-11-28 11:44:01 +00:00
}
2019-09-29 04:24:38 +00:00
2020-01-06 21:28:27 +00:00
if ( allSubstringsContainedInCompareString )
2020-01-13 20:36:53 +00:00
{
int count = query . Count ( c = > ! char . IsWhiteSpace ( c ) ) ;
2020-04-25 03:02:46 +00:00
//10 per char is too much for long query strings, this threshhold is to avoid where long strings will override the other results too much
int threshold = 4 ;
if ( count < = threshold )
{
score + = count * 10 ;
}
else
{
score + = threshold * 10 + ( count - threshold ) * 5 ;
}
2020-01-13 20:36:53 +00:00
}
2019-12-29 23:13:33 +00:00
2019-09-29 04:24:38 +00:00
return score ;
}
}
public class MatchOption
{
2019-12-29 23:13:33 +00:00
public bool IgnoreCase { get ; set ; } = true ;
2015-02-07 08:53:33 +00:00
}
2021-01-28 10:21:35 +00:00
}