Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
912 changes: 875 additions & 37 deletions Light.GuardClauses.SingleFile.cs

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions ai-plans/0145-additional-assertions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Additional Assertions

## Rationale

Light.GuardClauses already covers the common null, equality, comparison, string, collection, date/time, type, enum, and URI preconditions, but several useful invariants and cross-container overloads are still missing. Add focused guards for UUIDv7 identifiers, finite floating-point values, ranged collection counts, canonical UTC `DateTimeOffset` values, and ASCII data, and complete the most useful `Span<T>` and `Memory<T>` length and content checks.

The additions must preserve the library's existing fluent return values, exception-factory overloads, nullable annotations, broad target-framework support, Native AOT compatibility, and customizable single-file source distribution. UUIDv7 validation is performance-sensitive and must inspect the GUID structure without allocations on every supported target.

## Acceptance Criteria

- [x] `Guid` values can be tested with `IsUuidVersion7` and guarded with `MustBeUuidVersion7` on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; a value is accepted only when both its UUID version and RFC/IETF variant bits identify UUIDv7.
- [x] Successful UUIDv7 checks perform no heap allocation and have a microbenchmark covering the Boolean check and successful throwing guard against an equivalent direct structural check.
- [x] The benchmark project targets and executes only .NET 10, with no .NET Framework target or legacy runtime job, so benchmarks can run consistently on every operating system supported by .NET 10 and BenchmarkDotNet.
- [x] `float` and `double` values can be tested and guarded for finiteness on all package targets, while the .NET 10 asset also supports generic IEEE 754 floating-point types such as `Half`.
- [x] General collections can require their count to fall within a `Range<int>` through `MustHaveCountIn`, preserving the original collection shape and the established collection-count behavior.
- [x] Span and memory values have consistent non-empty and ranged-length guards, memory values have exact-length guards, and character span and memory values have a throwing counterpart to `IsEmptyOrWhiteSpace`.
- [x] `MustBeUtc` supports `DateTimeOffset` and accepts only values whose offset is `TimeSpan.Zero`, without changing the instant or returned value.
- [x] Characters, bytes, strings, and character/byte span and memory values can be tested and guarded as ASCII across all package targets; empty inputs are valid ASCII, while null strings are rejected by the throwing guard.
- [x] Every new throwing guard returns the successfully validated input, captures the guarded expression for the default exception, accepts an optional custom message, and provides an exception-factory overload consistent with the existing API for its input shape.
- [x] Automated tests cover successful values, every distinct failure condition, boundary values, default exceptions, custom messages, custom exception factories, caller argument expressions, return values, and target-specific overloads.
- [x] The source-export whitelist catalog and committed settings contain all new assertion families, framework flattening and reachability retain their required helpers, and focused source-export tests cover the new entries and relevant target-specific APIs.
- [x] The committed .NET Standard 2.0 single-file distribution is regenerated and validates with the complete new portable API surface.
- [x] The assertion overview and any affected usage or source-inclusion documentation describe the new families, their supported input shapes, target-specific overloads, and UUIDv7 structural-validation semantics.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Follow the existing convention of one `Check.<Assertion>.cs` file per assertion family. Boolean checks are allocation-free predicates; throwing guards use `[CallerArgumentExpression]`, return the original value or collection shape, and offer the established default-exception and custom-factory overloads. Reuse the current exception hierarchy where it accurately describes the failure, preferring standard `ArgumentException` or `ArgumentOutOfRangeException` over adding narrowly useful public exception types. Span factories use the existing span delegate types so no ref struct is captured or boxed.

**UUIDv7.** The exact public names are `IsUuidVersion7` and `MustBeUuidVersion7`. Validation is structural: the version field must be 7 and the variant must be the RFC/IETF `10xx` variant defined by RFC 9562. This inherently rejects `Guid.Empty`; do not add a redundant emptiness check or claim to verify timestamp provenance, entropy, randomness, or monotonic generation.

