From 4a3131ee909a3a66ac21d667b23e9f8216a59ca7 Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Tue, 14 Jul 2026 13:59:53 -0300 Subject: [PATCH 1/7] Refactor CSV and Excel enumerators to use shared base classes for synchronous and asynchronous row enumeration - Introduced `SyncRowEnumerator` and `AsyncRowEnumerator` base classes to encapsulate common enumeration logic for CSV and Excel formats. - Updated `CsvEnumerable` and `ExcelEnumerable` to inherit from the new base classes, simplifying their implementations. - Removed redundant code and improved performance by leveraging shared functionality. - Added `ZipReaderOpen` utility class to streamline ZIP archive handling in `XlsxReader` and `XlsbReader`. - Implemented `WriterStateGuard` for consistent state management across workbook writers. - Enhanced error handling and resource management in ZIP-related operations. - Deleted obsolete `XlsExcelEnumerable` class as its functionality is now covered by the refactored enumerators. --- src/ExcelReader.Core/Parser/ExcelParser.cs | 8 +- .../Parser/Internal/ColumnParserFactory.cs | 267 ++++++------------ .../Parser/Internal/CsvEnumerable.cs | 123 +------- .../Parser/Internal/ExcelEnumerable.cs | 136 ++------- .../Parser/Internal/RowEnumeratorBase.cs | 149 ++++++++++ .../Parser/Internal/XlsExcelEnumerable.cs | 120 -------- src/ExcelReader.Core/Reader/Excel.cs | 38 ++- src/ExcelReader.Core/Reader/XlsbReader.cs | 38 +-- src/ExcelReader.Core/Reader/XlsxReader.cs | 38 +-- src/ExcelReader.Core/Reader/ZipReaderOpen.cs | 44 +++ .../Writer/Internal/WriterStateGuard.cs | 48 ++++ src/ExcelReader.Core/Writer/WorkbookWriter.cs | 39 +-- .../Writer/XlsWorkbookWriter.cs | 21 +- .../Writer/XlsbWorkbookWriter.cs | 33 +-- 14 files changed, 411 insertions(+), 691 deletions(-) create mode 100644 src/ExcelReader.Core/Parser/Internal/RowEnumeratorBase.cs delete mode 100644 src/ExcelReader.Core/Parser/Internal/XlsExcelEnumerable.cs create mode 100644 src/ExcelReader.Core/Reader/ZipReaderOpen.cs create mode 100644 src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs 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..3413004a 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -196,12 +196,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,66 +216,80 @@ 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) + 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 provider, 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 provider, 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; + } + + private static bool ReadTextDateTime(in Cell cell, bool isDate1904, IFormatProvider provider, out DateTime value) + { + return TryParseDateTimeText(in cell, provider, out value); + } + + private static bool ReadTextDateOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out DateOnly value) + { + return TryParseDateOnlyText(in cell, provider, out value); } + private static bool ReadTextTimeOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out TimeOnly value) + { + return TryParseTimeOnlyText(in cell, provider, out value); + } + + private static ColumnParser BuildBoolParser(PropertyInfo prop) => BuildValue(prop, ReadBool); + + private static ColumnParser BuildDateTimeParser(PropertyInfo prop) => BuildValue(prop, ReadDateTime); + + private static ColumnParser BuildDateOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadDateOnly); + + private static ColumnParser BuildTimeOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTimeOnly); + + private static ColumnParser BuildNullableTimeOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTimeOnly); + // 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) @@ -282,89 +302,17 @@ private static TimeOnly TimeOnlyFromSerial(double serial) // 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) - { - 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; - }; - } + private static ColumnParser BuildTextDateTimeParser(PropertyInfo prop) => BuildValue(prop, ReadTextDateTime); - private static ColumnParser BuildTextNullableDateTimeParser(PropertyInfo prop) - { - 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; - }; - } + private static ColumnParser BuildTextNullableDateTimeParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextDateTime); - private static ColumnParser BuildTextDateOnlyParser(PropertyInfo prop) - { - 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; - }; - } + private static ColumnParser BuildTextDateOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTextDateOnly); - private static ColumnParser BuildTextNullableDateOnlyParser(PropertyInfo prop) - { - 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; - }; - } + private static ColumnParser BuildTextNullableDateOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextDateOnly); - private static ColumnParser BuildTextTimeOnlyParser(PropertyInfo prop) - { - 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; - }; - } + private static ColumnParser BuildTextTimeOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTextTimeOnly); - private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo prop) - { - 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; - }; - } + private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextTimeOnly); // DateTime/DateOnly implement ISpanParsable (char) and IUtf8SpanFormattable, but NOT // IUtf8SpanParsable (no parse-from-UTF-8) on either net8 or net10. So decode the short date @@ -431,47 +379,11 @@ 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 BuildNullableBoolParser(PropertyInfo prop) => BuildNullableValue(prop, ReadBool); - 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 BuildNullableDateTimeParser(PropertyInfo prop) => BuildNullableValue(prop, ReadDateTime); - 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; - }; - } + private static ColumnParser BuildNullableDateOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadDateOnly); [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.")] @@ -533,35 +445,16 @@ 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. + private static ColumnParser BuildGuidParser(PropertyInfo prop) => BuildValue(prop, ReadGuid); + + private static ColumnParser BuildNullableGuidParser(PropertyInfo prop) => BuildNullableValue(prop, ReadGuid); #endif private static class EnumCache diff --git a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs index 5931b723..376b039a 100644 --- a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs +++ b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs @@ -13,7 +13,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 +29,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 +62,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 +73,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() - { - 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() + private protected override ProjectionStep Project() { - 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 +96,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); } } } 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/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/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/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index fd3850de..95261cd0 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -234,6 +234,26 @@ private static async ValueTask OpenSeekableAsync(Stream stream, // 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; + } + private static ExcelFileFormat DetectSeekable(Stream stream) { RequireSeekable(stream); @@ -241,14 +261,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)) - { - return ExcelFileFormat.Xls; - } - if (!sig.StartsWith(ZipSignature)) + if (TryClassifyHeader(header[..read], out ExcelFileFormat format)) { - 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 +279,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/XlsbReader.cs b/src/ExcelReader.Core/Reader/XlsbReader.cs index 5c023e23..fa534480 100644 --- a/src/ExcelReader.Core/Reader/XlsbReader.cs +++ b/src/ExcelReader.Core/Reader/XlsbReader.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; namespace ExcelReader.Core.Reader { @@ -90,21 +91,12 @@ private XlsbReader(Stream stream, bool leaveOpen, ZipArchive zip, _sharedOffsets = sharedOffsets; } - [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", - Justification = "zip ownership transfers to the returned reader on success, disposed in the catch on failure.")] - internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) + internal static ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) { ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes); - ZipArchive? zip = null; - try + return ZipReaderOpen.OpenAsync(stream, leaveOpen, ct, async zip => { -#if NET10_0_OR_GREATER - zip = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null, ct).ConfigureAwait(false); -#else - ct.ThrowIfCancellationRequested(); - zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); -#endif var wb = await ZipEntryBytes.ReadAsync(zip, "xl/workbook.bin", decompressedBytes, ct).ConfigureAwait(false); var zipEntryData = await ZipEntryBytes.ReadAsync(zip, "xl/_rels/workbook.bin.rels", decompressedBytes, ct).ConfigureAwait(false); var sheets = XlsbWorkbook.ParseSheets(wb, zipEntryData); @@ -119,23 +111,7 @@ await ZipEntryBytes.ReadAsync(zip, "xl/sharedStrings.bin", decompressedBytes, ct nameof(ExcelReaderOptions.MaxSharedStringBytes), effectiveOptions.MaxSharedStringBytes).ConfigureAwait(false), effectiveOptions); return new XlsbReader(stream, leaveOpen, zip, sheets, styleIsDate, date1904, flat, offsets, effectiveOptions, decompressedBytes); - } - catch - { - if (zip is not null) - { -#if NET10_0_OR_GREATER - await zip.DisposeAsync().ConfigureAwait(false); -#else - zip.Dispose(); -#endif - } - if (!leaveOpen) - { - await stream.DisposeAsync().ConfigureAwait(false); - } - throw; - } + }); } // --- IExcelReader --- @@ -225,11 +201,7 @@ public async ValueTask DisposeAsync() { if (_zip is not null) { -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } if (!_leaveOpen && _stream is not null) { diff --git a/src/ExcelReader.Core/Reader/XlsxReader.cs b/src/ExcelReader.Core/Reader/XlsxReader.cs index 96d82489..720f14a9 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; namespace ExcelReader.Core.Reader { @@ -71,21 +72,12 @@ private XlsxReader(Stream stream, bool leaveOpen, ZipArchive zip, } // Async open: central directory and parts are read with the .NET 10 async zip APIs. - [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", - Justification = "zip ownership transfers to the returned reader; disposed there or in the catch.")] - internal static async ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) + internal static ValueTask CreateAsync(Stream stream, bool leaveOpen, ExcelReaderOptions? options = null, CancellationToken ct = default) { ExcelReaderOptions effectiveOptions = options ?? ExcelReaderOptions.Default; DecompressedByteCounter decompressedBytes = new(effectiveOptions.MaxTotalDecompressedBytes); - ZipArchive? zip = null; - try + return ZipReaderOpen.OpenAsync(stream, leaveOpen, ct, async zip => { -#if NET10_0_OR_GREATER - zip = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null, ct).ConfigureAwait(false); -#else - ct.ThrowIfCancellationRequested(); - zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); -#endif var wb = await ZipEntryBytes.ReadAsync(zip, "xl/workbook.xml", decompressedBytes, ct).ConfigureAwait(false); var rels = await ZipEntryBytes.ReadAsync(zip, "xl/_rels/workbook.xml.rels", decompressedBytes, ct).ConfigureAwait(false); var sheets = ParseSheets(wb, rels); @@ -96,23 +88,7 @@ internal static async ValueTask CreateAsync(Stream stream, bool leav var styleIsDate = ParseStyleDateFlags(await ZipEntryBytes.ReadAsync(zip, "xl/styles.xml", decompressedBytes, ct).ConfigureAwait(false)); bool date1904 = ParseDate1904(wb); return new XlsxReader(stream, leaveOpen, zip, sheets, styleIsDate, date1904, effectiveOptions, decompressedBytes); - } - catch - { - if (zip is not null) - { -#if NET10_0_OR_GREATER - await zip.DisposeAsync().ConfigureAwait(false); -#else - zip.Dispose(); -#endif - } - if (!leaveOpen) - { - await stream.DisposeAsync().ConfigureAwait(false); - } - throw; - } + }); } public string SheetName => _sheets[_current].Name; @@ -211,11 +187,7 @@ public async ValueTask DisposeAsync() ArrayPool.Shared.Return(_sharedFlat); _sharedFlat = []; } -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); if (!_leaveOpen) { await _stream.DisposeAsync().ConfigureAwait(false); diff --git a/src/ExcelReader.Core/Reader/ZipReaderOpen.cs b/src/ExcelReader.Core/Reader/ZipReaderOpen.cs new file mode 100644 index 00000000..5d6f4e8b --- /dev/null +++ b/src/ExcelReader.Core/Reader/ZipReaderOpen.cs @@ -0,0 +1,44 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using ExcelReader.Core.Writer.Internal; + +namespace ExcelReader.Core.Reader +{ + // Shared CreateAsync scaffolding for the ZIP-backed readers (XlsxReader, XlsbReader): open the + // archive (.NET 10 async API, or the sync ctor as a fallback on earlier targets), run the + // format-specific part-parsing body, and on any failure dispose the zip and (unless leaveOpen) + // the stream before rethrowing. parseBody owns zip-entry reads and returns the fully-constructed + // reader; ownership of `zip` transfers to that returned reader on success. + internal static class ZipReaderOpen + { + [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP001:Dispose created", + Justification = "zip ownership transfers to parseBody's returned reader on success; disposed here in the catch on failure.")] + internal static async ValueTask OpenAsync( + Stream stream, bool leaveOpen, CancellationToken ct, Func> parseBody) + { + ZipArchive? zip = null; + try + { +#if NET10_0_OR_GREATER + zip = await ZipArchive.CreateAsync(stream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null, ct).ConfigureAwait(false); +#else + ct.ThrowIfCancellationRequested(); + zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); +#endif + return await parseBody(zip).ConfigureAwait(false); + } + catch + { + if (zip is not null) + { + await ZipArchiveDisposal.DisposeAsync(zip).ConfigureAwait(false); + } + if (!leaveOpen) + { + await stream.DisposeAsync().ConfigureAwait(false); + } + throw; + } + } + } +} diff --git a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs new file mode 100644 index 00000000..ed6f2ac4 --- /dev/null +++ b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs @@ -0,0 +1,48 @@ +using System.IO.Compression; + +namespace ExcelReader.Core.Writer.Internal +{ + // Shared WriterState guard checks for the three state-tracking workbook writers (WorkbookWriter, + // XlsbWorkbookWriter, XlsWorkbookWriter): each StartAsync/AddSheet/EndAsync call site repeats the + // same "already disposed" / "wrong state" checks, differing only in the writer's type name and + // the action being guarded. + internal static class WriterStateGuard + { + internal static void ThrowIfEnded(WriterState state, object writer) + { + ObjectDisposedException.ThrowIf(state == WriterState.Ended, writer); + } + + internal static void RequireCreated(WriterState state, string typeName) + { + if (state != WriterState.Created) + { + throw new InvalidOperationException($"{typeName} has already been started."); + } + } + + internal static void RequireStarted(WriterState state, string typeName, string action) + { + if (state != WriterState.Started) + { + throw new InvalidOperationException($"{typeName} must be started before {action}."); + } + } + } + + // The "#if NET10_0_OR_GREATER await zip.DisposeAsync() #else zip.Dispose()" idiom, shared by the + // two ZIP-backed writers (WorkbookWriter, XlsbWorkbookWriter) across their EndAsync/DisposeAsync + // paths. + internal static class ZipArchiveDisposal + { + internal static ValueTask DisposeAsync(ZipArchive zip) + { +#if NET10_0_OR_GREATER + return zip.DisposeAsync(); +#else + zip.Dispose(); + return ValueTask.CompletedTask; +#endif + } + } +} diff --git a/src/ExcelReader.Core/Writer/WorkbookWriter.cs b/src/ExcelReader.Core/Writer/WorkbookWriter.cs index 6932e2bb..793e904a 100644 --- a/src/ExcelReader.Core/Writer/WorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/WorkbookWriter.cs @@ -50,11 +50,8 @@ public static ValueTask CreateAsync( public ValueTask StartAsync(CancellationToken ct = default) { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Created) - { - throw new InvalidOperationException("WorkbookWriter has already been started."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireCreated(_state, nameof(WorkbookWriter)); ct.ThrowIfCancellationRequested(); _state = WriterState.Started; return WriteRootRelsAsync(ct); @@ -63,11 +60,8 @@ public ValueTask StartAsync(CancellationToken ct = default) public SheetWriter AddSheet(string name) { ArgumentNullException.ThrowIfNull(name); - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("WorkbookWriter must be started before adding sheets."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(WorkbookWriter), "adding sheets"); if (_sheetActive) { throw new InvalidOperationException("The previous SheetWriter must be ended before adding a new sheet."); @@ -98,11 +92,8 @@ internal int GetSharedStringIndex(string value) public async ValueTask EndAsync(CancellationToken ct = default) { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("WorkbookWriter must be started before ending."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(WorkbookWriter), "ending"); ct.ThrowIfCancellationRequested(); if (_sheets.Count == 0) { @@ -121,11 +112,7 @@ public async ValueTask EndAsync(CancellationToken ct = default) await WriteWorkbookAsync(ct).ConfigureAwait(false); await WriteWorkbookRelsAsync(ct).ConfigureAwait(false); await WriteContentTypesAsync(ct).ConfigureAwait(false); -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } public ValueTask FlushAsync(CancellationToken ct = default) @@ -147,11 +134,7 @@ public async ValueTask DisposeAsync() if (_sheets.Count == 0) { _state = WriterState.Ended; -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } else { @@ -160,11 +143,7 @@ public async ValueTask DisposeAsync() } else if (_state == WriterState.Created) { -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } if (!_leaveOpen) { diff --git a/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs b/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs index 88380f54..54ac6955 100644 --- a/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs @@ -37,11 +37,8 @@ public static XlsWorkbookWriter Create(Stream stream, bool leaveOpen = false, bo public void Start() { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Created) - { - throw new InvalidOperationException("XlsWorkbookWriter has already been started."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireCreated(_state, nameof(XlsWorkbookWriter)); _state = WriterState.Started; } @@ -56,11 +53,8 @@ public ValueTask StartAsync(CancellationToken ct = default) public XlsSheetWriter AddSheet(string name) { ArgumentNullException.ThrowIfNull(name); - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("XlsWorkbookWriter must be started before adding sheets."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(XlsWorkbookWriter), "adding sheets"); if (name.Length is 0 or > MaxSheetNameLength) { throw new ArgumentException($"Sheet names must be 1 to {MaxSheetNameLength} characters.", nameof(name)); @@ -90,11 +84,8 @@ internal void NotifySheetEnded() public async ValueTask EndAsync(CancellationToken ct = default) { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("XlsWorkbookWriter must be started before ending."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(XlsWorkbookWriter), "ending"); ct.ThrowIfCancellationRequested(); _state = WriterState.Ended; if (_activeSheet is not null) diff --git a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs index eee70ecc..15afd154 100644 --- a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs @@ -56,11 +56,8 @@ public static ValueTask CreateAsync( public ValueTask StartAsync(CancellationToken ct = default) { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Created) - { - throw new InvalidOperationException("XlsbWorkbookWriter has already been started."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireCreated(_state, nameof(XlsbWorkbookWriter)); ct.ThrowIfCancellationRequested(); _state = WriterState.Started; return ValueTask.CompletedTask; @@ -69,11 +66,8 @@ public ValueTask StartAsync(CancellationToken ct = default) public XlsbSheetWriter AddSheet(string name) { ArgumentNullException.ThrowIfNull(name); - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("XlsbWorkbookWriter must be started before adding sheets."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(XlsbWorkbookWriter), "adding sheets"); if (name.Length is 0 or > MaxSheetNameLength) { throw new ArgumentException($"Sheet names must be 1 to {MaxSheetNameLength} characters.", nameof(name)); @@ -106,11 +100,8 @@ internal int GetSharedStringIndex(string value) public async ValueTask EndAsync(CancellationToken ct = default) { - ObjectDisposedException.ThrowIf(_state == WriterState.Ended, this); - if (_state != WriterState.Started) - { - throw new InvalidOperationException("XlsbWorkbookWriter must be started before ending."); - } + WriterStateGuard.ThrowIfEnded(_state, this); + WriterStateGuard.RequireStarted(_state, nameof(XlsbWorkbookWriter), "ending"); ct.ThrowIfCancellationRequested(); _state = WriterState.Ended; if (_activeSheet is not null) @@ -129,11 +120,7 @@ public async ValueTask EndAsync(CancellationToken ct = default) await WriteSharedStringsAsync(ct).ConfigureAwait(false); await WriteAppPropertiesAsync(ct).ConfigureAwait(false); await WriteContentTypesAsync(ct).ConfigureAwait(false); -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } public ValueTask FlushAsync(CancellationToken ct = default) @@ -161,11 +148,7 @@ public async ValueTask DisposeAsync() } else if (_state == WriterState.Created) { -#if NET10_0_OR_GREATER - await _zip.DisposeAsync().ConfigureAwait(false); -#else - _zip.Dispose(); -#endif + await ZipArchiveDisposal.DisposeAsync(_zip).ConfigureAwait(false); } if (!_leaveOpen) { From c6e8d2d5d06acf27811820851273c3db4919068c Mon Sep 17 00:00:00 2001 From: Gabriel Matte Date: Thu, 16 Jul 2026 12:15:56 -0300 Subject: [PATCH 2/7] Enable unsafe code blocks and apply [SkipLocalsInit] attribute for performance optimization across multiple files. --- src/ExcelReader.Core/ExcelReader.Core.csproj | 1 + .../Parser/Internal/ColumnParserFactory.cs | 7 +++++++ src/ExcelReader.Core/Reader/Excel.cs | 2 ++ src/ExcelReader.Core/Reader/XlsCompoundFile.cs | 2 ++ src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs | 2 ++ src/ExcelReader.Core/Reader/XlsxXml.cs | 1 + src/ExcelReader.Core/ValueObjects/Cell.cs | 2 ++ src/ExcelReader.Core/Writer/CsvRowWriter.cs | 4 ++-- src/ExcelReader.Core/Writer/Internal/CellFormatter.cs | 2 ++ src/ExcelReader.Core/Writer/XlsbRowWriter.cs | 4 ++-- src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs | 2 ++ 11 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj index 3bb8342a..b210320f 100644 --- a/src/ExcelReader.Core/ExcelReader.Core.csproj +++ b/src/ExcelReader.Core/ExcelReader.Core.csproj @@ -5,6 +5,7 @@ latest enable enable + true true diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 3413004a..7fc1902f 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; @@ -320,6 +321,7 @@ private static TimeOnly TimeOnlyFromSerial(double serial) // 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; @@ -340,6 +342,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; @@ -352,6 +355,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; @@ -404,6 +408,7 @@ private static ColumnParser BuildNullableParsableCore(PropertyInfo } #if NET8_0 + [SkipLocalsInit] private static bool TryParseGuid(in Cell cell, out Guid value) { ReadOnlySpan utf8 = cell.Value; @@ -537,6 +542,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/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index 95261cd0..cfe15f72 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 @@ -254,6 +255,7 @@ private static bool TryClassifyHeader(ReadOnlySpan sig, out ExcelFileForma return false; } + [SkipLocalsInit] private static ExcelFileFormat DetectSeekable(Stream stream) { RequireSeekable(stream); 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/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs index 826211c3..8748e4a0 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs @@ -1,6 +1,7 @@ using System.Buffers.Text; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Runtime.CompilerServices; using ExcelReader.Core.Enums; using ExcelReader.Core.ValueObjects; @@ -661,6 +662,7 @@ private void EmitIsoDate(ReadOnlySpan v, int col, int style) _acc.Add(col, s, _acc.ValueLength - s, CellType.ExcelString, style, fromShared: false); } + [SkipLocalsInit] private static bool TryParseIsoDate(ReadOnlySpan utf8, out DateTime value) { // ST_Xstring ISO dates are always ASCII; transcode to chars for DateTime.TryParse. diff --git a/src/ExcelReader.Core/Reader/XlsxXml.cs b/src/ExcelReader.Core/Reader/XlsxXml.cs index aceda0f3..16e0492d 100644 --- a/src/ExcelReader.Core/Reader/XlsxXml.cs +++ b/src/ExcelReader.Core/Reader/XlsxXml.cs @@ -281,6 +281,7 @@ private static int HexVal(byte d) // Decode an XML-entity-encoded attribute/text value straight to a string. Small values (the // common case: rIds, sheet names, part paths) use a stack buffer; larger ones use a pooled array. + [SkipLocalsInit] internal static string DecodeToString(ReadOnlySpan src) { if (src.IsEmpty) diff --git a/src/ExcelReader.Core/ValueObjects/Cell.cs b/src/ExcelReader.Core/ValueObjects/Cell.cs index 7cba0b18..615520d7 100644 --- a/src/ExcelReader.Core/ValueObjects/Cell.cs +++ b/src/ExcelReader.Core/ValueObjects/Cell.cs @@ -63,6 +63,7 @@ public bool TryGetDouble(out double value) return FastDouble.TryParse(Value, out value) || double.TryParse(Value, CultureInfo.InvariantCulture, out value); } + [SkipLocalsInit] public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T result) where T : IUtf8SpanParsable { // Fast path for binary doubles: hand back the stored value without round-tripping @@ -253,6 +254,7 @@ public bool TryFormat(Span destination, out int bytesWritten) // Allocates — only call when you actually need a string. For a shared-string cell backed by a // dedup cache (see the constructor), a repeated value (categorical columns are the common case) // returns the same cached instance instead of decoding UTF-8 and allocating again. + [SkipLocalsInit] public string GetString() { if (_hasNumber) diff --git a/src/ExcelReader.Core/Writer/CsvRowWriter.cs b/src/ExcelReader.Core/Writer/CsvRowWriter.cs index e39a1139..95827186 100644 --- a/src/ExcelReader.Core/Writer/CsvRowWriter.cs +++ b/src/ExcelReader.Core/Writer/CsvRowWriter.cs @@ -175,8 +175,8 @@ public void Skip(int count = 1) } } - private void WriteUtf8Field(T value, ReadOnlySpan format) - where T : IUtf8SpanFormattable + [SkipLocalsInit] + private void WriteUtf8Field(T value, ReadOnlySpan format) where T : IUtf8SpanFormattable { Span buf = stackalloc byte[StackFieldBytes]; // Utf8Formatter is culture-free (no per-field NumberFormatInfo lookup) and matches the diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index 4d34f9f2..6c678cf4 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -17,6 +17,7 @@ internal static class CellFormatter // Writes the cell reference (e.g. "B7") directly to the writer. // Max XLSX cell is XFD1048576 -> 3 column letters + 7 row digits. + [SkipLocalsInit] private static void WriteRef(BiffBuffer xml, int columnIndex, int rowNumber) { Span buf = stackalloc byte[10]; @@ -347,6 +348,7 @@ private static bool IsXHHHHUnderscorePattern(ReadOnlySpan value, int i) return value[i + 6] == '_'; } + [SkipLocalsInit] private static void WriteHexEscape(BiffBuffer xml, char c) { Span buf = stackalloc byte[7]; diff --git a/src/ExcelReader.Core/Writer/XlsbRowWriter.cs b/src/ExcelReader.Core/Writer/XlsbRowWriter.cs index 8db32d16..81a6b5e6 100644 --- a/src/ExcelReader.Core/Writer/XlsbRowWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsbRowWriter.cs @@ -211,8 +211,8 @@ private void WriteDouble(double value, int style) // typeof(T) == typeof(...) folds to a JIT constant per generic instantiation, so the dead // branches are elided and — unlike a type-pattern switch on an unconstrained T — no boxing // occurs. Mirrors the pattern in CellFormatter.WriteValue/CsvRowWriter.WriteUtf8Field. - internal static double ToDouble(T value) - where T : IUtf8SpanFormattable + [SkipLocalsInit] + internal static double ToDouble(T value) where T : IUtf8SpanFormattable { if (typeof(T) == typeof(double)) { diff --git a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs index 15afd154..5d97873a 100644 --- a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Compression; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using ExcelReader.Core.Reader; @@ -310,6 +311,7 @@ private ValueTask WriteAppPropertiesAsync(CancellationToken ct) return WriteEntryAsync("docProps/app.xml", xml, ct); } + [SkipLocalsInit] private static void WriteXf(BiffBuffer data, BiffBuffer payload, int numFmtId, bool isStyleXf = false) { payload.Reset(); From 5c74a5900e56ad7574b875f2b6c946291be02a40 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 20 Jul 2026 19:20:00 -0300 Subject: [PATCH 3/7] Fix reader and writer robustness limits --- Directory.Build.props | 4 +-- .../Reader/Biff12RecordReader.cs | 2 +- .../Reader/CellAccumulator.cs | 9 ++++- src/ExcelReader.Core/Reader/LimitChecks.cs | 7 ++-- src/ExcelReader.Core/Writer/CsvRowWriter.cs | 1 + .../Writer/Internal/WriterStateGuard.cs | 12 +++++++ src/ExcelReader.Core/Writer/RowWriter.cs | 1 + src/ExcelReader.Core/Writer/WorkbookWriter.cs | 1 + src/ExcelReader.Core/Writer/XlsRowWriter.cs | 1 + .../Writer/XlsWorkbookWriter.cs | 6 +--- .../Writer/XlsbWorkbookWriter.cs | 7 +--- .../Biff12RecordReaderTests.cs | 10 ++++++ tests/ExcelReader.Tests/CsvReaderTests.cs | 13 ++++++++ tests/ExcelReader.Tests/CsvWriterTests.cs | 12 +++++++ .../ExcelReader.Tests/WorkbookWriterTests.cs | 26 +++++++++++++++ tests/ExcelReader.Tests/XlsWriterTests.cs | 33 +++++++++++++++++++ tests/ExcelReader.Tests/XlsbWriterTests.cs | 12 +++++++ 17 files changed, 138 insertions(+), 19 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 4e80ab7a..d6bd3c6b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,8 +19,6 @@ true - none - false $(DefineConstants.Replace("DEBUG;", "")) $(DefineConstants.Replace("TRACE;", "")) @@ -82,4 +80,4 @@ all - \ No newline at end of file + 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..f83f46da 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; @@ -90,7 +91,13 @@ internal void Add(int col, int start, int len, CellType type, int style, bool fr { 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/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/Writer/CsvRowWriter.cs b/src/ExcelReader.Core/Writer/CsvRowWriter.cs index 95827186..e3c72cf0 100644 --- a/src/ExcelReader.Core/Writer/CsvRowWriter.cs +++ b/src/ExcelReader.Core/Writer/CsvRowWriter.cs @@ -169,6 +169,7 @@ public void Write(T? value) public void Skip(int count = 1) { ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(count); for (int i = 0; i < count; i++) { BeginField(); diff --git a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs index ed6f2ac4..d8c2905d 100644 --- a/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs +++ b/src/ExcelReader.Core/Writer/Internal/WriterStateGuard.cs @@ -28,6 +28,18 @@ internal static void RequireStarted(WriterState state, string typeName, string a throw new InvalidOperationException($"{typeName} must be started before {action}."); } } + + internal static void ValidateSheetName(string name) + { + if (name.Length is 0 or > 31) + { + throw new ArgumentException("Sheet names must be 1 to 31 characters.", nameof(name)); + } + if (name.IndexOfAny([':', '\\', '/', '?', '*', '[', ']']) >= 0) + { + throw new ArgumentException("Sheet names cannot contain : \\ / ? * [ or ].", nameof(name)); + } + } } // The "#if NET10_0_OR_GREATER await zip.DisposeAsync() #else zip.Dispose()" idiom, shared by the diff --git a/src/ExcelReader.Core/Writer/RowWriter.cs b/src/ExcelReader.Core/Writer/RowWriter.cs index e32ff71e..b5076287 100644 --- a/src/ExcelReader.Core/Writer/RowWriter.cs +++ b/src/ExcelReader.Core/Writer/RowWriter.cs @@ -243,6 +243,7 @@ public void Write(T? value) public void Skip(int count = 1) { ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(count); if (count > 0) { if (_columnIndex > 16_384 - count) diff --git a/src/ExcelReader.Core/Writer/WorkbookWriter.cs b/src/ExcelReader.Core/Writer/WorkbookWriter.cs index 793e904a..0480e1db 100644 --- a/src/ExcelReader.Core/Writer/WorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/WorkbookWriter.cs @@ -62,6 +62,7 @@ public SheetWriter AddSheet(string name) ArgumentNullException.ThrowIfNull(name); WriterStateGuard.ThrowIfEnded(_state, this); WriterStateGuard.RequireStarted(_state, nameof(WorkbookWriter), "adding sheets"); + WriterStateGuard.ValidateSheetName(name); if (_sheetActive) { throw new InvalidOperationException("The previous SheetWriter must be ended before adding a new sheet."); diff --git a/src/ExcelReader.Core/Writer/XlsRowWriter.cs b/src/ExcelReader.Core/Writer/XlsRowWriter.cs index 880776a9..8d9b22bc 100644 --- a/src/ExcelReader.Core/Writer/XlsRowWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsRowWriter.cs @@ -195,6 +195,7 @@ public void Write(T? value) public void Skip(int count = 1) { ThrowIfDisposed(); + ArgumentOutOfRangeException.ThrowIfNegative(count); _columnIndex += count; } diff --git a/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs b/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs index 54ac6955..299d1dd1 100644 --- a/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsWorkbookWriter.cs @@ -11,7 +11,6 @@ namespace ExcelReader.Core.Writer // sheets, so memory scales with total row count rather than being bounded per-sheet. public sealed class XlsWorkbookWriter : IWorkbookWriter { - private const int MaxSheetNameLength = 31; private readonly Stream _stream; private readonly bool _leaveOpen; private readonly bool _date1904; @@ -55,10 +54,7 @@ public XlsSheetWriter AddSheet(string name) ArgumentNullException.ThrowIfNull(name); WriterStateGuard.ThrowIfEnded(_state, this); WriterStateGuard.RequireStarted(_state, nameof(XlsWorkbookWriter), "adding sheets"); - if (name.Length is 0 or > MaxSheetNameLength) - { - throw new ArgumentException($"Sheet names must be 1 to {MaxSheetNameLength} characters.", nameof(name)); - } + WriterStateGuard.ValidateSheetName(name); if (_activeSheet is not null) { throw new InvalidOperationException("The previous XlsSheetWriter must be ended before adding a new sheet."); diff --git a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs index 5d97873a..f4017a7e 100644 --- a/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsbWorkbookWriter.cs @@ -11,8 +11,6 @@ namespace ExcelReader.Core.Writer { public sealed class XlsbWorkbookWriter : IWorkbookWriter { - private const int MaxSheetNameLength = 31; - private readonly ZipArchive _zip; private readonly Stream _stream; private readonly bool _leaveOpen; @@ -69,10 +67,7 @@ public XlsbSheetWriter AddSheet(string name) ArgumentNullException.ThrowIfNull(name); WriterStateGuard.ThrowIfEnded(_state, this); WriterStateGuard.RequireStarted(_state, nameof(XlsbWorkbookWriter), "adding sheets"); - if (name.Length is 0 or > MaxSheetNameLength) - { - throw new ArgumentException($"Sheet names must be 1 to {MaxSheetNameLength} characters.", nameof(name)); - } + WriterStateGuard.ValidateSheetName(name); if (_activeSheet is not null) { throw new InvalidOperationException("The previous XlsbSheetWriter must be ended before adding a new sheet."); diff --git a/tests/ExcelReader.Tests/Biff12RecordReaderTests.cs b/tests/ExcelReader.Tests/Biff12RecordReaderTests.cs index c5961a27..5c26ea4d 100644 --- a/tests/ExcelReader.Tests/Biff12RecordReaderTests.cs +++ b/tests/ExcelReader.Tests/Biff12RecordReaderTests.cs @@ -56,6 +56,16 @@ public void TruncatedLengthVarintReturnsFalse() Assert.False(reader.TryReadRecord(out _, out _)); } + [Fact] + public void OverlongLengthVarintReturnsFalseWithoutAdvancing() + { + byte[] data = [.. Id(5), 0x80, 0x80, 0x80, 0x80]; + var reader = new Biff12RecordReader(data); + + Assert.False(reader.TryReadRecord(out _, out _)); + Assert.Equal(0, reader.Position); + } + [Fact] public void EmptyDataReadsNothing() { diff --git a/tests/ExcelReader.Tests/CsvReaderTests.cs b/tests/ExcelReader.Tests/CsvReaderTests.cs index bdd3e408..dd32c512 100644 --- a/tests/ExcelReader.Tests/CsvReaderTests.cs +++ b/tests/ExcelReader.Tests/CsvReaderTests.cs @@ -277,6 +277,19 @@ public void FieldExceedingMaxCellBytesThrows() Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); } + [Fact] + public void EmptyFieldsExceedingMaxCellBytesThrows() + { + // Empty fields add no value bytes, so this specifically exercises the cell-descriptor limit. + using var ms = Csv(new string(',', 32)); + var options = new CsvReaderOptions { MaxCellBytes = 1024 }; + using var reader = Excel.FromCsv(ms, options: options); + using CsvReader.Enumerator e = reader.GetEnumerator(); + + ExcelLimitExceededException ex = Assert.Throws(() => e.MoveNext()); + Assert.Equal(nameof(CsvReaderOptions.MaxCellBytes), ex.LimitName); + } + [Fact] public void SemicolonDelimiterIsRespected() { diff --git a/tests/ExcelReader.Tests/CsvWriterTests.cs b/tests/ExcelReader.Tests/CsvWriterTests.cs index cc3cc296..6a63e7f6 100644 --- a/tests/ExcelReader.Tests/CsvWriterTests.cs +++ b/tests/ExcelReader.Tests/CsvWriterTests.cs @@ -79,6 +79,18 @@ public void MultipleRowsAreWritten() Assert.Equal("a,b\r\n1,2\r\n", csv); } + [Fact] + public void NegativeSkipThrows() + { + string csv = Write(w => + { + using CsvRowWriter row = w.StartRow(); + Assert.Throws(() => row.Skip(-1)); + }); + + Assert.Equal("\r\n", csv); + } + [Fact] public void FieldContainingDelimiterIsQuoted() { diff --git a/tests/ExcelReader.Tests/WorkbookWriterTests.cs b/tests/ExcelReader.Tests/WorkbookWriterTests.cs index 76525f8d..cc7b4607 100644 --- a/tests/ExcelReader.Tests/WorkbookWriterTests.cs +++ b/tests/ExcelReader.Tests/WorkbookWriterTests.cs @@ -721,6 +721,32 @@ public async Task SkipCreatesColumnGap() Assert.Equal("ccc", rows[0].C); } + [Fact] + public async Task NegativeSkipThrows() + { + await using var ms = new MemoryStream(); + await using var wb = await WorkbookWriter.CreateAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken).ConfigureAwait(true); + await wb.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + SheetWriter sheet = wb.AddSheet("Sheet1"); + await sheet.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + await using RowWriter row = await sheet.StartRowAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + Assert.Throws(() => row.Skip(-1)); + } + + [Theory] + [InlineData("")] + [InlineData("12345678901234567890123456789012")] + [InlineData("Bad[Name")] + public async Task InvalidSheetNameThrows(string name) + { + await using var ms = new MemoryStream(); + await using var wb = await WorkbookWriter.CreateAsync(ms, leaveOpen: true, ct: TestContext.Current.CancellationToken).ConfigureAwait(true); + await wb.StartAsync(TestContext.Current.CancellationToken).ConfigureAwait(true); + + Assert.Throws(() => wb.AddSheet(name)); + } + // --- XML special characters in strings --- [Fact] diff --git a/tests/ExcelReader.Tests/XlsWriterTests.cs b/tests/ExcelReader.Tests/XlsWriterTests.cs index 9f75f1a1..2fb7de56 100644 --- a/tests/ExcelReader.Tests/XlsWriterTests.cs +++ b/tests/ExcelReader.Tests/XlsWriterTests.cs @@ -283,6 +283,39 @@ public async Task SheetNameTooLongThrows() Assert.Throws(() => wb.AddSheet(new string('x', 32))); } + [Fact] + public async Task EmptySheetNameThrows() + { + var ms = new MemoryStream(); + await using var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true); + wb.Start(); + + Assert.Throws(() => wb.AddSheet(string.Empty)); + } + + [Fact] + public async Task InvalidSheetCharacterThrows() + { + var ms = new MemoryStream(); + await using var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true); + wb.Start(); + + Assert.Throws(() => wb.AddSheet("Bad[Name")); + } + + [Fact] + public async Task NegativeSkipThrows() + { + var ms = new MemoryStream(); + await using var wb = XlsWorkbookWriter.Create(ms, leaveOpen: true); + wb.Start(); + XlsSheetWriter sheet = wb.AddSheet("S1"); + sheet.Start(); + using XlsRowWriter row = sheet.StartRow(); + + Assert.Throws(() => row.Skip(-1)); + } + [Fact] public async Task EmptyWorkbookThrows() { diff --git a/tests/ExcelReader.Tests/XlsbWriterTests.cs b/tests/ExcelReader.Tests/XlsbWriterTests.cs index e08366ef..8d08a72a 100644 --- a/tests/ExcelReader.Tests/XlsbWriterTests.cs +++ b/tests/ExcelReader.Tests/XlsbWriterTests.cs @@ -249,6 +249,18 @@ public async Task NegativeSkipThrows() Assert.False(e.MoveNext()); } + [Theory] + [InlineData("")] + [InlineData("12345678901234567890123456789012")] + [InlineData("Bad[Name")] + public async Task InvalidSheetNameThrows(string name) + { + await using var wb = await XlsbWorkbookWriter.CreateAsync(new MemoryStream(), ct: TestContext.Current.CancellationToken); + await wb.StartAsync(TestContext.Current.CancellationToken); + + Assert.Throws(() => wb.AddSheet(name)); + } + [Fact] public async Task BulkXlsbCellRowRoundTrip() { From 833df96dcdfe8cc1bffcad5d4b890206123ed799 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 20 Jul 2026 19:28:46 -0300 Subject: [PATCH 4/7] Remove unused WriteRecordHeader and WriteLong methods from Biff12RecordWriter and BiffStringEncoder for cleaner code --- .../Writer/Internal/Biff12RecordWriter.cs | 10 ---------- .../Writer/Internal/BiffStringEncoder.cs | 8 -------- 2 files changed, 18 deletions(-) diff --git a/src/ExcelReader.Core/Writer/Internal/Biff12RecordWriter.cs b/src/ExcelReader.Core/Writer/Internal/Biff12RecordWriter.cs index 138a9d8d..0a8588d2 100644 --- a/src/ExcelReader.Core/Writer/Internal/Biff12RecordWriter.cs +++ b/src/ExcelReader.Core/Writer/Internal/Biff12RecordWriter.cs @@ -11,16 +11,6 @@ internal static void WriteRecord(BiffBuffer dest, int id, ReadOnlySpan pay dest.Write(payload); } - // For records whose payload length the caller already knows (fixed-layout cells, row headers): - // writes just the id + length header, so the caller can then write the payload's fields straight - // into `dest` (e.g. via WriteCellHeader/WriteU32/WriteDouble) instead of building it in a temp - // buffer and copying it in — one fewer memcpy per cell. - internal static void WriteRecordHeader(BiffBuffer dest, int id, int length) - { - WriteId(dest, id); - WriteVarint(dest, length); - } - // One reservation (one Ensure/bounds check) for the whole record — id + length header + payload // — instead of a separate Ensure per field (WriteRecordHeader's two WriteByte calls, then one // per WriteU32/WriteDouble the caller would otherwise make). The returned span aliases dest's diff --git a/src/ExcelReader.Core/Writer/Internal/BiffStringEncoder.cs b/src/ExcelReader.Core/Writer/Internal/BiffStringEncoder.cs index 18d7d777..80874070 100644 --- a/src/ExcelReader.Core/Writer/Internal/BiffStringEncoder.cs +++ b/src/ExcelReader.Core/Writer/Internal/BiffStringEncoder.cs @@ -12,14 +12,6 @@ namespace ExcelReader.Core.Writer.Internal // specials on read, so those (and anything > 0xFF) force UTF-16 to stay lossless. internal static class BiffStringEncoder { - // u16 char count + flags + chars. Used by Label and Format records. - internal static void WriteLong(BiffBuffer buffer, ReadOnlySpan value) - { - bool compressed = CanCompress(value); - buffer.WriteU16(value.Length); - WriteFlagsAndChars(buffer, value, compressed); - } - // u8 char count + flags + chars. Used by BoundSheet sheet names. internal static void WriteShort(BiffBuffer buffer, ReadOnlySpan value) { From 484edbb7883c8b500b24978dcbd0eacc2d50ef33 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 20 Jul 2026 19:47:38 -0300 Subject: [PATCH 5/7] Refactor CSV and Excel Reader Components - Introduced ProjectionRules class to encapsulate row classification logic and error handling for missing required values. - Updated CsvEnumerable and RowProjector to utilize ProjectionRules for row processing. - Enhanced ProjectionStep enum to include BuildMap step for better control flow. - Refactored CellAccumulator to add methods for handling boolean and error cell types. - Created PooledStreamRowEnumerator base class to reduce code duplication in CSV, XLSB, and XLSX enumerators. - Improved error handling in Excel reader methods to ensure proper disposal of streams on failure. - Streamlined cell writing logic in CellFormatter to reduce redundancy. - Updated DateSerial to leverage ExcelEpoch for date serial conversions. - Simplified RowWriter methods to directly write values instead of calling formatter methods. - Added tests to cover edge cases in formatting and parsing. --- src/ExcelReader.Core/ExcelEpoch.cs | 39 ++++++++ .../Parser/Internal/ColumnParserFactory.cs | 99 +++++++------------ .../Parser/Internal/CsvEnumerable.cs | 16 +-- .../Parser/Internal/ProjectionRules.cs | 27 +++++ .../Parser/Internal/ProjectionStep.cs | 1 + .../Parser/Internal/RowProjector.cs | 15 +-- .../Reader/CellAccumulator.cs | 14 +++ .../Reader/CsvReader.Enumerator.cs | 40 +------- src/ExcelReader.Core/Reader/Excel.cs | 44 +++++---- .../Reader/PooledStreamRowEnumerator.cs | 50 ++++++++++ .../Reader/XlsReader.Enumerator.cs | 17 ++-- .../Reader/XlsbReader.Enumerator.cs | 63 +++--------- .../Reader/XlsxReader.Enumerator.cs | 59 +++-------- .../Reader/XlsxReader.Loading.cs | 2 +- .../Reader/XlsxReader.Styles.cs | 10 +- src/ExcelReader.Core/Reader/XlsxXml.cs | 6 ++ src/ExcelReader.Core/ValueObjects/Cell.cs | 11 +-- .../Writer/Internal/CellFormatter.cs | 94 ++++-------------- .../Writer/Internal/DateSerial.cs | 16 +-- src/ExcelReader.Core/Writer/RowWriter.cs | 27 ++--- tests/ExcelReader.Tests/CoverageGapTests.cs | 2 +- 21 files changed, 278 insertions(+), 374 deletions(-) create mode 100644 src/ExcelReader.Core/ExcelEpoch.cs create mode 100644 src/ExcelReader.Core/Parser/Internal/ProjectionRules.cs create mode 100644 src/ExcelReader.Core/Reader/PooledStreamRowEnumerator.cs 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/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 7fc1902f..0b13c96e 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -63,27 +63,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 @@ -105,7 +89,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)) { @@ -113,24 +97,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) @@ -148,29 +132,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) { @@ -232,17 +216,18 @@ private static ColumnParser BuildNullableValue(PropertyInfo prop, CellR }; } +#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) { return TryParseBool(in cell, out value); } - private static bool ReadDateTime(in Cell cell, bool isDate1904, IFormatProvider provider, out DateTime value) + private static bool ReadDateTime(in Cell cell, bool isDate1904, IFormatProvider _, out DateTime value) { return cell.TryGetDateTime(isDate1904, out value); } - private static bool ReadDateOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out DateOnly value) + private static bool ReadDateOnly(in Cell cell, bool isDate1904, IFormatProvider _, out DateOnly value) { if (!cell.TryGetDateTime(isDate1904, out DateTime dt)) { @@ -266,30 +251,36 @@ private static bool ReadTimeOnly(in Cell cell, bool isDate1904, IFormatProvider return true; } - private static bool ReadTextDateTime(in Cell cell, bool isDate1904, IFormatProvider provider, out DateTime value) + private static bool ReadTextDateTime(in Cell cell, bool _, IFormatProvider provider, out DateTime value) { return TryParseDateTimeText(in cell, provider, out value); } - private static bool ReadTextDateOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out DateOnly value) + private static bool ReadTextDateOnly(in Cell cell, bool _, IFormatProvider provider, out DateOnly value) { return TryParseDateOnlyText(in cell, provider, out value); } - private static bool ReadTextTimeOnly(in Cell cell, bool isDate1904, IFormatProvider provider, out TimeOnly value) + private static bool ReadTextTimeOnly(in Cell cell, bool _, IFormatProvider provider, out TimeOnly value) { return TryParseTimeOnlyText(in cell, provider, out value); } +#pragma warning restore S1172 - private static ColumnParser BuildBoolParser(PropertyInfo prop) => BuildValue(prop, ReadBool); - - private static ColumnParser BuildDateTimeParser(PropertyInfo prop) => BuildValue(prop, ReadDateTime); - - private static ColumnParser BuildDateOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadDateOnly); + private static CellReader DateTimeReader(bool textDates) + { + return textDates ? ReadTextDateTime : ReadDateTime; + } - private static ColumnParser BuildTimeOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTimeOnly); + private static CellReader DateOnlyReader(bool textDates) + { + return textDates ? ReadTextDateOnly : ReadDateOnly; + } - private static ColumnParser BuildNullableTimeOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTimeOnly); + private static CellReader TimeOnlyReader(bool textDates) + { + return textDates ? ReadTextTimeOnly : ReadTimeOnly; + } // 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. @@ -300,21 +291,6 @@ private static TimeOnly TimeOnlyFromSerial(double serial) return new TimeOnly(ticks == TimeSpan.TicksPerDay ? 0 : ticks); } - // 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) => BuildValue(prop, ReadTextDateTime); - - private static ColumnParser BuildTextNullableDateTimeParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextDateTime); - - private static ColumnParser BuildTextDateOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTextDateOnly); - - private static ColumnParser BuildTextNullableDateOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextDateOnly); - - private static ColumnParser BuildTextTimeOnlyParser(PropertyInfo prop) => BuildValue(prop, ReadTextTimeOnly); - - private static ColumnParser BuildTextNullableTimeOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadTextTimeOnly); - // DateTime/DateOnly implement ISpanParsable (char) and IUtf8SpanFormattable, but NOT // IUtf8SpanParsable (no parse-from-UTF-8) on either net8 or net10. So decode the short date // field to a stack char buffer — allocation-free — and parse culture-aware (honors Culture, @@ -383,12 +359,6 @@ private static ColumnParser BuildParsableCore(PropertyInfo prop) }; } - private static ColumnParser BuildNullableBoolParser(PropertyInfo prop) => BuildNullableValue(prop, ReadBool); - - private static ColumnParser BuildNullableDateTimeParser(PropertyInfo prop) => BuildNullableValue(prop, ReadDateTime); - - private static ColumnParser BuildNullableDateOnlyParser(PropertyInfo prop) => BuildNullableValue(prop, ReadDateOnly); - [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) @@ -457,9 +427,6 @@ private static bool ReadGuid(in Cell cell, bool isDate1904, IFormatProvider prov // 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) => BuildValue(prop, ReadGuid); - - private static ColumnParser BuildNullableGuidParser(PropertyInfo prop) => BuildNullableValue(prop, ReadGuid); #endif private static class EnumCache diff --git a/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs b/src/ExcelReader.Core/Parser/Internal/CsvEnumerable.cs index 376b039a..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; @@ -138,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. @@ -238,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/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/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/Reader/CellAccumulator.cs b/src/ExcelReader.Core/Reader/CellAccumulator.cs index f83f46da..bb57a5c3 100644 --- a/src/ExcelReader.Core/Reader/CellAccumulator.cs +++ b/src/ExcelReader.Core/Reader/CellAccumulator.cs @@ -87,6 +87,20 @@ 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) 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 cfe15f72..be211430 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -176,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."); + throw UnknownFormat(stream, leaveOpen); } return format switch { @@ -208,18 +201,12 @@ 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); - } + await DisposeOnFailureAsync(stream, leaveOpen).ConfigureAwait(false); throw new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook."); } return format switch @@ -231,6 +218,29 @@ 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(); + } + + private static InvalidDataException UnknownFormat(Stream stream, bool leaveOpen) + { + DisposeOnFailure(stream, leaveOpen); + return 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 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/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs index 1896534b..6f5d596d 100644 --- a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs @@ -173,7 +173,7 @@ private void ParseCellRecord(int id, ReadOnlySpan data) case Rec.Formula: ParseFormula(data); break; - // Blank / MulBlank and unknown records contribute no value. + // Blank / MulBlank and unknown records contribute no value. } } @@ -244,15 +244,12 @@ private void ParseBoolErr(ReadOnlySpan data) int style = ReadU16(data, 4); byte value = data[6]; bool isError = data[7] != 0; - int start = _acc.ValueLength; if (isError) { - int len = _acc.AppendErrorText(value); - _acc.Add(col, start, len, CellType.Error, style, fromShared: false); + _acc.AddError(col, style, value); return; } - _acc.AppendByte(value == 0 ? (byte)'0' : (byte)'1'); - _acc.Add(col, start, 1, CellType.Boolean, style, fromShared: false); + _acc.AddBool(col, style, value); } private void ParseFormula(ReadOnlySpan data) @@ -264,23 +261,21 @@ private void ParseFormula(ReadOnlySpan data) int col = ReadU16(data, 2); int style = ReadU16(data, 4); ReadOnlySpan result = data.Slice(6, 8); - int start = _acc.ValueLength; if (result[6] == 0xFF && result[7] == 0xFF) { switch (result[0]) { case 1: - _acc.AppendByte(result[2] == 0 ? (byte)'0' : (byte)'1'); - _acc.Add(col, start, 1, CellType.Boolean, style, fromShared: false); + _acc.AddBool(col, style, result[2]); break; case 2: - int len = _acc.AppendErrorText(result[2]); - _acc.Add(col, start, len, CellType.Error, style, fromShared: false); + _acc.AddError(col, style, result[2]); break; case 0: // String result: the marker means "see the STRING record that follows". if (_cursor.PeekId() == Rec.StringRec && _cursor.TryReadRecord(out _, out ReadOnlySpan str) && str.Length >= 3) { + int start = _acc.ValueLength; int cch = ReadU16(str, 0); byte strFlags = str[2]; DecodeUnicodeString(str[3..], cch, strFlags); diff --git a/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs index 3924923e..35d48b6f 100644 --- a/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsbReader.Enumerator.cs @@ -12,33 +12,19 @@ public sealed partial class XlsbReader // buffer boundary is detected and retried after the next fill. [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 { [SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Borrowed, not owned.")] private readonly XlsbReader _reader; - private readonly CancellationToken _ct; - [SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Disposed in Dispose().")] - private Stream? _sheet; - 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 _ended; // A BrtRowHdr for the NEXT row was already consumed while collecting cells for the current row. // On the next MoveNext call, skip the "seek to row header" step. private bool _pendingRowHdr; - private readonly CellAccumulator _acc; - internal Enumerator(XlsbReader reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) + : base(sheet, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entryLength), ct) { _reader = reader; - _sheet = sheet; - _ct = ct; - _io = new BufferedStreamCursor(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), - WorkbookLookups.InitialBufferCapacity(entryLength)); - _acc = new CellAccumulator(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes)); } public Row Current => @@ -300,11 +286,11 @@ private void ProcessCell(int id, ReadOnlySpan payload) AddDouble(col, style, Biff12.ReadF64(payload, 8)); break; case Brt.CellIsst when payload.Length >= 12: - { - var (start, len) = _reader.SharedAt((int)Biff12.ReadU32(payload, 8)); - _acc.Add(col, start, len, CellType.ExcelString, style, fromShared: true); - break; - } + { + var (start, len) = _reader.SharedAt((int)Biff12.ReadU32(payload, 8)); + _acc.Add(col, start, len, CellType.ExcelString, style, fromShared: true); + break; + } case Brt.CellSt when Biff12.TryReadWideString(payload, 8, out ReadOnlySpan chars, out _): AppendString(col, style, chars); break; @@ -332,7 +318,7 @@ private void ProcessCell(int id, ReadOnlySpan payload) case Brt.CellRString when payload.Length >= 9 && Biff12.TryReadWideString(payload, 9, out ReadOnlySpan richChars, out _): AppendString(col, style, richChars); break; - // CellBlank: no value to emit + // CellBlank: no value to emit } } @@ -358,16 +344,12 @@ private void AppendString(int col, int style, ReadOnlySpan chars) private void AppendBool(int col, int style, byte value) { - int start = _acc.ValueLength; - _acc.AppendByte(value == 0 ? (byte)'0' : (byte)'1'); - _acc.Add(col, start, 1, CellType.Boolean, style, fromShared: false); + _acc.AddBool(col, style, value); } private void AppendError(int col, int style, byte error) { - int start = _acc.ValueLength; - int len = _acc.AppendErrorText(error); - _acc.Add(col, start, len, CellType.Error, style, fromShared: false); + _acc.AddError(col, style, error); } private void ResetRow() @@ -420,22 +402,12 @@ private void ThrowIfTruncated() } } - private void Fill() - { - _io.Fill(_sheet!); - } - - private ValueTask FillAsync() - { - return _io.FillAsync(_sheet!, _ct); - } - [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "_sheet is opened for this enumerator and owned by it.")] public void Dispose() { - _sheet?.Dispose(); - _sheet = null; + _source?.Dispose(); + _source = null; ReturnBuffers(); } @@ -443,19 +415,14 @@ public void Dispose() Justification = "_sheet is opened for this enumerator and owned by it.")] public async ValueTask DisposeAsync() { - if (_sheet is not null) + if (_source is not null) { - await _sheet.DisposeAsync().ConfigureAwait(false); - _sheet = null; + await _source.DisposeAsync().ConfigureAwait(false); + _source = null; } ReturnBuffers(); } - private void ReturnBuffers() - { - _io.Return(); - _acc.Return(); - } } } } diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs index 8748e4a0..e43d65b9 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs @@ -13,23 +13,14 @@ public sealed partial class XlsxReader // buffer; a single ... element is guaranteed contiguous (the buffer grows if needed). [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 { // Borrowed: the reader outlives the enumerator and owns its own disposal — do not dispose here. [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 XlsxReader _reader; - private readonly CancellationToken _ct; // honored only by the async path // Owned: opened by Get(Async)Enumerator for this enumerator alone; disposed in Dispose(Async). [SuppressMessage("SharpSource", "SS066:Disposable field is not disposed", Justification = "Disposed in Dispose().")] - private Stream? _sheet; - 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 readonly CellAccumulator _acc; // per-row decoded values + cell descriptors private int _nextCol; // Non-null only when this sheet's elements carry a namespace prefix (e.g. ); holds the @@ -43,13 +34,9 @@ public sealed class Enumerator : IExcelRowEnumerator private ReadOnlySpan CClose => _ns is null ? ""u8 : _ns.CClose; internal Enumerator(XlsxReader reader, Stream sheet, long entryLength = 0, CancellationToken ct = default) + : base(sheet, reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), WorkbookLookups.InitialBufferCapacity(entryLength), ct) { _reader = reader; - _sheet = sheet; - _ct = ct; - _io = new BufferedStreamCursor(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes), - WorkbookLookups.InitialBufferCapacity(entryLength)); - _acc = new CellAccumulator(reader._options.MaxCellBytes, nameof(ExcelReaderOptions.MaxCellBytes)); } public Row Current => @@ -429,7 +416,7 @@ private CellHeader ReadCellOpenTagSpan(byte[] buf, ref int p, int gt) col = _nextCol; } _nextCol = col + 1; - int style = ParseInt(sVal); + int style = XlsxXml.ParseIntOr(sVal, 0); var kind = ClassifyKind(tVal); bool selfClose = buf[gt - 1] == '/'; p = gt + 1; // consume open tag; p now at inner start (or next element if self-closed) @@ -791,10 +778,6 @@ private static bool SkipMarkupSpan(byte[] buf, int len, ref int p) return true; } - private static int ParseInt(ReadOnlySpan src) - { - return Utf8Parser.TryParse(src, out int v, out _) ? v : 0; - } private static bool IsBoundary(byte b) { @@ -818,7 +801,7 @@ private int IndexOf(byte b) { return -1; } - _io.Fill(_sheet!); + Fill(); } } @@ -835,7 +818,7 @@ private int IndexOfSeq(ReadOnlySpan seq) { return -1; } - _io.Fill(_sheet!); + Fill(); } } @@ -855,11 +838,6 @@ private static int IndexOfSeqBounded(byte[] buf, int boundExclusive, int from, R return rel < 0 ? -1 : from + rel; } - private void Ensure(int n) - { - _io.Ensure(_sheet!, n); - } - // Async twins of the search/refill primitives. Each is split so the common case (the target is // already in the buffered window) returns a completed task with no async state machine, and // only a real refill on a buffer miss takes the awaiting slow path. No span crosses an await. @@ -907,7 +885,7 @@ private int EnsureRowBuffered() { return _len; } - _io.Fill(_sheet!); + Fill(); } } @@ -979,22 +957,12 @@ private async ValueTask IndexOfSeqFromAsync(MarkupSeq seq) return -1; } - private ValueTask EnsureAsync(int n) - { - return _io.EnsureAsync(_sheet!, n, _ct); - } - - private ValueTask FillAsync() - { - return _io.FillAsync(_sheet!, _ct); - } - [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP007:Don't dispose injected", Justification = "_sheet is opened for this enumerator and owned by it.")] public void Dispose() { - _sheet?.Dispose(); - _sheet = null; + _source?.Dispose(); + _source = null; ReturnBuffers(); } @@ -1002,19 +970,14 @@ public void Dispose() Justification = "_sheet is opened for this enumerator and owned by it.")] public async ValueTask DisposeAsync() { - if (_sheet is not null) + if (_source is not null) { - await _sheet.DisposeAsync().ConfigureAwait(false); - _sheet = null; + await _source.DisposeAsync().ConfigureAwait(false); + _source = null; } ReturnBuffers(); } - private void ReturnBuffers() - { - _io.Return(); - _acc.Return(); - } } } } diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Loading.cs b/src/ExcelReader.Core/Reader/XlsxReader.Loading.cs index d4616725..5c6e83e1 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Loading.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Loading.cs @@ -143,7 +143,7 @@ private void ParseShared(ReadOnlySpan src) int sstEnd = IdxOf(src, sstPos, (byte)'>'); if (sstEnd > sstPos) { - uniqueCount = ParseIntOr(XlsxXml.Attr(src[sstPos..sstEnd], " uniqueCount="u8), 0); + uniqueCount = XlsxXml.ParseIntOr(XlsxXml.Attr(src[sstPos..sstEnd], " uniqueCount="u8), 0); } } diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs b/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs index bed15dc3..0d8a5d36 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Styles.cs @@ -1,5 +1,3 @@ -using System.Buffers.Text; - namespace ExcelReader.Core.Reader { public sealed partial class XlsxReader @@ -30,7 +28,7 @@ private static bool[] ParseStyleDateFlags(ReadOnlySpan src) Dictionary custom = new(capacity: 16); foreach (var tag in Tags(src, numFmtTag)) { - int id = ParseIntOr(XlsxXml.Attr(tag, " numFmtId="u8), -1); + int id = XlsxXml.ParseIntOr(XlsxXml.Attr(tag, " numFmtId="u8), -1); if (id >= 0) { custom[id] = NumberFormat.LooksLikeDate(XlsxXml.DecodeToString(XlsxXml.Attr(tag, " formatCode="u8))); @@ -52,15 +50,11 @@ private static bool[] ParseStyleDateFlags(ReadOnlySpan src) List flags = new(capacity: 16); foreach (var xf in Tags(src.Slice(open + 1, end - open - 1), xfTag)) { - int numFmtId = ParseIntOr(XlsxXml.Attr(xf, " numFmtId="u8), 0); + int numFmtId = XlsxXml.ParseIntOr(XlsxXml.Attr(xf, " numFmtId="u8), 0); flags.Add(WorkbookLookups.ResolveDateFlag(custom, numFmtId)); } return [.. flags]; } - private static int ParseIntOr(ReadOnlySpan src, int fallback) - { - return Utf8Parser.TryParse(src, out int v, out _) ? v : fallback; - } } } diff --git a/src/ExcelReader.Core/Reader/XlsxXml.cs b/src/ExcelReader.Core/Reader/XlsxXml.cs index 16e0492d..6d62115b 100644 --- a/src/ExcelReader.Core/Reader/XlsxXml.cs +++ b/src/ExcelReader.Core/Reader/XlsxXml.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Buffers.Text; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; @@ -9,6 +10,11 @@ namespace ExcelReader.Core.Reader // Everything works on UTF-8 ReadOnlySpan so cell values never round-trip through string. internal static class XlsxXml { + internal static int ParseIntOr(ReadOnlySpan source, int fallback) + { + return Utf8Parser.TryParse(source, out int value, out _) ? value : fallback; + } + // Returns the value of attribute `name` inside an open tag span like ``, // or its single-quoted form ``. `name` must include the leading space and trailing '=', // e.g. " t=" — the leading space gives a cheap word boundary so " r=" doesn't match inside another diff --git a/src/ExcelReader.Core/ValueObjects/Cell.cs b/src/ExcelReader.Core/ValueObjects/Cell.cs index 615520d7..33a40289 100644 --- a/src/ExcelReader.Core/ValueObjects/Cell.cs +++ b/src/ExcelReader.Core/ValueObjects/Cell.cs @@ -194,7 +194,7 @@ private static bool UsesDotDecimalSeparator(IFormatProvider? provider) { return provider is null || ReferenceEquals(provider, CultureInfo.InvariantCulture) - || NumberFormatInfo.GetInstance(provider).NumberDecimalSeparator == "."; + || string.Equals(NumberFormatInfo.GetInstance(provider).NumberDecimalSeparator, ".", StringComparison.Ordinal); } // Interprets the cell's numeric value as an Excel serial date (1900 date system). @@ -216,14 +216,7 @@ public bool TryGetDateTime(bool isDate1904, out DateTime result) result = default; return false; } - // Excel's 1900 calendar includes a fictitious 1900-02-29 at serial 60. - // Map it explicitly to the adjacent real day; serials 1–59 need the inverse writer shift. - double oadate = isDate1904 ? serial + 1462.0 : serial switch - { - < 60.0 => serial + 1.0, - 60.0 => 60.0, - _ => serial, - }; + double oadate = ExcelEpoch.SerialToOADate(serial, isDate1904); // FromOADate throws outside this range; guard first. if (oadate is > -657435.0 and < 2958466.0) { diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index 6c678cf4..4ea7ea45 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -34,72 +34,55 @@ private static void WriteCellOpenWithRef(BiffBuffer xml, int columnIndex, int ro xml.Write(tail); } - internal static void WriteEmpty(BiffBuffer xml, int columnIndex, int rowNumber, bool includeReference) + private static void WriteCellOpen(BiffBuffer xml, int columnIndex, int rowNumber, bool includeReference, ReadOnlySpan tail) { if (includeReference) { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, "/>"u8); + WriteCellOpenWithRef(xml, columnIndex, rowNumber, tail); return; } - xml.Write(""u8); + xml.Write(tail); + } + + internal static void WriteEmpty(BiffBuffer xml, int columnIndex, int rowNumber, bool includeReference) + { + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? "/>"u8 : ""u8); } internal static void WriteString(BiffBuffer xml, string value, int columnIndex, int rowNumber, bool includeReference) { + bool preserve = HasEdgeWhitespace(value); + ReadOnlySpan tail; if (includeReference) { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, HasEdgeWhitespace(value) - ? " t=\"inlineStr\">"u8 - : " t=\"inlineStr\">"u8); + tail = preserve ? " t=\"inlineStr\">"u8 : " t=\"inlineStr\">"u8; } else { - xml.Write(HasEdgeWhitespace(value) - ? ""u8 - : ""u8); + tail = preserve ? ""u8 : ""u8; } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, tail); WriteEscaped(xml, value); xml.Write(""u8); } internal static void WriteSharedString(BiffBuffer xml, int sharedStringIndex, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, " t=\"s\">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? " t=\"s\">"u8 : ""u8); WriteValue(xml, sharedStringIndex, sizeHint: 16); xml.Write(""u8); } internal static void WriteBool(BiffBuffer xml, bool value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, " t=\"b\">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? " t=\"b\">"u8 : ""u8); xml.WriteByte(value ? (byte)'1' : (byte)'0'); xml.Write(""u8); } internal static void WriteDateTime(BiffBuffer xml, DateTime value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, " s=\"1\">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? " s=\"1\">"u8 : ""u8); WriteValue(xml, DateSerial.ForEpoch(value.ToOADate(), date1904: false), sizeHint: 32); xml.Write(""u8); } @@ -107,14 +90,7 @@ internal static void WriteDateTime(BiffBuffer xml, DateTime value, int columnInd internal static void WriteNumber(BiffBuffer xml, T value, int columnIndex, int rowNumber, bool includeReference) where T : IUtf8SpanFormattable { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, ">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); WriteValue(xml, value, sizeHint: 64); xml.Write(""u8); } @@ -126,56 +102,28 @@ private static bool HasEdgeWhitespace(string value) internal static void WriteNumber(BiffBuffer xml, int value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, ">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); WriteValue(xml, value, sizeHint: 16); xml.Write(""u8); } internal static void WriteNumber(BiffBuffer xml, long value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, ">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); WriteValue(xml, value, sizeHint: 32); xml.Write(""u8); } internal static void WriteNumber(BiffBuffer xml, double value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, ">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); WriteValue(xml, value, sizeHint: 32); xml.Write(""u8); } internal static void WriteNumber(BiffBuffer xml, decimal value, int columnIndex, int rowNumber, bool includeReference) { - if (includeReference) - { - WriteCellOpenWithRef(xml, columnIndex, rowNumber, ">"u8); - } - else - { - xml.Write(""u8); - } + WriteCellOpen(xml, columnIndex, rowNumber, includeReference, includeReference ? ">"u8 : ""u8); WriteValue(xml, value, sizeHint: 64); xml.Write(""u8); } diff --git a/src/ExcelReader.Core/Writer/Internal/DateSerial.cs b/src/ExcelReader.Core/Writer/Internal/DateSerial.cs index 7352d1ad..d01d3000 100644 --- a/src/ExcelReader.Core/Writer/Internal/DateSerial.cs +++ b/src/ExcelReader.Core/Writer/Internal/DateSerial.cs @@ -6,21 +6,7 @@ internal static class DateSerial { internal static double ForEpoch(double serial, bool date1904) { - // OADate is one day ahead of Excel for Jan 1–Feb 28 1900 because Excel - // reserves serial 60 for its fictitious 1900-02-29. - if (!date1904 && serial < 61.0) - { - serial -= 1.0; - } - if (date1904) - { - serial -= 1462.0; - } - if (serial < 0) - { - throw new ArgumentOutOfRangeException(nameof(serial), "Dates before the workbook epoch cannot be written to Excel."); - } - return serial; + return ExcelEpoch.OADateToSerial(serial, date1904); } } } diff --git a/src/ExcelReader.Core/Writer/RowWriter.cs b/src/ExcelReader.Core/Writer/RowWriter.cs index b5076287..db6a3280 100644 --- a/src/ExcelReader.Core/Writer/RowWriter.cs +++ b/src/ExcelReader.Core/Writer/RowWriter.cs @@ -79,8 +79,7 @@ public void Write(bool? value) WriteEmptyCell(); return; } - CellFormatter.WriteBool(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(DateTime value) @@ -98,8 +97,7 @@ public void Write(DateTime? value) WriteEmptyCell(); return; } - CellFormatter.WriteDateTime(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } // DateOnly shares the DateTime date-serial cell format (midnight), so it round-trips as a date. @@ -118,8 +116,7 @@ public void Write(DateOnly? value) WriteEmptyCell(); return; } - CellFormatter.WriteDateTime(_row, value.Value.ToDateTime(TimeOnly.MinValue), _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } // TimeOnly is written as an Excel time serial: the fraction of a 24h day, in [0,1). This is a @@ -139,8 +136,7 @@ public void Write(TimeOnly? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value.Ticks / (double)TimeSpan.TicksPerDay, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(int value) @@ -158,8 +154,7 @@ public void Write(int? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(long value) @@ -177,8 +172,7 @@ public void Write(long? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(double value) @@ -196,8 +190,7 @@ public void Write(double? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(decimal value) @@ -215,8 +208,7 @@ public void Write(decimal? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Write(T value) @@ -236,8 +228,7 @@ public void Write(T? value) WriteEmptyCell(); return; } - CellFormatter.WriteNumber(_row, value.Value, _columnIndex, _rowNumber, ConsumeCellReference()); - _columnIndex++; + Write(value.Value); } public void Skip(int count = 1) diff --git a/tests/ExcelReader.Tests/CoverageGapTests.cs b/tests/ExcelReader.Tests/CoverageGapTests.cs index c947fdae..a71f7730 100644 --- a/tests/ExcelReader.Tests/CoverageGapTests.cs +++ b/tests/ExcelReader.Tests/CoverageGapTests.cs @@ -18,7 +18,7 @@ public class CoverageGapTests // IUtf8SpanFormattable but NOT IConvertible — forces the XLSB writer's format-then-parse fallback. private readonly struct Formattable(double value) : IUtf8SpanFormattable { - public string ToString(string? format, IFormatProvider? formatProvider) + public string ToString(IFormatProvider? formatProvider) { return value.ToString(formatProvider); } From 3ee1468ecaa1d24cfe5f1997fefd2539c563239c Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 20 Jul 2026 19:56:41 -0300 Subject: [PATCH 6/7] Refactor XlsReader and XlsxReader to improve record reading logic and enhance performance in cell parsing --- .../Reader/XlsReader.Enumerator.cs | 61 +++++++++------ .../Reader/XlsxReader.Enumerator.cs | 78 ++++++++++++------- src/ExcelReader.Core/ValueObjects/Cell.cs | 33 +++++--- 3 files changed, 109 insertions(+), 63 deletions(-) diff --git a/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsReader.Enumerator.cs index 6f5d596d..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(); diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs index e43d65b9..0bd8b9a6 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs @@ -134,40 +134,64 @@ public ValueTask MoveNextAsync() case HeadKind.End: return new ValueTask(false); case HeadKind.Row: - { - ValueTask beginTask = BeginRowAsync(); - if (!beginTask.IsCompletedSuccessfully) - { - return AwaitThenRestartAsync(beginTask); - } - if (!beginTask.Result) - { - ValueTask rowBufTask = EnsureRowBufferedAsync(); - if (!rowBufTask.IsCompletedSuccessfully) - { - return FinishRowAfterAsync(rowBufTask); - } - ParseRow(rowBufTask.Result); - } - return new ValueTask(true); - } + return ReadRowAsync(); default: + ValueTask skipResult = SkipMarkupOrContinueAsync(); + if (skipResult.IsCompletedSuccessfully) { - ValueTask skipTask = SkipMarkupAsync(); - if (!skipTask.IsCompletedSuccessfully) - { - return AwaitThenRestartAsync(skipTask); - } - if (!skipTask.Result) - { - return new ValueTask(false); - } - break; + return new ValueTask(skipResult.Result); } + break; } } } + [SuppressMessage("SharpSource", "SS034:Use await to get the result of a Task", + Justification = "Every .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")] + [SuppressMessage("VisualStudio.Threading", "VSTHRD103:Result synchronously blocks", + Justification = "Every .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")] + private ValueTask ReadRowAsync() + { + ValueTask beginTask = BeginRowAsync(); + if (!beginTask.IsCompletedSuccessfully) + { + return AwaitThenRestartAsync(beginTask); + } + if (beginTask.Result) + { + return new ValueTask(true); + } + + ValueTask rowBufferTask = EnsureRowBufferedAsync(); + if (!rowBufferTask.IsCompletedSuccessfully) + { + return FinishRowAfterAsync(rowBufferTask); + } + ParseRow(rowBufferTask.Result); + return new ValueTask(true); + } + + // Returns null only when markup was skipped and enumeration should continue immediately. + [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.")] + [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", + Justification = "The ValueTask is either returned through AwaitThenRestartAsync or consumed once after confirming synchronous completion.")] + private ValueTask SkipMarkupOrContinueAsync() + { + ValueTask skipTask = SkipMarkupAsync(); + if (!skipTask.IsCompletedSuccessfully) + { + return AwaitThenRestartAsync(skipTask); + } + if (skipTask.Result) + { + return new ValueTask(true); + } + return new ValueTask(false); + } + // Safe for every pending step above except the row-buffered check below: none of them // commit a position change until they resolve (BeginRowAsync only advances _pos once it // actually finds '>'; SkipMarkupAsync only advances _pos at its return statement). So once diff --git a/src/ExcelReader.Core/ValueObjects/Cell.cs b/src/ExcelReader.Core/ValueObjects/Cell.cs index 33a40289..1e2df780 100644 --- a/src/ExcelReader.Core/ValueObjects/Cell.cs +++ b/src/ExcelReader.Core/ValueObjects/Cell.cs @@ -106,10 +106,26 @@ public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T result = Unsafe.As(ref m); return true; } - // Integral targets: cast directly when the stored double is a whole number that fits the - // target's range — skips the format+parse round trip that the general path below needs. - // Non-integral values (e.g. 12.5) and out-of-range values fall through, matching - // int.TryParse("12.5") semantics. + if (TryParseIntegral(out result)) + { + return true; + } + // Other numeric targets (decimal, ...), plus out-of-range/non-integral cases above: + // format once and parse, which exactly matches "parse the formatted text" — + // e.g. int.TryParse fails on "12.5". + Span buffer = stackalloc byte[32]; + return Utf8Formatter.TryFormat(_number, buffer, out int written) + ? T.TryParse(buffer[..written], provider, out result) + : T.TryParse(Value, provider, out result); + } + + // Integral targets: cast directly when the stored double is a whole number that fits the + // target's range — skips the format+parse round trip that the general path below needs. + // Non-integral values (e.g. 12.5) and out-of-range values return false so the caller can + // preserve the general parser's exact semantics. + [SkipLocalsInit] + private bool TryParseIntegral([MaybeNullWhen(false)] out T result) where T : IUtf8SpanParsable + { bool isIntegral = _number == Math.Truncate(_number); if (typeof(T) == typeof(int)) { @@ -180,13 +196,8 @@ public bool TryParse(IFormatProvider? provider, [MaybeNullWhen(false)] out T result = Unsafe.As(ref v); return true; } - // Other numeric targets (decimal, ...), plus out-of-range/non-integral cases above: - // format once and parse, which exactly matches "parse the formatted text" — - // e.g. int.TryParse fails on "12.5". - Span buffer = stackalloc byte[32]; - return Utf8Formatter.TryFormat(_number, buffer, out int written) - ? T.TryParse(buffer[..written], provider, out result) - : T.TryParse(Value, provider, out result); + result = default; + return false; } [MethodImpl(MethodImplOptions.AggressiveInlining)] From 7178934b267ce287c48a1865fb8d0a0d39a240b8 Mon Sep 17 00:00:00 2001 From: GabrielMarquezMatte Date: Mon, 20 Jul 2026 22:58:28 -0300 Subject: [PATCH 7/7] Refactor project files and reader components for improved performance and code clarity --- Directory.Build.props | 4 ++ src/ExcelReader.Core/ExcelReader.Core.csproj | 4 -- .../Parser/Internal/ColumnParserFactory.cs | 2 + src/ExcelReader.Core/Reader/Excel.cs | 15 ++++-- src/ExcelReader.Core/Reader/XlsReader.cs | 51 +++++++++++-------- .../Reader/XlsxReader.Enumerator.cs | 14 ++--- src/ExcelReader.Core/Writer/CsvWriter.cs | 3 +- .../Writer/Internal/CellFormatter.cs | 8 +-- .../Writer/XlsbSheetWriter.cs | 6 +-- .../ExcelReader.Benchmarks.csproj | 2 - .../ExcelReader.Tests.csproj | 2 - 11 files changed, 62 insertions(+), 49 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d6bd3c6b..75d18ff9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,9 @@ + latest + enable + enable + true latest All diff --git a/src/ExcelReader.Core/ExcelReader.Core.csproj b/src/ExcelReader.Core/ExcelReader.Core.csproj index b210320f..2746696b 100644 --- a/src/ExcelReader.Core/ExcelReader.Core.csproj +++ b/src/ExcelReader.Core/ExcelReader.Core.csproj @@ -2,9 +2,6 @@ net10.0;net8.0 - latest - enable - enable true @@ -35,7 +32,6 @@ portable true true - true true diff --git a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs index 0b13c96e..1643b3cb 100644 --- a/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs +++ b/src/ExcelReader.Core/Parser/Internal/ColumnParserFactory.cs @@ -53,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), ]; diff --git a/src/ExcelReader.Core/Reader/Excel.cs b/src/ExcelReader.Core/Reader/Excel.cs index be211430..a9488353 100644 --- a/src/ExcelReader.Core/Reader/Excel.cs +++ b/src/ExcelReader.Core/Reader/Excel.cs @@ -181,7 +181,7 @@ private static IExcelRowReader OpenSeekable(Stream stream, bool leaveOpen, Excel } if (format is ExcelFileFormat.Unknown) { - throw UnknownFormat(stream, leaveOpen); + UnknownFormat(stream, leaveOpen); } return format switch { @@ -207,7 +207,7 @@ private static async ValueTask OpenSeekableAsync(Stream stream, if (format is ExcelFileFormat.Unknown) { await DisposeOnFailureAsync(stream, leaveOpen).ConfigureAwait(false); - throw new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook."); + UnknownFormatException(); } return format switch { @@ -235,10 +235,17 @@ private static ValueTask DisposeOnFailureAsync(Stream stream, bool leaveOpen) return leaveOpen ? ValueTask.CompletedTask : stream.DisposeAsync(); } - private static InvalidDataException UnknownFormat(Stream stream, bool leaveOpen) + [DoesNotReturn] + private static void UnknownFormat(Stream stream, bool leaveOpen) { DisposeOnFailure(stream, leaveOpen); - return new InvalidDataException("Unrecognized file format; expected an XLSX/XLSB (ZIP) or XLS (OLE2) workbook."); + 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 diff --git a/src/ExcelReader.Core/Reader/XlsReader.cs b/src/ExcelReader.Core/Reader/XlsReader.cs index a97ce8b9..a2d2d89c 100644 --- a/src/ExcelReader.Core/Reader/XlsReader.cs +++ b/src/ExcelReader.Core/Reader/XlsReader.cs @@ -317,27 +317,7 @@ private static void DecodeSharedStrings(ReadOnlySpan sst, ReadOnlySpan= sst.Length) { truncated = true; break; } - compressed = (sst[pos] & 1) == 0; - pos++; - boundaryIdx++; - } - int step = compressed ? 1 : 2; - if (pos + step > sst.Length) { truncated = true; break; } - scratch[produced++] = compressed - ? DecodeCp1252(sst[pos]) - : (char)(sst[pos] | (sst[pos + 1] << 8)); - pos += step; - } + int produced = DecodeChars(sst, boundaries, chars, scratch, ref pos, ref boundaryIdx, ref compressed, out bool truncated); int maxBytes = System.Text.Encoding.UTF8.GetMaxByteCount(produced); EnsureSharedCapacity(options, ref flat, flatLen + maxBytes); flatLen += System.Text.Encoding.UTF8.GetBytes(scratch.AsSpan(0, produced), flat.AsSpan(flatLen)); @@ -360,6 +340,35 @@ private static void DecodeSharedStrings(ReadOnlySpan sst, ReadOnlySpan sst, ReadOnlySpan boundaries, int chars, char[] scratch, ref int pos, ref int boundaryIdx, ref bool compressed, out bool truncated) + { + truncated = false; + int produced = 0; + for (int c = 0; c < chars; c++) + { + // Drop boundaries already behind us (splits outside the character array), then + // consume the grbit for a boundary that lands exactly on this character. + while (boundaryIdx < boundaries.Length && boundaries[boundaryIdx] < pos) { boundaryIdx++; } + if (boundaryIdx < boundaries.Length && boundaries[boundaryIdx] == pos) + { + if (pos >= sst.Length) { truncated = true; break; } + compressed = (sst[pos] & 1) == 0; + pos++; + boundaryIdx++; + } + int step = compressed ? 1 : 2; + if (pos + step > sst.Length) { truncated = true; break; } + scratch[produced++] = compressed + ? DecodeCp1252(sst[pos]) + : (char)(sst[pos] | (sst[pos + 1] << 8)); + pos += step; + } + return produced; + } + private static void EnsureSharedCapacity(ExcelReaderOptions options, ref byte[] buffer, int needed) { if (needed <= buffer.Length) diff --git a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs index 0bd8b9a6..594721b6 100644 --- a/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs +++ b/src/ExcelReader.Core/Reader/XlsxReader.Enumerator.cs @@ -136,12 +136,12 @@ public ValueTask MoveNextAsync() case HeadKind.Row: return ReadRowAsync(); default: - ValueTask skipResult = SkipMarkupOrContinueAsync(); - if (skipResult.IsCompletedSuccessfully) + ValueTask? skipResult = SkipMarkupOrContinue(); + if (skipResult is null) { - return new ValueTask(skipResult.Result); + break; // markup skipped — continue scanning for the next element } - break; + return skipResult.Value; } } } @@ -178,7 +178,7 @@ private ValueTask ReadRowAsync() Justification = "The .Result access is guarded by IsCompletedSuccessfully immediately above it — never blocks.")] [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly", Justification = "The ValueTask is either returned through AwaitThenRestartAsync or consumed once after confirming synchronous completion.")] - private ValueTask SkipMarkupOrContinueAsync() + private ValueTask? SkipMarkupOrContinue() { ValueTask skipTask = SkipMarkupAsync(); if (!skipTask.IsCompletedSuccessfully) @@ -187,9 +187,9 @@ private ValueTask SkipMarkupOrContinueAsync() } if (skipTask.Result) { - return new ValueTask(true); + return null; // markup skipped — caller continues the scan loop } - return new ValueTask(false); + return new ValueTask(false); // end of sheetData/worksheet } // Safe for every pending step above except the row-buffered check below: none of them diff --git a/src/ExcelReader.Core/Writer/CsvWriter.cs b/src/ExcelReader.Core/Writer/CsvWriter.cs index 995c335c..ddb66155 100644 --- a/src/ExcelReader.Core/Writer/CsvWriter.cs +++ b/src/ExcelReader.Core/Writer/CsvWriter.cs @@ -119,8 +119,7 @@ public async ValueTask FlushAsync(CancellationToken ct = default) ct.ThrowIfCancellationRequested(); if (_buffer.Length > 0) { - await _stream.WriteAsync(_buffer.Memory, ct).ConfigureAwait(false); - _buffer.Reset(); + await FlushBufferAsync(ct).ConfigureAwait(false); } await _stream.FlushAsync(ct).ConfigureAwait(false); } diff --git a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs index 4ea7ea45..109404aa 100644 --- a/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs +++ b/src/ExcelReader.Core/Writer/Internal/CellFormatter.cs @@ -10,7 +10,7 @@ internal static class CellFormatter // The 5 XML entity chars, '_' (to detect literal "_xHHHH_" escape sequences that must be // themselves escaped), and every C0 control char that's illegal in XML 1.0 text content // (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F — tab/LF/CR are legal and excluded). - private static readonly SearchValues specialChars = SearchValues.Create( + private static readonly SearchValues SpecialChars = SearchValues.Create( "&<>\"'_" + "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u000B\u000C" + "\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F"); @@ -206,7 +206,7 @@ private static void ThrowIfNonFinite(double value) internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value) { int start = 0; - int next = value.IndexOfAny(specialChars); + int next = value.IndexOfAny(SpecialChars); while (next >= 0) { int i = start + next; @@ -238,7 +238,7 @@ internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value) { // A plain '_' remains in the pending run, but the next scan must move past // it; otherwise this loop would rediscover the same underscore forever. - int following = value[(i + 1)..].IndexOfAny(specialChars); + int following = value[(i + 1)..].IndexOfAny(SpecialChars); if (following < 0) { break; @@ -258,7 +258,7 @@ internal static void WriteEscaped(BiffBuffer xml, ReadOnlySpan value) WriteHexEscape(xml, c); start = i + 1; } - next = value[start..].IndexOfAny(specialChars); + next = value[start..].IndexOfAny(SpecialChars); } if (start < value.Length) { diff --git a/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs b/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs index 4c4ad181..6faef40c 100644 --- a/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs +++ b/src/ExcelReader.Core/Writer/XlsbSheetWriter.cs @@ -158,9 +158,6 @@ internal void WriteRecord(int id, ReadOnlySpan payload = default) MaybeFlush(); } - // Fixed-length records (known-size cells, row headers) write header + fields straight into - // _records, skipping the Payload-buffer round trip that WriteRecord/Payload.Reset() needs for - // variable-length records. private void MaybeFlush() { if (_records.Length >= SpillThreshold) @@ -239,6 +236,9 @@ private void BeginRow() _rowActive = true; } + // Fixed-length records (known-size cells, row headers) write header + fields straight into + // _records, skipping the Payload-buffer round trip that WriteRecord/Payload.Reset() needs for + // variable-length records. private void WriteRowHeader(int rowNumber) { const int Length = (6 * 4) + 1; // 6 x u32 + 1 byte diff --git a/tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj b/tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj index 5dcca050..e9771f5d 100644 --- a/tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj +++ b/tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable $(NoWarn);CA2007;MA0004 diff --git a/tests/ExcelReader.Tests/ExcelReader.Tests.csproj b/tests/ExcelReader.Tests/ExcelReader.Tests.csproj index 31ee8812..ad576cbc 100644 --- a/tests/ExcelReader.Tests/ExcelReader.Tests.csproj +++ b/tests/ExcelReader.Tests/ExcelReader.Tests.csproj @@ -1,8 +1,6 @@ - enable - enable false Exe ExcelReader.Tests