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
74 changes: 56 additions & 18 deletions src/CsvColumnizer/CsvColumnizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -176,35 +176,59 @@ public void Selected (ILogLineMemoryColumnizerCallback callback)
{
ArgumentNullException.ThrowIfNull(callback, nameof(callback));

DetectDelimiterFromFile(callback);
BuildColumns(callback);
}

/// <summary>
/// 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.
/// </summary>
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();
var line = _config.HasFieldNames
? _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)));
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -360,6 +394,15 @@ private void AutoDetectDelimiter (ReadOnlyMemory<char> lineContent)
}
}

/// <summary>
/// Parses one line into its fields; avoids ReadHeader(), which throws when HasHeaderRecord is false.
/// </summary>
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)
Expand All @@ -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)
{
Expand Down
121 changes: 121 additions & 0 deletions src/LogExpert.Tests/ColumnizerTests/CSVColumnizerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ()
{
Expand All @@ -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<ILogLineMemoryColumnizerCallback>();
_ = 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<ILogLineMemoryColumnizerCallback>();
_ = 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<ILogLineMemoryColumnizerCallback>();
_ = 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<ILogLineMemoryColumnizerCallback>();
_ = 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<ILogLineMemoryColumnizerCallback>();
_ = 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<ILogLineMemoryColumnizerCallback>();

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 ()
{
Expand Down
Loading