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; } }