diff --git a/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs index afbbc1fd..cc61f918 100644 --- a/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs +++ b/src/LogExpert.Core/Classes/Bookmark/HighlightBookmarkScanner.cs @@ -84,7 +84,7 @@ private static (bool SetBookmark, string BookmarkComment, string SourceHighlight var bookmarkCommentBuilder = new StringBuilder(); var sourceHighlightText = string.Empty; - foreach (var entry in bookmarkEntries.Where(entry => CheckHighlightEntryMatch(entry, line))) + foreach (var entry in bookmarkEntries.Where(entry => HighlightEvaluator.IsMatch(entry, line))) { setBookmark = true; sourceHighlightText = entry.SearchText; @@ -98,40 +98,6 @@ private static (bool SetBookmark, string BookmarkComment, string SourceHighlight return (setBookmark, bookmarkCommentBuilder.ToString().TrimEnd('\r', '\n'), sourceHighlightText); } - /// - /// Matches a highlight entry against a line. Replicates the logic from LogWindow.CheckHighlightEntryMatch so the - /// scanner works identically to the existing tail-mode matching. - /// - private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValueMemory column) - { - if (entry.IsRegex) - { - if (entry.Regex.IsMatch(column.Text.ToString())) - { - return true; - } - } - else - { - if (entry.IsCaseSensitive) - { - if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.Ordinal)) - { - return true; - } - } - else - { - if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - /// /// Resolves the bookmark comment template using ParamParser, matching SetBookmarkFromTrigger behavior. /// diff --git a/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs new file mode 100644 index 00000000..6f24ec38 --- /dev/null +++ b/src/LogExpert.Core/Classes/Highlight/HighlightEvaluator.cs @@ -0,0 +1,111 @@ +using System.Text; + +using ColumnizerLib; + +namespace LogExpert.Core.Classes.Highlight; + +/// +/// Pure evaluation of rules against a log line: which entries match and +/// which trigger actions their matches imply. This is the single home of highlight-match semantics, +/// shared by the tail trigger path (LogWindow.CheckFilterAndHighlight) and the bulk +/// . +/// +/// It reports the decision only. Side-effecting triggers (Audio Alert, Set Bookmark, +/// Stop Tail) are fired by the caller, so the "Audio Alert fires only on the tail path" invariant +/// stays at the call site and cannot leak onto bulk/paint paths. +/// +/// +public static class HighlightEvaluator +{ + /// + /// Returns whether a single matches the given line. + /// + public static bool IsMatch (HighlightEntry entry, ITextValueMemory line) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(line); + + if (entry.IsRegex) + { + return entry.Regex.IsMatch(line.Text.ToString()); + } + + var comparison = entry.IsCaseSensitive + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + + return line.Text.Span.Contains(entry.SearchText.AsSpan(), comparison); + } + + /// + /// Returns every entry in that matches the line, preserving order. + /// A null line matches nothing. + /// + public static IList FindMatchingEntries (IEnumerable entries, ITextValueMemory line) + { + ArgumentNullException.ThrowIfNull(entries); + + List result = []; + if (line == null) + { + return result; + } + + foreach (var entry in entries.Where(e => IsMatch(e, line))) + { + result.Add(entry); + } + + return result; + } + + /// + /// Classifies the non-plugin trigger actions implied by a set of matching entries: whether to + /// suppress the dirty LED, stop tailing, set a bookmark, and the concatenated bookmark comment. + /// Plugin and Audio Alert triggers are intentionally not handled here — they are fired by the + /// caller. + /// + public static HighlightActions GetTriggerActions (IEnumerable matchingEntries) + { + ArgumentNullException.ThrowIfNull(matchingEntries); + + var suppressLed = false; + var stopTail = false; + var setBookmark = false; + var bookmarkCommentBuilder = new StringBuilder(); + + foreach (var entry in matchingEntries) + { + if (entry.IsLedSwitch) + { + suppressLed = true; + } + + if (entry.IsSetBookmark) + { + setBookmark = true; + if (!string.IsNullOrEmpty(entry.BookmarkComment)) + { + + _ = bookmarkCommentBuilder.Append(entry.BookmarkComment); + _ = bookmarkCommentBuilder.Append("\r\n"); + } + } + + if (entry.IsStopTail) + { + stopTail = true; + } + } + + var bookmarkComment = bookmarkCommentBuilder.ToString().TrimEnd(['\r', '\n']); + + return new HighlightActions(suppressLed, stopTail, setBookmark, bookmarkComment); + } +} + +/// +/// The non-plugin trigger decision for a line: which LED/tail/bookmark side effects its matching +/// entries imply. Carries no side effects of its own — the caller acts on it. +/// +public readonly record struct HighlightActions (bool SuppressLed, bool StopTail, bool SetBookmark, string BookmarkComment); diff --git a/src/LogExpert.Tests/Highlight/HighlightEvaluatorTests.cs b/src/LogExpert.Tests/Highlight/HighlightEvaluatorTests.cs new file mode 100644 index 00000000..bed2c3d7 --- /dev/null +++ b/src/LogExpert.Tests/Highlight/HighlightEvaluatorTests.cs @@ -0,0 +1,138 @@ +using ColumnizerLib; + +using LogExpert.Core.Classes.Highlight; + +using NUnit.Framework; + +namespace LogExpert.Tests.Highlight; + +[TestFixture] +public class HighlightEvaluatorTests +{ + #region Helper + + private sealed class TestLine (string text) : ITextValueMemory + { + public ReadOnlyMemory Text => text.AsMemory(); + } + + private static ITextValueMemory Line (string text) => new TestLine(text); + + #endregion + + [Test] + public void IsMatch_PlainSubstring_MatchesCaseInsensitiveByDefault () + { + var entry = new HighlightEntry { SearchText = "error" }; + + Assert.That(HighlightEvaluator.IsMatch(entry, Line("Something ERROR happened")), Is.True); + } + + [Test] + public void IsMatch_CaseSensitive_RespectsCase () + { + var entry = new HighlightEntry { SearchText = "error", IsCaseSensitive = true }; + + Assert.That(HighlightEvaluator.IsMatch(entry, Line("ERROR happened")), Is.False); + Assert.That(HighlightEvaluator.IsMatch(entry, Line("an error here")), Is.True); + } + + [Test] + public void IsMatch_Regex_Matches () + { + var entry = new HighlightEntry { SearchText = "ERR\\w+", IsRegex = true }; + + Assert.That(HighlightEvaluator.IsMatch(entry, Line("ERROR happened")), Is.True); + } + + [Test] + public void IsMatch_NoSubstring_ReturnsFalse () + { + var entry = new HighlightEntry { SearchText = "FATAL" }; + + Assert.That(HighlightEvaluator.IsMatch(entry, Line("just an info line")), Is.False); + } + + [Test] + public void FindMatchingEntries_ReturnsOnlyMatches_InOrder () + { + var error = new HighlightEntry { SearchText = "error" }; + var warn = new HighlightEntry { SearchText = "warn" }; + var fatal = new HighlightEntry { SearchText = "fatal" }; + var entries = new[] { error, warn, fatal }; + + var result = HighlightEvaluator.FindMatchingEntries(entries, Line("warn: an error occurred")); + + Assert.That(result, Is.EqualTo(new[] { error, warn })); + } + + [Test] + public void FindMatchingEntries_NullLine_ReturnsEmpty () + { + var entries = new[] { new HighlightEntry { SearchText = "error" } }; + + var result = HighlightEvaluator.FindMatchingEntries(entries, null); + + Assert.That(result, Is.Empty); + } + + [Test] + public void GetTriggerActions_EmptyList_AllFalse () + { + var actions = HighlightEvaluator.GetTriggerActions([]); + + Assert.That(actions.SuppressLed, Is.False); + Assert.That(actions.StopTail, Is.False); + Assert.That(actions.SetBookmark, Is.False); + Assert.That(actions.BookmarkComment, Is.Empty); + } + + [Test] + public void GetTriggerActions_AggregatesFlagsAcrossEntries () + { + var matching = new[] + { + new HighlightEntry { IsLedSwitch = true }, + new HighlightEntry { IsStopTail = true }, + new HighlightEntry { IsSetBookmark = true } + }; + + var actions = HighlightEvaluator.GetTriggerActions(matching); + + Assert.That(actions.SuppressLed, Is.True); + Assert.That(actions.StopTail, Is.True); + Assert.That(actions.SetBookmark, Is.True); + } + + [Test] + public void GetTriggerActions_JoinsBookmarkComments_WithCrlf_TrimmingTrailing () + { + var matching = new[] + { + new HighlightEntry { IsSetBookmark = true, BookmarkComment = "first" }, + new HighlightEntry { IsSetBookmark = true, BookmarkComment = "second" } + }; + + var actions = HighlightEvaluator.GetTriggerActions(matching); + + Assert.That(actions.BookmarkComment, Is.EqualTo("first\r\nsecond")); + } + + [Test] + public void IsMatch_NullEntry_Throws () + { + _ = Assert.Throws(() => HighlightEvaluator.IsMatch(null, Line("x"))); + } + + [Test] + public void FindMatchingEntries_NullEntries_Throws () + { + _ = Assert.Throws(() => HighlightEvaluator.FindMatchingEntries(null, Line("x"))); + } + + [Test] + public void GetTriggerActions_NullList_Throws () + { + _ = Assert.Throws(() => HighlightEvaluator.GetTriggerActions(null)); + } +} diff --git a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs index 76d42c08..c433cb3d 100644 --- a/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs +++ b/src/LogExpert.UI/Controls/LogWindow/LogWindow.cs @@ -3204,7 +3204,7 @@ private void CheckFilterAndHighlight (LogEventArgs e) var matchingList = FindMatchingHighlightEntries(line); LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = GetHighlightActions(matchingList); + var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); SafeTriggerAudioAlert(matchingList); if (setBookmark) { @@ -3256,7 +3256,7 @@ private void CheckFilterAndHighlight (LogEventArgs e) { var matchingList = FindMatchingHighlightEntries(line); LaunchHighlightPlugins(matchingList, i); - var (suppressLed, stopTail, setBookmark, bookmarkComment) = GetHighlightActions(matchingList); + var (suppressLed, stopTail, setBookmark, bookmarkComment) = HighlightEvaluator.GetTriggerActions(matchingList); SafeTriggerAudioAlert(matchingList); if (setBookmark) { @@ -3814,58 +3814,15 @@ private HighlightEntry FindFirstNoWordMatchHighlightEntry (ITextValueMemory line return FindHighlightEntry(line, true); } - private static bool CheckHighlightEntryMatch (HighlightEntry entry, ITextValueMemory column) - { - if (entry.IsRegex) - { - //Regex rex = new Regex(entry.SearchText, entry.IsCaseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase); - if (entry.Regex.IsMatch(column.Text.ToString())) - { - return true; - } - } - else - { - if (entry.IsCaseSensitive) - { - if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.Ordinal)) - { - return true; - } - } - else - { - if (column.Text.Span.Contains(entry.SearchText.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - /// /// Returns all HighlightEntry entries which matches the given line /// private IList FindMatchingHighlightEntries (ITextValueMemory line) { - IList resultList = []; - if (line != null) + lock (_currentHighlightGroupLock) { - lock (_currentHighlightGroupLock) - { - foreach (var entry in _currentHighlightGroup.HighlightEntryList) - { - if (CheckHighlightEntryMatch(entry, line)) - { - resultList.Add(entry); - } - } - } + return HighlightEvaluator.FindMatchingEntries(_currentHighlightGroup.HighlightEntryList, line); } - - return resultList; } private static void GetHighlightEntryMatches (ITextValueMemory line, IList hilightEntryList, IList resultList) @@ -3889,7 +3846,7 @@ private static void GetHighlightEntryMatches (ITextValueMemory line, IList matchingList) - { - var noLed = false; - var stopTail = false; - var setBookmark = false; - var bookmarkComment = string.Empty; - - foreach (var entry in matchingList) - { - if (entry.IsLedSwitch) - { - noLed = true; - } - - if (entry.IsSetBookmark) - { - setBookmark = true; - if (!string.IsNullOrEmpty(entry.BookmarkComment)) - { - bookmarkComment += entry.BookmarkComment + "\r\n"; - } - } - - if (entry.IsStopTail) - { - stopTail = true; - } - } - - bookmarkComment = bookmarkComment.TrimEnd(['\r', '\n']); - - return (noLed, stopTail, setBookmark, bookmarkComment); - } - /// /// Fires an audio alert for the first matching highlight entry that has /// enabled. Iteration stops after the @@ -7046,7 +6969,7 @@ public HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatc continue; } - if (CheckHighlightEntryMatch(entry, line)) + if (HighlightEvaluator.IsMatch(entry, line)) { return entry; } @@ -7062,7 +6985,7 @@ public HighlightEntry FindHighlightEntry (ITextValueMemory line, bool noWordMatc continue; } - if (CheckHighlightEntryMatch(entry, line)) + if (HighlightEvaluator.IsMatch(entry, line)) { return entry; } diff --git a/src/PluginRegistry/PluginHashGenerator.Generated.cs b/src/PluginRegistry/PluginHashGenerator.Generated.cs index 2640e4fd..7a160ebe 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-06-25 15:09:22 UTC + /// Generated: 2026-06-25 16:07:16 UTC /// Configuration: Release /// Plugin count: 21 /// @@ -18,27 +18,27 @@ public static Dictionary GetBuiltInPluginHashes() { return new Dictionary(StringComparer.OrdinalIgnoreCase) { - ["AutoColumnizer.dll"] = "ED598729A6BCB3175F02FF54576DDD5DAD7CBBC0E47A1E07ADC8A9B4AB330675", + ["AutoColumnizer.dll"] = "01A6AE19C49DFF72F14E95621D8D656740610E7347BF76FF7BD521173A7E2942", ["BouncyCastle.Cryptography.dll"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", ["BouncyCastle.Cryptography.dll (x86)"] = "E5EEAF6D263C493619982FD3638E6135077311D08C961E1FE128F9107D29EBC6", - ["CsvColumnizer.dll"] = "D2200DE16E125F7B3FD4A0603F094455712987773C98AE71B46EBEB1F181D5FC", - ["CsvColumnizer.dll (x86)"] = "D2200DE16E125F7B3FD4A0603F094455712987773C98AE71B46EBEB1F181D5FC", - ["DefaultPlugins.dll"] = "9E7CD9B83067F83273D4D0BDD0E50DEBD3073015B55F63E7683AAF768FFB913F", - ["FlashIconHighlighter.dll"] = "0E9DB7648FF41A3AAF7924A2A8A01476B803A0471711B9A3FE1D3C927EC02199", - ["GlassfishColumnizer.dll"] = "F65B6305AF29B1F883C2523140C230CBB900046022572618A41C3C128703FF3D", - ["JsonColumnizer.dll"] = "514EDAC4465FF75D604989F20B0B12ADBFD2092E514B4E80FE8CBF2272CF7C9A", - ["JsonCompactColumnizer.dll"] = "04B454BDBA03DD8683F9065AF8080E7ED5A443A63611D738AC3B2B11A912E537", - ["Log4jXmlColumnizer.dll"] = "7CEB38D227528C08C21D403F7CA91DFE689655022EFB7280761167C7F7C2AFBC", - ["LogExpert.Resources.dll"] = "A4AC70A6FCB3997821127C3F59BF78933794CF0B07F26DFFCD9004FD506F03C5", + ["CsvColumnizer.dll"] = "8C088610C395B467035FF822CBA5FC9752B039ED3EC32FEBD78B04F6E085498B", + ["CsvColumnizer.dll (x86)"] = "8C088610C395B467035FF822CBA5FC9752B039ED3EC32FEBD78B04F6E085498B", + ["DefaultPlugins.dll"] = "9A34EDE8590C3E14CC8F3B8E1A03672E3DFFB14F13814B7E8393EFF4B5F21B7E", + ["FlashIconHighlighter.dll"] = "4AB87A53B7C8C1F02D241754FB8CE008C64DF752DD2A3E311562CC9C80637921", + ["GlassfishColumnizer.dll"] = "85A92A1F23A33648EBB368CE81E8BF435A3D6E71C0417A818382713E2C342058", + ["JsonColumnizer.dll"] = "6C20EA5127BEFF1932387CF3538BE175FA458F9901C29999378BAB18DD5EE8F8", + ["JsonCompactColumnizer.dll"] = "79D4921588313832BA2044B0D4F898AF9A52A568A26A0135953FEAA27CB9906F", + ["Log4jXmlColumnizer.dll"] = "12DB495986480AC150E2AAAF4F0003ABDF39E5F52A6D16ABB0AD498D401BC8C6", + ["LogExpert.Resources.dll"] = "8646D8D2D2B34331F4595A9A0A4E92D0A6B48EAE6816026DC5B91625DC050535", ["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"] = "7D343457626A42062C885716047E6DC6649983E2AD612B7C38D73F695250355A", - ["SftpFileSystem.dll"] = "3011E98941B793FBBC891AA38939058CC057F3CBDC0AD1E410A7B1715821FF3B", - ["SftpFileSystem.dll (x86)"] = "5D79B06F7109C61C5A5A9049DB355627F4E4EB85E557DB7786C0412954A85B72", - ["SftpFileSystem.Resources.dll"] = "0CE8807D06B4027FA3358773581C0B1DBC646BB2B607F4F358853AC8640C4976", - ["SftpFileSystem.Resources.dll (x86)"] = "0CE8807D06B4027FA3358773581C0B1DBC646BB2B607F4F358853AC8640C4976", + ["RegexColumnizer.dll"] = "AD1427C21C83F0087D2DDC6161D80A1F976C91D05CCB6164769116A2EFDD5758", + ["SftpFileSystem.dll"] = "F998A62944D8B39AFCF67142B9A75711EED3AE3B2373009ADA00CFCEC4BFF18B", + ["SftpFileSystem.dll (x86)"] = "3735FEF98FCAA13973AF5549649F60D0D717FAF42F965FD3F1AA70B93E060E56", + ["SftpFileSystem.Resources.dll"] = "ACCC15F3AE6C8888A3CC99AC32BBFA45E798B546FB8E0C9C6A8CFA22FB2C20B7", + ["SftpFileSystem.Resources.dll (x86)"] = "ACCC15F3AE6C8888A3CC99AC32BBFA45E798B546FB8E0C9C6A8CFA22FB2C20B7", }; }