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",
};
}