diff --git a/src/CsvColumnizer/CsvColumnizer.cs b/src/CsvColumnizer/CsvColumnizer.cs
index e780fd2c..b5e0c8c8 100644
--- a/src/CsvColumnizer/CsvColumnizer.cs
+++ b/src/CsvColumnizer/CsvColumnizer.cs
@@ -176,6 +176,25 @@ public void Selected (ILogLineMemoryColumnizerCallback callback)
{
ArgumentNullException.ThrowIfNull(callback, nameof(callback));
+ DetectDelimiterFromFile(callback);
+ BuildColumns(callback);
+ }
+
+ ///
+ /// This instance may not have pre-processed the current file (e.g. a clone that loaded the saved config),
+ /// so re-detect the delimiter from the file's first line, like PreProcessLine() does on load.
+ ///
+ private void DetectDelimiterFromFile (ILogLineMemoryColumnizerCallback callback)
+ {
+ var line = _firstLine ?? callback.GetLogLineMemory(0);
+ if (line != null)
+ {
+ AutoDetectDelimiter(line.FullLine);
+ }
+ }
+
+ private void BuildColumns (ILogLineMemoryColumnizerCallback callback)
+ {
if (_isValidCsv) // see PreProcessLine()
{
_columnList.Clear();
@@ -183,28 +202,33 @@ public void Selected (ILogLineMemoryColumnizerCallback callback)
? _firstLine ?? callback.GetLogLineMemory(0)
: callback.GetLogLineMemory(0);
+ string[]? fields = null;
if (line != null)
{
- using CsvReader csv = new(new StringReader(line.FullLine.ToString()), _config.ReaderConfiguration);
- _ = csv.Read();
- _ = csv.ReadHeader();
-
- var fieldCount = csv.Parser.Count;
-
- var headerRecord = csv.HeaderRecord;
+ try
+ {
+ fields = ReadFields(line.FullLine.ToString());
+ }
+ catch (CsvHelperException)
+ {
+ // first line doesn't parse with the current settings (e.g. wrong delimiter); same fallback as SplitCsvLine
+ }
+ }
- if (_config.HasFieldNames && headerRecord != null)
+ if (fields != null)
+ {
+ if (_config.HasFieldNames)
{
- foreach (var headerColumn in headerRecord)
+ foreach (var headerColumn in fields)
{
_columnList.Add(new CsvColumn(headerColumn));
}
}
else
{
- for (var i = 0; i < fieldCount; ++i)
+ for (var i = 0; i < fields.Length; ++i)
{
- _columnList.Add(new CsvColumn("Column " + i + 1));
+ _columnList.Add(new CsvColumn(string.Format(CultureInfo.InvariantCulture, "Column {0}", i + 1)));
}
}
}
@@ -254,6 +278,12 @@ public void Configure (ILogLineMemoryColumnizerCallback callback, string configD
var configPath = configDir + "\\" + CONFIGFILENAME;
FileInfo fileInfo = new(configPath);
+ // show the file's delimiter rather than the one from the saved config
+ if (callback != null)
+ {
+ DetectDelimiterFromFile(callback);
+ }
+
CsvColumnizerConfigDlg dlg = new(_config);
if (dlg.ShowDialog() == DialogResult.OK)
@@ -268,7 +298,11 @@ public void Configure (ILogLineMemoryColumnizerCallback callback, string configD
_config.ConfigureReaderConfiguration();
- Selected(callback);
+ // no re-detection here: keep the delimiter the user chose in the dialog
+ if (callback != null)
+ {
+ BuildColumns(callback);
+ }
}
}
@@ -360,6 +394,15 @@ private void AutoDetectDelimiter (ReadOnlyMemory lineContent)
}
}
+ ///
+ /// Parses one line into its fields; avoids ReadHeader(), which throws when HasHeaderRecord is false.
+ ///
+ private string[]? ReadFields (string line)
+ {
+ using CsvReader csv = new(new StringReader(line), _config.ReaderConfiguration);
+ return csv.Read() ? csv.Parser.Record : null;
+ }
+
private ColumnizedLogLine SplitCsvLine (ILogLineMemory line)
{
if (line.FullLine.IsEmpty)
@@ -374,12 +417,7 @@ private ColumnizedLogLine SplitCsvLine (ILogLineMemory line)
try
{
- using CsvReader csv = new(new StringReader(line.FullLine.ToString()), _config.ReaderConfiguration);
- _ = csv.Read();
- _ = csv.ReadHeader();
-
- //we only read line by line and not the whole file so it is always the header
- var records = csv.HeaderRecord;
+ var records = ReadFields(line.FullLine.ToString());
if (records != null)
{
diff --git a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs
index ed7a404d..0e03e196 100644
--- a/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs
+++ b/src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs
@@ -62,6 +62,37 @@ private static void SetPrivateField (CsvColumnizer.CsvColumnizer columnizer, str
field!.SetValue(columnizer, value);
}
+ private const string HEADERLESS_LINE = "2026-09-01 07:09:58.1980; INFO; 2001:7c0:3006:3014::8602:1a1d; ; ; ; zrnsh01 logged in";
+
+ // Valid-CSV mode with "first line contains field names" unchecked
+ private static CsvColumnizer.CsvColumnizer CreateColumnizerWithoutFieldNames ()
+ {
+ CsvColumnizerConfig config = new();
+ config.InitDefaults();
+ config.HasFieldNames = false;
+ config.ConfigureReaderConfiguration();
+
+ CsvColumnizer.CsvColumnizer columnizer = new();
+ SetPrivateField(columnizer, "_config", config);
+ SetPrivateField(columnizer, "_isValidCsv", true);
+ return columnizer;
+ }
+
+ // Valid-CSV mode with ',' as configured delimiter, as if loaded from a saved csvcolumnizer.json
+ private static CsvColumnizer.CsvColumnizer CreateColumnizerWithSavedCommaDelimiter (bool hasFieldNames)
+ {
+ CsvColumnizerConfig config = new();
+ config.InitDefaults();
+ config.DelimiterChar = ",";
+ config.HasFieldNames = hasFieldNames;
+ config.ConfigureReaderConfiguration();
+
+ CsvColumnizer.CsvColumnizer columnizer = new();
+ SetPrivateField(columnizer, "_config", config);
+ SetPrivateField(columnizer, "_isValidCsv", true);
+ return columnizer;
+ }
+
[Test]
public void Selected_HasFieldNames_FirstLineNull_FallsBackToCallback ()
{
@@ -81,6 +112,96 @@ public void Selected_HasFieldNames_FirstLineNull_FallsBackToCallback ()
Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["name", "age", "city"]));
}
+ [Test]
+ public void Selected_NoFieldNames_ColumnCountFromFirstLine ()
+ {
+ var columnizer = CreateColumnizerWithoutFieldNames();
+ var mockCallback = new Mock();
+ _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine(HEADERLESS_LINE, 0));
+
+ columnizer.Selected(mockCallback.Object);
+
+ Assert.That(columnizer.GetColumnCount(), Is.EqualTo(7));
+ }
+
+ [Test]
+ public void Selected_NoFieldNames_GeneratesOneBasedColumnNames ()
+ {
+ var columnizer = CreateColumnizerWithoutFieldNames();
+ var mockCallback = new Mock();
+ _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine(HEADERLESS_LINE, 0));
+
+ columnizer.Selected(mockCallback.Object);
+
+ Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Column 1", "Column 2", "Column 3", "Column 4", "Column 5", "Column 6", "Column 7"]));
+ }
+
+ [Test]
+ public void SplitLine_NoFieldNames_SplitsIntoFields ()
+ {
+ var columnizer = CreateColumnizerWithoutFieldNames();
+ var line = new CsvLogLine(HEADERLESS_LINE, 0);
+ var mockCallback = new Mock();
+ _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(line);
+ columnizer.Selected(mockCallback.Object);
+
+ var result = columnizer.SplitLine(mockCallback.Object, line);
+
+ Assert.That(result.ColumnValues.Select(c => c.FullValue.ToString()), Is.EqualTo(["2026-09-01 07:09:58.1980", " INFO", " 2001:7c0:3006:3014::8602:1a1d", " ", " ", " ", " zrnsh01 logged in"]));
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void Selected_FirstLineIsBadData_FallsBackToTextColumn (bool hasFieldNames)
+ {
+ var columnizer = CreateColumnizerWithSavedCommaDelimiter(hasFieldNames);
+ var mockCallback = new Mock();
+ _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(new CsvLogLine("\"2021-12-12\"x;\"TRACE\"y", 0));
+
+ columnizer.Selected(mockCallback.Object);
+
+ Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Text"]));
+ }
+
+ [Test]
+ public void Selected_NoFieldNames_SavedDelimiterDiffersFromFile_DetectsFileDelimiter ()
+ {
+ // Instance that never pre-processed this file, e.g. a clone that loaded ',' from csvcolumnizer.json
+ var columnizer = CreateColumnizerWithSavedCommaDelimiter(false);
+ var line = new CsvLogLine("\"2021-12-12\";\"TRACE\";\"semicolon file \"", 0);
+ var mockCallback = new Mock();
+ _ = mockCallback.Setup(c => c.GetLogLineMemory(0)).Returns(line);
+
+ columnizer.Selected(mockCallback.Object);
+ var result = columnizer.SplitLine(mockCallback.Object, line);
+
+ Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Column 1", "Column 2", "Column 3"]));
+ Assert.That(result.ColumnValues.Select(c => c.FullValue.ToString()), Is.EqualTo(["2021-12-12", "TRACE", "semicolon file "]));
+ }
+
+ [Test]
+ public void Selected_HasFieldNames_SavedDelimiterDiffersFromFile_DetectsFileDelimiter ()
+ {
+ var columnizer = CreateColumnizerWithSavedCommaDelimiter(true);
+ SetPrivateField(columnizer, "_firstLine", new CsvLogLine("\"Date\";\"Level\";\"Message\"", 0));
+ var mockCallback = new Mock();
+
+ columnizer.Selected(mockCallback.Object);
+
+ Assert.That(columnizer.GetColumnNames(), Is.EqualTo(["Date", "Level", "Message"]));
+ }
+
+ [Test]
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", Justification = "Unit Tests")]
+ public void PreProcessLine_NoFieldNames_KeepsFirstLineVisible ()
+ {
+ var columnizer = CreateColumnizerWithoutFieldNames();
+
+ var result = columnizer.PreProcessLine(HEADERLESS_LINE.AsMemory(), 0, 0);
+
+ Assert.That(result.ToString(), Is.EqualTo(HEADERLESS_LINE));
+ }
+
[Test]
public void Selected_HasFieldNames_NoLineAvailable_FallsBackToTextColumn ()
{