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
810 changes: 757 additions & 53 deletions Light.GuardClauses.SingleFile.cs

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions ai-plans/0173-must-be-positive-feature-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Integral Sign-Guard Feature Parity

## Rationale

Plan 0168 addressed parent issue #162 (point 5) by adding concrete `MustBePositive` overloads for the integral types previously covered only by the modern generic `INumber<T>` API. The other four sign-guard families remain asymmetric: their .NET Standard assets and portable source exports omit smaller signed integral types and, where the predicate remains meaningful, unsigned integral types, although the .NET 10 generic overloads already define their behavior.

Extend the concrete integral surface of `MustBeNegative`, `MustNotBeNegative`, `MustNotBePositive`, and `MustNotBeZero` on every supported target while preserving the existing generic APIs and exception contracts. Omit unsigned overloads whose predicate would be constant rather than adding APIs that either always fail or cannot validate anything.

## Acceptance Criteria

- [x] `MustBeNegative` and `MustNotBeNegative` each have default and custom-exception-factory overloads for `sbyte` and `short`, but do not add concrete unsigned overloads whose checks would always fail or always succeed.
- [x] `MustNotBePositive` and `MustNotBeZero` each have default and custom-exception-factory overloads for `sbyte`, `byte`, `short`, `ushort`, `uint`, and `ulong`.
- [x] Every new overload is available on .NET Standard 2.0, .NET Standard 2.1, and .NET 10, returns the original exactly typed value when its predicate succeeds, and matches the corresponding existing .NET 10 generic semantics.
- [x] Default failures preserve each guard family's existing `ArgumentOutOfRangeException` contract, including caller-argument-expression parameter names, optional custom messages, and the standard generated message containing the rejected value.
- [x] Each new factory overload passes the original value with its exact integral type, invokes the factory only when validation fails, propagates the factory's exception, and throws `ArgumentNullException` through the existing `Throw.CustomException` convention when a failing check receives a null factory.
- [x] Automated tests cover signed and applicable unsigned boundary behavior for every new overload, return values, exception parameter names and messages, factory arguments and exceptions, factories not invoked on success, and null-factory failures.
- [x] Source-export tests verify the exact per-family concrete type matrix in portable and modern exports, including the deliberate omission of unsigned `MustBeNegative` and `MustNotBeNegative` overloads; generic `INumber<T>` overloads appear only in modern exports; and every custom-exception-factory overload is trimmed when that option is disabled.
- [x] The committed .NET Standard 2.0 `Light.GuardClauses.SingleFile.cs` distribution is regenerated with the expanded APIs, and generated source validates for both supported source-export targets.
- [x] The sign-guard documentation describes the per-family concrete integral surface, and the package release notes mention the expanded support across all five sign-guard families.
- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK.

## Technical Details

Add the concrete overload pairs outside the `NET8_0_OR_GREATER` regions according to this matrix:

| Guard | New concrete types | Predicate |
| --- | --- | --- |
| `MustBeNegative` | `sbyte`, `short` | Less than zero |
| `MustNotBeNegative` | `sbyte`, `short` | Greater than or equal to zero |
| `MustNotBePositive` | `sbyte`, `byte`, `short`, `ushort`, `uint`, `ulong` | Less than or equal to zero |
| `MustNotBeZero` | `sbyte`, `byte`, `short`, `ushort`, `uint`, `ulong` | Not equal to zero |

Unsigned overloads are appropriate for `MustNotBePositive`, where zero succeeds and positive values fail, and `MustNotBeZero`, where zero fails and positive values succeed. Do not add concrete unsigned overloads to `MustBeNegative`, which could never succeed, or `MustNotBeNegative`, which could never fail.

For each listed guard and type, follow the annotations, XML documentation, parameter ordering, and implementation conventions of the corresponding existing `int` and `long` pair. The following is an illustrative shape for the concrete APIs, not a new generic API:

```csharp
public static T Guard(
this T parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
);

public static T Guard(this T parameter, Func<T, Exception> exceptionFactory);
```

Compare values directly to type-appropriate zero without widening to `int` or `long`, so return values, throw-helper inputs, and factory arguments retain their exact types. The generic throw helpers already support these types, so no new exception or `Throw` API is required.

