mirror of
https://github.com/SabreTools/SabreTools.IO.git
synced 2026-07-08 17:57:02 +00:00
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.
246 lines
7.8 KiB
C#
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
|
|
}
|
|
}
|