Skip to content

Commit 658d9c1

Browse files
authored
Merge pull request #589 from LogExperts/450-settings-in-columnizers-tab-are-ignored
450 settings in columnizers tab are ignored
2 parents 88a818e + 0fa4753 commit 658d9c1

39 files changed

Lines changed: 1320 additions & 265 deletions

src/LogExpert.Configuration/ConfigManager.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ public void RemoveFromFileHistory (string fileName)
328328
Save(SettingsFlags.FileHistory);
329329
}
330330

331-
332331
public void ClearLastOpenFilesList ()
333332
{
334333
lock (_loadSaveLock)
@@ -596,6 +595,9 @@ UnauthorizedAccessException or
596595

597596
private static Settings InitializeSettings (Settings settings)
598597
{
598+
// Apply any pending schema migrations before any consumer reads the settings.
599+
_ = LegacyPreferencesMigrator.Migrate(settings);
600+
599601
settings.Preferences ??= new Preferences();
600602
settings.Preferences.ToolEntries ??= [];
601603
settings.Preferences.ColumnizerMaskList ??= [];
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using LogExpert.Core.Config;
2+
3+
namespace LogExpert.Configuration;
4+
5+
/// <summary>
6+
/// Applies one-shot, in-memory migrations to a freshly deserialised <see cref="Settings"/> object so that
7+
/// older settings files behave equivalently under newer schema versions. Idempotent: calling it on already
8+
/// up-to-date settings is a no-op.
9+
/// </summary>
10+
public static class LegacyPreferencesMigrator
11+
{
12+
/// <summary>Current schema version. Bumped whenever a new migration step is added.</summary>
13+
public const int CURRENT_SETTINGS_VERSION = 1;
14+
15+
/// <summary>
16+
/// Migrates the given <see cref="Settings"/> in place. Returns <see langword="true"/> if any
17+
/// migration step was applied (the caller may use this signal to persist the upgraded settings).
18+
/// </summary>
19+
public static bool Migrate (Settings settings)
20+
{
21+
ArgumentNullException.ThrowIfNull(settings);
22+
23+
var changed = false;
24+
25+
if (settings.SettingsVersion < 1)
26+
{
27+
MigrateToV1(settings);
28+
settings.SettingsVersion = 1;
29+
changed = true;
30+
}
31+
32+
return changed;
33+
}
34+
35+
private static void MigrateToV1 (Settings settings)
36+
{
37+
// Existing ColumnizerMaskEntry rows pre-date the per-row Type field — they were regex-only.
38+
// Their default-loaded value would be Glob, which would silently change behaviour. Rewrite to Regex.
39+
if (settings.Preferences?.ColumnizerMaskList != null)
40+
{
41+
foreach (var entry in settings.Preferences.ColumnizerMaskList.Where(entry => entry != null))
42+
{
43+
entry.Type = MaskType.Regex;
44+
}
45+
}
46+
47+
// Preserve the deprecated MaskPrio bool's intent on the new enum, but only if the enum is still
48+
// at its default — otherwise the user has already chosen.
49+
#pragma warning disable CS0618 // Migrating away from MaskPrio
50+
if (settings.Preferences != null
51+
&& settings.Preferences.ColumnizerSelectionPriority == ColumnizerSelectionPriority.HistoryThenMask
52+
&& settings.Preferences.MaskPrio)
53+
{
54+
settings.Preferences.ColumnizerSelectionPriority = ColumnizerSelectionPriority.MaskThenHistory;
55+
}
56+
#pragma warning restore CS0618
57+
}
58+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Text;
2+
using System.Text.RegularExpressions;
3+
4+
using LogExpert.Core.Config;
5+
6+
namespace LogExpert.Core.Classes.Columnizer;
7+
8+
/// <summary>
9+
/// Pure mask-matching for <see cref="ColumnizerMaskEntry"/>. Supports glob (<c>*</c>, <c>?</c>) and
10+
/// .NET regular expression patterns. Match is case-insensitive (file names on Windows are case-insensitive).
11+
/// </summary>
12+
/// <remarks>
13+
/// The matcher never throws — malformed input returns <see langword="false"/>. Glob translation rules:
14+
/// <list type="bullet">
15+
/// <item><c>*</c> → <c>.*</c></item>
16+
/// <item><c>?</c> → <c>.</c> (single character)</item>
17+
/// <item>Every other character is regex-escaped</item>
18+
/// <item>Result is anchored with <c>^…$</c></item>
19+
/// </list>
20+
/// </remarks>
21+
public static class ColumnizerMaskMatcher
22+
{
23+
private const RegexOptions OPTIONS = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
24+
25+
public static bool Matches (ColumnizerMaskEntry entry, string fileName)
26+
{
27+
if (entry == null || string.IsNullOrEmpty(entry.Mask) || string.IsNullOrEmpty(fileName))
28+
{
29+
return false;
30+
}
31+
32+
var pattern = entry.Type == MaskType.Glob
33+
? GlobToRegex(entry.Mask)
34+
: entry.Mask;
35+
36+
try
37+
{
38+
return Regex.IsMatch(fileName, pattern, OPTIONS);
39+
}
40+
catch (ArgumentException)
41+
{
42+
// Malformed regex (user-supplied) — treat as non-match rather than throwing.
43+
return false;
44+
}
45+
catch (RegexMatchTimeoutException)
46+
{
47+
return false;
48+
}
49+
}
50+
51+
private static string GlobToRegex (string glob)
52+
{
53+
var sb = new StringBuilder(glob.Length + 4);
54+
_ = sb.Append('^');
55+
56+
foreach (var ch in glob)
57+
{
58+
_ = ch switch
59+
{
60+
'*' => sb.Append(".*"),
61+
'?' => sb.Append('.'),
62+
_ => sb.Append(Regex.Escape(ch.ToString())),
63+
};
64+
}
65+
66+
_ = sb.Append('$');
67+
return sb.ToString();
68+
}
69+
}

src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public static class ColumnizerPicker
1818
/// Cannot be null.</param>
1919
/// <param name="list">The list of available columnizers to search. Cannot be null.</param>
2020
/// <returns>The first columnizer from the list whose name matches the specified value; otherwise, null if no match is found.</returns>
21-
public static ILogLineMemoryColumnizer FindMemorColumnizerByName (string name, IList<ILogLineMemoryColumnizer> list)
21+
public static ILogLineMemoryColumnizer FindMemoryColumnizerByName (string name, IList<ILogLineMemoryColumnizer> list)
2222
{
2323
ArgumentNullException.ThrowIfNull(name, nameof(name));
2424
ArgumentNullException.ThrowIfNull(list, nameof(list));
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using ColumnizerLib;
2+
3+
using LogExpert.Core.Config;
4+
5+
namespace LogExpert.Core.Classes.Columnizer;
6+
7+
/// <summary>
8+
/// Pure precedence-chain resolver that picks a columnizer for a given file from up to four sources: per-file
9+
/// persistence, columnizer history, columnizer-mask list, and AutoPick.
10+
/// </summary>
11+
/// <remarks>
12+
/// The exact order of consultation is controlled by <see cref="ColumnizerSelectionPriority"/>. AutoPick fires only when
13+
/// every other source produced <see langword="null"/>; it never outranks an explicit Mask, History, or Persistence hit.
14+
/// <para> Stale Mask entries — those whose <see cref="ColumnizerMaskEntry.ColumnizerName"/> is not registered — are
15+
/// skipped (an optional callback is invoked once per skipped entry) and resolution continues with the next entry in the
16+
/// list. </para>
17+
/// </remarks>
18+
public static class ColumnizerResolver
19+
{
20+
/// <summary>
21+
/// Returns the winning columnizer for the inputs, or <see langword="null"/> if no source produced a match.
22+
/// </summary>
23+
public static ILogLineMemoryColumnizer? Resolve (ResolveInputs inputs)
24+
{
25+
ArgumentNullException.ThrowIfNull(inputs);
26+
27+
// Look up a columnizer by name with "no signal" semantics — returns null when the name is
28+
// missing OR not registered, so the precedence chain falls through to the next source.
29+
ILogLineMemoryColumnizer? byName (string? name) => string.IsNullOrEmpty(name) ? null : ColumnizerPicker.FindMemoryColumnizerByName(name, inputs.Registered);
30+
ILogLineMemoryColumnizer? mask () => TryGetMaskColumnizer(inputs.MaskList, inputs.ShortFileName, inputs.Registered, inputs.OnStaleMaskEntry);
31+
ILogLineMemoryColumnizer? history () => byName(inputs.HistoryLookup?.Invoke(inputs.FileName));
32+
ILogLineMemoryColumnizer? persistence () => byName(inputs.PersistenceColumnizerName);
33+
34+
var winner = inputs.Priority switch
35+
{
36+
ColumnizerSelectionPriority.MaskThenHistory => persistence() ?? mask() ?? history(),
37+
ColumnizerSelectionPriority.MaskOverridesPersistence => mask() ?? persistence() ?? history(),
38+
ColumnizerSelectionPriority.HistoryThenMask => persistence() ?? history() ?? mask(),
39+
_ => persistence() ?? history() ?? mask(),
40+
};
41+
42+
return winner ?? inputs.AutoPick?.Invoke();
43+
}
44+
45+
/// <summary>
46+
/// Iterates the mask list and returns the first non-stale match. Entries whose columnizer is not registered invoke
47+
/// <paramref name="onStale"/> and the iteration continues. Never throws.
48+
/// </summary>
49+
public static ILogLineMemoryColumnizer? TryGetMaskColumnizer (
50+
IReadOnlyList<ColumnizerMaskEntry> maskList,
51+
string shortFileName,
52+
IList<ILogLineMemoryColumnizer> registered,
53+
Action<ColumnizerMaskEntry>? onStale = null)
54+
{
55+
if (maskList == null || maskList.Count == 0 || string.IsNullOrEmpty(shortFileName))
56+
{
57+
return null;
58+
}
59+
60+
foreach (var entry in maskList)
61+
{
62+
if (!ColumnizerMaskMatcher.Matches(entry, shortFileName))
63+
{
64+
continue;
65+
}
66+
67+
68+
// FindMemoryColumnizerByName returns null when the name isn't registered, which is
69+
// exactly the "stale entry" signal we need.
70+
var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(entry.ColumnizerName, registered);
71+
if (columnizer != null)
72+
{
73+
return columnizer;
74+
}
75+
76+
onStale?.Invoke(entry);
77+
}
78+
79+
return null;
80+
}
81+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using ColumnizerLib;
2+
3+
using LogExpert.Core.Config;
4+
5+
namespace LogExpert.Core.Classes.Columnizer;
6+
7+
/// <summary>
8+
/// Inputs to <see cref="Resolve"/>. Designed so the module is exercisable from unit tests with no
9+
/// dependency on settings storage, plugin registry singletons, or UI.
10+
/// </summary>
11+
public sealed class ResolveInputs
12+
{
13+
public ColumnizerSelectionPriority Priority { get; init; }
14+
15+
/// <summary>The full path or identifier of the file being opened.</summary>
16+
public string FileName { get; init; } = string.Empty;
17+
18+
/// <summary>The short (filename-only) form of <see cref="FileName"/>, used for mask matching.</summary>
19+
public string ShortFileName { get; init; } = string.Empty;
20+
21+
public IReadOnlyList<ColumnizerMaskEntry> MaskList { get; init; } = [];
22+
23+
/// <summary>Lookup of a saved history columnizer name for <see cref="FileName"/>. May be <see langword="null"/>.</summary>
24+
public Func<string, string?>? HistoryLookup { get; init; }
25+
26+
/// <summary>Columnizer name supplied by the per-file persistence (<c>.lxp</c>). May be <see langword="null"/>.</summary>
27+
public string? PersistenceColumnizerName { get; init; }
28+
29+
/// <summary>AutoPick callback (e.g. content-based detection). May be <see langword="null"/>.</summary>
30+
public Func<ILogLineMemoryColumnizer?>? AutoPick { get; init; }
31+
32+
public IList<ILogLineMemoryColumnizer> Registered { get; init; } = [];
33+
34+
/// <summary>Invoked once for each Mask entry that matched but referenced a missing columnizer.</summary>
35+
public Action<ColumnizerMaskEntry>? OnStaleMaskEntry { get; init; }
36+
}

src/LogExpert.Core/Config/ColumnizerMaskEntry.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,10 @@ public class ColumnizerMaskEntry
66
public string ColumnizerName { get; set; }
77

88
public string Mask { get; set; }
9+
10+
/// <summary>
11+
/// How <see cref="Mask"/> is interpreted. Defaults to <see cref="MaskType.Glob"/> for new entries;
12+
/// the settings migrator rewrites pre-1.21 entries to <see cref="MaskType.Regex"/> to preserve behaviour.
13+
/// </summary>
14+
public MaskType Type { get; set; } = MaskType.Glob;
915
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using Newtonsoft.Json;
2+
using Newtonsoft.Json.Converters;
3+
4+
namespace LogExpert.Core.Config;
5+
6+
/// <summary>
7+
/// Controls the precedence order used by the columnizer resolver when multiple sources
8+
/// (per-file persistence, history, mask list) could supply a columnizer for a file.
9+
/// </summary>
10+
[JsonConverter(typeof(StringEnumConverter))]
11+
public enum ColumnizerSelectionPriority
12+
{
13+
/// <summary>Persistence → History → Mask → AutoPick (default — preserves legacy behaviour).</summary>
14+
HistoryThenMask = 0,
15+
16+
/// <summary>Persistence → Mask → History → AutoPick.</summary>
17+
MaskThenHistory = 1,
18+
19+
/// <summary>Mask → Persistence → History → AutoPick. A matching mask outranks the saved <c>.lxp</c> columnizer.</summary>
20+
MaskOverridesPersistence = 2,
21+
}

src/LogExpert.Core/Config/ControlCharSettings.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
using System.Collections.Generic;
21
using System.Drawing;
3-
using System.Linq;
42

53
using Newtonsoft.Json;
64

75
namespace LogExpert.Core.Config;
86

7+
[Serializable]
98
public sealed class ControlCharSettings
109
{
1110
public bool Substitute { get; set; }
@@ -31,9 +30,10 @@ public ControlCharStyle Style
3130

3231
internal static HashSet<int> BuildNonWhitespacePreset ()
3332
{
34-
return Enumerable.Range(0x00, 0x20)
35-
.Where(c => c is not 0x09 and not 0x0A and not 0x0D)
36-
.Append(0x7F)
37-
.ToHashSet();
33+
return
34+
[
35+
.. Enumerable.Range(0x00, 0x20).Where(c => c is not 0x09 and not 0x0A and not 0x0D),
36+
0x7F,
37+
];
3838
}
3939
}

src/LogExpert.Core/Config/ControlCharStyle.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
using Newtonsoft.Json;
2+
using Newtonsoft.Json.Converters;
3+
14
namespace LogExpert.Core.Config;
25

6+
[JsonConverter(typeof(StringEnumConverter))]
37
public enum ControlCharStyle
48
{
59
ControlPictures = 0,

0 commit comments

Comments
 (0)