diff --git a/Directory.Build.props b/Directory.Build.props
index 4e80ab7a..75d18ff9 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,9 @@
+ latest
+ enable
+ enable
+ true
latest
All
@@ -19,8 +23,6 @@
true
- none
- false
$(DefineConstants.Replace("DEBUG;", ""))
$(DefineConstants.Replace("TRACE;", ""))
@@ -82,4 +84,4 @@
all
-
\ No newline at end of file
+
diff --git a/src/ExcelReader.Core/ExcelEpoch.cs b/src/ExcelReader.Core/ExcelEpoch.cs
new file mode 100644
index 00000000..d44dce30
--- /dev/null
+++ b/src/ExcelReader.Core/ExcelEpoch.cs
@@ -0,0 +1,39 @@
+namespace ExcelReader.Core
+{
+ // Excel reserves serial 60 for the fictitious 1900-02-29; OADate does not. Keep this conversion
+ // shared so the binary writers and the public Cell date reader cannot drift at the boundary.
+ internal static class ExcelEpoch
+ {
+ internal static double SerialToOADate(double serial, bool date1904)
+ {
+ if (date1904)
+ {
+ return serial + 1462.0;
+ }
+ return serial switch
+ {
+ < 60.0 => serial + 1.0,
+ 60.0 => 60.0,
+ _ => serial,
+ };
+ }
+
+ internal static double OADateToSerial(double oadate, bool date1904)
+ {
+ double serial = oadate;
+ if (date1904)
+ {
+ serial -= 1462.0;
+ }
+ else if (oadate < 61.0)
+ {
+ serial--;
+ }
+ if (serial < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(oadate), "Dates before the workbook epoch cannot be written to Excel.");
+ }
+ return serial;
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj
index 3bb8342a..2746696b 100644
--- a/src/ExcelReader.Core/ExcelReader.Core.csproj
+++ b/src/ExcelReader.Core/ExcelReader.Core.csproj
@@ -2,9 +2,7 @@
net10.0;net8.0
- latest
- enable
- enable
+ true
true
@@ -34,7 +32,6 @@
portable
true
true
- true
true
diff --git a/src/ExcelReader.Core/Parser/ExcelParser.cs b/src/ExcelReader.Core/Parser/ExcelParser.cs
index 6c74863c..b926b48a 100644
--- a/src/ExcelReader.Core/Parser/ExcelParser.cs
+++ b/src/ExcelReader.Core/Parser/ExcelParser.cs
@@ -33,10 +33,10 @@ public ExcelEnumerable Parse(XlsxReader reader)
[SuppressMessage("Usage", "VSTHRD200:Use \"Async\" suffix for async methods",
Justification = "Synchronous entry point; the enumerable also implements IAsyncEnumerable, but ParseAsync is the async counterpart.")]
- public XlsExcelEnumerable Parse(XlsReader reader)
+ public ExcelEnumerable Parse(XlsReader reader)
{
ArgumentNullException.ThrowIfNull(reader);
- return new XlsExcelEnumerable(reader, _config);
+ return new ExcelEnumerable(reader, _config);
}
[SuppressMessage("Usage", "VSTHRD200:Use \"Async\" suffix for async methods",
@@ -75,10 +75,10 @@ public ExcelEnumerable ParseAsync(XlsxReader reader, CancellationToken ct = d
return new ExcelEnumerable(reader, _config, ct);
}
- public XlsExcelEnumerable ParseAsync(XlsReader reader, CancellationToken ct = default)
+ public ExcelEnumerable ParseAsync(XlsReader reader, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(reader);
- return new XlsExcelEnumerable(reader, _config, ct);
+ return new ExcelEnumerable(reader, _config, ct);
}
public ExcelEnumerable ParseAsync(XlsbReader reader, CancellationToken ct = default)
diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
index 943018ac..1643b3cb 100644
--- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
+++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs
@@ -5,6 +5,7 @@
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Text;
using ExcelReader.Core.Enums;
using ExcelReader.Core.ValueObjects;
@@ -52,6 +53,8 @@ internal static class ColumnParserFactory
[
typeof(int), typeof(long), typeof(double), typeof(float), typeof(decimal),
typeof(short), typeof(byte), typeof(uint), typeof(ulong), typeof(ushort),
+ // Guid is only reached here on net9+, where it implements IUtf8SpanParsable. On net8 the
+ // dedicated Guid build paths (guarded by #if NET8_0 below) intercept it before this set.
typeof(Guid),
];
@@ -62,27 +65,11 @@ internal static class ColumnParserFactory
{
Type propType = prop.PropertyType;
Type? innerNullable = Nullable.GetUnderlyingType(propType);
- if (csvTextDates)
- {
- Type effective = innerNullable ?? propType;
- if (effective == typeof(DateTime))
- {
- return innerNullable is null ? BuildTextDateTimeParser(prop) : BuildTextNullableDateTimeParser(prop);
- }
- if (effective == typeof(DateOnly))
- {
- return innerNullable is null ? BuildTextDateOnlyParser(prop) : BuildTextNullableDateOnlyParser(prop);
- }
- if (effective == typeof(TimeOnly))
- {
- return innerNullable is null ? BuildTextTimeOnlyParser(prop) : BuildTextNullableTimeOnlyParser(prop);
- }
- }
if (innerNullable is not null)
{
- return BuildNullableParser(prop, innerNullable);
+ return BuildNullableParser(prop, innerNullable, csvTextDates);
}
- return BuildConcreteParser(prop, propType);
+ return BuildConcreteParser(prop, propType, csvTextDates);
}
// Builds a parser from a user-supplied IExcelCellConverter. The converter type must
@@ -104,7 +91,7 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver
.Invoke(null, [prop, converter])!;
}
- private static ColumnParser? BuildConcreteParser(PropertyInfo prop, Type propType)
+ private static ColumnParser? BuildConcreteParser(PropertyInfo prop, Type propType, bool textDates)
{
if (propType == typeof(string))
{
@@ -112,24 +99,24 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver
}
if (propType == typeof(bool))
{
- return BuildBoolParser(prop);
+ return BuildValue(prop, ReadBool);
}
if (propType == typeof(DateTime))
{
- return BuildDateTimeParser(prop);
+ return BuildValue(prop, DateTimeReader(textDates));
}
if (propType == typeof(DateOnly))
{
- return BuildDateOnlyParser(prop);
+ return BuildValue(prop, DateOnlyReader(textDates));
}
if (propType == typeof(TimeOnly))
{
- return BuildTimeOnlyParser(prop);
+ return BuildValue(prop, TimeOnlyReader(textDates));
}
#if NET8_0
if (propType == typeof(Guid))
{
- return BuildGuidParser(prop);
+ return BuildValue(prop, ReadGuid);
}
#endif
if (propType.IsEnum)
@@ -147,29 +134,29 @@ internal static ColumnParser BuildConverter(PropertyInfo prop, Type conver
.Invoke(null, [prop]);
}
- private static ColumnParser? BuildNullableParser(PropertyInfo prop, Type innerType)
+ private static ColumnParser? BuildNullableParser(PropertyInfo prop, Type innerType, bool textDates)
{
if (innerType == typeof(bool))
{
- return BuildNullableBoolParser(prop);
+ return BuildNullableValue(prop, ReadBool);
}
if (innerType == typeof(DateTime))
{
- return BuildNullableDateTimeParser(prop);
+ return BuildNullableValue(prop, DateTimeReader(textDates));
}
#if NET8_0
if (innerType == typeof(Guid))
{
- return BuildNullableGuidParser(prop);
+ return BuildNullableValue(prop, ReadGuid);
}
#endif
if (innerType == typeof(DateOnly))
{
- return BuildNullableDateOnlyParser(prop);
+ return BuildNullableValue(prop, DateOnlyReader(textDates));
}
if (innerType == typeof(TimeOnly))
{
- return BuildNullableTimeOnlyParser(prop);
+ return BuildNullableValue(prop, TimeOnlyReader(textDates));
}
if (innerType.IsEnum)
{
@@ -196,12 +183,18 @@ private static ColumnParser BuildStringParser(PropertyInfo prop)
};
}
- private static ColumnParser BuildBoolParser(PropertyInfo prop)
+ // Shared shape behind every value-type column parser below: read the cell into a V via one of
+ // the Read*/TryParse* strategies, then assign through the compiled setter. Build*Parser methods
+ // differ only in which reader they plug in, so they collapse to one-line factories over these
+ // two generics instead of ~12 structurally identical bodies.
+ private delegate bool CellReader(in Cell cell, bool isDate1904, IFormatProvider provider, out V value);
+
+ private static ColumnParser BuildValue(PropertyInfo prop, CellReader read)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
+ RefAction setter = CompileSetter(prop);
+ return (ref model, in cell, isDate1904, provider) =>
{
- if (!TryParseBool(in cell, out bool value))
+ if (!read(in cell, isDate1904, provider, out V value))
{
return false;
}
@@ -210,160 +203,94 @@ private static ColumnParser BuildBoolParser(PropertyInfo prop)
};
}
- private static ColumnParser BuildDateTimeParser(PropertyInfo prop)
+ private static ColumnParser BuildNullableValue(PropertyInfo prop, CellReader read)
+ where V : struct
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, isDate1904, _) =>
+ RefAction setter = CompileSetter(prop);
+ return (ref model, in cell, isDate1904, provider) =>
{
- if (!cell.TryGetDateTime(isDate1904, out DateTime dt))
+ if (!read(in cell, isDate1904, provider, out V value))
{
return false;
}
- setter(ref model, dt);
+ setter(ref model, value);
return true;
};
}
- private static ColumnParser BuildDateOnlyParser(PropertyInfo prop)
+#pragma warning disable S1172 // CellReader has one fixed signature for all typed cell readers.
+ private static bool ReadBool(in Cell cell, bool isDate1904, IFormatProvider provider, out bool value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, isDate1904, _) =>
- {
- if (!cell.TryGetDateTime(isDate1904, out DateTime dt))
- {
- return false;
- }
- setter(ref model, DateOnly.FromDateTime(dt));
- return true;
- };
+ return TryParseBool(in cell, out value);
}
- private static ColumnParser BuildTimeOnlyParser(PropertyInfo prop)
+ private static bool ReadDateTime(in Cell cell, bool isDate1904, IFormatProvider _, out DateTime value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
+ return cell.TryGetDateTime(isDate1904, out value);
+ }
+
+ private static bool ReadDateOnly(in Cell cell, bool isDate1904, IFormatProvider _, out DateOnly value)
+ {
+ if (!cell.TryGetDateTime(isDate1904, out DateTime dt))
{
- // TryGetDouble reads the binary double (XLS/XLSB) or parses the text invariantly (XLSX),
- // matching how the serial is written; a culture-aware parse would misread "0.5" cells.
- if (!cell.TryGetDouble(out double serial))
- {
- return false;
- }
- setter(ref model, TimeOnlyFromSerial(serial));
- return true;
- };
+ value = default;
+ return false;
+ }
+ value = DateOnly.FromDateTime(dt);
+ return true;
}
- private static ColumnParser BuildNullableTimeOnlyParser(PropertyInfo prop)
+ // TryGetDouble reads the binary double (XLS/XLSB) or parses the text invariantly (XLSX),
+ // matching how the serial is written; a culture-aware parse would misread "0.5" cells.
+ private static bool ReadTimeOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out TimeOnly value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
+ if (!cell.TryGetDouble(out double serial))
{
- // TryGetDouble reads the binary double (XLS/XLSB) or parses the text invariantly (XLSX),
- // matching how the serial is written; a culture-aware parse would misread "0.5" cells.
- if (!cell.TryGetDouble(out double serial))
- {
- return false;
- }
- setter(ref model, TimeOnlyFromSerial(serial));
- return true;
- };
+ value = default;
+ return false;
+ }
+ value = TimeOnlyFromSerial(serial);
+ return true;
}
- // Excel time serial -> TimeOnly: the fractional part of the day, rounded to the nearest tick to
- // undo the double round-trip. A value that rounds up to a whole day wraps back to midnight.
- private static TimeOnly TimeOnlyFromSerial(double serial)
+ private static bool ReadTextDateTime(in Cell cell, bool _, IFormatProvider provider, out DateTime value)
{
- double fraction = serial - Math.Floor(serial);
- long ticks = (long)Math.Round(fraction * TimeSpan.TicksPerDay, MidpointRounding.AwayFromZero);
- return new TimeOnly(ticks == TimeSpan.TicksPerDay ? 0 : ticks);
+ return TryParseDateTimeText(in cell, provider, out value);
}
- // CSV text-date parsers: the cell holds a date string (e.g. "2026-07-02" or ISO "O" form).
- // DateTime/DateOnly implement ISpanParsable (char) but not IUtf8SpanParsable, so decode the
- // short field to a stack char buffer and parse culture-aware — no heap allocation.
- private static ColumnParser BuildTextDateTimeParser(PropertyInfo prop)
+ private static bool ReadTextDateOnly(in Cell cell, bool _, IFormatProvider provider, out DateOnly value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseDateTimeText(in cell, provider, out DateTime dt))
- {
- return false;
- }
- setter(ref model, dt);
- return true;
- };
+ return TryParseDateOnlyText(in cell, provider, out value);
}
- private static ColumnParser BuildTextNullableDateTimeParser(PropertyInfo prop)
+ private static bool ReadTextTimeOnly(in Cell cell, bool _, IFormatProvider provider, out TimeOnly value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseDateTimeText(in cell, provider, out DateTime dt))
- {
- return false;
- }
- setter(ref model, dt);
- return true;
- };
+ return TryParseTimeOnlyText(in cell, provider, out value);
}
+#pragma warning restore S1172
- private static ColumnParser BuildTextDateOnlyParser(PropertyInfo prop)
+ private static CellReader DateTimeReader(bool textDates)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseDateOnlyText(in cell, provider, out DateOnly d))
- {
- return false;
- }
- setter(ref model, d);
- return true;
- };
+ return textDates ? ReadTextDateTime : ReadDateTime;
}
- private static ColumnParser BuildTextNullableDateOnlyParser(PropertyInfo prop)
+ private static CellReader DateOnlyReader(bool textDates)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseDateOnlyText(in cell, provider, out DateOnly d))
- {
- return false;
- }
- setter(ref model, d);
- return true;
- };
+ return textDates ? ReadTextDateOnly : ReadDateOnly;
}
- private static ColumnParser BuildTextTimeOnlyParser(PropertyInfo prop)
+ private static CellReader TimeOnlyReader(bool textDates)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseTimeOnlyText(in cell, provider, out TimeOnly t))
- {
- return false;
- }
- setter(ref model, t);
- return true;
- };
+ return textDates ? ReadTextTimeOnly : ReadTimeOnly;
}
- private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo prop)
+ // Excel time serial -> TimeOnly: the fractional part of the day, rounded to the nearest tick to
+ // undo the double round-trip. A value that rounds up to a whole day wraps back to midnight.
+ private static TimeOnly TimeOnlyFromSerial(double serial)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, provider) =>
- {
- if (!TryParseTimeOnlyText(in cell, provider, out TimeOnly t))
- {
- return false;
- }
- setter(ref model, t);
- return true;
- };
+ double fraction = serial - Math.Floor(serial);
+ long ticks = (long)Math.Round(fraction * TimeSpan.TicksPerDay, MidpointRounding.AwayFromZero);
+ return new TimeOnly(ticks == TimeSpan.TicksPerDay ? 0 : ticks);
}
// DateTime/DateOnly implement ISpanParsable (char) and IUtf8SpanFormattable, but NOT
@@ -372,6 +299,7 @@ private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo p
// e.g. pt-BR "02/07/2026"). Falls back to a string for pathologically long fields.
private const int MaxStackDateChars = 128;
+ [SkipLocalsInit]
private static bool TryParseDateTimeText(in Cell cell, IFormatProvider provider, out DateTime value)
{
ReadOnlySpan utf8 = cell.Value;
@@ -392,6 +320,7 @@ private static bool TryParseDateTimeText(in Cell cell, IFormatProvider provider,
return DateTime.TryParse(cell.GetString(), provider, DateTimeStyles.None, out value);
}
+ [SkipLocalsInit]
private static bool TryParseDateOnlyText(in Cell cell, IFormatProvider provider, out DateOnly value)
{
ReadOnlySpan utf8 = cell.Value;
@@ -404,6 +333,7 @@ private static bool TryParseDateOnlyText(in Cell cell, IFormatProvider provider,
return DateOnly.TryParse(cell.GetString(), provider, DateTimeStyles.None, out value);
}
+ [SkipLocalsInit]
private static bool TryParseTimeOnlyText(in Cell cell, IFormatProvider provider, out TimeOnly value)
{
ReadOnlySpan utf8 = cell.Value;
@@ -431,48 +361,6 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop)
};
}
- private static ColumnParser BuildNullableBoolParser(PropertyInfo prop)
- {
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
- {
- if (!TryParseBool(in cell, out bool value))
- {
- return false;
- }
- setter(ref model, value);
- return true;
- };
- }
-
- private static ColumnParser BuildNullableDateTimeParser(PropertyInfo prop)
- {
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, isDate1904, _) =>
- {
- if (!cell.TryGetDateTime(isDate1904, out DateTime dt))
- {
- return false;
- }
- setter(ref model, dt);
- return true;
- };
- }
-
- private static ColumnParser BuildNullableDateOnlyParser(PropertyInfo prop)
- {
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, isDate1904, _) =>
- {
- if (!cell.TryGetDateTime(isDate1904, out DateTime dt))
- {
- return false;
- }
- setter(ref model, DateOnly.FromDateTime(dt));
- return true;
- };
- }
-
[SuppressMessage("Blocker Code Smell", "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields",
Justification = "Called via MakeGenericMethod dispatch; private access is intentional and type-safe.")]
private static ColumnParser BuildNullableParsableCore(PropertyInfo prop)
@@ -492,6 +380,7 @@ private static ColumnParser BuildNullableParsableCore(PropertyInfo
}
#if NET8_0
+ [SkipLocalsInit]
private static bool TryParseGuid(in Cell cell, out Guid value)
{
ReadOnlySpan utf8 = cell.Value;
@@ -533,35 +422,13 @@ private static bool TryParseGuid(in Cell cell, out Guid value)
}
}
- // Guid does not implement IUtf8SpanParsable on all targets, so parse from the string
- // form rather than the UTF-8 generic dispatch. Culture is irrelevant for Guid.
- private static ColumnParser BuildGuidParser(PropertyInfo prop)
+ private static bool ReadGuid(in Cell cell, bool isDate1904, IFormatProvider provider, out Guid value)
{
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
- {
- if (!TryParseGuid(in cell, out Guid value))
- {
- return false;
- }
- setter(ref model, value);
- return true;
- };
+ return TryParseGuid(in cell, out value);
}
- private static ColumnParser BuildNullableGuidParser(PropertyInfo prop)
- {
- RefAction setter = CompileSetter(prop);
- return (ref model, in cell, _, _) =>
- {
- if (!TryParseGuid(in cell, out Guid value))
- {
- return false;
- }
- setter(ref model, value);
- return true;
- };
- }
+ // Guid does not implement IUtf8SpanParsable on all targets, so parse from the string
+ // form rather than the UTF-8 generic dispatch. Culture is irrelevant for Guid.
#endif
private static class EnumCache
@@ -644,6 +511,8 @@ private static FrozenDictionary BuildValueMap()
}
return map.ToFrozenDictionary();
}
+
+ [SkipLocalsInit]
public static bool TryParse(in Cell cell, out TEnum value)
{
if (cell.Type == CellType.Number && cell.TryGetDouble(out double d))
diff --git a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
index 5931b723..68cb6b8a 100644
--- a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
+++ b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs
@@ -1,6 +1,5 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
using ExcelReader.Core.Enums;
using ExcelReader.Core.Reader;
using ExcelReader.Core.ValueObjects;
@@ -13,7 +12,7 @@ namespace ExcelReader.Core.Parser.Internal
// CellDesc re-walk, no per-cell binding search. It reuses the same compiled ColumnParser
// delegates as the generic parser, but from the CSV type-map (dates parse text, not serials).
[SuppressMessage("Design", "CA1034:Nested types should not be visible",
- Justification = "Public nested struct Enumerator is the standard foreach pattern.")]
+ Justification = "Public nested Enumerator/AsyncEnumerator are the standard foreach/await-foreach pattern.")]
public sealed class CsvEnumerable : IEnumerable, IAsyncEnumerable
{
private readonly CsvReader _reader;
@@ -29,6 +28,8 @@ internal CsvEnumerable(CsvReader reader, ExcelParserConfig config, CancellationT
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP015:Member should not return created and cached instance",
Justification = "Each call creates a fresh enumerator; no caching.")]
+ [SuppressMessage("Performance", "HLQ006:GetEnumerator should return a value type",
+ Justification = "Enumerator is a class so the sync and async paths can share the SyncRowEnumerator/AsyncRowEnumerator base plumbing.")]
public Enumerator GetEnumerator()
{
TypeMapInfo info = TypeMapper.GetCsvInfo();
@@ -60,13 +61,9 @@ public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken =
return new AsyncEnumerator(_reader, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, effective);
}
- public struct Enumerator : IEnumerator
+ public sealed class Enumerator : SyncRowEnumerator
{
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP006:Implement IDisposable",
- Justification = "Struct implements IDisposable; rows disposed in Dispose().")]
- private readonly CsvReader.Enumerator _rows;
private CsvRowProjector _projector;
- private T _current = default!;
internal Enumerator(
CsvReader.Enumerator rows,
@@ -75,52 +72,20 @@ internal Enumerator(
HeaderNormalization normalization,
int headerRow,
IFormatProvider provider)
+ : base(rows)
{
- _rows = rows;
_projector = new CsvRowProjector(typeInfo, comparer, normalization, headerRow, provider);
}
- public readonly T Current => _current;
-
- readonly object? IEnumerator.Current => _current;
-
- public bool MoveNext()
+ private protected override ProjectionStep Project()
{
- while (_rows.MoveNext())
- {
- switch (_projector.Advance(_rows, ref _current))
- {
- case ProjectionStep.Yield:
- return true;
- case ProjectionStep.Stop:
- return false;
- }
- }
- return false;
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
- Justification = "The enumerator owns _rows; it was created by GetEnumerator, not injected from outside.")]
- public readonly void Dispose()
- {
- _rows.Dispose();
- }
-
- public void Reset()
- {
- throw new NotSupportedException();
+ return _projector.Advance(Rows, ref CurrentValue);
}
}
- public sealed class AsyncEnumerator : IAsyncEnumerator
+ public sealed class AsyncEnumerator : AsyncRowEnumerator
{
- // Borrowed: the caller owns the CsvReader's lifetime. Only _rows (opened here) is disposed.
- [SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Borrowed, not owned.")]
- private readonly CsvReader _reader;
- private readonly CancellationToken _ct;
private CsvRowProjector _projector;
- private CsvReader.Enumerator? _rows;
- private T _current = default!;
internal AsyncEnumerator(
CsvReader reader,
@@ -130,81 +95,16 @@ internal AsyncEnumerator(
int headerRow,
IFormatProvider provider,
CancellationToken ct)
+ : base(reader, ct)
{
- _reader = reader;
_projector = new CsvRowProjector(typeInfo, comparer, normalization, headerRow, provider);
- _ct = ct;
- }
-
- public T Current => _current;
-
- // Non-async fast path once _rows exists — see ExcelEnumerable.AsyncEnumerator.MoveNextAsync
- // for the rationale: CsvReader.Enumerator.MoveNextAsync already resolves synchronously for
- // ~99.9% of records, so this avoids paying for a second state machine on top of that one.
- [SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task",
- Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
- [SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks",
- Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
- public ValueTask MoveNextAsync()
- {
- if (_rows is null)
- {
- return AdvanceAsync();
- }
- while (true)
- {
- ValueTask moveTask = _rows.MoveNextAsync();
- if (!moveTask.IsCompletedSuccessfully)
- {
- return AwaitThenContinueAsync(moveTask);
- }
- if (!moveTask.Result)
- {
- return new ValueTask(false);
- }
- switch (Project())
- {
- case ProjectionStep.Yield:
- return new ValueTask(true);
- case ProjectionStep.Stop:
- return new ValueTask(false);
- // Skip: loop again, still synchronous.
- }
- }
- }
-
- private async ValueTask AwaitThenContinueAsync(ValueTask pendingMoveNext)
- {
- if (!await pendingMoveNext.ConfigureAwait(false))
- {
- return false;
- }
- switch (Project())
- {
- case ProjectionStep.Yield:
- return true;
- case ProjectionStep.Stop:
- return false;
- }
- return await MoveNextAsync().ConfigureAwait(false); // Skip: resume the fast path.
- }
-
- private async ValueTask AdvanceAsync()
- {
- _rows = await _reader.GetAsyncEnumeratorAsync(_ct).ConfigureAwait(false);
- return await MoveNextAsync().ConfigureAwait(false);
}
// Synchronous projection step: the ref-struct Cells never escape this call, so no span
- // is held across the await in AdvanceAsync.
- private ProjectionStep Project()
- {
- return _projector.Advance(_rows!, ref _current);
- }
-
- public ValueTask DisposeAsync()
+ // is held across the await in the base class's AdvanceAsync.
+ private protected override ProjectionStep Project()
{
- return _rows is null ? ValueTask.CompletedTask : _rows.DisposeAsync();
+ return _projector.Advance(Rows!, ref CurrentValue);
}
}
}
@@ -237,19 +137,15 @@ internal CsvRowProjector(TypeMapInfo typeInfo, StringComparer comparer, Heade
internal ProjectionStep Advance(CsvReader.Enumerator rows, ref T model)
{
- _rowNumber++;
- if (_rowNumber < _headerRow)
- {
- return ProjectionStep.Skip;
- }
- if (_rowNumber == _headerRow)
+ ProjectionStep step = ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _fieldParsers is not null);
+ if (step == ProjectionStep.BuildMap)
{
BuildColumnMap(rows);
return ProjectionStep.Skip;
}
- if (_fieldParsers is null)
+ if (step != ProjectionStep.Yield)
{
- return ProjectionStep.Stop;
+ return step;
}
// The raw CSV reader intentionally exposes a terminal blank line as one empty field.
// Typed projection treats it as absent so it cannot yield a phantom model or fail Required.
@@ -337,8 +233,7 @@ private readonly void ParseCurrentRow(CsvReader.Enumerator rows, ref T model)
{
if (field >= fieldCount || rows.FieldAt(field).Type == CellType.Empty)
{
- throw new InvalidOperationException(
- $"Required column '{name}' has no value in row {_rowNumber.ToString(CultureInfo.InvariantCulture)}.");
+ throw ProjectionRules.MissingRequiredValue(name, _rowNumber);
}
}
}
diff --git a/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs
index c55e241d..e1ddba92 100644
--- a/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs
+++ b/src/ExcelReader.Core/Parser/Internal/ExcelEnumerable.cs
@@ -14,10 +14,10 @@ internal ExcelEnumerable(XlsxReader reader, ExcelParserConfig config, Cancellati
}
[SuppressMessage("Design", "CA1034:Nested types should not be visible",
- Justification = "Public nested struct Enumerator is the standard foreach pattern.")]
+ Justification = "Public nested Enumerator/AsyncEnumerator are the standard foreach/await-foreach pattern.")]
public class ExcelEnumerable : IEnumerable, IAsyncEnumerable
where TReader : IExcelRowReader
- where TEnumerator : IExcelRowEnumerator
+ where TEnumerator : class, IExcelRowEnumerator
{
private readonly TReader _reader;
private readonly ExcelParserConfig _config;
@@ -32,6 +32,8 @@ internal ExcelEnumerable(TReader reader, ExcelParserConfig config, CancellationT
[SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP015:Member should not return created and cached instance",
Justification = "Each call creates a fresh enumerator; no caching.")]
+ [SuppressMessage("Performance", "HLQ006:GetEnumerator should return a value type",
+ Justification = "Enumerator is a class so the sync and async paths can share the SyncRowEnumerator/AsyncRowEnumerator base plumbing.")]
public Enumerator GetEnumerator()
{
TypeMapInfo info = TypeMapper.GetInfo();
@@ -54,6 +56,8 @@ IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken can
return GetAsyncEnumerator(cancellationToken);
}
+ [SuppressMessage("Performance", "HLQ006:GetAsyncEnumerator should return a value type",
+ Justification = "Async enumerator requires a class to host the async state machine.")]
public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
TypeMapInfo info = TypeMapper.GetInfo();
@@ -61,13 +65,9 @@ public AsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken =
return new AsyncEnumerator(_reader, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _config.Culture, effective);
}
- public struct Enumerator : IEnumerator
+ public sealed class Enumerator : SyncRowEnumerator
{
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP006:Implement IDisposable",
- Justification = "Struct implements IDisposable; rows disposed in Dispose().")]
- private readonly TEnumerator _rows;
private RowProjector _projector;
- private T _current = default!;
internal Enumerator(
TEnumerator rows,
@@ -77,51 +77,21 @@ internal Enumerator(
int headerRow,
bool isDate1904,
IFormatProvider provider)
+ : base(rows)
{
- _rows = rows;
_projector = new RowProjector(typeInfo, comparer, normalization, headerRow, isDate1904, provider);
}
- public readonly T Current => _current;
-
- readonly object? IEnumerator.Current => _current;
-
- public bool MoveNext()
- {
- while (_rows.MoveNext())
- {
- Row row = _rows.Current;
- switch (_projector.Advance(in row, ref _current))
- {
- case ProjectionStep.Yield:
- return true;
- case ProjectionStep.Stop:
- return false;
- }
- }
- return false;
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
- Justification = "The enumerator owns _rows; it was created by GetEnumerator, not injected from outside.")]
- public readonly void Dispose()
+ private protected override ProjectionStep Project()
{
- _rows.Dispose();
- }
-
- public void Reset()
- {
- throw new NotSupportedException();
+ Row row = Rows.Current;
+ return _projector.Advance(in row, ref CurrentValue);
}
}
- public sealed class AsyncEnumerator : IAsyncEnumerator
+ public sealed class AsyncEnumerator : AsyncRowEnumerator
{
- private readonly TReader _reader;
- private readonly CancellationToken _ct;
private RowProjector _projector;
- private TEnumerator? _rows;
- private T _current = default!;
internal AsyncEnumerator(
TReader reader,
@@ -131,88 +101,16 @@ internal AsyncEnumerator(
int headerRow,
IFormatProvider provider,
CancellationToken ct)
+ : base(reader, ct)
{
- _reader = reader;
- _ct = ct;
_projector = new RowProjector(typeInfo, comparer, normalization, headerRow, reader.IsDate1904, provider);
}
- public T Current => _current;
-
- // Non-async fast path once _rows exists: TEnumerator.MoveNextAsync is itself already
- // "check synchronously, only await on a genuine buffer miss" (see e.g.
- // XlsxReader.Enumerator.MoveNextAsync), but stacking this method's own `async` on top of
- // that meant every row paid for a second state machine. This returns a completed ValueTask
- // whenever the row-enumerator call and the projection both resolve synchronously, only
- // falling to an awaiting continuation on a genuine miss.
- [SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task",
- Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
- [SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks",
- Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
- public ValueTask MoveNextAsync()
- {
- if (_rows is null)
- {
- return AdvanceAsync();
- }
- while (true)
- {
- ValueTask moveTask = _rows.MoveNextAsync();
- if (!moveTask.IsCompletedSuccessfully)
- {
- return AwaitThenContinueAsync(moveTask);
- }
- if (!moveTask.Result)
- {
- return new ValueTask(false);
- }
- switch (Project())
- {
- case ProjectionStep.Yield:
- return new ValueTask(true);
- case ProjectionStep.Stop:
- return new ValueTask(false);
- // Skip: loop again, still synchronous.
- }
- }
- }
-
- private async ValueTask AwaitThenContinueAsync(ValueTask pendingMoveNext)
- {
- if (!await pendingMoveNext.ConfigureAwait(false))
- {
- return false;
- }
- switch (Project())
- {
- case ProjectionStep.Yield:
- return true;
- case ProjectionStep.Stop:
- return false;
- }
- return await MoveNextAsync().ConfigureAwait(false); // Skip: resume the fast path.
- }
-
- private async ValueTask AdvanceAsync()
- {
- _rows = await _reader.GetAsyncEnumeratorAsync(_ct).ConfigureAwait(false);
- return await MoveNextAsync().ConfigureAwait(false);
- }
-
- private ProjectionStep Project()
- {
- Row row = _rows!.Current;
- return _projector.Advance(in row, ref _current);
- }
-
- public ValueTask DisposeAsync()
+ private protected override ProjectionStep Project()
{
- if (_rows is null)
- {
- return ValueTask.CompletedTask;
- }
- return _rows.DisposeAsync();
+ Row row = Rows!.Current;
+ return _projector.Advance(in row, ref CurrentValue);
}
}
}
-}
\ No newline at end of file
+}
diff --git a/src/ExcelReader.Core/Parser/Internal/ProjectionRules.cs b/src/ExcelReader.Core/Parser/Internal/ProjectionRules.cs
new file mode 100644
index 00000000..9246a514
--- /dev/null
+++ b/src/ExcelReader.Core/Parser/Internal/ProjectionRules.cs
@@ -0,0 +1,27 @@
+using System.Globalization;
+
+namespace ExcelReader.Core.Parser.Internal
+{
+ internal static class ProjectionRules
+ {
+ internal static ProjectionStep ClassifyRow(ref int rowNumber, int headerRow, bool mapBuilt)
+ {
+ rowNumber++;
+ if (rowNumber < headerRow)
+ {
+ return ProjectionStep.Skip;
+ }
+ if (rowNumber == headerRow)
+ {
+ return ProjectionStep.BuildMap;
+ }
+ return mapBuilt ? ProjectionStep.Yield : ProjectionStep.Stop;
+ }
+
+ internal static InvalidOperationException MissingRequiredValue(string name, int row)
+ {
+ return new InvalidOperationException(
+ $"Required column '{name}' has no value in row {row.ToString(CultureInfo.InvariantCulture)}.");
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Parser/Internal/ProjectionStep.cs b/src/ExcelReader.Core/Parser/Internal/ProjectionStep.cs
index cd464e09..6b883a49 100644
--- a/src/ExcelReader.Core/Parser/Internal/ProjectionStep.cs
+++ b/src/ExcelReader.Core/Parser/Internal/ProjectionStep.cs
@@ -5,6 +5,7 @@ namespace ExcelReader.Core.Parser.Internal
internal enum ProjectionStep
{
Skip,
+ BuildMap,
Yield,
Stop,
}
diff --git a/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs b/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs
new file mode 100644
index 00000000..94b0b7e7
--- /dev/null
+++ b/src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs
@@ -0,0 +1,149 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+using ExcelReader.Core.Reader;
+
+namespace ExcelReader.Core.Parser.Internal
+{
+ // Shared sync enumerator plumbing for every row-projecting IEnumerable (Excel formats + CSV):
+ // loop until Rows.MoveNext() is exhausted, project each row via the format-specific Project()
+ // override, stopping early on ProjectionStep.Stop. Project() is the only thing that differs per
+ // format (Excel walks Row.Cells generically via RowProjector; CSV binds fields by dense index
+ // via CsvRowProjector) - see ExcelEnumerable.Enumerator and
+ // CsvEnumerable.Enumerator. Public because it is the base class of those public nested types
+ // (a base class can never be less accessible than its derived type).
+ [SuppressMessage("Design", "CA1034:Nested types should not be visible",
+ Justification = "Base class of the public nested Enumerator types; not itself meant for direct external use.")]
+ public abstract class SyncRowEnumerator : IEnumerator
+ where TRows : class, IExcelRowEnumerator
+ {
+ [SuppressMessage("Performance", "HLQ011:ReadOnlyEnumeratorField",
+ Justification = "TRows is constrained to `class` here, so it is always a reference type — no copy-on-mutate risk from a readonly field.")]
+ protected readonly TRows Rows;
+ protected T CurrentValue = default!;
+
+ protected SyncRowEnumerator(TRows rows)
+ {
+ Rows = rows;
+ }
+
+ public T Current => CurrentValue;
+
+ object? IEnumerator.Current => CurrentValue;
+
+ public bool MoveNext()
+ {
+ while (Rows.MoveNext())
+ {
+ switch (Project())
+ {
+ case ProjectionStep.Yield:
+ return true;
+ case ProjectionStep.Stop:
+ return false;
+ }
+ }
+ return false;
+ }
+
+ private protected abstract ProjectionStep Project();
+
+ public void Dispose()
+ {
+ Rows.Dispose();
+ }
+
+ public void Reset()
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ // Shared async enumerator plumbing. Mirrors SyncRowEnumerator, plus the lazy TRows acquisition
+ // (the reader's GetAsyncEnumeratorAsync may itself need to await) and the sync-completion fast
+ // path: MoveNextAsync returns an already-completed ValueTask whenever the row-enumerator call and
+ // the projection both resolve synchronously (the common case), only falling to an awaiting
+ // continuation on a genuine buffer miss - this avoids paying for a second state machine on top of
+ // the row-enumerator's own (e.g. XlsxReader.Enumerator.MoveNextAsync / CsvReader.Enumerator.MoveNextAsync).
+ [SuppressMessage("Design", "CA1034:Nested types should not be visible",
+ Justification = "Base class of the public nested AsyncEnumerator types; not itself meant for direct external use.")]
+ public abstract class AsyncRowEnumerator : IAsyncEnumerator
+ where TReader : IExcelRowReader
+ where TRows : class, IExcelRowEnumerator
+ {
+ // Borrowed: the caller owns the reader's lifetime. Only Rows (opened here) is disposed.
+ [SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Borrowed, not owned.")]
+ private readonly TReader _reader;
+ private readonly CancellationToken _ct;
+ protected TRows? Rows;
+ protected T CurrentValue = default!;
+
+ protected AsyncRowEnumerator(TReader reader, CancellationToken ct)
+ {
+ _reader = reader;
+ _ct = ct;
+ }
+
+ public T Current => CurrentValue;
+
+ [SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task",
+ Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
+ [SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks",
+ Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")]
+ public ValueTask MoveNextAsync()
+ {
+ if (Rows is null)
+ {
+ return AdvanceAsync();
+ }
+ while (true)
+ {
+ ValueTask moveTask = Rows.MoveNextAsync();
+ if (!moveTask.IsCompletedSuccessfully)
+ {
+ return AwaitThenContinueAsync(moveTask);
+ }
+ if (!moveTask.Result)
+ {
+ return new ValueTask(false);
+ }
+ switch (Project())
+ {
+ case ProjectionStep.Yield:
+ return new ValueTask(true);
+ case ProjectionStep.Stop:
+ return new ValueTask(false);
+ // Skip: loop again, still synchronous.
+ }
+ }
+ }
+
+ private protected abstract ProjectionStep Project();
+
+ private async ValueTask AwaitThenContinueAsync(ValueTask pendingMoveNext)
+ {
+ if (!await pendingMoveNext.ConfigureAwait(false))
+ {
+ return false;
+ }
+ switch (Project())
+ {
+ case ProjectionStep.Yield:
+ return true;
+ case ProjectionStep.Stop:
+ return false;
+ }
+ return await MoveNextAsync().ConfigureAwait(false); // Skip: resume the fast path.
+ }
+
+ private async ValueTask AdvanceAsync()
+ {
+ Rows = await _reader.GetAsyncEnumeratorAsync(_ct).ConfigureAwait(false);
+ return await MoveNextAsync().ConfigureAwait(false);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ return Rows is null ? ValueTask.CompletedTask : Rows.DisposeAsync();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs
index 868b8ac1..13e5cd41 100644
--- a/src/ExcelReader.Core/Parser/Internal/RowProjector.cs
+++ b/src/ExcelReader.Core/Parser/Internal/RowProjector.cs
@@ -33,19 +33,15 @@ internal RowProjector(TypeMapInfo typeInfo, StringComparer comparer, HeaderNo
// the header, build the column map at the header row, then project each subsequent row.
internal ProjectionStep Advance(in Row row, ref T model)
{
- _rowNumber++;
- if (_rowNumber < _headerRow)
- {
- return ProjectionStep.Skip;
- }
- if (_rowNumber == _headerRow)
+ ProjectionStep step = ProjectionRules.ClassifyRow(ref _rowNumber, _headerRow, _bindings is not null);
+ if (step == ProjectionStep.BuildMap)
{
BuildColumnMap(in row);
return ProjectionStep.Skip;
}
- if (_bindings is null)
+ if (step != ProjectionStep.Yield)
{
- return ProjectionStep.Stop;
+ return step;
}
model = _typeInfo.CreateInstance();
ParseCurrentRow(in row, ref model);
@@ -161,8 +157,7 @@ private readonly void ValidateRowValues(ColumnBinding[] bindings)
{
if (bindings[i].RequireValue && !_seen[i])
{
- throw new InvalidOperationException(
- $"Required column '{bindings[i].Name}' has no value in row {_rowNumber.ToString(System.Globalization.CultureInfo.InvariantCulture)}.");
+ throw ProjectionRules.MissingRequiredValue(bindings[i].Name, _rowNumber);
}
}
}
diff --git a/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs
deleted file mode 100644
index 3f0e93fe..00000000
--- a/src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using System.Collections;
-using System.Diagnostics.CodeAnalysis;
-using ExcelReader.Core.Reader;
-using ExcelReader.Core.ValueObjects;
-
-namespace ExcelReader.Core.Parser.Internal
-{
- [SuppressMessage("Design", "CA1034:Nested types should not be visible",
- Justification = "Public nested struct Enumerator is the standard foreach pattern.")]
- public sealed class XlsExcelEnumerable : IEnumerable, IAsyncEnumerable
- {
- private readonly XlsReader _reader;
- private readonly ExcelParserConfig _config;
- private readonly CancellationToken _ct;
-
- internal XlsExcelEnumerable(XlsReader reader, ExcelParserConfig config, CancellationToken ct = default)
- {
- _reader = reader;
- _config = config;
- _ct = ct;
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP015:Member should not return created and cached instance",
- Justification = "Each call creates a fresh enumerator; no caching.")]
- public Enumerator GetEnumerator()
- {
- TypeMapInfo info = TypeMapper.GetInfo();
- XlsReader.Enumerator rows = _reader.GetEnumerator();
- return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _reader.IsDate1904, _config.Culture);
- }
-
- IEnumerator IEnumerable.GetEnumerator()
- {
- return GetEnumerator();
- }
-
- IEnumerator IEnumerable.GetEnumerator()
- {
- return GetEnumerator();
- }
-
- public Enumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
- {
- TypeMapInfo info = TypeMapper.GetInfo();
- CancellationToken effective = cancellationToken.CanBeCanceled ? cancellationToken : _ct;
- XlsReader.Enumerator rows = _reader.GetAsyncEnumerator(effective);
- return new Enumerator(rows, info, _config.ColumnNameComparer, _config.HeaderNormalization, _config.HeaderRow, _reader.IsDate1904, _config.Culture);
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP005:Return type should indicate that the value should be disposed",
- Justification = "Returns a fresh enumerator the await-foreach pattern disposes; the struct overload it forwards to does not surface IAsyncDisposable.")]
- IAsyncEnumerator IAsyncEnumerable.GetAsyncEnumerator(CancellationToken cancellationToken)
- {
- return GetAsyncEnumerator(cancellationToken);
- }
-
- public struct Enumerator : IEnumerator, IAsyncEnumerator
- {
- private readonly XlsReader.Enumerator _rows;
- private RowProjector _projector;
- private T _current = default!;
-
- internal Enumerator(
- XlsReader.Enumerator rows,
- TypeMapInfo typeInfo,
- StringComparer comparer,
- HeaderNormalization normalization,
- int headerRow,
- bool isDate1904,
- IFormatProvider provider)
- {
- _rows = rows;
- _projector = new RowProjector(typeInfo, comparer, normalization, headerRow, isDate1904, provider);
- }
-
- public readonly T Current => _current;
- readonly object? IEnumerator.Current => _current;
-
- public bool MoveNext()
- {
- while (_rows.MoveNext())
- {
- Row row = _rows.Current;
- switch (_projector.Advance(in row, ref _current))
- {
- case ProjectionStep.Yield:
- return true;
- case ProjectionStep.Stop:
- return false;
- }
- }
- return false;
- }
-
- public ValueTask MoveNextAsync()
- {
- return new ValueTask(MoveNext());
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
- Justification = "The enumerator owns _rows — it was created by GetEnumerator, not injected from outside.")]
- public readonly void Dispose()
- {
- _rows.Dispose();
- }
-
- [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
- Justification = "The enumerator owns _rows — it was created by GetAsyncEnumerator, not injected from outside.")]
- public readonly ValueTask DisposeAsync()
- {
- return _rows.DisposeAsync();
- }
-
- public readonly void Reset()
- {
- throw new NotSupportedException();
- }
- }
- }
-}
diff --git a/src/ExcelReader.Core/Reader/Biff12RecordReader.cs b/src/ExcelReader.Core/Reader/Biff12RecordReader.cs
index a2b3c23a..9853f424 100644
--- a/src/ExcelReader.Core/Reader/Biff12RecordReader.cs
+++ b/src/ExcelReader.Core/Reader/Biff12RecordReader.cs
@@ -83,7 +83,7 @@ private readonly bool TryReadLength(ref int pos, out int length)
return true;
}
}
- return true;
+ return false;
}
}
}
diff --git a/src/ExcelReader.Core/Reader/CellAccumulator.cs b/src/ExcelReader.Core/Reader/CellAccumulator.cs
index f8983259..bb57a5c3 100644
--- a/src/ExcelReader.Core/Reader/CellAccumulator.cs
+++ b/src/ExcelReader.Core/Reader/CellAccumulator.cs
@@ -1,4 +1,5 @@
using System.Buffers;
+using System.Runtime.CompilerServices;
using ExcelReader.Core.Enums;
using ExcelReader.Core.ValueObjects;
@@ -86,11 +87,31 @@ internal int AppendErrorText(byte code)
return text.Length;
}
+ internal void AddBool(int col, int style, byte value)
+ {
+ int start = ValueLength;
+ AppendByte(value == 0 ? (byte)'0' : (byte)'1');
+ Add(col, start, 1, CellType.Boolean, style, fromShared: false);
+ }
+
+ internal void AddError(int col, int style, byte code)
+ {
+ int start = ValueLength;
+ int length = AppendErrorText(code);
+ Add(col, start, length, CellType.Error, style, fromShared: false);
+ }
+
internal void Add(int col, int start, int len, CellType type, int style, bool fromShared, double number = 0, bool hasNumber = false)
{
if (Count == _cells.Length)
{
- CellDesc[] bigger = ArrayPool.Shared.Rent(_cells.Length * 2);
+ int capacity = LimitChecks.NextBufferSize(
+ _maxCellBytes,
+ _limitName,
+ _cells.Length,
+ Count + 1,
+ Unsafe.SizeOf());
+ CellDesc[] bigger = ArrayPool.Shared.Rent(capacity);
Array.Copy(_cells, bigger, Count);
ArrayPool.Shared.Return(_cells);
_cells = bigger;
diff --git a/src/ExcelReader.Core/Reader/CsvReader.Enumerator.cs b/src/ExcelReader.Core/Reader/CsvReader.Enumerator.cs
index 0880d3f5..330be21c 100644
--- a/src/ExcelReader.Core/Reader/CsvReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/CsvReader.Enumerator.cs
@@ -16,7 +16,7 @@ public sealed partial class CsvReader
// sync and async share one parser and the async path awaits once per refill, not per field.
[SuppressMessage("Design", "CA1034:Nested types should not be visible",
Justification = "Public nested enumerator is the standard foreach pattern.")]
- public sealed class Enumerator : IExcelRowEnumerator
+ public sealed class Enumerator : PooledStreamRowEnumerator, IExcelRowEnumerator
{
private const byte Cr = (byte)'\r';
private const byte Lf = (byte)'\n';
@@ -24,20 +24,12 @@ public sealed class Enumerator : IExcelRowEnumerator
// Borrowed: CsvReader owns the stream's lifetime (it may be reused across enumerations).
[SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Borrowed, not owned.")]
[SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Borrowed, not owned.")]
- private readonly Stream _stream;
- private readonly CancellationToken _ct;
private readonly byte _delimiter;
private readonly byte _quote;
private readonly bool _stripBom;
- private readonly BufferedStreamCursor _io;
- private byte[] _buf => _io.Buf;
- private int _pos { get => _io.Pos; set => _io.Pos = value; }
- private int _len => _io.Len;
- private bool _eof => _io.Eof;
private bool _bomChecked;
- private readonly CellAccumulator _acc; // per-record decoded values + cell descriptors
private int _col;
// Current field's bytes, built incrementally as either a single contiguous run in _buf
@@ -55,14 +47,11 @@ private struct FieldState
}
internal Enumerator(Stream stream, CsvReaderOptions options, CancellationToken ct = default)
+ : base(stream, options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes), 64 * 1024, ct)
{
- _stream = stream;
- _ct = ct;
_delimiter = options.Delimiter;
_quote = options.Quote;
_stripBom = options.DetectEncodingFromByteOrderMark;
- _io = new BufferedStreamCursor(options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes));
- _acc = new CellAccumulator(options.MaxCellBytes, nameof(CsvReaderOptions.MaxCellBytes));
}
// Cells point either into _buf (the common, zero-copy case: unquoted or plain-quoted
@@ -431,26 +420,6 @@ private void CommitField(FieldState f)
// --- buffer management (shared with XlsxReader/XlsbReader via BufferedStreamCursor) ---
- private void Fill()
- {
- _io.Fill(_stream);
- }
-
- private ValueTask FillAsync()
- {
- return _io.FillAsync(_stream, _ct);
- }
-
- private void Ensure(int n)
- {
- _io.Ensure(_stream, n);
- }
-
- private ValueTask EnsureAsync(int n)
- {
- return _io.EnsureAsync(_stream, n, _ct);
- }
-
public void Dispose()
{
ReturnBuffers();
@@ -462,11 +431,6 @@ public ValueTask DisposeAsync()
return ValueTask.CompletedTask;
}
- private void ReturnBuffers()
- {
- _io.Return();
- _acc.Return();
- }
}
}
}
diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs
index fd3850de..a9488353 100644
--- a/src/ExcelReader.Core/Reader/Excel.cs
+++ b/src/ExcelReader.Core/Reader/Excel.cs
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Compression;
+using System.Runtime.CompilerServices;
using ExcelReader.Core.Enums;
namespace ExcelReader.Core.Reader
@@ -175,19 +176,12 @@ private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, Excel
}
catch
{
- if (!leaveOpen)
- {
- stream.Dispose();
- }
+ DisposeOnFailure(stream, leaveOpen);
throw;
}
if (format is ExcelFileFormat.Unknown)
{
- if (!leaveOpen)
- {
- stream.Dispose();
- }
- throw new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook.");
+ UnknownFormat(stream, leaveOpen);
}
return format switch
{
@@ -207,19 +201,13 @@ private static async ValueTask OpenSeekableAsync(Stream stream,
}
catch
{
- if (!leaveOpen)
- {
- await stream.DisposeAsync().ConfigureAwait(false);
- }
+ await DisposeOnFailureAsync(stream, leaveOpen).ConfigureAwait(false);
throw;
}
if (format is ExcelFileFormat.Unknown)
{
- if (!leaveOpen)
- {
- await stream.DisposeAsync().ConfigureAwait(false);
- }
- throw new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook.");
+ await DisposeOnFailureAsync(stream, leaveOpen).ConfigureAwait(false);
+ UnknownFormatException();
}
return format switch
{
@@ -230,10 +218,61 @@ private static async ValueTask OpenSeekableAsync(Stream stream,
};
}
+ [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
+ Justification = "Open and OpenAsync transfer ownership when leaveOpen is false.")]
+ private static void DisposeOnFailure(Stream stream, bool leaveOpen)
+ {
+ if (!leaveOpen)
+ {
+ stream.Dispose();
+ }
+ }
+
+ [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected",
+ Justification = "Open and OpenAsync transfer ownership when leaveOpen is false.")]
+ private static ValueTask DisposeOnFailureAsync(Stream stream, bool leaveOpen)
+ {
+ return leaveOpen ? ValueTask.CompletedTask : stream.DisposeAsync();
+ }
+
+ [DoesNotReturn]
+ private static void UnknownFormat(Stream stream, bool leaveOpen)
+ {
+ DisposeOnFailure(stream, leaveOpen);
+ UnknownFormatException();
+ }
+
+ [DoesNotReturn]
+ private static void UnknownFormatException()
+ {
+ throw new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook.");
+ }
+
// Detection peeks the 8-byte signature then rewinds. For ZIP streams, opens a temporary
// ZipArchive to distinguish XLSB ("xl/workbook.bin" present) from XLSX (XML workbook).
// Both XLSX and XLSB readers need a seekable source anyway (ZipArchive seeks the central
// directory), so requiring seek here costs nothing and keeps the peek cheap.
+ // Classifies the leading signature bytes shared by DetectSeekable/DetectSeekableAsync. Returns
+ // true (with the final answer) for XLS/Unknown; false means "it's a ZIP" and the caller must
+ // still peek the central directory to tell XLSB from XLSX - the one step that genuinely
+ // differs between the sync (stackalloc) and async (heap buffer, awaited zip dispose) paths.
+ private static bool TryClassifyHeader(ReadOnlySpan sig, out ExcelFileFormat format)
+ {
+ if (sig.StartsWith(XlsCompoundFile.Signature))
+ {
+ format = ExcelFileFormat.Xls;
+ return true;
+ }
+ if (!sig.StartsWith(ZipSignature))
+ {
+ format = ExcelFileFormat.Unknown;
+ return true;
+ }
+ format = default;
+ return false;
+ }
+
+ [SkipLocalsInit]
private static ExcelFileFormat DetectSeekable(Stream stream)
{
RequireSeekable(stream);
@@ -241,14 +280,9 @@ private static ExcelFileFormat DetectSeekable(Stream stream)
Span header = stackalloc byte[8];
int read = stream.ReadAtLeast(header, header.Length, throwOnEndOfStream: false);
stream.Position = start;
- ReadOnlySpan sig = header[..read];
- if (sig.StartsWith(XlsCompoundFile.Signature))
+ if (TryClassifyHeader(header[..read], out ExcelFileFormat format))
{
- return ExcelFileFormat.Xls;
- }
- if (!sig.StartsWith(ZipSignature))
- {
- return ExcelFileFormat.Unknown;
+ return format;
}
// Peek the central directory to distinguish XLSB from XLSX.
using var zipPeek = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
@@ -264,14 +298,9 @@ private static async ValueTask DetectSeekableAsync(Stream strea
byte[] header = new byte[8];
int read = await stream.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false, ct).ConfigureAwait(false);
stream.Position = start;
- ReadOnlySpan sig = header.AsSpan(0, read);
- if (sig.StartsWith(XlsCompoundFile.Signature))
- {
- return ExcelFileFormat.Xls;
- }
- if (!sig.StartsWith(ZipSignature))
+ if (TryClassifyHeader(header.AsSpan(0, read), out ExcelFileFormat format))
{
- return ExcelFileFormat.Unknown;
+ return format;
}
// Central directory read: open a temporary archive to peek entry names, then rewind.
// Declared outside await using so the ZipArchive variable is accessible inside the block.
diff --git a/src/ExcelReader.Core/Reader/LimitChecks.cs b/src/ExcelReader.Core/Reader/LimitChecks.cs
index 789b9ae1..486b62d0 100644
--- a/src/ExcelReader.Core/Reader/LimitChecks.cs
+++ b/src/ExcelReader.Core/Reader/LimitChecks.cs
@@ -12,13 +12,14 @@ internal static void ThrowIfOverSharedStringLimit(ExcelReaderOptions options, lo
// Format-agnostic core shared by ExcelReaderOptions (XLSX/XLSB/XLS) and CsvReaderOptions —
// both cap a single buffered cell/record the same way, just under different option types.
- internal static int NextBufferSize(int maxCellBytes, string limitName, int current, int needed)
+ internal static int NextBufferSize(int maxCellBytes, string limitName, int current, int needed, int elementSize = 1)
{
long doubled = (long)current * 2;
long next = Math.Max(doubled, needed);
- if (maxCellBytes > 0 && next > maxCellBytes)
+ long bytes = next * elementSize;
+ if (maxCellBytes > 0 && bytes > maxCellBytes)
{
- throw new ExcelLimitExceededException(limitName, maxCellBytes, next);
+ throw new ExcelLimitExceededException(limitName, maxCellBytes, bytes);
}
if (next > Array.MaxLength)
{
diff --git a/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs b/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs
new file mode 100644
index 00000000..226679fd
--- /dev/null
+++ b/src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs
@@ -0,0 +1,50 @@
+namespace ExcelReader.Core.Reader
+{
+ // Shared refill plumbing for concrete XLSX/XLSB/CSV enumerators. MoveNext stays concrete in each
+ // format; this base only owns the per-buffer operations and pooled row storage.
+ public abstract class PooledStreamRowEnumerator
+ {
+ private protected Stream? _source;
+ private protected readonly CancellationToken _ct;
+ private protected readonly BufferedStreamCursor _io;
+ private protected readonly CellAccumulator _acc;
+ private protected byte[] _buf => _io.Buf;
+ private protected int _pos { get => _io.Pos; set => _io.Pos = value; }
+ private protected int _len => _io.Len;
+ private protected bool _eof => _io.Eof;
+
+ private protected PooledStreamRowEnumerator(Stream source, int maxCellBytes, string limitName, int initialCapacity, CancellationToken ct)
+ {
+ _source = source;
+ _ct = ct;
+ _io = new BufferedStreamCursor(maxCellBytes, limitName, initialCapacity);
+ _acc = new CellAccumulator(maxCellBytes, limitName);
+ }
+
+ private protected void Fill()
+ {
+ _io.Fill(_source!);
+ }
+
+ private protected ValueTask FillAsync()
+ {
+ return _io.FillAsync(_source!, _ct);
+ }
+
+ private protected void Ensure(int count)
+ {
+ _io.Ensure(_source!, count);
+ }
+
+ private protected ValueTask EnsureAsync(int count)
+ {
+ return _io.EnsureAsync(_source!, count, _ct);
+ }
+
+ private protected void ReturnBuffers()
+ {
+ _io.Return();
+ _acc.Return();
+ }
+ }
+}
diff --git a/src/ExcelReader.Core/Reader/XlsCompoundFile.cs b/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
index 97e1ac2b..65b432b7 100644
--- a/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
+++ b/src/ExcelReader.Core/Reader/XlsCompoundFile.cs
@@ -1,6 +1,7 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
using static ExcelReader.Core.Reader.Biff12;
namespace ExcelReader.Core.Reader
@@ -84,6 +85,7 @@ private static (Stream Source, bool OwnsSource) EnsureSeekable(Stream stream, bo
return (ms, true);
}
+ [SkipLocalsInit]
private static WorkbookStream BuildWorkbook(Stream source, bool ownsSource)
{
if (source.Length < HeaderSize)
diff --git a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
index 1896534b..5cde940a 100644
--- a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
+++ b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs
@@ -62,34 +62,10 @@ private bool MoveNextCore()
break;
}
- if (id == Rec.Bof)
+ if (!ReadRecord(cursor, recordStart, id, data))
{
- if (data.Length < 4 || ReadU16(data, 0) != Biff8Version || ReadU16(data, 2) != SubstreamWorksheet)
- {
- throw new NotSupportedException("Only BIFF8 worksheet streams are supported.");
- }
- continue;
- }
- if (id == Rec.Eof)
- {
- _ended = true;
break;
}
- if (!TryGetCellRow(id, data, out int row))
- {
- continue;
- }
- if (_row < 0)
- {
- _row = row;
- }
- else if (row != _row)
- {
- cursor.Position = recordStart;
- break;
- }
-
- ParseCellRecord(id, data);
}
if (FinishRow())
{
@@ -99,6 +75,41 @@ private bool MoveNextCore()
return false;
}
+ // Returns false when the current row has ended or the worksheet stream reached EOF.
+ private bool ReadRecord(BiffCursor cursor, long recordStart, int id, ReadOnlySpan data)
+ {
+ if (id == Rec.Bof)
+ {
+ if (data.Length < 4 || ReadU16(data, 0) != Biff8Version || ReadU16(data, 2) != SubstreamWorksheet)
+ {
+ throw new NotSupportedException("Only BIFF8 worksheet streams are supported.");
+ }
+ return true;
+ }
+ if (id == Rec.Eof)
+ {
+ _ended = true;
+ return false;
+ }
+ if (!TryGetCellRow(id, data, out int row))
+ {
+ return true;
+ }
+ if (_row < 0)
+ {
+ _row = row;
+ ParseCellRecord(id, data);
+ return true;
+ }
+ if (row != _row)
+ {
+ cursor.Position = recordStart;
+ return false;
+ }
+ ParseCellRecord(id, data);
+ return true;
+ }
+
private bool FinishRow()
{
_acc.SortByColumn();
@@ -173,7 +184,7 @@ private void ParseCellRecord(int id, ReadOnlySpan