Skip to content
Merged
17 changes: 17 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ bare "session file" when you mean the workspace (that's a **Session**).
Spread**), "additional filter results" (the old internal name — use
**Filter Spread**).

## Log Search

- **Log Search** — The Ctrl+F / F3 / Shift+F3 search-in-file feature of a
Log Window: finds the next or previous line matching a search text
(plain or regex, case-sensitive or not), wrapping around the file
boundary once before giving up. Executed by
`LogExpert.Core.Classes.Search.LogSearcher`, the single owner of
direction resolution (forward / find-next / Shift+F3), the
wrap-around-once rule, and the matching; the Log Window narrates the
returned `SearchResult` (status line, progress bar, scrolling).
`Find` snapshots its `SearchParams` at entry, so mutating the shared
instance (F3) never affects a run in flight.

*Avoid*: bare "search" when the filter panel is meant (that is
**filtering** — the `FilterSearch` methods in the code belong to the
filter path, not Log Search), "find dialog" (use **Search dialog**).

## Columnizer selection

- **Columnizer** (`ILogLineMemoryColumnizer`) — A plugin that parses a log
Expand Down
123 changes: 123 additions & 0 deletions src/LogExpert.Core/Classes/Search/LogSearcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Text.RegularExpressions;

using LogExpert.Core.Entities;
using LogExpert.Core.Interfaces;

namespace LogExpert.Core.Classes.Search;

