Skip to content

Commit 8a06589

Browse files
authored
Merge pull request #651 from LogExperts/feature/filter-spread-extraction
Extract Filter Spread into a pure, tested Core module
2 parents 49cdfb8 + 4d38341 commit 8a06589

6 files changed

Lines changed: 314 additions & 173 deletions

File tree

‎CONTEXT.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,24 @@ bare "session file" when you mean the workspace (that's a **Session**).
8686
*Avoid*: "control char rendering" (use **Substitution**), "escape" alone
8787
(ambiguous between the **C escape** style and the general concept).
8888

89+
## Filtering
90+
91+
- **Filter Spread** — Context expansion around a filter hit: **Back Spread**
92+
(`FilterParams.SpreadBefore`) lines before and **Fore Spread**
93+
(`FilterParams.SpreadBehind`) lines after the hit are included in the
94+
filter result, deduplicated against recently emitted lines and clamped
95+
to the file's line range (line 0 is a valid context line). Owned by
96+
`LogExpert.Core.Classes.Filter.FilterSpread` — the single home of the
97+
expansion, history-trim, and rollover-shift rules; the serial filter,
98+
the parallel filter, and Filter Pipes all delegate to it. The maximum
99+
UI-configurable spread (99) and the duplicate-suppression window
100+
(2 × 99) derive from `FilterSpread.SPREAD_MAX`, which bounds the UI
101+
spread knobs (`FilterParams` itself does not clamp its values).
102+
103+
*Avoid*: "spread" alone when ambiguous (say **Back Spread** / **Fore
104+
Spread**), "additional filter results" (the old internal name — use
105+
**Filter Spread**).
106+
89107
## Columnizer selection
90108

91109
- **Columnizer** (`ILogLineMemoryColumnizer`) — A plugin that parses a log

‎src/LogExpert.Core/Classes/Filter/Filter.cs‎

Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ internal class Filter
1111
#region Fields
1212

1313
private const int PROGRESS_BAR_MODULO = 1000;
14-
private const int SPREAD_MAX = 50;
1514
private static readonly Logger _logger = LogManager.GetCurrentClassLogger();
1615

1716
private readonly ColumnizerCallback _callback;
@@ -108,66 +107,13 @@ private int DoFilter (FilterParams filterParams, int startLine, int maxCount, Li
108107
private void AddFilterLine (int lineNum, FilterParams filterParams, List<int> filterResultLines, List<int> lastFilterLinesList, List<int> filterHitList)
109108
{
110109
filterHitList.Add(lineNum);
111-
var filterResult = GetAdditionalFilterResults(filterParams, lineNum, lastFilterLinesList);
110+
var filterResult = FilterSpread.Expand(lineNum, filterParams.SpreadBefore, filterParams.SpreadBehind, _callback.GetLineCount(), lastFilterLinesList);
112111

113112
filterResultLines.AddRange(filterResult);
114113

115114
lastFilterLinesList.AddRange(filterResult);
116115

117-
if (lastFilterLinesList.Count > SPREAD_MAX * 2)
118-
{
119-
lastFilterLinesList.RemoveRange(0, lastFilterLinesList.Count - SPREAD_MAX * 2);
120-
}
121-
}
122-
123-
/// <summary>
124-
/// Returns a list with 'additional filter results'. This is the given line number
125-
/// and (if back spread and/or fore spread is enabled) some additional lines.
126-
/// This function doesn't check the filter condition!
127-
/// </summary>
128-
/// <param name="filterParams"></param>
129-
/// <param name="lineNum"></param>
130-
/// <param name="checkList"></param>
131-
/// <returns></returns>
132-
private IList<int> GetAdditionalFilterResults (FilterParams filterParams, int lineNum, IList<int> checkList)
133-
{
134-
IList<int> resultList = [];
135-
136-
if (filterParams.SpreadBefore == 0 && filterParams.SpreadBehind == 0)
137-
{
138-
resultList.Add(lineNum);
139-
return resultList;
140-
}
141-
142-
// back spread
143-
for (var i = filterParams.SpreadBefore; i > 0; --i)
144-
{
145-
if (lineNum - i > 0)
146-
{
147-
if (!resultList.Contains(lineNum - i) && !checkList.Contains(lineNum - i))
148-
{
149-
resultList.Add(lineNum - i);
150-
}
151-
}
152-
}
153-
// direct filter hit
154-
if (!resultList.Contains(lineNum) && !checkList.Contains(lineNum))
155-
{
156-
resultList.Add(lineNum);
157-
}
158-
// after spread
159-
for (var i = 1; i <= filterParams.SpreadBehind; ++i)
160-
{
161-
if (lineNum + i < _callback.GetLineCount())
162-
{
163-
if (!resultList.Contains(lineNum + i) && !checkList.Contains(lineNum + i))
164-
{
165-
resultList.Add(lineNum + i);
166-
}
167-
}
168-
}
169-
170-
return resultList;
116+
FilterSpread.TrimHistory(lastFilterLinesList);
171117
}
172118

173119
#endregion
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
namespace LogExpert.Core.Classes.Filter;
2+
3+
/// <summary>
4+
/// Owner of the Filter Spread rules: expanding a filter hit into its back/fore spread
5+
/// context lines and trimming the duplicate-suppression history.
6+
/// Pure — no dependency on controls, callbacks, readers or locks.
7+
/// </summary>
8+
public static class FilterSpread
9+
{
10+
/// <summary>
11+
/// Maximum configurable spread. The duplicate-suppression history window derives from
12+
/// this value so it always covers the largest configurable spread.
13+
/// </summary>
14+
public const int SPREAD_MAX = 99;
15+
16+
/// <summary>
17+
/// Trims the duplicate-suppression history to the last 2 × <see cref="SPREAD_MAX"/> entries.
18+
/// </summary>
19+
public static void TrimHistory (IList<int> history)
20+
{
21+
ArgumentNullException.ThrowIfNull(history);
22+
23+
while (history.Count > SPREAD_MAX * 2)
24+
{
25+
history.RemoveAt(0);
26+
}
27+
}
28+
29+
/// <summary>
30+
/// Shifts collected line numbers by a multi-file rollover offset. Lines that roll off
31+
/// the start of the virtual file are dropped.
32+
/// </summary>
33+
public static List<int> ShiftLines (IEnumerable<int> lines, int offset)
34+
{
35+
ArgumentNullException.ThrowIfNull(lines);
36+
37+
return [.. lines
38+
.Select(lineNum => lineNum - offset)
39+
.Where(line => line >= 0)];
40+
}
41+
42+
/// <summary>
43+
/// Rebuilds the duplicate-suppression history after a rollover shift: the last
44+
/// <see cref="SPREAD_MAX"/> entries of the shifted result lines (all of them when
45+
/// there are fewer). Capping at SPREAD_MAX — not the 2× trim window — preserves the
46+
/// original rollover behavior.
47+
/// </summary>
48+
public static List<int> RebuildHistory (List<int> shiftedResultLines)
49+
{
50+
ArgumentNullException.ThrowIfNull(shiftedResultLines);
51+
52+
var count = Math.Min(SPREAD_MAX, shiftedResultLines.Count);
53+
return shiftedResultLines.GetRange(shiftedResultLines.Count - count, count);
54+
}
55+
56+
/// <summary>
57+
/// Returns the lines a filter hit contributes to the filter result: the hit itself,
58+
/// plus (if configured) back-spread and fore-spread context lines. Lines already present
59+
/// in <paramref name="alreadyTaken"/> are suppressed. This function does not evaluate
60+
/// the filter condition.
61+
/// </summary>
62+
public static IList<int> Expand (int lineNum, int spreadBefore, int spreadBehind, int lineCount, IList<int> alreadyTaken)
63+
{
64+
ArgumentNullException.ThrowIfNull(alreadyTaken);
65+
66+
IList<int> resultList = [];
67+
68+
if (spreadBefore == 0 && spreadBehind == 0)
69+
{
70+
resultList.Add(lineNum);
71+
return resultList;
72+
}
73+
74+
// back spread (ascending, clamped at line 0 — line 0 is a valid context line)
75+
for (var i = spreadBefore; i > 0; --i)
76+
{
77+
var line = lineNum - i;
78+
if (line >= 0 && !resultList.Contains(line) && !alreadyTaken.Contains(line))
79+
{
80+
resultList.Add(line);
81+
}
82+
}
83+
84+
// the hit itself
85+
if (!resultList.Contains(lineNum) && !alreadyTaken.Contains(lineNum))
86+
{
87+
resultList.Add(lineNum);
88+
}
89+
90+
// fore spread (ascending, clamped at the last line)
91+
for (var i = 1; i <= spreadBehind; ++i)
92+
{
93+
var line = lineNum + i;
94+
if (line < lineCount && !resultList.Contains(line) && !alreadyTaken.Contains(line))
95+
{
96+
resultList.Add(line);
97+
}
98+
}
99+
100+
return resultList;
101+
}
102+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using System.Collections.ObjectModel;
2+
3+
using LogExpert.Core.Classes.Filter;
4+
5+
using NUnit.Framework;
6+
7+
namespace LogExpert.Tests.Filter;
8+
9+
[TestFixture]
10+
public class FilterSpreadTests
11+
{
12+
// Row format: hit line, spread before, spread behind, line count, already-taken history, expected lines.
13+
// Expected values are worked examples from the spec (docs/specs/filter-spread-extraction.md).
14+
[TestCase(5, 0, 0, 100, new int[0], new[] { 5 },
15+
TestName = "Expand_NoSpreadConfigured_ReturnsOnlyTheHitLine")]
16+
[TestCase(5, 0, 0, 100, new[] { 5 }, new[] { 5 },
17+
TestName = "Expand_NoSpreadConfigured_IgnoresHistory_HistoricalQuirkPreserved")]
18+
[TestCase(0, 3, 2, 100, new int[0], new[] { 0, 1, 2 },
19+
TestName = "Expand_HitAtLineZero_EmitsNoNegativeLines")]
20+
[TestCase(3, 5, 0, 100, new int[0], new[] { 0, 1, 2, 3 },
21+
TestName = "Expand_BackSpreadReachingPastTopOfFile_ClampsAtLineZeroAndIncludesIt")]
22+
[TestCase(97, 0, 5, 100, new int[0], new[] { 97, 98, 99 },
23+
TestName = "Expand_ForeSpreadReachingPastEndOfFile_ClampsAtLastLine")]
24+
[TestCase(99, 0, 3, 100, new int[0], new[] { 99 },
25+
TestName = "Expand_HitAtLastLineWithForeSpread_ReturnsOnlyTheHit")]
26+
[TestCase(10, 1, 3, 100, new int[0], new[] { 9, 10, 11, 12, 13 },
27+
TestName = "Expand_AsymmetricSpread_ReturnsBackLinesHitThenForeLines")]
28+
[TestCase(12, 2, 2, 100, new[] { 8, 9, 10, 11, 12 }, new[] { 13, 14 },
29+
TestName = "Expand_OverlappingWithEarlierHit_SuppressesLinesAlreadyTaken")]
30+
[TestCase(5, 1, 1, 100, new[] { 5 }, new[] { 4, 6 },
31+
TestName = "Expand_HitItselfAlreadyTaken_IsNotEmittedAgain")]
32+
public void Expand_Table (int lineNum, int spreadBefore, int spreadBehind, int lineCount, int[] alreadyTaken, int[] expected)
33+
{
34+
var result = FilterSpread.Expand(lineNum, spreadBefore, spreadBehind, lineCount, alreadyTaken);
35+
36+
Assert.That(result, Is.EqualTo(expected));
37+
}
38+
39+
[Test]
40+
public void TrimHistory_LongerThanWindow_DropsOldestEntriesDownToWindowSize ()
41+
{
42+
// Window is 2 × 99 (the maximum configurable spread) — unifies the divergent 50/99 constants.
43+
var history = new List<int>(Enumerable.Range(0, 250));
44+
45+
FilterSpread.TrimHistory(history);
46+
47+
Assert.Multiple(() =>
48+
{
49+
Assert.That(history, Has.Count.EqualTo(198));
50+
Assert.That(history[0], Is.EqualTo(52));
51+
Assert.That(history[^1], Is.EqualTo(249));
52+
});
53+
}
54+
55+
[Test]
56+
public void TrimHistory_WithinWindow_IsUnchanged ()
57+
{
58+
var history = new List<int>(Enumerable.Range(0, 198));
59+
60+
FilterSpread.TrimHistory(history);
61+
62+
Assert.That(history, Has.Count.EqualTo(198));
63+
}
64+
65+
[Test]
66+
public void TrimHistory_WorksOnAnyIListImplementation ()
67+
{
68+
// The Filter Pipe history is an IList<int>, not a List<int> — the trim rule must not care.
69+
IList<int> history = new Collection<int>();
70+
foreach (var i in Enumerable.Range(0, 200))
71+
{
72+
history.Add(i);
73+
}
74+
75+
FilterSpread.TrimHistory(history);
76+
77+
Assert.Multiple(() =>
78+
{
79+
Assert.That(history, Has.Count.EqualTo(198));
80+
Assert.That(history[0], Is.EqualTo(2));
81+
});
82+
}
83+
84+
[Test]
85+
public void ShiftLines_OnRollover_SubtractsOffsetFromEveryLine ()
86+
{
87+
var shifted = FilterSpread.ShiftLines([10, 20, 30], offset: 5);
88+
89+
Assert.That(shifted, Is.EqualTo(new[] { 5, 15, 25 }));
90+
}
91+
92+
[Test]
93+
public void ShiftLines_LinesRolledOffTheStart_AreDropped ()
94+
{
95+
// Offset 15 rolls lines 10 and 14 off the virtual file; 15 shifts to 0 and survives.
96+
var shifted = FilterSpread.ShiftLines([10, 14, 15, 40], offset: 15);
97+
98+
Assert.That(shifted, Is.EqualTo(new[] { 0, 25 }));
99+
}
100+
101+
[Test]
102+
public void RebuildHistory_ResultsLongerThanSpreadMax_TakesTheLastSpreadMaxLines ()
103+
{
104+
// The rollover rebuild caps the history at SPREAD_MAX (99), not the 2×99 trim
105+
// window — a preserved quirk of the original rollover code.
106+
var results = new List<int>(Enumerable.Range(0, 150));
107+
108+
var history = FilterSpread.RebuildHistory(results);
109+
110+
Assert.Multiple(() =>
111+
{
112+
Assert.That(history, Has.Count.EqualTo(99));
113+
Assert.That(history[0], Is.EqualTo(51));
114+
Assert.That(history[^1], Is.EqualTo(149));
115+
});
116+
}
117+
118+
[Test]
119+
public void RebuildHistory_ResultsShorterThanSpreadMax_TakesAllOfThem ()
120+
{
121+
var history = FilterSpread.RebuildHistory([3, 4, 5]);
122+
123+
Assert.That(history, Is.EqualTo(new[] { 3, 4, 5 }));
124+
}
125+
126+
[Test]
127+
public void RebuildHistory_EmptyResults_ReturnsEmptyHistory ()
128+
{
129+
var history = FilterSpread.RebuildHistory([]);
130+
131+
Assert.That(history, Is.Empty);
132+
}
133+
134+
/// <summary>
135+
/// Equivalence guard: the single-threaded Log Window filter and the multi-threaded
136+
/// Core filter accumulate per-hit results through the same recipe
137+
/// (hit → Expand → append to results/history → TrimHistory). This pins that shared
138+
/// recipe's output for a hit sequence with overlapping spreads; a call site that
139+
/// deviates from the recipe no longer matches this specification. Expected values are
140+
/// a worked example: hits 5, 7 and 50 with spread 2/2 in a 100-line file.
141+
/// </summary>
142+
[Test]
143+
public void AccumulationRecipe_OverlappingHitSequence_ProducesPinnedResultHitAndHistoryLists ()
144+
{
145+
int[] hits = [5, 7, 50];
146+
List<int> resultLines = [];
147+
List<int> history = [];
148+
List<int> hitList = [];
149+
150+
foreach (var hit in hits)
151+
{
152+
hitList.Add(hit);
153+
var expanded = FilterSpread.Expand(hit, spreadBefore: 2, spreadBehind: 2, lineCount: 100, alreadyTaken: history);
154+
resultLines.AddRange(expanded);
155+
history.AddRange(expanded);
156+
FilterSpread.TrimHistory(history);
157+
}
158+
159+
Assert.Multiple(() =>
160+
{
161+
// Hit 5 contributes 3..7; hit 7 contributes only 8,9 (5..7 already taken); hit 50 contributes 48..52.
162+
Assert.That(resultLines, Is.EqualTo(new[] { 3, 4, 5, 6, 7, 8, 9, 48, 49, 50, 51, 52 }));
163+
Assert.That(hitList, Is.EqualTo(new[] { 5, 7, 50 }));
164+
Assert.That(history, Is.EqualTo(resultLines), "history below the window size mirrors the result lines");
165+
});
166+
}
167+
}

0 commit comments

Comments
 (0)