using System.Globalization;
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Commons.Csv;
///
/// A pure, allocation-lean RFC 4180 CSV reader with no file/stream I/O of its own — it consumes a
/// or a and yields rows of fields. This is the single
/// CSV-reading authority in the tree; keep the RFC contract pinned here (and mirrored in
/// ) rather than re-deriving it at each call site.
///
///
/// Faithful to RFC 4180. The grammar handled:
///
/// - Fields are separated by the delimiter (default ','); records are separated by
/// newlines.
/// - A field may be quoted with double-quotes ("). A quoted field may contain the
/// delimiter, CR, LF, and CRLF literally, and represents a literal double-quote by doubling it
/// ("" → ").
/// - 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).
/// - CRLF, bare LF, and bare CR are all accepted as record terminators.
///
/// Empty-line policy. Faithful to the RFC: a line with no characters yields a single row
/// containing one empty field ([""]); it is NOT silently dropped. Callers that want blank lines
/// skipped should filter the result (e.g. rows.Where(r => r.Length > 1 || r[0].Length > 0)).
/// 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.
/// Malformed-input policy. This parser is strict. It throws
/// (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.
/// ab"c), (2) a stray character after a closing quote other than the delimiter or a newline
/// (e.g. "ab"c), and (3) an unterminated quoted field at end-of-input. Strictness is deliberate
/// — silent lenient recovery hides data-shape bugs in imported files.
///
public static class CsvParser
{
private const char Quote = '"';
private const char Cr = '\r';
private const char Lf = '\n';
///
/// Parses fully into rows of fields. Convenience wrapper over
/// ; materialises the whole document.
///
/// The CSV document. null is treated as empty.
/// The field separator (default comma).
/// The rows, each an array of field values. Empty input yields zero rows.
/// The input violates the strict RFC 4180 grammar.
public static IReadOnlyList Parse(string? text, char delimiter = ',')
{
using var reader = new StringReader(text ?? string.Empty);
var rows = new List();
foreach (var row in Parse(reader, delimiter))
{
rows.Add(row);
}
return rows;
}
///
/// Streams rows from lazily — a single forward pass, one row
/// materialised at a time. The reader is not disposed by this method.
///
/// The source. Read to end-of-input.
/// The field separator (default comma).
/// A lazily-evaluated sequence of rows; each row is a freshly allocated field array.
/// is null.
/// is a quote, CR, or LF.
/// The input violates the strict RFC 4180 grammar.
public static IEnumerable 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 Iterate(TextReader reader, char delimiter)
{
var field = new StringBuilder();
var row = new List();
// 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();
}
}
///
/// Reads the body of a quoted field into . The opening quote has already
/// been consumed. On return the reader sits immediately after the closing quote.
///
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.");
}
/// Consumes a newline (CRLF, CR, or LF) whose first character has been peeked but not read.
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;
}
///
/// Header-aware convenience: parses and maps every subsequent row onto the
/// first (header) row's field names. Thin wrapper over .
///
/// The CSV document; the first row is treated as the header.
/// The field separator (default comma).
///
/// 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.
///
/// The input violates the strict RFC 4180 grammar, or the header contains a duplicate column name.
public static IReadOnlyList> ParseWithHeader(string? text, char delimiter = ',')
{
var rows = Parse(text, delimiter);
if (rows.Count == 0)
{
return Array.Empty>();
}
var header = rows[0];
var seen = new HashSet(StringComparer.Ordinal);
foreach (var name in header)
{
if (!seen.Add(name))
{
throw new FormatException($"Duplicate header column name '{name}'.");
}
}
var result = new List>(rows.Count - 1);
for (var i = 1; i < rows.Count; i++)
{
var cells = rows[i];
var map = new Dictionary(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;
}
}