diff --git a/CONTEXT.md b/CONTEXT.md index adbf054b..dde30b65 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/src/LogExpert.Core/Classes/Search/LogSearcher.cs b/src/LogExpert.Core/Classes/Search/LogSearcher.cs new file mode 100644 index 00000000..b7b60f5a --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/LogSearcher.cs @@ -0,0 +1,123 @@ +using System.Text.RegularExpressions; + +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; + +namespace LogExpert.Core.Classes.Search; + +/// +/// Owner of Log Search execution: direction resolution, the wrap-around-once rule, and +/// regex/ordinal matching over an . +/// Pure — no dependency on controls or status lines; progress and the wrap event are +/// reported through a progress sink, cancellation through a token. +/// +public static class LogSearcher +{ + /// Lines scanned between two progress reports. + private const int PROGRESS_REPORT_MODULO = 1000; + + /// + /// Resolves the effective direction of a search from the forward / find-next / Shift+F3 flags. + /// + public static SearchDirection ResolveDirection (SearchParams searchParams) + { + ArgumentNullException.ThrowIfNull(searchParams); + + return (searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed + ? SearchDirection.Forward + : SearchDirection.Backward; + } + + /// + /// Runs the search and returns how it ended. Never throws for user input errors — + /// an uncompilable regex yields . + /// + public static SearchResult Find (SearchParams searchParams, ILogfileReader reader, CancellationToken cancellationToken, IProgress? 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)); + } + } + } +} diff --git a/src/LogExpert.Core/Classes/Search/SearchDirection.cs b/src/LogExpert.Core/Classes/Search/SearchDirection.cs new file mode 100644 index 00000000..f3282c22 --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/SearchDirection.cs @@ -0,0 +1,10 @@ +namespace LogExpert.Core.Classes.Search; + +/// +/// Effective direction of a Log Search after resolving the forward / find-next / Shift+F3 flags. +/// +public enum SearchDirection +{ + Forward, + Backward, +} diff --git a/src/LogExpert.Core/Classes/Search/SearchOutcome.cs b/src/LogExpert.Core/Classes/Search/SearchOutcome.cs new file mode 100644 index 00000000..9fa63004 --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/SearchOutcome.cs @@ -0,0 +1,19 @@ +namespace LogExpert.Core.Classes.Search; + +/// +/// How a Log Search run ended. +/// +public enum SearchOutcome +{ + /// A matching line was found. + Found, + + /// No matching line exists (including after a full wrap-around), or the search text was empty. + NotFound, + + /// The search was cancelled before a match was found. + Cancelled, + + /// The search text was a regular expression that does not compile. No line was read. + InvalidPattern, +} diff --git a/src/LogExpert.Core/Classes/Search/SearchProgress.cs b/src/LogExpert.Core/Classes/Search/SearchProgress.cs new file mode 100644 index 00000000..48a1f967 --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/SearchProgress.cs @@ -0,0 +1,9 @@ +namespace LogExpert.Core.Classes.Search; + +/// +/// Progress report from a running Log Search. +/// +/// Lines scanned in the current segment (resets when the search wraps), +/// suitable for driving a progress bar whose maximum is the line count. +/// The wrap event, when this report announces one. +public readonly record struct SearchProgress (int LinesScanned, SearchWrap Wrap); diff --git a/src/LogExpert.Core/Classes/Search/SearchResult.cs b/src/LogExpert.Core/Classes/Search/SearchResult.cs new file mode 100644 index 00000000..cfe8116b --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/SearchResult.cs @@ -0,0 +1,10 @@ +namespace LogExpert.Core.Classes.Search; + +/// +/// Result of a Log Search run. The caller narrates the outcome (status line, scrolling); +/// the searcher never touches UI. +/// +/// How the search ended. +/// Zero-based hit line when ; -1 otherwise. +/// Whether the search wrapped around the file boundary before ending. +public readonly record struct SearchResult (SearchOutcome Outcome, int LineNumber, bool Wrapped); diff --git a/src/LogExpert.Core/Classes/Search/SearchWrap.cs b/src/LogExpert.Core/Classes/Search/SearchWrap.cs new file mode 100644 index 00000000..a98698a6 --- /dev/null +++ b/src/LogExpert.Core/Classes/Search/SearchWrap.cs @@ -0,0 +1,17 @@ +namespace LogExpert.Core.Classes.Search; + +/// +/// 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. +/// +public enum SearchWrap +{ + /// Ordinary progress report; no wrap occurred. + None, + + /// A forward search passed the last line and continued from line 0. + ToStart, + + /// A backward search passed line 0 and continued from the last line. + ToEnd, +} diff --git a/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs b/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs new file mode 100644 index 00000000..45d4df41 --- /dev/null +++ b/src/LogExpert.Tests/Dialogs/RegexHelperDialogTests.cs @@ -0,0 +1,67 @@ +using LogExpert.UI.Dialogs; + +using NUnit.Framework; + +namespace LogExpert.Tests.Dialogs; + +/// +/// 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". +/// +[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")); + }); + } +} diff --git a/src/LogExpert.Tests/Search/LogSearcherTests.cs b/src/LogExpert.Tests/Search/LogSearcherTests.cs new file mode 100644 index 00000000..db2ef527 --- /dev/null +++ b/src/LogExpert.Tests/Search/LogSearcherTests.cs @@ -0,0 +1,398 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Search; +using LogExpert.Core.Entities; +using LogExpert.Core.Interfaces; + +using Moq; + +using NUnit.Framework; + +namespace LogExpert.Tests.Search; + +[TestFixture] +public class LogSearcherTests +{ + [Test] + public void Find_ForwardFromStart_ReturnsFirstHitAfterCurrentLine () + { + var reader = ReaderOf("alpha", "bravo", "target", "delta", "target"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false))); + } + + // Truth table for the effective-direction rule the Log Window triplicated: + // forward when (IsForward || IsFindNext), reversed by Shift+F3. Pins today's semantics, + // including the quirk that F3 (find next) on a backward search goes forward. + [TestCase(true, false, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_IsForward")] + [TestCase(true, true, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_IsForwardAndFindNext")] + [TestCase(false, true, false, SearchDirection.Forward, TestName = "ResolveDirection_Forward_FindNextOverridesBackward")] + [TestCase(false, false, false, SearchDirection.Backward, TestName = "ResolveDirection_Backward_NeitherForwardNorFindNext")] + [TestCase(true, false, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesForward")] + [TestCase(true, true, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesFindNext")] + [TestCase(false, true, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3ReversesBackwardFindNext")] + [TestCase(false, false, true, SearchDirection.Backward, TestName = "ResolveDirection_Backward_ShiftF3OnBackwardStaysBackward")] + public void ResolveDirection_Table (bool isForward, bool isFindNext, bool isShiftF3Pressed, SearchDirection expected) + { + var searchParams = new SearchParams { IsForward = isForward, IsFindNext = isFindNext, IsShiftF3Pressed = isShiftF3Pressed }; + + Assert.That(LogSearcher.ResolveDirection(searchParams), Is.EqualTo(expected)); + } + + [Test] + public void Find_Backward_ReturnsNearestHitBeforeCurrentLine () + { + var reader = ReaderOf("target", "bravo", "target", "delta", "echo"); + var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 4 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false))); + } + + [Test] + public void Find_ForwardPastEndOfFile_WrapsToStartAndFindsEarlierHit () + { + var reader = ReaderOf("alpha", "target", "bravo", "charlie"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 2 }; + var progress = new ProgressRecorder(); + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: true))); + Assert.That(progress.Reports.Select(r => r.Wrap), Does.Contain(SearchWrap.ToStart), + "the wrap event must reach the progress sink so the control can show the notice mid-search"); + }); + } + + [Test] + public void Find_BackwardPastTopOfFile_WrapsToEndAndFindsLaterHit () + { + var reader = ReaderOf("alpha", "bravo", "charlie", "target"); + var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 1 }; + var progress = new ProgressRecorder(); + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 3, Wrapped: true))); + Assert.That(progress.Reports.Select(r => r.Wrap), Does.Contain(SearchWrap.ToEnd)); + }); + } + + [Test] + public void Find_WrappedFullCircleWithoutMatch_ReturnsNotFoundWrapped () + { + var reader = ReaderOf("alpha", "bravo", "charlie"); + var searchParams = new SearchParams { SearchText = "missing", IsForward = true, CurrentLine = 1 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: true))); + } + + [Test] + public void Find_BackwardWrappedFullCircleWithoutMatch_ReturnsNotFoundWrapped () + { + var reader = ReaderOf("alpha", "bravo", "charlie"); + var searchParams = new SearchParams { SearchText = "missing", IsForward = false, CurrentLine = 1 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: true))); + } + + [Test] + public void Find_StartPositionPastEndOfFile_WrapsImmediatelyAndScansFromTop () + { + var reader = ReaderOf("target", "alpha", "bravo"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 99 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: true))); + } + + [Test] + public void Find_WrapEvent_IsReportedBeforeThePostWrapHitIsReturned () + { + var reader = ReaderOf("target", "alpha", "bravo"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 1 }; + var scannedAtWrap = -1; + var progress = new ProgressRecorder(); + progress.OnWrap = () => scannedAtWrap = progress.Reports.Count; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress); + + Assert.Multiple(() => + { + Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.Found)); + Assert.That(scannedAtWrap, Is.GreaterThanOrEqualTo(0), "wrap must be reported during the run, not inferred afterwards"); + }); + } + + [Test] + public void Find_CaseInsensitive_MatchesDifferentCasing () + { + var reader = ReaderOf("alpha", "some Target here"); + var searchParams = new SearchParams { SearchText = "TARGET", IsForward = true, CurrentLine = 0, IsCaseSensitive = false }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false))); + } + + [Test] + public void Find_CaseSensitive_SkipsWrongCasingAndFindsExactMatch () + { + var reader = ReaderOf("target wrong case: ERROR", "exact: error"); + var searchParams = new SearchParams { SearchText = "error", IsForward = true, CurrentLine = 0, IsCaseSensitive = true }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false))); + } + + [Test] + public void Find_FromTop_StartsAtLineZeroRegardlessOfCurrentLine () + { + var reader = ReaderOf("target", "alpha", "bravo", "charlie"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFromTop = true, CurrentLine = 3 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false)), + "from-top must scan from line 0 without needing to wrap"); + } + + [Test] + public void Find_FromTopWithFindNext_ContinuesFromCurrentLineInstead () + { + // Today's rule: F3 (find next) overrides the from-top start so repeat-search advances. + var reader = ReaderOf("target", "alpha", "target", "charlie"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFromTop = true, IsFindNext = true, CurrentLine = 1 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false))); + } + + [Test] + public void Find_HitOnLastLine_IsFound () + { + var reader = ReaderOf("alpha", "bravo", "target"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false))); + } + + [Test] + public void Find_HitOnLineZeroBackward_IsFound () + { + var reader = ReaderOf("target", "alpha", "bravo"); + var searchParams = new SearchParams { SearchText = "target", IsForward = false, CurrentLine = 2 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false))); + } + + [Test] + public void Find_Regex_MatchesByPattern () + { + var reader = ReaderOf("alpha", "code err42 raised", "bravo"); + var searchParams = new SearchParams { SearchText = @"err\d+", IsRegex = true, IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false))); + } + + [Test] + public void Find_CaseSensitiveRegex_SkipsWrongCasing () + { + var reader = ReaderOf("ERR1", "err2"); + var searchParams = new SearchParams { SearchText = @"err\d", IsRegex = true, IsCaseSensitive = true, IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 1, Wrapped: false))); + } + + [Test] + public void Find_CaseInsensitiveRegex_MatchesWrongCasing () + { + var reader = ReaderOf("ERR1", "err2"); + var searchParams = new SearchParams { SearchText = @"err\d", IsRegex = true, IsCaseSensitive = false, IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false))); + } + + [Test] + public void Find_InvalidRegex_ReturnsInvalidPatternWithoutReadingAnyLine () + { + var readerMock = ReaderMockOf("alpha", "bravo"); + var searchParams = new SearchParams { SearchText = "(unclosed", IsRegex = true, IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, readerMock.Object, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.InvalidPattern, -1, Wrapped: false))); + readerMock.Verify(r => r.GetLogLineMemory(It.IsAny()), Times.Never, + "an uncompilable pattern must fail before any line is read"); + } + + [Test] + public void Find_ShiftF3OnFindNext_ScansBackwardThroughTheSeam () + { + // Forward would find line 3; Shift+F3 must reverse the find-next and reach line 0. + var reader = ReaderOf("target", "alpha", "bravo", "target"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, IsFindNext = true, IsShiftF3Pressed = true, CurrentLine = 2 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false))); + } + + [Test] + public void Find_CancelledTokenWithMatchOnCurrentLine_StillReturnsTheHit () + { + // Pins the original loop's ordering: the match test runs before the cancel check. + var reader = ReaderOf("target", "alpha"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = LogSearcher.Find(searchParams, reader, cts.Token); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 0, Wrapped: false))); + } + + [Test] + public void Find_CancelledToken_ReturnsCancelled () + { + var reader = ReaderOf("alpha", "bravo", "target"); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var result = LogSearcher.Find(searchParams, reader, cts.Token); + + Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.Cancelled)); + } + + [TestCase(null, TestName = "Find_NullSearchText_ReturnsNotFound")] + [TestCase("", TestName = "Find_EmptySearchText_ReturnsNotFound")] + public void Find_MissingSearchText_ReturnsNotFound (string? searchText) + { + var reader = ReaderOf("alpha", "bravo"); + var searchParams = new SearchParams { SearchText = searchText!, IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.NotFound, -1, Wrapped: false))); + } + + [Test] + public void Find_ReaderReturnsNullLine_EndsAsNotFound () + { + // The reader contract: null for unavailable lines (deleted file, timeout). Never throw. + var readerMock = new Mock(); + _ = readerMock.Setup(r => r.LineCount).Returns(5); + _ = readerMock.Setup(r => r.GetLogLineMemory(It.IsAny())).Returns((ILogLineMemory)null!); + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + + var result = LogSearcher.Find(searchParams, readerMock.Object, CancellationToken.None); + + Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.NotFound)); + } + + [Test] + public void Find_MutatingSearchParamsMidRun_DoesNotAffectTheRunningSearch () + { + // The shared coordinator SearchParams is mutated by F3 while a search runs; Find + // must operate on a snapshot taken at entry. + var searchParams = new SearchParams { SearchText = "target", IsForward = true, CurrentLine = 0 }; + + var mock = new Mock(); + _ = mock.Setup(r => r.LineCount).Returns(3); + _ = mock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => + { + searchParams.SearchText = "changed-mid-run"; + searchParams.IsForward = false; + return LineOf(lineNum == 2 ? "target" : "alpha"); + }); + + var result = LogSearcher.Find(searchParams, mock.Object, CancellationToken.None); + + Assert.That(result, Is.EqualTo(new SearchResult(SearchOutcome.Found, 2, Wrapped: false))); + } + + [Test] + public void Find_LongScan_ReportsProgressEveryThousandLinesAndResetsOnWrap () + { + var lines = Enumerable.Repeat("filler", 2500).ToArray(); + var reader = ReaderOf(lines); + var searchParams = new SearchParams { SearchText = "missing", IsForward = true, CurrentLine = 0 }; + var progress = new ProgressRecorder(); + + var result = LogSearcher.Find(searchParams, reader, CancellationToken.None, progress); + + var scanReports = progress.Reports.Where(r => r.Wrap == SearchWrap.None).Select(r => r.LinesScanned); + Assert.Multiple(() => + { + Assert.That(result.Outcome, Is.EqualTo(SearchOutcome.NotFound)); + // Segment counter resets on wrap, matching a progress bar whose maximum is the line count. + Assert.That(scanReports, Is.EqualTo(new[] { 1000, 2000, 1000, 2000 })); + }); + } + + #region Fake reader over known lines + + private static ILogfileReader ReaderOf (params string[] lines) => ReaderMockOf(lines).Object; + + private static Mock ReaderMockOf (params string[] lines) + { + var mock = new Mock(); + _ = mock.Setup(r => r.LineCount).Returns(lines.Length); + _ = mock.Setup(r => r.GetLogLineMemory(It.IsAny())) + .Returns((int lineNum) => lineNum >= 0 && lineNum < lines.Length ? LineOf(lines[lineNum]) : null!); + return mock; + } + + private static ILogLineMemory LineOf (string text) + { + var mock = new Mock(); + _ = mock.Setup(l => l.FullLine).Returns(text.AsMemory()); + return mock.Object; + } + + /// Synchronous recorder — System.Progress posts asynchronously, which tests must not rely on. + private sealed class ProgressRecorder : IProgress + { + public List Reports { get; } = []; + + public Action? OnWrap { get; set; } + + public void Report (SearchProgress value) + { + Reports.Add(value); + if (value.Wrap != SearchWrap.None) + { + OnWrap?.Invoke(); + } + } + } + + #endregion +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index a6dbbf71..9a831067 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -17,6 +17,7 @@ using LogExpert.Core.Classes.Highlight; using LogExpert.Core.Classes.Log; using LogExpert.Core.Classes.Persister; +using LogExpert.Core.Classes.Search; using LogExpert.Core.Config; using LogExpert.Core.Entities; using LogExpert.Core.EventArguments; @@ -120,6 +121,7 @@ internal partial class LogWindow : DockContent, ILogPaintContextUI, ILogView, IL private HighlightGroup _currentHighlightGroup = new(); private SearchParams _currentSearchParams; + private CancellationTokenSource? _searchCts; private string[] _fileNames; private List _filterHitList = []; @@ -2707,6 +2709,7 @@ private void EnterLoadFileStatus () _isLoading = true; _shouldCancel = true; + _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); dataGridView.ClearSelection(); @@ -2746,6 +2749,7 @@ private void LogfileDead () _statusEventArgs.CurrentLineNum = 0; SendStatusLineUpdate(); _shouldCancel = true; + _searchCts?.Cancel(); ClearFilterList(); ClearBookmarkList(); @@ -4057,119 +4061,42 @@ private void StatusLineFileSize (long size) SendStatusLineUpdate(); } - [SupportedOSPlatform("windows")] - private int Search (SearchParams searchParams) + /// + /// Narrates a running search's progress: wrap notices on the status line (mid-search, + /// like the old in-loop reporting) and scanned-line counts to the progress bar. + /// Called synchronously on the search's background thread. + /// + private void ReportSearchProgress (SearchProgress searchProgress) { - if (searchParams.SearchText == null) + switch (searchProgress.Wrap) { - return -1; + case SearchWrap.ToStart: + StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromBeginningOfFile); + return; + case SearchWrap.ToEnd: + StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromEndOfFile); + return; } - var lineNum = searchParams.IsFromTop && !searchParams.IsFindNext - ? 0 - : searchParams.CurrentLine; - - var lowerSearchText = searchParams.SearchText.ToUpperInvariant(); - var count = 0; - var hasWrapped = false; - - while (true) + try { - if ((searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed) - { - if (lineNum >= _logFileReader.LineCount) - { - if (hasWrapped) - { - StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, searchParams.SearchText)); - return -1; - } - - lineNum = 0; - count = 0; - hasWrapped = true; - StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromBeginningOfFile); - } - } - else - { - if (lineNum < 0) - { - if (hasWrapped) - { - StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, searchParams.SearchText)); - return -1; - } - - count = 0; - lineNum = _logFileReader.LineCount - 1; - hasWrapped = true; - StatusLineError(Resources.LogWindow_UI_StatusLineError_StartedFromEndOfFile); - } - } - - var line = _logFileReader.GetLogLineMemory(lineNum); - if (line == null) - { - return -1; - } - - if (searchParams.IsRegex) - { - Regex rex = new(searchParams.SearchText, searchParams.IsCaseSensitive - ? RegexOptions.None - : RegexOptions.IgnoreCase); - if (rex.IsMatch(line.FullLine.ToString())) - { - return lineNum; - } - } - else + if (!Disposing) { - if (searchParams.IsCaseSensitive) - { - if (line.FullLine.Span.Contains(searchParams.SearchText, StringComparison.Ordinal)) - { - return lineNum; - } - } - else - { - if (line.FullLine.Span.Contains(lowerSearchText, StringComparison.OrdinalIgnoreCase)) - { - return lineNum; - } - } - } - - if ((searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed) - { - lineNum++; - } - else - { - lineNum--; - } - - if (_shouldCancel) - { - return -1; + _ = Invoke(UpdateProgressBar, [searchProgress.LinesScanned]); } + } + catch (ObjectDisposedException ex) // can occur when closing the app while searching + { + _logger.Warn(ex); + } + } - if (++count % PROGRESS_BAR_MODULO == 0) - { - try - { - if (!Disposing) - { - _ = Invoke(UpdateProgressBar, [count]); - } - } - catch (ObjectDisposedException ex) // can occur when closing the app while searching - { - _logger.Warn(ex); - } - } + /// Relays reports synchronously — System.Progress would post them asynchronously. + private sealed class SynchronousProgress (Action handler) : IProgress + { + public void Report (T value) + { + handler(value); } } @@ -4197,10 +4124,8 @@ private void SelectLine (int lineNum, bool triggerSyncCall, bool shouldScroll) return; } - if (lineNum == -1) + if (lineNum < 0) { - // Hmm... is that experimental code from early days? - _ = MessageBox.Show(this, Resources.LogWindow_UI_SelectLine_SearchResultNotFound, Resources.LogExpert_Common_UI_Title_LogExpert); return; } @@ -6504,6 +6429,7 @@ public void CloseLogWindow () StopTimestampSyncThread(); StopLogEventWorkerThread(); _shouldCancel = true; + _searchCts?.Cancel(); if (_logFileReader != null) { @@ -6997,14 +6923,14 @@ public void StartSearch () GuiStateUpdate(this, _guiStateArgs); var searchParams = _logWindowCoordinator.SearchParams; - searchParams.CurrentLine = (searchParams.IsForward || searchParams.IsFindNext) && !searchParams.IsShiftF3Pressed + searchParams.CurrentLine = LogSearcher.ResolveDirection(searchParams) == SearchDirection.Forward ? dataGridView.CurrentCellAddress.Y + 1 : dataGridView.CurrentCellAddress.Y - 1; _currentSearchParams = searchParams; // remember for async "not found" messages _isSearching = true; - _shouldCancel = false; + _shouldCancel = false; // shared flag: SelectLine's cancel gate and the filter loops read it StatusLineText(Resources.LogWindow_UI_StatusLineText_SearchingPressESCToCancel); _progressEventArgs.MinValue = 0; @@ -7013,13 +6939,26 @@ public void StartSearch () _progressEventArgs.Visible = true; SendProgressBarUpdate(); - _ = Task.Run(() => Search(searchParams)).ContinueWith(SearchComplete, TaskScheduler.Default); + // Replace the token source only after a consumed cancellation (mirrors the old + // _shouldCancel = false reset); a still-running previous search keeps sharing it, + // so one ESC cancels every running search. + if (_searchCts == null || _searchCts.IsCancellationRequested) + { + _searchCts?.Dispose(); + _searchCts = new CancellationTokenSource(); + } + + var cancellationToken = _searchCts.Token; + var progress = new SynchronousProgress(ReportSearchProgress); + + _ = Task.Run(() => LogSearcher.Find(searchParams, _logFileReader, cancellationToken, progress), CancellationToken.None) + .ContinueWith(SearchComplete, TaskScheduler.Default); RemoveAllSearchHighlightEntries(); AddSearchHitHighlightEntry(searchParams); } - private void SearchComplete (Task task) + private void SearchComplete (Task task) { if (Disposing) { @@ -7029,15 +6968,28 @@ private void SearchComplete (Task task) try { _ = Invoke(new MethodInvoker(ResetProgressBar)); - var line = task.Result; + var result = task.Result; _guiStateArgs.MenuEnabled = true; GuiStateUpdate(this, _guiStateArgs); - if (line == -1) + + switch (result.Outcome) { - return; - } + case SearchOutcome.Found: + _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), result.LineNumber, true); + break; + + case SearchOutcome.NotFound: + StatusLineError(string.Format(CultureInfo.InvariantCulture, Resources.LogWindow_UI_StatusLineError_NotFound, _currentSearchParams.SearchText)); + break; - _ = dataGridView.Invoke(new SelectLineFx((line1, triggerSyncCall) => SelectLine(line1, triggerSyncCall, true)), line, true); + case SearchOutcome.InvalidPattern: + StatusLineError(Resources.LogWindow_UI_StatusLineError_InvalidRegularExpression); + break; + + default: // Cancelled — clear the busy status + StatusLineText(string.Empty); + break; + } } catch (Exception ex) // in the case the windows is already destroyed { @@ -7106,7 +7058,8 @@ public void OnLogWindowKeyDown (object sender, KeyEventArgs e) { if (_isSearching) { - _shouldCancel = true; + _shouldCancel = true; // shared flag: filter loops and SelectLine's cancel gate read it + _searchCts?.Cancel(); } CancelHighlightBookmarkScan(); diff --git a/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs b/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs index b9b265f0..fa9114ba 100644 --- a/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs +++ b/src/LogExpert.UI/Dialogs/RegexHelperDialog.cs @@ -107,7 +107,7 @@ private void UpdateMatches () } } - private void LoadHistory () + internal void LoadHistory () { comboBoxRegex.Items.Clear(); comboBoxRegex.DataSource = ExpressionHistoryList; @@ -131,26 +131,27 @@ private void OnCaseSensitiveCheckBoxCheckedChanged (object sender, EventArgs e) UpdateMatches(); } - private void OnButtonOkClick (object sender, EventArgs e) + internal void OnButtonOkClick (object sender, EventArgs e) { + // Both combos are DataSource-bound to the history lists (LoadHistory), so their + // Items collections must not be touched — mutate the bound lists instead. The + // dialog closes with DialogResult.OK right after, so no rebind is needed. var text = comboBoxRegex.Text; _ = ExpressionHistoryList.Remove(text); ExpressionHistoryList.Insert(0, text); - comboBoxRegex.Items.Remove(text); - comboBoxRegex.Items.Insert(0, text); text = comboBoxTestText.Text; _ = TesttextHistoryList.Remove(text); TesttextHistoryList.Insert(0, text); - if (comboBoxRegex.Items.Count > MAX_HISTORY) + if (ExpressionHistoryList.Count > MAX_HISTORY) { - comboBoxRegex.Items.Remove(comboBoxRegex.Items.Count - 1); + ExpressionHistoryList.RemoveAt(ExpressionHistoryList.Count - 1); } - if (comboBoxTestText.Items.Count > MAX_HISTORY) + if (TesttextHistoryList.Count > MAX_HISTORY) { - comboBoxTestText.Items.Remove(comboBoxTestText.Items.Count - 1); + TesttextHistoryList.RemoveAt(TesttextHistoryList.Count - 1); } } diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index f5d185bb..7c958309 100644 --- a/src/PluginRegistry/PluginHashGenerator.Generated.cs +++ b/src/PluginRegistry/PluginHashGenerator.Generated.cs @@ -10,7 +10,7 @@ public static partial class PluginValidator { /// /// Gets pre-calculated SHA256 hashes for built-in plugins. - /// Generated: 2026-07-09 17:38:54 UTC + /// Generated: 2026-07-09 21:05:43 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "CDEFDC033AD707622C49BD4EC937EB9EDE54F8C1EAB19678E29CDC938A91365A", + ["AutoColumnizer.dll"] = "81F2CD2C2F2A67C577F3DA2BB1C52884249332B4A2741FC921C18D5AF9F68B30", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "4EBBA85D9A259D82342E605E4381CD242A9212186711036E93AFD8DADDA23486", - ["CsvColumnizer.dll (x86)"] = "4EBBA85D9A259D82342E605E4381CD242A9212186711036E93AFD8DADDA23486", - ["DefaultPlugins.dll"] = "CB04A2942034D9FF2506379A81D04A438E9C3F55225C28CE4249CB2639724F83", - ["FlashIconHighlighter.dll"] = "A3DC19BF3D3E660325E54B6ABBEB38A81351DC269CD2C292C7038A0181250B39", - ["GlassfishColumnizer.dll"] = "55FF72EC8D78E1E33EECE105C625BEEE633A1AB114A9A8F6EF3D7F489770F2BE", - ["JsonColumnizer.dll"] = "EAF4FE2D20542C7747807083088B222CCE201AF8E9B2287255D0CF76EB52C114", - ["JsonCompactColumnizer.dll"] = "0F8A8BF3FF85F063D2B219A27F11598954A4A4679C2A972D303836D12E8D7C8E", - ["Log4jXmlColumnizer.dll"] = "113BD3CACB5D0376BD74DBE98A51E0A4F60405CC0BF10987097234162A3275F0", - ["LogExpert.Resources.dll"] = "4F151AC6D75E0844E5B4EFEDE591252EE9C998BF61CB9B715E2A6DB7CEA0DBB3", + ["CsvColumnizer.dll"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", + ["CsvColumnizer.dll (x86)"] = "FC08674EDE813811C46F89A6B0D5DE3869568E5526815B3F6F7222C34CE09726", + ["DefaultPlugins.dll"] = "4B5206DB37D091B48037DE2C03A573BD3678772F412B128C1E57614E1EDC9065", + ["FlashIconHighlighter.dll"] = "D12A6517CC0402A8E3A580CFE50F2AB81C15F4019159B784416EC7F8FE73FBA8", + ["GlassfishColumnizer.dll"] = "2BA6B760A044FEE75EDC3BC478605E9E0AA9CE4F52C9F435EBA38A9EDD45A109", + ["JsonColumnizer.dll"] = "FD407BC814B287588F4DB0FE90BAA104BE5050D7F6097D183F9E0BE906EC8C5E", + ["JsonCompactColumnizer.dll"] = "B80B2AD30181E94C08286835CE01C607FA7FD66722EE0C5DB2A7C16AB357A09F", + ["Log4jXmlColumnizer.dll"] = "942FCD055F0C9BC2D141D9D2B407BAB14F89719F8AF35F50326F77FC89C74C42", + ["LogExpert.Resources.dll"] = "85E2154489ADDC1DC52B67EA0784519AB6427BECB4AD1B8A2BE29D796D3C41C8", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.DependencyInjection.Abstractions.dll (x86)"] = "67FA4325000DB017DC0C35829B416F024F042D24EFB868BCF17A895EE6500A93", ["Microsoft.Extensions.Logging.Abstractions.dll"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", ["Microsoft.Extensions.Logging.Abstractions.dll (x86)"] = "BB853130F5AFAF335BE7858D661F8212EC653835100F5A4E3AA2C66A4D4F685D", - ["RegexColumnizer.dll"] = "ADAF0EA008A488EB108C6B4C8562EBA96E65A6EAD58ED351DA696C8F7DA02B86", - ["SftpFileSystem.dll"] = "712F494AA8627BC4D6FA5EE729F5E225BCB61DAE3483D57C8BFD71CBC54841B4", - ["SftpFileSystem.dll (x86)"] = "F37E477D24B41C506CEE321423E1D5E1EEC731E04E9FD7378ADD695736CE62CC", - ["SftpFileSystem.Resources.dll"] = "3BD8924373C41D64FCB8AD7A6EE9B4E8FCF5BE311A7E4222A75FECF6567D6BEE", - ["SftpFileSystem.Resources.dll (x86)"] = "3BD8924373C41D64FCB8AD7A6EE9B4E8FCF5BE311A7E4222A75FECF6567D6BEE", + ["RegexColumnizer.dll"] = "ECDDA17BBC01DF90E82065BAE66223568C4F0B44E3F49F595A6178CC4D5A5F5B", + ["SftpFileSystem.dll"] = "EA5235D8C1787798A349D38BEE086035AB79AA25DBD1C7C1713FA36379614690", + ["SftpFileSystem.dll (x86)"] = "398428B6D28C3FA4494BDD67156CAC72240B0E74D963F24B8B77295CA11C7E93", + ["SftpFileSystem.Resources.dll"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", + ["SftpFileSystem.Resources.dll (x86)"] = "A77058BC091EBEDBDBB57B1B80FD563EE1759EE12E6927A6AEE6C31124799890", }; }