Keep all `INumber<T>` overloads under `NET8_0_OR_GREATER`. Normal modern calls for types in the matrix should resolve to the new concrete overloads, while explicitly generic calls, deliberately omitted unsigned combinations, and numeric types such as `Half` remain supported by the generic surface with identical predicates. Retain focused tests for that distinction. Do not add `nint` or `nuint` overloads.

Extend the four guard-specific test classes and `NumericCustomFactorySuccessTests` with the new concrete matrix, retaining focused modern tests for explicitly generic calls and types without concrete overloads. Strengthen `SourceFileMergerWhitelistTests.SignGuardWhitelistsUseTargetSpecificSurface` to cover every sign-guard family and both factory settings, update `docs/assertion-overview.md` and `src/Directory.Build.props`, and regenerate the root `Light.GuardClauses.SingleFile.cs` through the committed source-export settings. No microbenchmarks are required because these overloads add only primitive comparisons equivalent to the existing generic behavior.
4 changes: 2 additions & 2 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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 `INumber<T>` overloads for `MustBePositive`, `MustBeNegative`, `MustNotBePositive`, `MustNotBeNegative`, and `MustNotBeZero`; for `MustBePositive`, these extend the common concrete integral overloads to remaining numeric types such as `Half`;
- generic `INumber<T>` overloads for `MustBePositive`, `MustBeNegative`, `MustNotBePositive`, `MustNotBeNegative`, and `MustNotBeZero`, extending the common concrete integral overloads to remaining numeric types such as `Half`;
- 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 Down Expand Up @@ -72,7 +72,7 @@ UUIDv7 validation checks the version-7 nibble and RFC/IETF `10xx` variant bits d
| `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 |

The five sign guard families have concrete overloads for `int`, `long`, `decimal`, `float`, `double`, and `TimeSpan` on all package targets. `MustBePositive` additionally has concrete `sbyte`, `byte`, `short`, `ushort`, `uint`, and `ulong` overloads on every target. The .NET 10 asset adds the generic `INumber<T>` overloads listed above. All checks compare the value against zero with the type's comparison operators. Consequently, `NaN` is rejected by the four sign guards and accepted by `MustNotBeZero`, positive and negative infinity satisfy the guards matching their sign (compose with `MustBeFinite` to reject non-finite values), and negative zero — including `decimal`'s signed zero representations — behaves exactly like zero. `MustNotBeZero` uses exact equality; tolerance-based comparisons remain the domain of the approximation guards.
The five sign guard families have concrete overloads for `int`, `long`, `decimal`, `float`, `double`, and `TimeSpan` on all package targets. `MustBePositive`, `MustNotBePositive`, and `MustNotBeZero` additionally have concrete `sbyte`, `byte`, `short`, `ushort`, `uint`, and `ulong` overloads on every target, while `MustBeNegative` and `MustNotBeNegative` add concrete `sbyte` and `short` overloads — unsigned overloads are omitted for these two families because their predicates would be constant. The .NET 10 asset adds the generic `INumber<T>` overloads listed above. All checks compare the value against zero with the type's comparison operators. Consequently, `NaN` is rejected by the four sign guards and accepted by `MustNotBeZero`, positive and negative infinity satisfy the guards matching their sign (compose with `MustBeFinite` to reject non-finite values), and negative zero — including `decimal`'s signed zero representations — behaves exactly like zero. `MustNotBeZero` uses exact equality; tolerance-based comparisons remain the domain of the approximation guards.

Create ranges with the `Range<T>` fluent API:

Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
--------------------------------

- new assertions: MustBeAssignableTo, MustBeConcreteClass, MustBeUri, ObjectDisposed
- expanded MustBePositive support for sbyte, byte, short, ushort, uint, and ulong on all target frameworks
- expanded sign-guard support on all target frameworks: MustBePositive, MustNotBePositive, and MustNotBeZero for sbyte, byte, short, ushort, uint, and ulong; MustBeNegative and MustNotBeNegative for sbyte and short
</PackageReleaseNotes>
</PropertyGroup>

Expand Down
102 changes: 99 additions & 3 deletions src/Light.GuardClauses/Check.MustBeNegative.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,111 @@
using System;
#if NET8_0_OR_GREATER
using System.Numerics;
#endif
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;
#if NET8_0_OR_GREATER
using System.Numerics;
#endif

namespace Light.GuardClauses;

public static partial class Check
{
/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is negative (less than zero), or otherwise
/// throws an <see cref="ArgumentOutOfRangeException" />.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="parameter" /> is zero or positive.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static sbyte MustBeNegative(
this sbyte parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
if (!(parameter < 0))
{
Throw.MustBeNegative(parameter, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is negative (less than zero), or otherwise
/// throws your custom exception.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="exceptionFactory">
/// The delegate that creates your custom exception. <paramref name="parameter" /> is passed to this delegate.
/// </param>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is zero or positive.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("exceptionFactory:null => halt")]
public static sbyte MustBeNegative(this sbyte parameter, Func<sbyte, Exception> exceptionFactory)
{
if (!(parameter < 0))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is negative (less than zero), or otherwise
/// throws an <see cref="ArgumentOutOfRangeException" />.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="parameterName">The name of the parameter (optional).</param>
/// <param name="message">The message that will be passed to the resulting exception (optional).</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="parameter" /> is zero or positive.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static short MustBeNegative(
this short parameter,
[CallerArgumentExpression("parameter")] string? parameterName = null,
string? message = null
)
{
if (!(parameter < 0))
{
Throw.MustBeNegative(parameter, parameterName, message);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is negative (less than zero), or otherwise
/// throws your custom exception.
/// </summary>
/// <param name="parameter">The value to be checked.</param>
/// <param name="exceptionFactory">
/// The delegate that creates your custom exception. <paramref name="parameter" /> is passed to this delegate.
/// </param>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is zero or positive.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("exceptionFactory:null => halt")]
public static short MustBeNegative(this short parameter, Func<short, Exception> exceptionFactory)
{
if (!(parameter < 0))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the specified <paramref name="parameter" /> is negative (less than zero), or otherwise
/// throws an <see cref="ArgumentOutOfRangeException" />.
Expand Down
22 changes: 11 additions & 11 deletions src/Light.GuardClauses/Check.MustBePositive.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
using System;
#if NET8_0_OR_GREATER
using System.Numerics;
#endif
using System.Runtime.CompilerServices;
using JetBrains.Annotations;
using Light.GuardClauses.ExceptionFactory;
#if NET8_0_OR_GREATER
using System.Numerics;
#endif

namespace Light.GuardClauses;

Expand All @@ -27,7 +27,7 @@ public static sbyte MustBePositive(
string? message = null
)
{
if (!(parameter > (sbyte) 0))
if (!(parameter > 0))
{
Throw.MustBePositive(parameter, parameterName, message);
}
Expand All @@ -50,7 +50,7 @@ public static sbyte MustBePositive(
[ContractAnnotation("exceptionFactory:null => halt")]
public static sbyte MustBePositive(this sbyte parameter, Func<sbyte, Exception> exceptionFactory)
{
if (!(parameter > (sbyte) 0))
if (!(parameter > 0))
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand All @@ -75,7 +75,7 @@ public static byte MustBePositive(
string? message = null
)
{
if (!(parameter > (byte) 0))
if (!(parameter > 0))
{
Throw.MustBePositive(parameter, parameterName, message);
}
Expand All @@ -98,7 +98,7 @@ public static byte MustBePositive(
[ContractAnnotation("exceptionFactory:null => halt")]
public static byte MustBePositive(this byte parameter, Func<byte, Exception> exceptionFactory)
{
if (!(parameter > (byte) 0))
if (!(parameter > 0))
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand All @@ -123,7 +123,7 @@ public static short MustBePositive(
string? message = null
)
{
if (!(parameter > (short) 0))
if (!(parameter > 0))
{
Throw.MustBePositive(parameter, parameterName, message);
}
Expand All @@ -146,7 +146,7 @@ public static short MustBePositive(
[ContractAnnotation("exceptionFactory:null => halt")]
public static short MustBePositive(this short parameter, Func<short, Exception> exceptionFactory)
{
if (!(parameter > (short) 0))
if (!(parameter > 0))
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand All @@ -171,7 +171,7 @@ public static ushort MustBePositive(
string? message = null
)
{
if (!(parameter > (ushort) 0))
if (!(parameter > 0))
{
Throw.MustBePositive(parameter, parameterName, message);
}
Expand All @@ -194,7 +194,7 @@ public static ushort MustBePositive(
[ContractAnnotation("exceptionFactory:null => halt")]
public static ushort MustBePositive(this ushort parameter, Func<ushort, Exception> exceptionFactory)
{
if (!(parameter > (ushort) 0))
if (!(parameter > 0))
{
Throw.CustomException(exceptionFactory, parameter);
}
Expand Down
Loading
Loading