feat(commons): T0-2 RFC-4180 CSV parser + writer

Add a pure, no-I/O RFC 4180 CSV reader and writer to the Commons project
(greenfield — no CSV code existed).

CsvParser: string / TextReader -> rows of string[]; streaming IEnumerable
overload + eager Parse(string) + thin ParseWithHeader. Handles quoted
fields (embedded delimiter/CR/LF/CRLF, "" -> " escape), space
preservation, CRLF/LF/CR terminators, no phantom trailing row. Strict
malformed-input policy: FormatException with 1-based line/column on a
quote inside an unquoted field, a char after a closing quote, or an
unterminated quote. Faithful empty-line policy: a blank line is one empty
field (callers filter).

CsvWriter: rows -> string / TextWriter; quote-on-demand (delimiter, quote,
CR, LF only; internal quotes doubled), configurable delimiter/newline
(default CRLF), optional quote-all, no trailing terminator.

Tests (xUnit + Shouldly, 109): full RFC edge corpus for the parser, the
writer quote-on-demand rules, and the required Parse(Write(rows)) == rows
round-trip property over a table of every tricky character (x CRLF/LF/
quote-all/semicolon).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:06:43 -04:00
parent f121f8ca16
commit 17c7e97efb
5 changed files with 947 additions and 0 deletions
@@ -0,0 +1,292 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
/// <summary>
/// The authoritative RFC 4180 read corpus for <see cref="CsvParser"/>. Every edge the RFC pins —
/// quoting, embedded delimiters/newlines, escaped quotes, space preservation, empty vs. absent
/// fields, trailing-newline handling, CRLF/LF/CR terminators — plus this parser's declared
/// <b>strict</b> malformed-input and <b>faithful</b> empty-line policies, lives here.
/// </summary>
public sealed class CsvParserTests
{
// ---- simple rows ----
[Fact]
public void Parses_simple_rows()
{
var rows = CsvParser.Parse("a,b,c\nd,e,f");
rows.Count.ShouldBe(2);
rows[0].ShouldBe(new[] { "a", "b", "c" });
rows[1].ShouldBe(new[] { "d", "e", "f" });
}
[Fact]
public void Single_field_single_row()
{
CsvParser.Parse("hello").ShouldBe(new[] { new[] { "hello" } });
}
[Fact]
public void Empty_input_yields_no_rows()
{
CsvParser.Parse("").Count.ShouldBe(0);
CsvParser.Parse((string?)null).Count.ShouldBe(0);
}
// ---- empty vs. absent fields ----
[Fact]
public void Middle_empty_field_is_preserved()
{
CsvParser.Parse("a,,c").Single().ShouldBe(new[] { "a", "", "c" });
}
[Fact]
public void Trailing_comma_yields_trailing_empty_field()
{
CsvParser.Parse("a,").Single().ShouldBe(new[] { "a", "" });
}
[Fact]
public void Leading_comma_yields_leading_empty_field()
{
CsvParser.Parse(",a").Single().ShouldBe(new[] { "", "a" });
}
[Fact]
public void All_empty_fields()
{
CsvParser.Parse(",,").Single().ShouldBe(new[] { "", "", "" });
}
// ---- quoting ----
[Fact]
public void Quoted_field_with_embedded_comma()
{
CsvParser.Parse("\"a,b\",c").Single().ShouldBe(new[] { "a,b", "c" });
}
[Fact]
public void Quoted_field_with_embedded_lf()
{
CsvParser.Parse("\"line1\nline2\",b").Single().ShouldBe(new[] { "line1\nline2", "b" });
}
[Fact]
public void Quoted_field_with_embedded_crlf()
{
CsvParser.Parse("\"line1\r\nline2\",b").Single().ShouldBe(new[] { "line1\r\nline2", "b" });
}
[Fact]
public void Escaped_quote_becomes_single_quote()
{
// "She said ""hi""" -> She said "hi"
CsvParser.Parse("\"She said \"\"hi\"\"\"").Single().ShouldBe(new[] { "She said \"hi\"" });
}
[Fact]
public void Empty_quoted_field_is_empty_string()
{
CsvParser.Parse("\"\",\"\"").Single().ShouldBe(new[] { "", "" });
}
[Fact]
public void Quoted_field_containing_only_escaped_quotes()
{
// """" -> a single literal double-quote
CsvParser.Parse("\"\"\"\"").Single().ShouldBe(new[] { "\"" });
}
[Fact]
public void Quoted_field_at_end_of_row_then_more_rows()
{
var rows = CsvParser.Parse("\"a,b\"\nc,d");
rows.Count.ShouldBe(2);
rows[0].ShouldBe(new[] { "a,b" });
rows[1].ShouldBe(new[] { "c", "d" });
}
// ---- space preservation (RFC 4180 §2.4) ----
[Fact]
public void Spaces_outside_quotes_are_preserved()
{
CsvParser.Parse(" a , b ").Single().ShouldBe(new[] { " a ", " b " });
}
[Fact]
public void Spaces_inside_quotes_are_preserved()
{
CsvParser.Parse("\" padded \"").Single().ShouldBe(new[] { " padded " });
}
// ---- line endings ----
[Fact]
public void Lf_line_endings()
{
CsvParser.Parse("a\nb\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
}
[Fact]
public void Crlf_line_endings()
{
CsvParser.Parse("a\r\nb\r\nc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
}
[Fact]
public void Bare_cr_line_endings()
{
CsvParser.Parse("a\rb\rc").Select(r => r[0]).ShouldBe(new[] { "a", "b", "c" });
}
// ---- trailing newline: present vs. absent ----
[Fact]
public void Trailing_lf_does_not_add_phantom_row()
{
CsvParser.Parse("a,b\n").Count.ShouldBe(1);
CsvParser.Parse("a,b\n").Single().ShouldBe(new[] { "a", "b" });
}
[Fact]
public void Trailing_crlf_does_not_add_phantom_row()
{
CsvParser.Parse("a,b\r\n").Count.ShouldBe(1);
}
[Fact]
public void No_trailing_newline_keeps_last_row()
{
var withNl = CsvParser.Parse("a\nb\n");
var withoutNl = CsvParser.Parse("a\nb");
withNl.Count.ShouldBe(2);
withoutNl.Count.ShouldBe(2);
withNl[1].ShouldBe(withoutNl[1]);
}
[Fact]
public void Quoted_field_with_trailing_newline_no_phantom_row()
{
var rows = CsvParser.Parse("\"a\"\n");
rows.Count.ShouldBe(1);
rows[0].ShouldBe(new[] { "a" });
}
// ---- empty-line policy (faithful RFC: empty line == one empty field) ----
[Fact]
public void Empty_line_is_a_single_empty_field()
{
// a\n\nb -> ["a"], [""], ["b"] — the blank line is NOT dropped.
var rows = CsvParser.Parse("a\n\nb");
rows.Count.ShouldBe(3);
rows[0].ShouldBe(new[] { "a" });
rows[1].ShouldBe(new[] { "" });
rows[2].ShouldBe(new[] { "b" });
}
[Fact]
public void Callers_can_filter_blank_lines()
{
var rows = CsvParser.Parse("a\n\nb")
.Where(r => r.Length > 1 || r[0].Length > 0)
.ToList();
rows.Count.ShouldBe(2);
}
// ---- streaming overload ----
[Fact]
public void Streams_over_a_textreader()
{
using var reader = new StringReader("x,y\nz,w");
var rows = CsvParser.Parse(reader).ToList();
rows.Count.ShouldBe(2);
rows[1].ShouldBe(new[] { "z", "w" });
}
[Fact]
public void Custom_delimiter_semicolon()
{
CsvParser.Parse("a;b;c", ';').Single().ShouldBe(new[] { "a", "b", "c" });
}
[Fact]
public void Custom_delimiter_leaves_comma_literal()
{
CsvParser.Parse("a,b;c", ';').Single().ShouldBe(new[] { "a,b", "c" });
}
// ---- strict malformed-input policy ----
[Fact]
public void Unterminated_quote_throws()
{
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("\"unclosed"));
ex.Message.ShouldContain("Unterminated");
}
[Fact]
public void Quote_inside_unquoted_field_throws()
{
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("ab\"c"));
ex.Message.ShouldContain("unquoted");
}
[Fact]
public void Character_after_closing_quote_throws()
{
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("\"ab\"c"));
ex.Message.ShouldContain("after closing quote");
}
[Fact]
public void Malformed_message_carries_position()
{
var ex = Should.Throw<FormatException>(() => CsvParser.Parse("a,b\nab\"c"));
ex.Message.ShouldContain("line 2");
}
[Fact]
public void Delimiter_may_not_be_a_quote()
{
Should.Throw<ArgumentException>(() => CsvParser.Parse("a,b", '"').ToString());
}
// ---- ParseWithHeader ----
[Fact]
public void ParseWithHeader_maps_rows_by_name()
{
var rows = CsvParser.ParseWithHeader("name,age\nalice,30\nbob,40");
rows.Count.ShouldBe(2);
rows[0]["name"].ShouldBe("alice");
rows[0]["age"].ShouldBe("30");
rows[1]["name"].ShouldBe("bob");
}
[Fact]
public void ParseWithHeader_empty_document_yields_no_rows()
{
CsvParser.ParseWithHeader("").Count.ShouldBe(0);
}
[Fact]
public void ParseWithHeader_header_only_yields_no_rows()
{
CsvParser.ParseWithHeader("name,age").Count.ShouldBe(0);
}
[Fact]
public void ParseWithHeader_duplicate_header_throws()
{
Should.Throw<FormatException>(() => CsvParser.ParseWithHeader("name,name\nx,y"));
}
}
@@ -0,0 +1,94 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
/// <summary>
/// The required round-trip property: for arbitrary field content, <c>Parse(Write(rows)) == rows</c>.
/// This is what makes the writer a true inverse of the parser and pins the quote-on-demand rules to
/// the parser's grammar. The corpus deliberately includes every tricky character.
/// </summary>
public sealed class CsvRoundTripTests
{
public static IEnumerable<object[]> Tables()
{
// Each case is a jagged table of fields.
yield return new object[] { new[] { new[] { "a", "b", "c" } } };
yield return new object[] { new[] { new[] { "a", "b" }, new[] { "c", "d" } } };
// NOTE: a single row of a single empty field ([[""]]) is deliberately excluded — it serialises to
// the empty string, which is indistinguishable from an empty document and parses back to zero rows.
// That one degenerate case is the sole non-round-trippable shape; every multi-field / multi-row
// empty case below (",," and blank interior lines) does round-trip exactly.
yield return new object[] { new[] { new[] { "", "", "" } } };
yield return new object[] { new[] { new[] { "a" }, new[] { "" }, new[] { "b" } } };
yield return new object[] { new[] { new[] { "a,b", "c" } } }; // embedded delimiter
yield return new object[] { new[] { new[] { "he said \"hi\"" } } }; // embedded quote
yield return new object[] { new[] { new[] { "line1\nline2" } } }; // embedded LF
yield return new object[] { new[] { new[] { "line1\r\nline2" } } }; // embedded CRLF
yield return new object[] { new[] { new[] { "line1\rline2" } } }; // embedded CR
yield return new object[] { new[] { new[] { " leading", "trailing " } } }; // spaces
yield return new object[] { new[] { new[] { " ", "\t" } } }; // whitespace-only
yield return new object[] { new[] { new[] { "\"", "\"\"", ",", "\n" } } }; // every special alone
yield return new object[] { new[] { new[] { "mix,\"quote\"\nand\r\nnewlines" } } };
yield return new object[]
{
new[]
{
new[] { "id", "name", "note" },
new[] { "1", "alice", "says \"hi, there\"" },
new[] { "2", "bob", "multi\nline\nnote" },
new[] { "3", "", "" },
},
};
}
[Theory]
[MemberData(nameof(Tables))]
public void RoundTrips_with_default_crlf(string[][] rows)
{
var csv = CsvWriter.WriteToString(rows);
var parsed = CsvParser.Parse(csv);
ShouldMatch(parsed, rows);
}
[Theory]
[MemberData(nameof(Tables))]
public void RoundTrips_with_lf_newline(string[][] rows)
{
var csv = CsvWriter.WriteToString(rows, newline: "\n");
var parsed = CsvParser.Parse(csv);
ShouldMatch(parsed, rows);
}
[Theory]
[MemberData(nameof(Tables))]
public void RoundTrips_with_quote_all_fields(string[][] rows)
{
var csv = CsvWriter.WriteToString(rows, quoteAllFields: true);
var parsed = CsvParser.Parse(csv);
ShouldMatch(parsed, rows);
}
[Theory]
[MemberData(nameof(Tables))]
public void RoundTrips_with_semicolon_delimiter(string[][] rows)
{
var csv = CsvWriter.WriteToString(rows, delimiter: ';');
var parsed = CsvParser.Parse(csv, ';');
ShouldMatch(parsed, rows);
}
private static void ShouldMatch(IReadOnlyList<string[]> parsed, string[][] expected)
{
parsed.Count.ShouldBe(expected.Length);
for (var i = 0; i < expected.Length; i++)
{
parsed[i].ShouldBe(expected[i], $"row {i} mismatch");
}
}
}
@@ -0,0 +1,126 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Csv;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Csv;
/// <summary>
/// Quote-on-demand and terminator behaviour for <see cref="CsvWriter"/>: a field is quoted only when
/// it must be (delimiter / quote / CR / LF), internal quotes double, CRLF terminates records by
/// default (configurable), and no terminator trails the final row.
/// </summary>
public sealed class CsvWriterTests
{
[Fact]
public void Writes_simple_rows_with_crlf_default()
{
var csv = CsvWriter.WriteToString(new[]
{
new[] { "a", "b", "c" },
new[] { "d", "e", "f" },
});
csv.ShouldBe("a,b,c\r\nd,e,f");
}
[Fact]
public void No_trailing_newline_after_last_row()
{
CsvWriter.WriteToString(new[] { new[] { "a" } }).ShouldBe("a");
}
[Fact]
public void Empty_rows_yield_empty_string()
{
CsvWriter.WriteToString(Array.Empty<string[]>()).ShouldBe("");
}
// ---- quote-on-demand ----
[Fact]
public void Plain_field_is_not_quoted()
{
CsvWriter.WriteToString(new[] { new[] { "plain" } }).ShouldBe("plain");
}
[Fact]
public void Field_with_comma_is_quoted()
{
CsvWriter.WriteToString(new[] { new[] { "a,b" } }).ShouldBe("\"a,b\"");
}
[Fact]
public void Field_with_quote_is_quoted_and_doubled()
{
CsvWriter.WriteToString(new[] { new[] { "a\"b" } }).ShouldBe("\"a\"\"b\"");
}
[Fact]
public void Field_with_lf_is_quoted()
{
CsvWriter.WriteToString(new[] { new[] { "a\nb" } }).ShouldBe("\"a\nb\"");
}
[Fact]
public void Field_with_cr_is_quoted()
{
CsvWriter.WriteToString(new[] { new[] { "a\rb" } }).ShouldBe("\"a\rb\"");
}
[Fact]
public void Leading_and_trailing_spaces_are_not_quoted()
{
// RFC does not require quoting for spaces; the parser preserves them either way.
CsvWriter.WriteToString(new[] { new[] { " a " } }).ShouldBe(" a ");
}
[Fact]
public void Empty_fields_are_written_bare()
{
CsvWriter.WriteToString(new[] { new[] { "a", "", "c" } }).ShouldBe("a,,c");
}
[Fact]
public void Null_field_is_written_as_empty()
{
CsvWriter.WriteToString(new[] { new[] { "a", null, "c" } }).ShouldBe("a,,c");
}
// ---- options ----
[Fact]
public void QuoteAllFields_forces_every_field_quoted()
{
CsvWriter.WriteToString(new[] { new[] { "a", "b" } }, quoteAllFields: true)
.ShouldBe("\"a\",\"b\"");
}
[Fact]
public void Custom_newline_lf()
{
CsvWriter.WriteToString(new[] { new[] { "a" }, new[] { "b" } }, newline: "\n")
.ShouldBe("a\nb");
}
[Fact]
public void Custom_delimiter_semicolon_quotes_on_semicolon_not_comma()
{
CsvWriter.WriteToString(new[] { new[] { "a,b", "c;d" } }, delimiter: ';')
.ShouldBe("a,b;\"c;d\"");
}
[Fact]
public void Writes_to_a_textwriter()
{
var sw = new StringWriter();
CsvWriter.Write(sw, new[] { new[] { "x", "y" } });
sw.ToString().ShouldBe("x,y");
}
[Fact]
public void Delimiter_may_not_be_a_quote()
{
Should.Throw<ArgumentException>(() =>
CsvWriter.WriteToString(new[] { new[] { "a" } }, delimiter: '"'));
}
}