Skip to content

Commit 89c3765

Browse files
Merge pull request #160 from ktsu-dev/fix/151-stream-glob-regex-filter
2 parents 3b3784e + 6ee3b67 commit 89c3765

2 files changed

Lines changed: 77 additions & 6 deletions

File tree

‎TextFilter.Test/TextFilterTests.cs‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2023-2026 ktsu-dev contributors
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
22

33
namespace TextFilter.Test;
44

@@ -1167,4 +1167,67 @@ public void FuzzyFilterIgnoresFilterPadding(string paddedFilter)
11671167
CollectionAssert.AreEquivalent(HelMatches, result,
11681168
$"Padding in '{paddedFilter}' should not hide items that 'hel' matches.");
11691169
}
1170+
1171+
[TestMethod]
1172+
[DataRow(TextFilterType.Glob, "*.cs")]
1173+
[DataRow(TextFilterType.Regex, "\\.cs$")]
1174+
public void FilterTakeStopsReadingOnceEnoughGlobOrRegexItemsMatch(TextFilterType filterType, string filter)
1175+
{
1176+
IEnumerable<string> items = Enumerable.Range(0, 10_000).Select(i => $"File{i}.cs");
1177+
int calls = 0;
1178+
string CountingKey(string s)
1179+
{
1180+
calls++;
1181+
return s;
1182+
}
1183+
1184+
List<string> page = [.. TextFilter.Filter(items, CountingKey, filter, filterType).Take(10)];
1185+
1186+
CollectionAssert.AreEqual(items.Take(10).ToList(), page);
1187+
Assert.AreEqual(10, calls, "Only the items needed for the first page should be matched.");
1188+
}
1189+
1190+
[TestMethod]
1191+
public void FilterGlobFirstDoesNotReadPastTheFirstMatch()
1192+
{
1193+
// Stands in for an endless or streaming source: reading past the first page fails the test
1194+
// rather than hanging it.
1195+
static IEnumerable<string> StreamingLines()
1196+
{
1197+
for (int i = 0; i < 1_000; i++)
1198+
{
1199+
yield return i % 3 == 2 ? $"line{i}.log" : $"line{i}.txt";
1200+
}
1201+
1202+
throw new InvalidOperationException("Filter read the whole source before yielding its first match.");
1203+
}
1204+
1205+
Assert.AreEqual("line2.log", TextFilter.Filter(StreamingLines(), "*.log").First());
1206+
}
1207+
1208+
[TestMethod]
1209+
[DataRow(TextFilterType.Glob, "b* a*")]
1210+
[DataRow(TextFilterType.Regex, "^(b|a)")]
1211+
public void FilterGlobOrRegexKeepsInputOrder(TextFilterType filterType, string filter)
1212+
{
1213+
List<string> strings = ["banana", "cherry", "apple", "blueberry", "avocado"];
1214+
List<string> result = [.. TextFilter.Filter(strings, filter, filterType)];
1215+
CollectionAssert.AreEqual(new List<string> { "banana", "apple", "blueberry", "avocado" }, result);
1216+
}
1217+
1218+
[TestMethod]
1219+
public void FilterFuzzyStillSortsByDescendingScore()
1220+
{
1221+
List<string> strings = ["hello world", "yellow", "hello", "help"];
1222+
List<string> result = [.. TextFilter.Filter(strings, "hello", TextFilterType.Fuzzy)];
1223+
static int ScoreOf(string s)
1224+
{
1225+
TextFilter.IsMatch(s, "hello", out int score, TextFilterType.Fuzzy);
1226+
return score;
1227+
}
1228+
1229+
List<int> scores = [.. result.Select(ScoreOf)];
1230+
CollectionAssert.AreEqual(scores.OrderByDescending(s => s).ToList(), scores);
1231+
Assert.IsNotEmpty(result);
1232+
}
11701233
}

‎TextFilter/TextFilter.cs‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) 2023-2026 ktsu-dev contributors
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
22

33
namespace ktsu.TextFilter;
44

@@ -181,14 +181,22 @@ public static IEnumerable<TItem> Filter<TItem>(IEnumerable<TItem> items, Func<TI
181181
Ensure.NotNull(keySelector);
182182
Ensure.NotNull(filter);
183183

184-
return items.Select(item =>
184+
IEnumerable<(TItem item, bool isMatch, int score)> matches = items.Select(item =>
185185
{
186186
bool isMatch = IsMatch(keySelector(item), filter, out int score, filterType, textFilterMatchOptions, caseSensitivity);
187187
return (item, isMatch, score);
188188
})
189-
.Where(t => t.isMatch)
190-
.OrderByDescending(t => t.score)
191-
.Select(t => t.item);
189+
.Where(t => t.isMatch);
190+
191+
// Only a non-blank fuzzy filter sets a score, so sorting anything else would reorder nothing
192+
// while still reading the whole source before yielding the first item. Skipping it lets glob
193+
// and regex results stream, so Take(n) or First() stops as soon as it has enough matches.
194+
if (filterType is TextFilterType.Fuzzy && !string.IsNullOrWhiteSpace(filter))
195+
{
196+
matches = matches.OrderByDescending(t => t.score);
197+
}
198+
199+
return matches.Select(t => t.item);
192200
}
193201

194202
/// <summary>

0 commit comments

Comments
 (0)