diff --git a/Directory.Build.props b/Directory.Build.props index 6dfe6eff..346a4bd4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable true net10.0 - 0.9.1 - 0.9.1 + 0.10.0-fixedWidth.11 + 0.10.0-fixedWidth.11 diff --git a/src/serde/FixedWidth/FieldOverflowHandling.cs b/src/serde/FixedWidth/FieldOverflowHandling.cs new file mode 100644 index 00000000..ad1f5653 --- /dev/null +++ b/src/serde/FixedWidth/FieldOverflowHandling.cs @@ -0,0 +1,21 @@ +using StaticCs; + +namespace Serde.FixedWidth +{ + /// + /// Enumerates the supported options for field values that are too long. + /// + [Closed] + public enum FieldOverflowHandling + { + /// + /// Indicates that an exception should be thrown if the field value is longer than the field length. + /// + Throw = 0, + + /// + /// Indicates that the field value should be truncated if the value is longer than the field length. + /// + Truncate = 1, + } +} diff --git a/src/serde/FixedWidth/FixedFieldInfoAttribute.cs b/src/serde/FixedWidth/FixedFieldInfoAttribute.cs new file mode 100644 index 00000000..c1554dc8 --- /dev/null +++ b/src/serde/FixedWidth/FixedFieldInfoAttribute.cs @@ -0,0 +1,102 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; + +namespace Serde.FixedWidth +{ + /// + /// Decorates a field in a fixed-width file with meta information about that field. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class FixedFieldInfoAttribute : Attribute + { + /// + /// Gets the offset that indicates where the field begins. + /// + public int Offset { get; } + + /// + /// Gets the length of the field. + /// + public int Length { get; } + + /// + /// Gets the format string for providing to TryParseExact. + /// + /// + /// Defaults to . + /// + public string Format { get; } + + /// + /// Gets a value indicating how to handle field overflows. + /// + /// + /// Defaults to . + /// + public FieldOverflowHandling OverflowHandling { get; } + + public FixedFieldInfoAttribute(int offset, int length, string format = "", FieldOverflowHandling overflowHandling = FieldOverflowHandling.Throw) + { + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + Offset = offset; + Length = length; + Format = string.IsNullOrWhiteSpace(format) ? string.Empty : format; + OverflowHandling = overflowHandling; + } + + public static FixedFieldInfoAttribute FromCustomAttributeData(CustomAttributeData? customAttribute) + { + if (!TryGetFixedFieldInfoAttribute(customAttribute, out var attribute)) + { + throw new InvalidOperationException($"Cannot write fixed field value without required '{nameof(FixedFieldInfoAttribute)}' annotation."); + } + + return attribute; + } + + public static bool TryGetFixedFieldInfoAttribute(CustomAttributeData? customAttribute, [NotNullWhen(true)] out FixedFieldInfoAttribute? fixedFieldInfoAttribute) + { + fixedFieldInfoAttribute = null; + + if (customAttribute is null) + { + return false; + } + + string format; + + if (!TryGetNamedArgumentValue(customAttribute, 0, out int offset)) + { + return false; + } + + if (!TryGetNamedArgumentValue(customAttribute, 1, out int length)) + { + return false; + } + + if (!TryGetNamedArgumentValue(customAttribute, 2, out string? formatValue)) + { + format = string.Empty; + } + format = formatValue ?? string.Empty; + + if (!TryGetNamedArgumentValue(customAttribute, 3, out FieldOverflowHandling fieldOverflowHandling)) + { + fieldOverflowHandling = FieldOverflowHandling.Throw; + } + + fixedFieldInfoAttribute = new(offset, length, format, fieldOverflowHandling); + return true; + + static bool TryGetNamedArgumentValue(CustomAttributeData customAttribute, int argumentIndex, [NotNullWhen(true)] out T? value) + { + value = (T?)customAttribute.ConstructorArguments[argumentIndex].Value; + return value is { }; + } + } + } +} diff --git a/src/serde/FixedWidth/FixedWidthDeserializer.Deserialize.cs b/src/serde/FixedWidth/FixedWidthDeserializer.Deserialize.cs new file mode 100644 index 00000000..d1738d78 --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthDeserializer.Deserialize.cs @@ -0,0 +1,38 @@ +using Serde.FixedWidth.Reader; +using System; +using System.Buffers; + +namespace Serde.FixedWidth +{ + internal sealed partial class FixedWidthDeserializer : IDeserializer + { + ITypeDeserializer IDeserializer.ReadType(ISerdeInfo typeInfo) + { + if (typeInfo.Kind is not InfoKind.CustomType) + { + throw new ArgumentException("Invalid type for ReadType: " + typeInfo.Kind); + } + + return this; + } + + string IDeserializer.ReadString() => throw new NotImplementedException(); + T? IDeserializer.ReadNullableRef(IDeserialize deserialize) where T : class => throw new NotImplementedException(); + bool IDeserializer.ReadBool() => throw new NotImplementedException(); + char IDeserializer.ReadChar() => throw new NotImplementedException(); + byte IDeserializer.ReadU8() => throw new NotImplementedException(); + ushort IDeserializer.ReadU16() => throw new NotImplementedException(); + uint IDeserializer.ReadU32() => throw new NotImplementedException(); + ulong IDeserializer.ReadU64() => throw new NotImplementedException(); + sbyte IDeserializer.ReadI8() => throw new NotImplementedException(); + short IDeserializer.ReadI16() => throw new NotImplementedException(); + int IDeserializer.ReadI32() => throw new NotImplementedException(); + long IDeserializer.ReadI64() => throw new NotImplementedException(); + float IDeserializer.ReadF32() => throw new NotImplementedException(); + double IDeserializer.ReadF64() => throw new NotImplementedException(); + decimal IDeserializer.ReadDecimal() => throw new NotImplementedException(); + DateTime IDeserializer.ReadDateTime() => throw new NotImplementedException(); + void IDeserializer.ReadBytes(IBufferWriter writer) => throw new NotImplementedException(); + void IDisposable.Dispose() => throw new NotImplementedException(); + } +} diff --git a/src/serde/FixedWidth/FixedWidthDeserializer.Type.cs b/src/serde/FixedWidth/FixedWidthDeserializer.Type.cs new file mode 100644 index 00000000..5cdd72a5 --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthDeserializer.Type.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Globalization; + +namespace Serde.FixedWidth +{ + internal sealed partial class FixedWidthDeserializer : ITypeDeserializer + { + private const NumberStyles Numeric = NumberStyles.Integer | NumberStyles.AllowThousands; + + int? ITypeDeserializer.SizeOpt => null; + + private int _fieldIndex = -1; + + T ITypeDeserializer.ReadValue(ISerdeInfo info, int index, IDeserialize deserialize) + => deserialize.Deserialize(this); + + string ITypeDeserializer.ReadString(ISerdeInfo info, int index) => _reader.ReadString(info, index); + bool ITypeDeserializer.ReadBool(ISerdeInfo info, int index) => _reader.ReadBool(info, index); + char ITypeDeserializer.ReadChar(ISerdeInfo info, int index) => _reader.ReadChar(info, index); + DateTime ITypeDeserializer.ReadDateTime(ISerdeInfo info, int index) => _reader.ReadDateTime(info, index); + decimal ITypeDeserializer.ReadDecimal(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, NumberStyles.Currency | NumberStyles.AllowLeadingSign); + float ITypeDeserializer.ReadF32(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, NumberStyles.Float); + double ITypeDeserializer.ReadF64(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, NumberStyles.Float); + short ITypeDeserializer.ReadI16(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + int ITypeDeserializer.ReadI32(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + long ITypeDeserializer.ReadI64(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + sbyte ITypeDeserializer.ReadI8(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + ushort ITypeDeserializer.ReadU16(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + uint ITypeDeserializer.ReadU32(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + ulong ITypeDeserializer.ReadU64(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + byte ITypeDeserializer.ReadU8(ISerdeInfo info, int index) => _reader.ReadNumber(info, index, Numeric); + + void ITypeDeserializer.SkipValue(ISerdeInfo info, int index) { } + + int ITypeDeserializer.TryReadIndex(ISerdeInfo info) => TryReadIndexWithName(info).Item1; + + (int, string? errorName) ITypeDeserializer.TryReadIndexWithName(ISerdeInfo info) => TryReadIndexWithName(info); + + private (int, string? errorName) TryReadIndexWithName(ISerdeInfo serdeInfo) + { + _fieldIndex++; + if (_fieldIndex == serdeInfo.FieldCount) + { + return (ITypeDeserializer.EndOfType, null); + } + + return (_fieldIndex, null); + } + + void ITypeDeserializer.ReadBytes(ISerdeInfo info, int index, IBufferWriter writer) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/serde/FixedWidth/FixedWidthDeserializer.cs b/src/serde/FixedWidth/FixedWidthDeserializer.cs new file mode 100644 index 00000000..0452525d --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthDeserializer.cs @@ -0,0 +1,16 @@ +// Contains implementations of data interfaces for core types + +using Serde.FixedWidth.Reader; +using Serde.IO; +using System.Text; + +namespace Serde.FixedWidth +{ + /// + /// Defines a type which handles deserializing a fixed-width file. + /// + internal sealed partial class FixedWidthDeserializer(string document) + { + private readonly FixedWidthReader _reader = new(document); + } +} diff --git a/src/serde/FixedWidth/FixedWidthSerializer.Serialize.cs b/src/serde/FixedWidth/FixedWidthSerializer.Serialize.cs new file mode 100644 index 00000000..33627a3a --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthSerializer.Serialize.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Serde.FixedWidth +{ + public sealed partial class FixedWidthSerializer : ISerializer + { + /// + ITypeSerializer ISerializer.WriteType(ISerdeInfo info) + { + if (info.Kind is not InfoKind.CustomType) + { + throw new ArgumentException("Invalid type for WriteType: " + info.Kind); + } + + return this; + } + + void ISerializer.WriteString(string s) => writer.WriteRaw(s); + void ISerializer.WriteBool(bool b) => writer.WriteRaw(b); + void ISerializer.WriteBytes(ReadOnlyMemory bytes) => throw new NotImplementedException(); + void ISerializer.WriteChar(char c) => writer.WriteRaw(c); + ITypeSerializer ISerializer.WriteCollection(ISerdeInfo info, int? count) => throw new NotImplementedException(); + void ISerializer.WriteDateTime(DateTime dt) => writer.WriteRaw(dt); + void ISerializer.WriteDateTimeOffset(DateTimeOffset dto) => writer.WriteRaw(dto); + void ISerializer.WriteDecimal(decimal d) => writer.WriteRaw(d); + void ISerializer.WriteF32(float f) => writer.WriteRaw(f); + void ISerializer.WriteF64(double d) => writer.WriteRaw(d); + void ISerializer.WriteI16(short i16) => writer.WriteRaw(i16); + void ISerializer.WriteI32(int i32) => writer.WriteRaw(i32); + void ISerializer.WriteI64(long i64) => writer.WriteRaw(i64); + void ISerializer.WriteI8(sbyte b) => writer.WriteRaw(b); + void ISerializer.WriteNull() { } + void ISerializer.WriteU16(ushort u16) => writer.WriteRaw(u16); + void ISerializer.WriteU32(uint u32) => writer.WriteRaw(u32); + void ISerializer.WriteU64(ulong u64) => writer.WriteRaw(u64); + void ISerializer.WriteU8(byte b) => writer.WriteRaw(b); + } +} diff --git a/src/serde/FixedWidth/FixedWidthSerializer.Type.cs b/src/serde/FixedWidth/FixedWidthSerializer.Type.cs new file mode 100644 index 00000000..e7d3d6e4 --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthSerializer.Type.cs @@ -0,0 +1,33 @@ +using System; + +namespace Serde.FixedWidth +{ + public sealed partial class FixedWidthSerializer : ITypeSerializer + { + void ITypeSerializer.WriteValue(ISerdeInfo typeInfo, int index, T value, ISerialize serialize) + => serialize.Serialize(value, this); + + void ITypeSerializer.End(ISerdeInfo info) => writer.WriteRaw(); + void ITypeSerializer.WriteBool(ISerdeInfo typeInfo, int index, bool b) + { + writer.WriteObject(typeInfo, index, b); + } + void ITypeSerializer.WriteChar(ISerdeInfo typeInfo, int index, char c) => writer.WriteObject(typeInfo, index, c); + void ITypeSerializer.WriteU8(ISerdeInfo typeInfo, int index, byte b) => writer.WriteObject(typeInfo, index, b); + void ITypeSerializer.WriteU16(ISerdeInfo typeInfo, int index, ushort u16) => writer.WriteObject(typeInfo, index, u16); + void ITypeSerializer.WriteU32(ISerdeInfo typeInfo, int index, uint u32) => writer.WriteObject(typeInfo, index, u32); + void ITypeSerializer.WriteU64(ISerdeInfo typeInfo, int index, ulong u64) => writer.WriteObject(typeInfo, index, u64); + void ITypeSerializer.WriteI8(ISerdeInfo typeInfo, int index, sbyte b) => writer.WriteObject(typeInfo, index, b); + void ITypeSerializer.WriteI16(ISerdeInfo typeInfo, int index, short i16) => writer.WriteObject(typeInfo, index, i16); + void ITypeSerializer.WriteI32(ISerdeInfo typeInfo, int index, int i32) => writer.WriteObject(typeInfo, index, i32); + void ITypeSerializer.WriteI64(ISerdeInfo typeInfo, int index, long i64) => writer.WriteObject(typeInfo, index, i64); + void ITypeSerializer.WriteF32(ISerdeInfo typeInfo, int index, float f) => writer.WriteObject(typeInfo, index, f); + void ITypeSerializer.WriteF64(ISerdeInfo typeInfo, int index, double d) => writer.WriteObject(typeInfo, index, d); + void ITypeSerializer.WriteDecimal(ISerdeInfo typeInfo, int index, decimal d) => writer.WriteObject(typeInfo, index, d); + void ITypeSerializer.WriteString(ISerdeInfo typeInfo, int index, string s) => writer.WriteObject(typeInfo, index, s); + void ITypeSerializer.WriteNull(ISerdeInfo typeInfo, int index) => writer.WriteObject(typeInfo, index, string.Empty); + void ITypeSerializer.WriteDateTime(ISerdeInfo typeInfo, int index, DateTime dt) => writer.WriteObject(typeInfo, index, dt); + void ITypeSerializer.WriteDateTimeOffset(ISerdeInfo typeInfo, int index, DateTimeOffset dt) => writer.WriteObject(typeInfo, index, dt); + void ITypeSerializer.WriteBytes(ISerdeInfo typeInfo, int index, ReadOnlyMemory bytes) => writer.WriteObject(typeInfo, index, bytes); + } +} diff --git a/src/serde/FixedWidth/FixedWidthSerializer.cs b/src/serde/FixedWidth/FixedWidthSerializer.cs new file mode 100644 index 00000000..c09fe93d --- /dev/null +++ b/src/serde/FixedWidth/FixedWidthSerializer.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serde.FixedWidth.Writer; + +namespace Serde.FixedWidth +{ + /// + /// Defines a serializer for Fixed Width files. + /// + public sealed partial class FixedWidthSerializer(FixedWidthWriter writer) + { + /// + /// Serializes a single line of a fixed-width text file. + /// + /// The type of the . + /// The object instance to serialize. + /// A type which can serialize. + /// A fixed-width string. + public static string Serialize(T value, ISerialize serde) + { + using var writer = new FixedWidthWriter(); + var serializer = new FixedWidthSerializer(writer); + serde.Serialize(value, serializer); + writer.Flush(); + return writer.GetStringBuilder().ToString(); + } + + /// + /// + /// The serialize provider.. + public static string Serialize(T value) + where TProvider : ISerializeProvider + => Serialize(value, TProvider.Instance); + + /// + public static string Serialize(T value) + where T : ISerializeProvider + => Serialize(value, T.Instance); + + /// + public static IEnumerable SerializeDocument(IEnumerable values) + where T : ISerializeProvider + => SerializeDocument(values, T.Instance); + + /// + /// Serializes a collection of , returning an enumerable for writing to a stream. + /// + /// The type of the object to serialize. + /// A collection of the items to serialize. + /// The serialize provider. + /// An enumerable of the rows of the document. + public static IEnumerable SerializeDocument(IEnumerable values, ISerialize serde) + where T : ISerializeProvider + { + foreach (var provider in values) + { + yield return Serialize(provider, serde); + } + } + + /// + /// Deserializes a single line of a fixed-width text file. + /// + /// The type of the value to return. + /// The line to deserialize. + /// A type which can deerialize. + /// A fixed-width string. + public static T Deserialize(string source, IDeserialize d) + { + T result; + var deserializer = new FixedWidthDeserializer(source); + result = d.Deserialize(deserializer); + return result; + } + + /// + public static T Deserialize(string source) + where T : IDeserializeProvider + => Deserialize(source); + + /// + /// + /// The deserialize provider to use. + public static T Deserialize(string source) + where TProvider : IDeserializeProvider + => Deserialize(source, TProvider.Instance); + + /// + public static IEnumerable DeserializeDocument(string document, int headerLines = 0) + where T : IDeserializeProvider + => DeserializeDocument(document, T.Instance, headerLines); + + /// + public static IEnumerable DeserializeDocument(string document, int headerLines = 0) + where TProvider : IDeserializeProvider + => DeserializeDocument(document, TProvider.Instance, headerLines); + + /// + /// Deserializes the provided document. + /// + /// The type of the value to return. + /// The document to deserialize. + /// The deserializer. + /// The number of lines to skip. + /// An enumerable of deserialized rows. + public static IEnumerable DeserializeDocument(string document, IDeserialize d, int headerLines = 0) + { + foreach (var line in document.Split(Environment.NewLine).Skip(headerLines)) + { + yield return Deserialize(line, d); + } + } + } +} diff --git a/src/serde/FixedWidth/Reader/FixedWidthReader.cs b/src/serde/FixedWidth/Reader/FixedWidthReader.cs new file mode 100644 index 00000000..b6978c39 --- /dev/null +++ b/src/serde/FixedWidth/Reader/FixedWidthReader.cs @@ -0,0 +1,83 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Numerics; + +namespace Serde.FixedWidth.Reader +{ + internal readonly struct FixedWidthReader(string line) + { + private readonly string _line = line; + + public string ReadString(ISerdeInfo typeInfo, int index) + => GetText(typeInfo, index, out _).Trim().ToString(); + + public bool ReadBool(ISerdeInfo typeInfo, int index) + { + var span = GetText(typeInfo, index, out var attribute); + + if (string.IsNullOrEmpty(attribute.Format)) + { + return bool.Parse(span); + } + + string[] splitFormat = attribute.Format.Split('/', StringSplitOptions.TrimEntries); + if (splitFormat.Length != 2) + { + throw new InvalidOperationException("Split format must be an empty string or have true and false text separated by a forward slash ('/')"); + } + + if (span.Equals(splitFormat[0], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + else if (span.Equals(splitFormat[1], StringComparison.OrdinalIgnoreCase)) + { + return false; + } + else + { + throw new InvalidOperationException($"Value '{span}' was neither '{splitFormat[0]}' nor '{splitFormat[1]}'."); + } + } + + public char ReadChar(ISerdeInfo typeInfo, int index) + { + var span = GetText(typeInfo, index, out _); + + return span.Length == 1 ? span[0] : throw new InvalidOperationException("Char field comprised of multiple non-space characters."); + } + + public DateTime ReadDateTime(ISerdeInfo typeInfo, int index) + { + var span = GetText(typeInfo, index, out var attribute); + + return string.IsNullOrEmpty(attribute.Format) + ? DateTime.Parse(span) + : DateTime.ParseExact(span, attribute.Format, CultureInfo.CurrentCulture); + } + + public TNumber ReadNumber(ISerdeInfo typeInfo, int index, NumberStyles numberStyles) + where TNumber : struct, INumber + { + var span = GetText(typeInfo, index, out _); + + var trimmedValue = span.Trim(); + + if (trimmedValue.IsEmpty) + { + return TNumber.Zero; + } + + return TNumber.Parse(trimmedValue, numberStyles, CultureInfo.InvariantCulture); + } + + private ReadOnlySpan GetText(ISerdeInfo typeInfo, int index, out FixedFieldInfoAttribute attribute) + { + var customAttribute = typeInfo.GetFieldAttributes(index).FirstOrDefault(it => it.AttributeType == typeof(FixedFieldInfoAttribute)); + attribute = FixedFieldInfoAttribute.FromCustomAttributeData(customAttribute); + + return _line.AsSpan(attribute.Offset, attribute.Length).Trim(); + } + } +} diff --git a/src/serde/FixedWidth/Writer/FixedWidthWriter.cs b/src/serde/FixedWidth/Writer/FixedWidthWriter.cs new file mode 100644 index 00000000..4243f82a --- /dev/null +++ b/src/serde/FixedWidth/Writer/FixedWidthWriter.cs @@ -0,0 +1,123 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace Serde.FixedWidth.Writer +{ + public sealed class FixedWidthWriter : IDisposable + { + private const char Padding = ' '; + private readonly StringWriter _writer = new(); + private int _pos = 0; + + public void Flush() + { + _writer.Flush(); + } + + public void Dispose() + { + Flush(); + } + + /// + public StringBuilder GetStringBuilder() => _writer.GetStringBuilder(); + + public void WriteObject(ISerdeInfo typeInfo, int index, T value) + { + var customAttribute = typeInfo.GetFieldAttributes(index).FirstOrDefault(it => it.AttributeType == typeof(FixedFieldInfoAttribute)); + var attribute = FixedFieldInfoAttribute.FromCustomAttributeData(customAttribute); + + WriteObject(value, attribute.Offset, attribute.Length, attribute.Format, attribute.OverflowHandling); + } + + private void WriteObject(T value, int fieldOffset, int fieldLength, string format, FieldOverflowHandling overflowHandling) + { + // attribute ctor sets white space strings to empty. + if (!string.IsNullOrEmpty(format)) + { + if (value is bool b) + { + WriteBool(b, fieldOffset, fieldLength, format, overflowHandling); + return; + } + + if (value is DateTime dt) + { + WriteText(dt.ToString(format, CultureInfo.CurrentCulture), fieldOffset, fieldLength, overflowHandling); + return; + } + + if (value is IFormattable formattable) + { + WriteText(formattable.ToString(format, CultureInfo.CurrentCulture), fieldOffset, fieldLength, overflowHandling); + return; + } + } + + if (value is ReadOnlyMemory byteMemory) + { + WriteUtf8Span(byteMemory.Span, fieldOffset, fieldLength, overflowHandling); + return; + } + + // Format string is either not used, null, or it's not a special case. + WriteText(value?.ToString() ?? string.Empty, fieldOffset, fieldLength, overflowHandling); + } + + private void WriteUtf8Span(Utf8Span span, int fieldOffset, int fieldLength, FieldOverflowHandling overflowHandling) + { + string text = Encoding.UTF8.GetString(span); + WriteText(text, fieldOffset, fieldLength, overflowHandling); + } + + private void WriteBool(bool value, int fieldOffset, int fieldLength, string format, FieldOverflowHandling overflowHandling) + { + string[] splitFormat = format.Split('/', StringSplitOptions.TrimEntries); + if (splitFormat.Length != 2) + { + throw new InvalidOperationException("Split format must be an empty string or have true and false text separated by a forward slash ('/')"); + } + + WriteText(value ? splitFormat[0] : splitFormat[1], fieldOffset, fieldLength, overflowHandling); + } + + private void WriteText(string value, int fieldOffset, int fieldLength, FieldOverflowHandling overflowHandling) + { + if (_pos < fieldOffset) + { + // Fill in any missing space with padding. + _writer.Write(new string(Padding, fieldOffset - _pos)); + _pos = fieldOffset; + } + + if (value.Length > fieldLength) + { + value = overflowHandling switch + { + FieldOverflowHandling.Throw => throw new InvalidOperationException($"Cannot write {value} (length {value.Length}) to a field that is only {fieldLength} long."), + FieldOverflowHandling.Truncate => value[..fieldLength], + _ => throw new ArgumentOutOfRangeException(nameof(overflowHandling), $"{overflowHandling} is not a valid value for {nameof(FieldOverflowHandling)}.") + }; + } + + _writer.Write(value.PadRight(fieldLength)); + _pos += fieldLength; + } + + internal void WriteRaw() + => _writer.WriteLine(); + + internal void WriteRaw(string value) + => _writer.WriteLine(value); + + internal void WriteRaw(T value) + where T : notnull + => _writer.WriteLine(value.ToString()); + + public override string ToString() + => GetStringBuilder().ToString(); + } +}