Use one zero-allocation implementation across all target frameworks. Overlay the GUID with this exact internal type:

```csharp
[StructLayout(LayoutKind.Explicit, Size = 16)]
internal readonly struct GuidLayout
{
[FieldOffset(0)]
private readonly Guid _value;

[FieldOffset(6)]
public readonly ushort TimeHighAndVersion;

[FieldOffset(8)]
public readonly byte ClockSequenceHighAndReserved;

public GuidLayout(Guid value) => _value = value;
}
```

The explicit overlay avoids array or string creation, unsafe compilation, and a dependency on `System.Runtime.CompilerServices.Unsafe`. Determine UUIDv7 with `(layout.TimeHighAndVersion & 0xF000) == 0x7000` and `(layout.ClockSequenceHighAndReserved & 0xC0) == 0x80`. Reading the version as the overlaid numeric field rather than indexing its component bytes keeps the check independent of machine endianness. Keep the layout helper reachable by the source exporter, document its dependency on the stable sequential GUID field layout, and cover it with tests using RFC UUIDv7 examples, `Guid.CreateVersion7()` on .NET 10, UUIDs of other versions, `Guid.Empty`, and a forged value with a version-7 nibble but a non-RFC variant. Add BenchmarkDotNet cases with memory diagnostics for valid and invalid Boolean checks and the successful guard path; the successful cases must report `0 B/op`.

Make the benchmark project .NET 10-only by replacing its target-framework list with `net10.0` and removing the .NET Framework 4.8 runtime job. Replace the stale explicit .NET 8 job with one .NET 10 job. Enable the cross-platform memory diagnoser by default and make disassembly diagnostics opt-in through a benchmark configuration or command-line option so an unsupported disassembler cannot prevent ordinary benchmark execution. The default configuration must not depend on Windows, an installed .NET Framework runtime, or platform-specific diagnostic tooling.

**Finite floating-point values.** Add `IsFinite` and `MustBeFinite` overloads for `float` and `double` to every target. The modern asset additionally exposes generic overloads constrained to `IFloatingPointIeee754<T>` under the repository's existing modern-framework conditional, thereby covering `Half` without treating `decimal` as an IEEE 754 type. Finite values include positive and negative zero, subnormal values, and normal values; reject `NaN`, positive infinity, and negative infinity. The portable overloads must not depend on APIs absent from .NET Standard.

**Collection count ranges.** Add `MustHaveCountIn<TCollection>(this TCollection? parameter, Range<int> range, ...) where TCollection : class, IEnumerable`, plus its factory overload. Determine the count through the existing optimized collection-count helpers and enumerate a lazy enumerable no more than once per guard invocation. Null uses the existing null behavior; an out-of-range count uses `InvalidCollectionCountException` and reports the actual count and requested range. Preserve inclusive and exclusive endpoints exactly as modeled by `Range<int>`.

**Span and memory parity.** Extend the existing families rather than introducing alternate length terminology:

- `MustNotBeEmpty` for `Span<T>`, `ReadOnlySpan<T>`, `Memory<T>`, and `ReadOnlyMemory<T>`;
- `MustHaveLengthIn` for those same four shapes;
- `MustHaveLength` for `Memory<T>` and `ReadOnlyMemory<T>`; and
- `MustNotBeEmptyOrWhiteSpace` for `Span<char>`, `ReadOnlySpan<char>`, `Memory<char>`, and `ReadOnlyMemory<char>`, implemented as the throwing counterpart to `IsEmptyOrWhiteSpace`.

Mutable overloads should delegate validation to the read-only span implementation and return the original mutable value. Memory overloads must not allocate or copy their contents. Use `InvalidCollectionCountException` or the existing empty/string exception contracts consistently with the corresponding collection, length, and whitespace guards.