/// <summary>
/// Owner of Log Search execution: direction resolution, the wrap-around-once rule, and
/// regex/ordinal matching over an <see cref="ILogfileReader"/>.
/// Pure — no dependency on controls or status lines; progress and the wrap event are
/// reported through a progress sink, cancellation through a token.
/// </summary>
public static class LogSearcher
{
/// <summary>Lines scanned between two progress reports.</summary>
private const int PROGRESS_REPORT_MODULO = 1000;

/// <summary>
/// Resolves the effective direction of a search from the forward / find-next / Shift+F3 flags.
/// </summary>
public static SearchDirection ResolveDirection (SearchParams searchParams)
{
ArgumentNullException.ThrowIfNull(searchParams);

return (searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed
? SearchDirection.Forward
: SearchDirection.Backward;
}

/// <summary>
/// Runs the search and returns how it ended. Never throws for user input errors —
/// an uncompilable regex yields <see cref="SearchOutcome.InvalidPattern"/>.
/// </summary>
public static SearchResult Find (SearchParams searchParams, ILogfileReader reader, CancellationToken cancellationToken, IProgress<SearchProgress>? progress = null)
{
ArgumentNullException.ThrowIfNull(searchParams);
ArgumentNullException.ThrowIfNull(reader);

if (string.IsNullOrEmpty(searchParams.SearchText))
{
return new SearchResult(SearchOutcome.NotFound, -1, false);
}

// Snapshot: the caller may share this instance (F3 mutates it while a search runs).
var search = new SearchParams();
search.CopyFrom(searchParams);

var isForward = ResolveDirection(search) == SearchDirection.Forward;

var lineNum = search.IsFromTop && !search.IsFindNext
? 0
: search.CurrentLine;

var comparison = search.IsCaseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;

// Compiled once per search, not once per line; a bad pattern fails before any line is read.
Regex? regex = null;
if (search.IsRegex)
{
try
{
regex = new Regex(search.SearchText, search.IsCaseSensitive
? RegexOptions.None
: RegexOptions.IgnoreCase);
}
catch (ArgumentException)
{
return new SearchResult(SearchOutcome.InvalidPattern, -1, false);
}
}

var hasWrapped = false;
var linesScanned = 0;

while (true)
{
if (isForward ? lineNum >= reader.LineCount : lineNum < 0)
{
if (hasWrapped)
{
return new SearchResult(SearchOutcome.NotFound, -1, true);
}

hasWrapped = true;
lineNum = isForward ? 0 : reader.LineCount - 1;
linesScanned = 0;
progress?.Report(new SearchProgress(0, isForward ? SearchWrap.ToStart : SearchWrap.ToEnd));
}

var line = reader.GetLogLineMemory(lineNum);
if (line == null)
{
return new SearchResult(SearchOutcome.NotFound, -1, hasWrapped);
}

var isMatch = regex != null
? regex.IsMatch(line.FullLine.ToString())
: line.FullLine.Span.Contains(search.SearchText, comparison);

if (isMatch)
{
return new SearchResult(SearchOutcome.Found, lineNum, hasWrapped);
}

lineNum = isForward ? lineNum + 1 : lineNum - 1;

// Checked after the match test, matching the original loop: a search whose
// current line matches returns the hit even if cancellation was requested.
if (cancellationToken.IsCancellationRequested)
{
return new SearchResult(SearchOutcome.Cancelled, -1, hasWrapped);
}

if (++linesScanned % PROGRESS_REPORT_MODULO == 0)
{
progress?.Report(new SearchProgress(linesScanned, SearchWrap.None));
}
}
}
}
10 changes: 10 additions & 0 deletions src/LogExpert.Core/Classes/Search/SearchDirection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace LogExpert.Core.Classes.Search;

/// <summary>
/// Effective direction of a Log Search after resolving the forward / find-next / Shift+F3 flags.
/// </summary>
public enum SearchDirection
{
Forward,
Backward,
}
19 changes: 19 additions & 0 deletions src/LogExpert.Core/Classes/Search/SearchOutcome.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace LogExpert.Core.Classes.Search;

/// <summary>
/// How a Log Search run ended.
/// </summary>
public enum SearchOutcome
{
/// <summary>A matching line was found.</summary>
Found,

/// <summary>No matching line exists (including after a full wrap-around), or the search text was empty.</summary>
NotFound,

/// <summary>The search was cancelled before a match was found.</summary>
Cancelled,

/// <summary>The search text was a regular expression that does not compile. No line was read.</summary>
InvalidPattern,
}
9 changes: 9 additions & 0 deletions src/LogExpert.Core/Classes/Search/SearchProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace LogExpert.Core.Classes.Search;

/// <summary>
/// Progress report from a running Log Search.
/// </summary>
/// <param name="LinesScanned">Lines scanned in the current segment (resets when the search wraps),
/// suitable for driving a progress bar whose maximum is the line count.</param>
/// <param name="Wrap">The wrap event, when this report announces one.</param>
public readonly record struct SearchProgress (int LinesScanned, SearchWrap Wrap);
10 changes: 10 additions & 0 deletions src/LogExpert.Core/Classes/Search/SearchResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace LogExpert.Core.Classes.Search;

/// <summary>
/// Result of a Log Search run. The caller narrates the outcome (status line, scrolling);
/// the searcher never touches UI.
/// </summary>
/// <param name="Outcome">How the search ended.</param>
/// <param name="LineNumber">Zero-based hit line when <see cref="SearchOutcome.Found"/>; -1 otherwise.</param>
/// <param name="Wrapped">Whether the search wrapped around the file boundary before ending.</param>
public readonly record struct SearchResult (SearchOutcome Outcome, int LineNumber, bool Wrapped);
17 changes: 17 additions & 0 deletions src/LogExpert.Core/Classes/Search/SearchWrap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace LogExpert.Core.Classes.Search;

/// <summary>
/// Wrap event reported through the progress sink, so the caller can show the
/// "started from beginning/end of file" notice while the search is still running.
/// </summary>
public enum SearchWrap
{
/// <summary>Ordinary progress report; no wrap occurred.</summary>
None,

/// <summary>A forward search passed the last line and continued from line 0.</summary>
ToStart,

/// <summary>A backward search passed line 0 and continued from the last line.</summary>
ToEnd,
}
67 changes: 67 additions & 0 deletions src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using LogExpert.UI.Dialogs;

using NUnit.Framework;

namespace LogExpert.Tests.Dialogs;

/// <summary>
/// Regression tests for the Regex Helper dialog's OK handler. Both combo boxes are
/// DataSource-bound to the history lists (LoadHistory, run on dialog Load), so the
/// handler must never touch ComboBox.Items — doing so throws
/// "Items collection cannot be modified when the DataSource property is set".
/// </summary>
[TestFixture]
[Apartment(ApartmentState.STA)]
public class RegexHelperDialogTests
{
[Test]
public void OkClick_AfterLoadBindsHistory_DoesNotThrowAndPromotesPattern ()
{
using RegexHelperDialog dialog = new()
{
ExpressionHistoryList = ["old-a", "old-b"],
TesttextHistoryList = ["text-a"],
};
dialog.LoadHistory(); // what dialog Load does when the form is shown
dialog.Pattern = "new-pattern";

dialog.OnButtonOkClick(dialog, EventArgs.Empty);

Assert.That(dialog.ExpressionHistoryList, Is.EqualTo(new[] { "new-pattern", "old-a", "old-b" }));
}

[Test]
public void OkClick_PatternAlreadyInHistory_MovesItToTheFrontWithoutDuplicate ()
{
using RegexHelperDialog dialog = new()
{
ExpressionHistoryList = ["old-a", "repeat", "old-b"],
};
dialog.LoadHistory();
dialog.Pattern = "repeat";

dialog.OnButtonOkClick(dialog, EventArgs.Empty);

Assert.That(dialog.ExpressionHistoryList, Is.EqualTo(new[] { "repeat", "old-a", "old-b" }));
}

[Test]
public void OkClick_HistoryAtCapacity_DropsTheOldestEntry ()
{
using RegexHelperDialog dialog = new()
{
ExpressionHistoryList = [.. Enumerable.Range(0, 30).Select(i => $"entry-{i}")],
};
dialog.LoadHistory();
dialog.Pattern = "newest";

dialog.OnButtonOkClick(dialog, EventArgs.Empty);

Assert.Multiple(() =>
{
Assert.That(dialog.ExpressionHistoryList, Has.Count.EqualTo(30));
Assert.That(dialog.ExpressionHistoryList[0], Is.EqualTo("newest"));
Assert.That(dialog.ExpressionHistoryList, Does.Not.Contain("entry-29"));
});
}
}
Loading