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,293 @@
using System.Globalization;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
/// <summary>
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
/// <see cref="string"/> or a <see cref="TextReader"/> and yields rows of fields. This is the single
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
/// <see cref="CsvWriter"/>) rather than re-deriving it at each call site.
/// </summary>
/// <remarks>
/// <para><b>Faithful to RFC 4180.</b> The grammar handled:</para>
/// <list type="bullet">
/// <item>Fields are separated by the delimiter (default <c>','</c>); records are separated by
/// newlines.</item>
/// <item>A field may be quoted with double-quotes (<c>"</c>). A quoted field may contain the
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
/// (<c>""</c> → <c>"</c>).</item>
/// <item>An unquoted field runs literally up to the next delimiter or newline; leading and
/// trailing spaces are preserved (RFC 4180 §2.4 — spaces are part of a field).</item>
/// <item>CRLF, bare LF, and bare CR are all accepted as record terminators.</item>
/// </list>
/// <para><b>Empty-line policy.</b> Faithful to the RFC: a line with no characters yields a single row
/// containing one empty field (<c>[""]</c>); it is NOT silently dropped. Callers that want blank lines
/// skipped should filter the result (e.g. <c>rows.Where(r =&gt; r.Length &gt; 1 || r[0].Length &gt; 0)</c>).
/// A trailing record terminator at end-of-input does NOT produce a phantom empty final row, and its
/// absence does not lose the last row.</para>
/// <para><b>Malformed-input policy.</b> This parser is <b>strict</b>. It throws
/// <see cref="System.FormatException"/> (carrying a 1-based line/column position) for the malformed
/// shapes RFC 4180 forbids: (1) a bare double-quote inside an otherwise-unquoted field (e.g.
/// <c>ab"c</c>), (2) a stray character after a closing quote other than the delimiter or a newline
/// (e.g. <c>"ab"c</c>), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
/// — silent lenient recovery hides data-shape bugs in imported files.</para>
/// </remarks>
public static class CsvParser
{
private const char Quote = '"';
private const char Cr = '\r';
private const char Lf = '\n';
/// <summary>
/// Parses <paramref name="text"/> fully into rows of fields. Convenience wrapper over
/// <see cref="Parse(TextReader,char)"/>; materialises the whole document.
/// </summary>
/// <param name="text">The CSV document. <c>null</c> is treated as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>The rows, each an array of field values. Empty input yields zero rows.</returns>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
public static IReadOnlyList<string[]> Parse(string? text, char delimiter = ',')
{
using var reader = new StringReader(text ?? string.Empty);
var rows = new List<string[]>();
foreach (var row in Parse(reader, delimiter))
{
rows.Add(row);
}
return rows;
}
/// <summary>
/// Streams rows from <paramref name="reader"/> lazily — a single forward pass, one row
/// materialised at a time. The reader is not disposed by this method.
/// </summary>
/// <param name="reader">The source. Read to end-of-input.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>A lazily-evaluated sequence of rows; each row is a freshly allocated field array.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="reader"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar.</exception>
public static IEnumerable<string[]> Parse(TextReader reader, char delimiter = ',')
{
ArgumentNullException.ThrowIfNull(reader);
if (delimiter is Quote or Cr or Lf)
{
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
}
return Iterate(reader, delimiter);
}
private static IEnumerable<string[]> Iterate(TextReader reader, char delimiter)
{
var field = new StringBuilder();
var row = new List<string>();
// True once the current row has produced any field boundary or content — i.e. once we've seen a
// char that commits us to emitting at least one field. Reset to false right after a record
// terminator closes a row, so a trailing newline at EOF does NOT synthesise a phantom row.
var rowOpen = false;
// 1-based cursor, maintained for FormatException messages.
var line = 1;
var col = 0;
int read;
while ((read = reader.Read()) != -1)
{
var c = (char)read;
col++;
if (c == Quote)
{
if (field.Length != 0)
{
throw new FormatException(
$"Unexpected double-quote inside an unquoted field at line {line}, column {col}. " +
"A field is quoted only when the quote is its first character.");
}
rowOpen = true;
// Consume the quoted body; the opening quote has been read.
ReadQuotedField(reader, field, ref line, ref col);
// A closing quote must be followed by the delimiter, a newline, or EOF.
var next = reader.Peek();
if (next == -1)
{
row.Add(field.ToString());
field.Clear();
yield return row.ToArray();
row.Clear();
rowOpen = false;
yield break;
}
var nc = (char)next;
if (nc == delimiter)
{
reader.Read();
col++;
row.Add(field.ToString());
field.Clear();
continue;
}
if (nc is Cr or Lf)
{
row.Add(field.ToString());
field.Clear();
ConsumeNewline(reader, ref line, ref col);
yield return row.ToArray();
row.Clear();
rowOpen = false;
continue;
}
throw new FormatException(
$"Unexpected character '{nc}' after closing quote at line {line}, column {col + 1}. " +
"A quoted field must be followed by a delimiter, a newline, or end-of-input.");
}
if (c == delimiter)
{
row.Add(field.ToString());
field.Clear();
rowOpen = true;
continue;
}
if (c is Cr or Lf)
{
row.Add(field.ToString());
field.Clear();
if (c == Cr && reader.Peek() == Lf)
{
reader.Read();
}
line++;
col = 0;
yield return row.ToArray();
row.Clear();
rowOpen = false;
continue;
}
field.Append(c);
rowOpen = true;
}
// EOF: emit a trailing row only if content is pending. A clean terminator already cleared rowOpen.
if (rowOpen || field.Length != 0 || row.Count != 0)
{
row.Add(field.ToString());
yield return row.ToArray();
}
}
/// <summary>
/// Reads the body of a quoted field into <paramref name="field"/>. The opening quote has already
/// been consumed. On return the reader sits immediately after the closing quote.
/// </summary>
private static void ReadQuotedField(TextReader reader, StringBuilder field, ref int line, ref int col)
{
var openLine = line;
var openCol = col;
int read;
while ((read = reader.Read()) != -1)
{
var c = (char)read;
col++;
if (c == Quote)
{
if (reader.Peek() == Quote)
{
reader.Read();
col++;
field.Append(Quote);
continue;
}
return; // closing quote
}
if (c == Lf)
{
line++;
col = 0;
}
field.Append(c);
}
throw new FormatException(
$"Unterminated quoted field opened at line {openLine}, column {openCol}: reached end-of-input " +
"before the closing double-quote.");
}
/// <summary>Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.</summary>
private static void ConsumeNewline(TextReader reader, ref int line, ref int col)
{
var first = reader.Read();
if (first == Cr && reader.Peek() == Lf)
{
reader.Read();
}
line++;
col = 0;
}
/// <summary>
/// Header-aware convenience: parses <paramref name="text"/> and maps every subsequent row onto the
/// first (header) row's field names. Thin wrapper over <see cref="Parse(string,char)"/>.
/// </summary>
/// <param name="text">The CSV document; the first row is treated as the header.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <returns>
/// One dictionary per data row, keyed by header name (ordinal, case-sensitive). A row shorter than
/// the header maps only the columns present; a column beyond the header's width is keyed by its
/// 0-based index rendered as a string. An empty document yields zero rows.
/// </returns>
/// <exception cref="System.FormatException">The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.</exception>
public static IReadOnlyList<IReadOnlyDictionary<string, string>> ParseWithHeader(string? text, char delimiter = ',')
{
var rows = Parse(text, delimiter);
if (rows.Count == 0)
{
return Array.Empty<IReadOnlyDictionary<string, string>>();
}
var header = rows[0];
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var name in header)
{
if (!seen.Add(name))
{
throw new FormatException($"Duplicate header column name '{name}'.");
}
}
var result = new List<IReadOnlyDictionary<string, string>>(rows.Count - 1);
for (var i = 1; i < rows.Count; i++)
{
var cells = rows[i];
var map = new Dictionary<string, string>(cells.Length, StringComparer.Ordinal);
for (var c = 0; c < cells.Length; c++)
{
var key = c < header.Length ? header[c] : c.ToString(CultureInfo.InvariantCulture);
map[key] = cells[c];
}
result.Add(map);
}
return result;
}
}
@@ -0,0 +1,142 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
/// <summary>
/// A pure RFC 4180 CSV writer with no file/stream I/O of its own — it renders rows of fields to a
/// <see cref="string"/> or a <see cref="TextWriter"/>. The inverse of <see cref="CsvParser"/>:
/// <c>CsvParser.Parse(CsvWriter.WriteToString(rows))</c> reproduces <c>rows</c> exactly for any field
/// content.
/// </summary>
/// <remarks>
/// <para><b>Quote-on-demand.</b> A field is wrapped in double-quotes only when it must be — i.e. when
/// it contains the delimiter, a double-quote, CR, or LF — and internal double-quotes are doubled
/// (<c>"</c> → <c>""</c>). Fields that need no quoting are emitted verbatim, so ordinary values stay
/// human-readable. Pass <c>quoteAllFields: true</c> to force every field quoted.</para>
/// <para><b>Newline.</b> The record terminator defaults to CRLF (<c>\r\n</c>) per RFC 4180 and is
/// configurable. No terminator is written after the final row (matching the parser's
/// no-phantom-trailing-row contract, so a round-trip is exact).</para>
/// </remarks>
public static class CsvWriter
{
private const char Quote = '"';
private const char Cr = '\r';
private const char Lf = '\n';
/// <summary>The RFC 4180 record terminator, <c>"\r\n"</c>. The default <c>newline</c> for every write.</summary>
public const string Crlf = "\r\n";
/// <summary>
/// Renders <paramref name="rows"/> to a CSV string.
/// </summary>
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
/// <returns>The CSV text. An empty <paramref name="rows"/> yields the empty string.</returns>
/// <exception cref="System.ArgumentNullException"><paramref name="rows"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
public static string WriteToString(
IEnumerable<IEnumerable<string?>> rows,
char delimiter = ',',
string newline = Crlf,
bool quoteAllFields = false)
{
var sb = new StringBuilder();
using var writer = new StringWriter(sb);
Write(writer, rows, delimiter, newline, quoteAllFields);
return sb.ToString();
}
/// <summary>
/// Writes <paramref name="rows"/> to <paramref name="writer"/>. The writer is not disposed or
/// flushed by this method.
/// </summary>
/// <param name="writer">The destination.</param>
/// <param name="rows">The rows to write; each inner sequence is one record's fields. A <c>null</c> field is written as empty.</param>
/// <param name="delimiter">The field separator (default comma).</param>
/// <param name="newline">The record terminator between rows (default CRLF). Not appended after the last row.</param>
/// <param name="quoteAllFields">When <c>true</c>, every field is quoted regardless of content.</param>
/// <exception cref="System.ArgumentNullException"><paramref name="writer"/> or <paramref name="rows"/> is <c>null</c>.</exception>
/// <exception cref="System.ArgumentException"><paramref name="delimiter"/> is a quote, CR, or LF.</exception>
public static void Write(
TextWriter writer,
IEnumerable<IEnumerable<string?>> rows,
char delimiter = ',',
string newline = Crlf,
bool quoteAllFields = false)
{
ArgumentNullException.ThrowIfNull(writer);
ArgumentNullException.ThrowIfNull(rows);
if (delimiter is Quote or Cr or Lf)
{
throw new ArgumentException("Delimiter must not be a double-quote, CR, or LF.", nameof(delimiter));
}
var firstRow = true;
foreach (var row in rows)
{
if (!firstRow)
{
writer.Write(newline);
}
firstRow = false;
WriteRow(writer, row, delimiter, quoteAllFields);
}
}
private static void WriteRow(TextWriter writer, IEnumerable<string?> row, char delimiter, bool quoteAllFields)
{
ArgumentNullException.ThrowIfNull(row);
var firstField = true;
foreach (var field in row)
{
if (!firstField)
{
writer.Write(delimiter);
}
firstField = false;
WriteField(writer, field ?? string.Empty, delimiter, quoteAllFields);
}
}
private static void WriteField(TextWriter writer, string field, char delimiter, bool quoteAllFields)
{
if (!quoteAllFields && !NeedsQuoting(field, delimiter))
{
writer.Write(field);
return;
}
writer.Write(Quote);
foreach (var ch in field)
{
if (ch == Quote)
{
writer.Write(Quote); // double an internal quote
}
writer.Write(ch);
}
writer.Write(Quote);
}
private static bool NeedsQuoting(string field, char delimiter)
{
foreach (var ch in field)
{
if (ch == delimiter || ch == Quote || ch == Cr || ch == Lf)
{
return true;
}
}
return false;
}
}