Files
Matt Nadareski d614379cf5 Libraries
This change looks dramatic, but it's just separating out the already-split namespaces into separate top-level folders. In theory, every single one could be built into their own Nuget package. `SabreTools.IO.Meta` builds the normal Nuget package that is used by all other projects and includes all namespaces. `SabreTools.IO` builds to `SabreTools.IO.Common` to avoid overwriting issues on publish.
2026-03-21 13:55:42 -04:00

246 lines
7.8 KiB
C#

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
/// <summary>
/// Get if at end of stream
/// </summary>
public bool EndOfStream => _reader.EndOfStream;
/// <summary>
/// Contents of the current line, unprocessed
/// </summary>
public string? CurrentLine { get; private set; } = string.Empty;
/// <summary>
/// Get the current line number
/// </summary>
public long LineNumber { get; private set; } = 0;
/// <summary>
/// Assume the first row is a header
/// </summary>
public bool Header { get; set; } = true;
/// <summary>
/// Header row values
/// </summary>
public List<string>? HeaderValues { get; set; } = null;
/// <summary>
/// Get the current line values
/// </summary>
public List<string>? Line { get; private set; } = null;
/// <summary>
/// Assume that values are wrapped in quotes
/// </summary>
public bool Quotes { get; set; } = true;
/// <summary>
/// Set what character should be used as a separator
/// </summary>
public char Separator { get; set; } = ',';
/// <summary>
/// Set if field count should be verified from the first row
/// </summary>
public bool VerifyFieldCount { get; set; } = true;
#endregion
#region Private Properties
/// <summary>
/// Internal stream reader
/// </summary>
private readonly StreamReader _reader;
/// <summary>
/// How many fields should be written
/// </summary>
private int _fieldCount = -1;
#endregion
#region Constructors
/// <summary>
/// Constructor for reading from a file
/// </summary>
public Reader(string filename)
{
_reader = new StreamReader(filename);
}
/// <summary>
/// Constructor for reading from a stream
/// </summary>
public Reader(Stream stream, Encoding encoding)
{
_reader = new StreamReader(stream, encoding);
}
/// <summary>
/// Constructor for reading from a stream reader
/// </summary>
public Reader(StreamReader streamReader)
{
_reader = streamReader;
}
#endregion
/// <summary>
/// Read the header line
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">
/// Thrown if either:
/// - A header line is requested when <see cref="Header"/> is false.
/// - A header line has already been read when <see cref="Header"/> is true.
/// </exception>
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();
}
/// <summary>
/// Read the next line in the separated value file
/// </summary>
/// <exception cref="InvalidDataException">
/// Thrown if an malformed line is encountered during processing
/// and <see cref="VerifyFieldCount"/> is true.
/// </exception>
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<string>();
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;
}
/// <summary>
/// Get the value for the current line for the current key
/// </summary>
/// <param name="key">Case-sensitive key based on header values</param>
/// <returns>Value associated with the key, null if the key doesn't exist</returns>
/// <exception cref="InvalidDataException">
/// Thrown if any required properties are missing.
/// </exception>
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);
}
/// <summary>
/// Get the value for the current line for the current index
/// </summary>
/// <param name="index">Index into the current line</param>
/// <returns>Value associated with the index</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown if <paramref name="index"/> is greater than the line count.
/// </exception>
/// <exception cref="InvalidDataException">
/// Thrown if any required properties are missing.
/// </exception>
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
/// <summary>
/// Dispose of the underlying reader
/// </summary>
public void Dispose()
{
_reader.Dispose();
}
#endregion
}
}