**UTC `DateTimeOffset`.** Extend `MustBeUtc` with `DateTimeOffset` default and factory overloads. UTC means `parameter.Offset == TimeSpan.Zero`; do not convert a non-zero-offset value with `ToUniversalTime`, because a guard validates rather than normalizes. Continue using `InvalidDateTimeException`, updating its documentation and throw helpers to cover both `DateTime` and `DateTimeOffset` accurately.

**ASCII.** Add `IsAscii` and `MustBeAscii` for `char`, `byte`, `string`, `Span<char>`, `ReadOnlySpan<char>`, `Memory<char>`, `ReadOnlyMemory<char>`, and the corresponding byte span and memory shapes. ASCII means every code unit or byte is at most `0x7F`; empty strings and buffers satisfy the predicate, `IsAscii(null)` is false, and `MustBeAscii` throws `ArgumentNullException` for a null string before checking content. Use the optimized framework implementation where appropriate on .NET 10 and a simple allocation-free portable loop elsewhere, with identical semantics across targets. Mutable and memory guards delegate to read-only span validation and return their original shape.

Update `AssertionWhitelist`, `settings.json`, and source-export reachability tests for `IsUuidVersion7`, `MustBeUuidVersion7`, `IsFinite`, `MustBeFinite`, `MustHaveCountIn`, `MustNotBeEmptyOrWhiteSpace`, `IsAscii`, and `MustBeAscii`; additions that only extend existing files use their existing whitelist entries. Regenerate `Light.GuardClauses.SingleFile.cs` as the .NET Standard 2.0 output and verify both portable and .NET 10 generated-source validation. Update `docs/assertion-overview.md` as the primary user-facing catalog, and adjust other documentation where the overload matrix or source-export examples would otherwise become incomplete.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;

namespace Light.GuardClauses.Performance.CommonAssertions;

[MemoryDiagnoser]
public class UuidVersion7Benchmark
{
private readonly Guid _invalidUuid = Guid.NewGuid();
private readonly Guid _validUuid = Guid.CreateVersion7();

[Benchmark(Baseline = true)]
public bool DirectValidStructuralCheck() => DirectCheck(_validUuid);

[Benchmark]
public bool IsUuidVersion7Valid() => _validUuid.IsUuidVersion7();

[Benchmark]
public Guid MustBeUuidVersion7Valid() => _validUuid.MustBeUuidVersion7();

[Benchmark]
public bool DirectInvalidStructuralCheck() => DirectCheck(_invalidUuid);

[Benchmark]
public bool IsUuidVersion7Invalid() => _invalidUuid.IsUuidVersion7();

private static bool DirectCheck(Guid value)
{
var layout = new BenchmarkGuidLayout(value);
return (layout.TimeHighAndVersion & 0xF000) == 0x7000 &&
(layout.ClockSequenceHighAndReserved & 0xC0) == 0x80;
}

[StructLayout(LayoutKind.Explicit, Size = 16)]
private readonly struct BenchmarkGuidLayout
{
[FieldOffset(0)]
private readonly Guid _value;

[FieldOffset(6)]
public readonly ushort TimeHighAndVersion;

[FieldOffset(8)]
public readonly byte ClockSequenceHighAndReserved;

public BenchmarkGuidLayout(Guid value) => _value = value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0;net48</TargetFrameworks>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand Down
40 changes: 30 additions & 10 deletions benchmarks/Light.GuardClauses.Performance/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using BenchmarkDotNet.Configs;
using System;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
Expand All @@ -8,13 +9,32 @@ namespace Light.GuardClauses.Performance;

public static class Program
{
private static IConfig DefaultConfiguration =>
DefaultConfig
.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core80))
.AddJob(Job.Default.WithRuntime(ClrRuntime.Net48))
.AddDiagnoser(MemoryDiagnoser.Default, new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig()));
private const string EnableDisassemblyOption = "--enable-disassembly";

