Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 54 additions & 12 deletions src/PowerShellRun/Application/InternalEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Management.Automation;
using System.Text.RegularExpressions;

namespace PowerShellRun;

Expand All @@ -12,9 +13,13 @@ internal class InternalEntry
public string Name { get; set; } = "";
public string SearchName { get; private set; } = "";
public string SearchNameLowerCase { get; private set; } = "";
public int SearchNameStartIndex { get; private set; } = 0;
public int SearchNameLength { get; private set; } = 0;
public string Description { get; set; } = "";
public string SearchDescription { get; set; } = "";
public string SearchDescriptionLowerCase { get; private set; } = "";
public int SearchDescriptionStartIndex { get; private set; } = 0;
public int SearchDescriptionLength { get; private set; } = 0;

public bool[] NameMatches { get; set; }
public bool[] DescriptionMatches { get; set; }
Expand All @@ -32,13 +37,21 @@ internal class InternalEntry
public InternalEntry(SelectorEntry selectorEntry)
{
SelectorEntry = selectorEntry;

Name = FormatWord(selectorEntry.Name);
SearchName = GenerateSearchWord(Name);
var nameSearchWord = GenerateSearchWord(Name, SelectorEntry.NameSearchablePattern);
SearchName = nameSearchWord.Word;
SearchNameStartIndex = nameSearchWord.StartIndex;
SearchNameLength = nameSearchWord.Length;
NameMatches = new bool[Name.Length];

if (selectorEntry.Description is not null)
{
Description = FormatWord(selectorEntry.Description);
SearchDescription = GenerateSearchWord(Description);
var descriptionSearchWord = GenerateSearchWord(Description, SelectorEntry.DescriptionSearchablePattern);
SearchDescription = descriptionSearchWord.Word;
SearchDescriptionStartIndex = descriptionSearchWord.StartIndex;
SearchDescriptionLength = descriptionSearchWord.Length;
DescriptionMatches = new bool[Description.Length];
}
else
Expand Down Expand Up @@ -132,12 +145,34 @@ private static string FormatWord(string word)
return word;
}

private static string GenerateSearchWord(string word)
private static (string Word, int StartIndex, int Length) GenerateSearchWord(string word, Regex? searchablePattern)
{
if (word.Contains('\x1b'))
bool containsEscapeSequence = word.Contains('\x1b', StringComparison.Ordinal);
if (!containsEscapeSequence && searchablePattern is null)
return (word, 0, word.Length);

// Enable all characters.
var characters = word.ToCharArray();

// Only enable parts that are matched by regex if provided.
if (searchablePattern is not null)
{
Array.Fill(characters, '\0');
var matches = searchablePattern.Matches(word);
foreach (Match match in matches)
{
for (int i = 0; i < match.Length; ++i)
{
int charIndex = match.Index + i;
characters[charIndex] = word[charIndex];
}
}
}

// Disable escape sequence characters.
if (containsEscapeSequence)
{
bool escaped = false;
var characters = word.ToCharArray();
for (int i = 0; i < characters.Length; ++i)
{
char character = characters[i];
Expand All @@ -155,17 +190,24 @@ private static string GenerateSearchWord(string word)
escaped = true;
characters[i] = '\0';
}
else
{
characters[i] = character;
}
}
return new string(characters);
}
else

int startIndex = -1;
int length = 0;
for (int i = 0; i < characters.Length; ++i)
{
return word;
if (characters[i] != '\0')
{
++length;
if (startIndex < 0)
{
startIndex = i;
}
}
}

return (new string(characters), startIndex, length);
}

private static string[] FormatLines(IEnumerable objs)
Expand Down
21 changes: 16 additions & 5 deletions src/PowerShellRun/Application/Searcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,19 @@ private void AddScores(InternalEntry[] entries, string query, ScoreOperation ope
string nameEntry = useLowerCase ? entry.SearchNameLowerCase : entry.SearchName;
string descriptionEntry = useLowerCase ? entry.SearchDescriptionLowerCase : entry.SearchDescription;

int nameScore = CalculateScore(nameEntry, entry.NameMatches, query);
int descriptionScore = CalculateScore(descriptionEntry, entry.DescriptionMatches, query);
int nameScore = CalculateScore(
nameEntry,
entry.SearchNameStartIndex,
entry.SearchNameLength,
entry.NameMatches,
query);

int descriptionScore = CalculateScore(
descriptionEntry,
entry.SearchDescriptionStartIndex,
entry.SearchDescriptionLength,
entry.DescriptionMatches,
query);

int score = Math.Max(nameScore, descriptionScore);
if (operation == ScoreOperation.And && score == 0)
Expand All @@ -84,7 +95,7 @@ private void AddScores(InternalEntry[] entries, string query, ScoreOperation ope
}
}

