using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.RegularExpressions; namespace SabreTools.Text.SeparatedValue { public class Reader : IDisposable { #region Fields /// /// Get if at end of stream /// public bool EndOfStream => _reader.EndOfStream; /// /// Contents of the current line, unprocessed /// public string? CurrentLine { get; private set; } = string.Empty; /// /// Get the current line number /// public long LineNumber { get; private set; } = 0; /// /// Assume the first row is a header /// public bool Header { get; set; } = true; /// /// Header row values /// public List? HeaderValues { get; set; } = null; /// /// Get the current line values /// public List? Line { get; private set; } = null; /// /// Assume that values are wrapped in quotes /// public bool Quotes { get; set; } = true; /// /// Set what character should be used as a separator /// public char Separator { get; set; } = ','; /// /// Set if field count should be verified from the first row /// public bool VerifyFieldCount { get; set; } = true; #endregion #region Private Properties /// /// Internal stream reader /// private readonly StreamReader _reader; /// /// How many fields should be written /// private int _fieldCount = -1; #endregion #region Constructors /// /// Constructor for reading from a file /// public Reader(string filename) { _reader = new StreamReader(filename); } /// /// Constructor for reading from a stream /// public Reader(Stream stream, Encoding encoding) { _reader = new StreamReader(stream, encoding); } /// /// Constructor for reading from a stream reader /// public Reader(StreamReader streamReader) { _reader = streamReader; } #endregion /// /// Read the header line /// /// /// /// Thrown if either: /// - A header line is requested when is false. /// - A header line has already been read when is true. /// public bool ReadHeader() { if (!Header) throw new InvalidOperationException("No header line expected"); if (HeaderValues is not null) throw new InvalidOperationException("No more than 1 header row in a file allowed"); return ReadNextLine(); } /// /// Read the next line in the separated value file /// /// /// Thrown if an malformed line is encountered during processing /// and is true. /// public bool ReadNextLine() { if (_reader.BaseStream is null) return false; if (!_reader.BaseStream.CanRead || _reader.EndOfStream) return false; string? fullLine = _reader.ReadLine(); CurrentLine = fullLine; LineNumber++; if (fullLine is null) return false; // If we have quotes, we need to split specially if (Quotes) { // https://stackoverflow.com/questions/3776458/split-a-comma-separated-string-with-both-quoted-and-unquoted-strings var lineSplitRegex = new Regex($"(?:^|{Separator})(\"(?:[^\"]+|\"\")*\"|[^{Separator}]*)"); var temp = new List(); foreach (Match? match in lineSplitRegex.Matches(fullLine)) { string? curr = match?.Value; if (curr is null) continue; if (curr.Length == 0) temp.Add(""); // Trim separator, whitespace, quotes, inter-quote whitespace curr = curr.TrimStart(Separator).Trim().Trim('\"').Trim(); temp.Add(curr); } Line = temp; } // Otherwise, just split on the delimiter else { var lineArr = fullLine.Split(Separator); lineArr = Array.ConvertAll(lineArr, f => f.Trim()); Line = [.. lineArr]; } // If we don't have a header yet and are expecting one, read this as the header if (Header && HeaderValues is null) { HeaderValues = Line; _fieldCount = HeaderValues.Count; } // If we're verifying field counts and the numbers are off, error out if (VerifyFieldCount && _fieldCount != -1 && Line.Count != _fieldCount) throw new InvalidDataException($"Invalid row found, cannot continue: {fullLine}"); return true; } /// /// Get the value for the current line for the current key /// /// Case-sensitive key based on header values /// Value associated with the key, null if the key doesn't exist /// /// Thrown if any required properties are missing. /// public string? GetValue(string key) { // No header means no key-based indexing if (!Header) throw new InvalidDataException("No header expected so no keys can be used"); // If we don't have the key, return null if (HeaderValues is null) throw new InvalidDataException($"Current line doesn't have key {key}"); if (!HeaderValues.Contains(key)) return null; int index = HeaderValues.IndexOf(key); return GetValue(index); } /// /// Get the value for the current line for the current index /// /// Index into the current line /// Value associated with the index /// /// Thrown if is greater than the line count. /// /// /// Thrown if any required properties are missing. /// public string GetValue(int index) { if (Line is null) throw new InvalidDataException($"Current line doesn't have index {index}"); if (Line.Count < index) throw new ArgumentOutOfRangeException($"Current line doesn't have index {index}"); return Line[index]; } #region IDisposable Implementation /// /// Dispose of the underlying reader /// public void Dispose() { _reader.Dispose(); } #endregion } }