public static void Main(string[] arguments) =>
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(arguments, DefaultConfiguration);
}
private static IConfig CreateConfiguration(bool enableDisassembly)
{
var configuration = DefaultConfig
.Instance
.AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0))
.AddDiagnoser(MemoryDiagnoser.Default);

return enableDisassembly ?
configuration.AddDiagnoser(new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig())) :
configuration;
}

public static void Main(string[] arguments)
{
var enableDisassembly = Array.Exists(
arguments,
argument => string.Equals(argument, EnableDisassemblyOption, StringComparison.OrdinalIgnoreCase)
);
var benchmarkArguments = Array.FindAll(
arguments,
argument => !string.Equals(argument, EnableDisassemblyOption, StringComparison.OrdinalIgnoreCase)
);

BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly)
.Run(benchmarkArguments, CreateConfiguration(enableDisassembly));
}
}
14 changes: 11 additions & 3 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The package has .NET Standard 2.0, .NET Standard 2.1, and .NET 10 assets. The co
The .NET 10 asset additionally provides:

- generic `INumber<T>` overloads for `IsApproximately`, `MustBeApproximately`, `MustNotBeApproximately`, `IsGreaterThanOrApproximately`, `MustBeGreaterThanOrApproximately`, `IsLessThanOrApproximately`, and `MustBeLessThanOrApproximately`;
- generic `IFloatingPointIeee754<T>` overloads for `IsFinite` and `MustBeFinite`, including `Half` but excluding `decimal`;
- `Span<char>`, `ReadOnlySpan<char>`, `Memory<char>`, and `ReadOnlyMemory<char>` overloads for `IsEmailAddress` and `MustBeEmailAddress`; and
- trimming annotations on the type-relation helpers where supported by the framework.

Expand All @@ -39,6 +40,9 @@ The seven `ImmutableArray<T>` guard families are available across all package ta
| `MustBeOfType` | Requires a value castable to `T` and returns the cast value |
| `IsValidEnumValue`, `MustBeValidEnumValue` | Validate defined enum constants and valid flags combinations |
| `IsEmpty`, `MustNotBeEmpty` | Test or reject `Guid.Empty` |
| `IsUuidVersion7`, `MustBeUuidVersion7` | Structurally test or require an RFC/IETF UUIDv7 |

UUIDv7 validation checks the version-7 nibble and RFC/IETF `10xx` variant bits directly in the GUID layout without allocation. It does not claim to validate timestamp provenance, entropy, randomness, or monotonic generation.

## Condition and state assertions

Expand All @@ -59,6 +63,7 @@ The seven `ImmutableArray<T>` guard families are available across all package ta
| `IsIn`, `MustBeIn` | Test or require membership in a `Range<T>` |
| `IsNotIn`, `MustNotBeIn` | Test or require non-membership in a `Range<T>` |
| `IsApproximately`, `MustBeApproximately`, `MustNotBeApproximately` | Compare floating-point values using a tolerance |
| `IsFinite`, `MustBeFinite` | Test or require finite `float` and `double` values; the .NET 10 asset also supports generic IEEE 754 types |
| `IsGreaterThanOrApproximately`, `MustBeGreaterThanOrApproximately` | Accept values greater than or within tolerance of the comparison value |
| `IsLessThanOrApproximately`, `MustBeLessThanOrApproximately` | Accept values less than or within tolerance of the comparison value |

