Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/LogExpert.Configuration/ConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,6 @@ public void RemoveFromFileHistory (string fileName)
Save(SettingsFlags.FileHistory);
}


public void ClearLastOpenFilesList ()
{
lock (_loadSaveLock)
Expand Down Expand Up @@ -596,6 +595,9 @@ UnauthorizedAccessException or

private static Settings InitializeSettings (Settings settings)
{
// Apply any pending schema migrations before any consumer reads the settings.
_ = LegacyPreferencesMigrator.Migrate(settings);

settings.Preferences ??= new Preferences();
settings.Preferences.ToolEntries ??= [];
settings.Preferences.ColumnizerMaskList ??= [];
Expand Down
58 changes: 58 additions & 0 deletions src/LogExpert.Configuration/LegacyPreferencesMigrator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using LogExpert.Core.Config;

namespace LogExpert.Configuration;

/// <summary>
/// Applies one-shot, in-memory migrations to a freshly deserialised <see cref="Settings"/> object so that
/// older settings files behave equivalently under newer schema versions. Idempotent: calling it on already
/// up-to-date settings is a no-op.
/// </summary>
public static class LegacyPreferencesMigrator
{
/// <summary>Current schema version. Bumped whenever a new migration step is added.</summary>
public const int CURRENT_SETTINGS_VERSION = 1;

/// <summary>
/// Migrates the given <see cref="Settings"/> in place. Returns <see langword="true"/> if any
/// migration step was applied (the caller may use this signal to persist the upgraded settings).
/// </summary>
public static bool Migrate (Settings settings)
{
ArgumentNullException.ThrowIfNull(settings);

var changed = false;

if (settings.SettingsVersion < 1)
{
MigrateToV1(settings);
settings.SettingsVersion = 1;
changed = true;
}

return changed;
}

private static void MigrateToV1 (Settings settings)
{
// Existing ColumnizerMaskEntry rows pre-date the per-row Type field — they were regex-only.
// Their default-loaded value would be Glob, which would silently change behaviour. Rewrite to Regex.
if (settings.Preferences?.ColumnizerMaskList != null)
{
foreach (var entry in settings.Preferences.ColumnizerMaskList.Where(entry => entry != null))
{
entry.Type = MaskType.Regex;
}
}

// Preserve the deprecated MaskPrio bool's intent on the new enum, but only if the enum is still
// at its default — otherwise the user has already chosen.
#pragma warning disable CS0618 // Migrating away from MaskPrio
if (settings.Preferences != null
&& settings.Preferences.ColumnizerSelectionPriority == ColumnizerSelectionPriority.HistoryThenMask
&& settings.Preferences.MaskPrio)
{
settings.Preferences.ColumnizerSelectionPriority = ColumnizerSelectionPriority.MaskThenHistory;
}
#pragma warning restore CS0618
}
}
69 changes: 69 additions & 0 deletions src/LogExpert.Core/Classes/Columnizer/ColumnizerMaskMatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Text;
using System.Text.RegularExpressions;

using LogExpert.Core.Config;

namespace LogExpert.Core.Classes.Columnizer;

/// <summary>
/// Pure mask-matching for <see cref="ColumnizerMaskEntry"/>. Supports glob (<c>*</c>, <c>?</c>) and
/// .NET regular expression patterns. Match is case-insensitive (file names on Windows are case-insensitive).
/// </summary>
/// <remarks>
/// The matcher never throws — malformed input returns <see langword="false"/>. Glob translation rules:
/// <list type="bullet">
/// <item><c>*</c> → <c>.*</c></item>
/// <item><c>?</c> → <c>.</c> (single character)</item>
/// <item>Every other character is regex-escaped</item>
/// <item>Result is anchored with <c>^…$</c></item>
/// </list>
/// </remarks>
public static class ColumnizerMaskMatcher
{
private const RegexOptions OPTIONS = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;

public static bool Matches (ColumnizerMaskEntry entry, string fileName)
{
if (entry == null || string.IsNullOrEmpty(entry.Mask) || string.IsNullOrEmpty(fileName))
{
return false;
}

var pattern = entry.Type == MaskType.Glob
? GlobToRegex(entry.Mask)
: entry.Mask;

try
{
return Regex.IsMatch(fileName, pattern, OPTIONS);
}
catch (ArgumentException)
{
// Malformed regex (user-supplied) — treat as non-match rather than throwing.
return false;
}
catch (RegexMatchTimeoutException)
{
return false;
}
}

private static string GlobToRegex (string glob)
{
var sb = new StringBuilder(glob.Length + 4);
_ = sb.Append('^');

foreach (var ch in glob)
{
_ = ch switch
{
'*' => sb.Append(".*"),
'?' => sb.Append('.'),
_ => sb.Append(Regex.Escape(ch.ToString())),
};
}

_ = sb.Append('$');
return sb.ToString();
}
}
2 changes: 1 addition & 1 deletion src/LogExpert.Core/Classes/Columnizer/ColumnizerPicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static class ColumnizerPicker
/// Cannot be null.</param>
/// <param name="list">The list of available columnizers to search. Cannot be null.</param>
/// <returns>The first columnizer from the list whose name matches the specified value; otherwise, null if no match is found.</returns>
public static ILogLineMemoryColumnizer FindMemorColumnizerByName (string name, IList<ILogLineMemoryColumnizer> list)
public static ILogLineMemoryColumnizer FindMemoryColumnizerByName (string name, IList<ILogLineMemoryColumnizer> list)
{
ArgumentNullException.ThrowIfNull(name, nameof(name));
ArgumentNullException.ThrowIfNull(list, nameof(list));
Expand Down
81 changes: 81 additions & 0 deletions src/LogExpert.Core/Classes/Columnizer/ColumnizerResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using ColumnizerLib;

using LogExpert.Core.Config;

namespace LogExpert.Core.Classes.Columnizer;

/// <summary>
/// Pure precedence-chain resolver that picks a columnizer for a given file from up to four sources: per-file
/// persistence, columnizer history, columnizer-mask list, and AutoPick.
/// </summary>
/// <remarks>
/// The exact order of consultation is controlled by <see cref="ColumnizerSelectionPriority"/>. AutoPick fires only when
/// every other source produced <see langword="null"/>; it never outranks an explicit Mask, History, or Persistence hit.
/// <para> Stale Mask entries — those whose <see cref="ColumnizerMaskEntry.ColumnizerName"/> is not registered — are
/// skipped (an optional callback is invoked once per skipped entry) and resolution continues with the next entry in the
/// list. </para>
/// </remarks>
public static class ColumnizerResolver
{
/// <summary>
/// Returns the winning columnizer for the inputs, or <see langword="null"/> if no source produced a match.
/// </summary>
public static ILogLineMemoryColumnizer? Resolve (ResolveInputs inputs)
{
ArgumentNullException.ThrowIfNull(inputs);

// Look up a columnizer by name with "no signal" semantics — returns null when the name is
// missing OR not registered, so the precedence chain falls through to the next source.
ILogLineMemoryColumnizer? byName (string? name) => string.IsNullOrEmpty(name) ? null : ColumnizerPicker.FindMemoryColumnizerByName(name, inputs.Registered);
ILogLineMemoryColumnizer? mask () => TryGetMaskColumnizer(inputs.MaskList, inputs.ShortFileName, inputs.Registered, inputs.OnStaleMaskEntry);
ILogLineMemoryColumnizer? history () => byName(inputs.HistoryLookup?.Invoke(inputs.FileName));
ILogLineMemoryColumnizer? persistence () => byName(inputs.PersistenceColumnizerName);

var winner = inputs.Priority switch
{
ColumnizerSelectionPriority.MaskThenHistory => persistence() ?? mask() ?? history(),
ColumnizerSelectionPriority.MaskOverridesPersistence => mask() ?? persistence() ?? history(),
ColumnizerSelectionPriority.HistoryThenMask => persistence() ?? history() ?? mask(),
_ => persistence() ?? history() ?? mask(),
};

return winner ?? inputs.AutoPick?.Invoke();
}

/// <summary>
/// Iterates the mask list and returns the first non-stale match. Entries whose columnizer is not registered invoke
/// <paramref name="onStale"/> and the iteration continues. Never throws.
/// </summary>
public static ILogLineMemoryColumnizer? TryGetMaskColumnizer (
IReadOnlyList<ColumnizerMaskEntry> maskList,
string shortFileName,
IList<ILogLineMemoryColumnizer> registered,
Action<ColumnizerMaskEntry>? onStale = null)
{
if (maskList == null || maskList.Count == 0 || string.IsNullOrEmpty(shortFileName))
{
return null;
}

foreach (var entry in maskList)
{
if (!ColumnizerMaskMatcher.Matches(entry, shortFileName))
{
continue;
}


// FindMemoryColumnizerByName returns null when the name isn't registered, which is
// exactly the "stale entry" signal we need.
var columnizer = ColumnizerPicker.FindMemoryColumnizerByName(entry.ColumnizerName, registered);
if (columnizer != null)
{
return columnizer;
}

onStale?.Invoke(entry);
}

return null;
}
}
36 changes: 36 additions & 0 deletions src/LogExpert.Core/Classes/Columnizer/ResolveInputs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using ColumnizerLib;

using LogExpert.Core.Config;

namespace LogExpert.Core.Classes.Columnizer;

/// <summary>
/// Inputs to <see cref="Resolve"/>. Designed so the module is exercisable from unit tests with no
/// dependency on settings storage, plugin registry singletons, or UI.
/// </summary>
public sealed class ResolveInputs
{
public ColumnizerSelectionPriority Priority { get; init; }

/// <summary>The full path or identifier of the file being opened.</summary>
public string FileName { get; init; } = string.Empty;

/// <summary>The short (filename-only) form of <see cref="FileName"/>, used for mask matching.</summary>
public string ShortFileName { get; init; } = string.Empty;

public IReadOnlyList<ColumnizerMaskEntry> MaskList { get; init; } = [];

/// <summary>Lookup of a saved history columnizer name for <see cref="FileName"/>. May be <see langword="null"/>.</summary>
public Func<string, string?>? HistoryLookup { get; init; }

/// <summary>Columnizer name supplied by the per-file persistence (<c>.lxp</c>). May be <see langword="null"/>.</summary>
public string? PersistenceColumnizerName { get; init; }

/// <summary>AutoPick callback (e.g. content-based detection). May be <see langword="null"/>.</summary>
public Func<ILogLineMemoryColumnizer?>? AutoPick { get; init; }

public IList<ILogLineMemoryColumnizer> Registered { get; init; } = [];

/// <summary>Invoked once for each Mask entry that matched but referenced a missing columnizer.</summary>
public Action<ColumnizerMaskEntry>? OnStaleMaskEntry { get; init; }
}
6 changes: 6 additions & 0 deletions src/LogExpert.Core/Config/ColumnizerMaskEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ public class ColumnizerMaskEntry
public string ColumnizerName { get; set; }

public string Mask { get; set; }

/// <summary>
/// How <see cref="Mask"/> is interpreted. Defaults to <see cref="MaskType.Glob"/> for new entries;
/// the settings migrator rewrites pre-1.21 entries to <see cref="MaskType.Regex"/> to preserve behaviour.
/// </summary>
public MaskType Type { get; set; } = MaskType.Glob;
}
21 changes: 21 additions & 0 deletions src/LogExpert.Core/Config/ColumnizerSelectionPriority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace LogExpert.Core.Config;

/// <summary>
/// Controls the precedence order used by the columnizer resolver when multiple sources
/// (per-file persistence, history, mask list) could supply a columnizer for a file.
/// </summary>
[JsonConverter(typeof(StringEnumConverter))]
public enum ColumnizerSelectionPriority
{
/// <summary>Persistence → History → Mask → AutoPick (default — preserves legacy behaviour).</summary>
HistoryThenMask = 0,

/// <summary>Persistence → Mask → History → AutoPick.</summary>
MaskThenHistory = 1,

/// <summary>Mask → Persistence → History → AutoPick. A matching mask outranks the saved <c>.lxp</c> columnizer.</summary>
MaskOverridesPersistence = 2,
}
12 changes: 6 additions & 6 deletions src/LogExpert.Core/Config/ControlCharSettings.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
using System.Collections.Generic;
using System.Drawing;
using System.Linq;

using Newtonsoft.Json;

namespace LogExpert.Core.Config;

[Serializable]
public sealed class ControlCharSettings
{
public bool Substitute { get; set; }
Expand All @@ -31,9 +30,10 @@ public ControlCharStyle Style

internal static HashSet<int> BuildNonWhitespacePreset ()
{
return Enumerable.Range(0x00, 0x20)
.Where(c => c is not 0x09 and not 0x0A and not 0x0D)
.Append(0x7F)
.ToHashSet();
return
[
.. Enumerable.Range(0x00, 0x20).Where(c => c is not 0x09 and not 0x0A and not 0x0D),
0x7F,
];
}
}
4 changes: 4 additions & 0 deletions src/LogExpert.Core/Config/ControlCharStyle.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace LogExpert.Core.Config;

[JsonConverter(typeof(StringEnumConverter))]
public enum ControlCharStyle
{
ControlPictures = 0,
Expand Down
18 changes: 18 additions & 0 deletions src/LogExpert.Core/Config/MaskType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace LogExpert.Core.Config;

/// <summary>
/// Determines how a <see cref="ColumnizerMaskEntry.Mask"/> string is interpreted when matching file names.
/// </summary>
/// <remarks>Serialized by name (e.g. "Glob"/"Regex"); the converter still reads legacy integer values.</remarks>
[JsonConverter(typeof(StringEnumConverter))]
public enum MaskType
{
/// <summary>Glob pattern using <c>*</c> and <c>?</c> wildcards (default for new entries).</summary>
Glob = 0,

/// <summary>.NET regular expression syntax (legacy default; preserved by migration for pre-existing entries).</summary>
Regex = 1,
}
10 changes: 6 additions & 4 deletions src/LogExpert.Core/Config/MultiFileOption.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

namespace LogExpert.Core.Config;

[Serializable]
[JsonConverter(typeof(StringEnumConverter))]
public enum MultiFileOption
{
SingleFiles,
MultiFile,
Ask
SingleFiles = 0,
MultiFile = 1,
Ask = 2
}
Loading