private int CalculateScore(string searchEntry, bool[] matches, string query)
private int CalculateScore(string searchEntry, int startIndex, int length, bool[] matches, string query)
{
int score = 0;
if (string.IsNullOrEmpty(searchEntry) || string.IsNullOrEmpty(query))
Expand All @@ -109,14 +120,14 @@ private int CalculateScore(string searchEntry, bool[] matches, string query)
if (score > 0)
{
// Short name entires get higher score
score += Math.Max(50 - searchEntry.Length, 1);
score += Math.Max(50 - length, 1);
}

{
if (matchIndexes.MatchStartIndex is int matchStartIndex)
{
// First character match gets higher score
if (matchStartIndex == 0)
if (matchStartIndex == startIndex)
{
score += 10;
}
Expand Down
3 changes: 3 additions & 0 deletions src/PowerShellRun/Application/SelectorEntry.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Management.Automation;
using System.Text.RegularExpressions;

namespace PowerShellRun;

Expand All @@ -8,7 +9,9 @@ public class SelectorEntry
public object? UserData { get; set; } = null;
public string? Icon { get; set; } = null;
public string Name { get; set; } = "";
public Regex? NameSearchablePattern { get; set; } = null;
public string? Description { get; set; } = null;
public Regex? DescriptionSearchablePattern { get; set; } = null;
public string[]? Preview { get; set; } = null;
public ScriptBlock? PreviewAsyncScript { get; set; } = null;
public object[]? PreviewAsyncScriptArgumentList { get; set; } = null;
Expand Down
18 changes: 18 additions & 0 deletions tests/Public/Invoke-PSRunSelector.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@
$match | Should -Be 'acb'
}

It 'should prioritize shorter entry' {
$context.Query = 'ab'
$match = 'abc', 'abcd', 'ab' | Invoke-PSRunSelector -Option $option -Context $context
$match | Should -Be 'ab'
}

It 'should prioritize the first character match' {
$context.Query = 'ab'
$match = 'cab', 'abc' | Invoke-PSRunSelector -Option $option -Context $context
Expand All @@ -65,6 +71,18 @@
$match | Should -BeNullOrEmpty
}

It 'should prioritize shorter entry even with escape sequences' {
$context.Query = 'ab'
$match = 'abc', 'abcd', ($PSStyle.Foreground.Red + 'ab' + $PSStyle.Reset) | Invoke-PSRunSelector -Option $option -Context $context
$match | Should -Be ($PSStyle.Foreground.Red + 'ab' + $PSStyle.Reset)
}

It 'should prioritize the first character match even with escape sequences' {
$context.Query = 'ab'
$match = 'cab', ($PSStyle.Foreground.Red + 'abc' + $PSStyle.Reset) | Invoke-PSRunSelector -Option $option -Context $context
$match | Should -Be ($PSStyle.Foreground.Red + 'abc' + $PSStyle.Reset)
}

It 'should not throw an exception with multi selection' {
$match = 'a' | Invoke-PSRunSelector -Option $option -MultiSelection
$match | Should -Be 'a'
Expand Down
25 changes: 25 additions & 0 deletions tests/Public/Invoke-PSRunSelectorCustom.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,31 @@
$result.FocusedEntry.UserData | Should -Be $items[0]
}

It 'should process searchable pattern for Name' {
$context.Query = 'abc'
$result = 'abc def', 'def abc' | ForEach-Object {
$entry = [PowerShellRun.SelectorEntry]::new()
$entry.UserData = $_
$entry.Name = $_
$entry.NameSearchablePattern = '(?<=^\S*\s).*'
$entry
} | Invoke-PSRunSelectorCustom -Option $option -Context $context
$result.FocusedEntry.UserData | Should -Be 'def abc'
}

It 'should process searchable pattern for Name' {
$context.Query = 'abc'
$result = 'abc def', 'def abc' | ForEach-Object {
$entry = [PowerShellRun.SelectorEntry]::new()
$entry.UserData = $_
$entry.Name = 'name'
$entry.Description = $_
$entry.DescriptionSearchablePattern = '(?<=^\S*\s).*'
$entry
} | Invoke-PSRunSelectorCustom -Option $option -Context $context
$result.FocusedEntry.UserData | Should -Be 'def abc'
}

AfterEach {
Remove-Module PowerShellRun -Force
}
Expand Down