Expand All @@ -76,6 +81,7 @@ percentage.MustBeIn(Range.FromExclusive(0).ToInclusive(100));
| `IsNullOrEmpty` | Tests `IEnumerable` or `string` for null or emptiness |
| `MustNotBeNullOrEmpty` | Requires a non-null, non-empty collection or string |
| `MustHaveCount` | Requires an exact collection count |
| `MustHaveCountIn` | Requires a collection count within an inclusive/exclusive `Range<int>` |
| `MustHaveMinimumCount`, `MustHaveMaximumCount` | Enforce inclusive collection-count bounds |
| `IsOneOf`, `MustBeOneOf`, `MustNotBeOneOf` | Test or enforce membership among supplied values |
| `MustContain`, `MustNotContain` | Require or reject an item in collections and immutable arrays; string overloads operate on substrings |
Expand All @@ -89,7 +95,8 @@ Collection overloads preserve and return the original collection shape where pos
| Assertion family | Behavior |
| --- | --- |
| `IsNullOrWhiteSpace`, `MustNotBeNullOrWhiteSpace` | Test or reject null, empty, or all-whitespace strings |
| `IsEmptyOrWhiteSpace` | Tests character span/memory values for emptiness or whitespace |
| `IsEmptyOrWhiteSpace`, `MustNotBeEmptyOrWhiteSpace` | Test character span/memory values for, or reject, emptiness and all-whitespace content |
| `IsAscii`, `MustBeAscii` | Test or require ASCII characters, bytes, strings, and character/byte span or memory values; empty inputs are valid |
| `IsWhiteSpace`, `IsLetter`, `IsLetterOrDigit`, `IsDigit` | Character classification |
| `IsNewLine`, `MustBeNewLine` | Recognize or require `"\n"` or `"\r\n"`, independently of the platform newline |
| `IsTrimmed`, `MustBeTrimmed` | Test or require no leading or trailing whitespace |
Expand All @@ -102,7 +109,8 @@ Collection overloads preserve and return the original collection shape where pos
| `IsSubstringOf`, `MustBeSubstringOf`, `MustNotBeSubstringOf` | Test, require, or reject substring relationships |
| `MustContain`, `MustNotContain` | Require or reject substrings, with comparison overloads |
| `MustStartWith`, `MustNotStartWith`, `MustEndWith`, `MustNotEndWith` | Enforce string boundaries; `MustNotStartWith` also has span overloads |
| `MustHaveLength`, `MustHaveLengthIn` | Enforce exact or ranged string lengths; `MustHaveLength` also supports spans and immutable arrays |
| `MustHaveLength`, `MustHaveLengthIn` | Enforce exact or ranged lengths for strings, spans, memory, and immutable arrays as provided by their overloads |
| `MustNotBeEmpty` | Reject empty spans and memory while preserving the mutable or read-only input shape |
| `MustBeLongerThan`, `MustBeLongerThanOrEqualTo` | Enforce lower string/span length bounds |
| `MustBeShorterThan`, `MustBeShorterThanOrEqualTo` | Enforce upper string/span length bounds |

Expand All @@ -112,7 +120,7 @@ The `Light.GuardClauses.FrameworkExtensions.StringExtensions.Contains` companion

| Assertion | Behavior |
| --- | --- |
| `MustBeUtc` | Requires `DateTimeKind.Utc` |
| `MustBeUtc` | Requires `DateTimeKind.Utc` for `DateTime`, or `TimeSpan.Zero` offset for `DateTimeOffset`; values are validated without conversion |
| `MustBeLocal` | Requires `DateTimeKind.Local` |
| `MustBeUnspecified` | Requires `DateTimeKind.Unspecified` |

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing-and-building.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dotnet build Light.GuardClauses.slnx -c Release --no-restore
dotnet test Light.GuardClauses.slnx -c Release --no-build
```

The product package targets .NET Standard 2.0, .NET Standard 2.1, and .NET 10. Tests and tools run on .NET 10 by default. The benchmark project targets .NET 10 and .NET Framework 4.8; running its .NET Framework target requires a compatible Windows environment.
The product package targets .NET Standard 2.0, .NET Standard 2.1, and .NET 10. Tests, tools, and benchmarks run on .NET 10. BenchmarkDotNet memory diagnostics are enabled by default; pass `--enable-disassembly` after the benchmark project's `--` separator to opt into platform-dependent disassembly diagnostics.

## Repository layout

Expand Down
Loading
Loading