diff --git a/Light.GuardClauses.SingleFile.cs b/Light.GuardClauses.SingleFile.cs index c6d7283..91c0437 100644 --- a/Light.GuardClauses.SingleFile.cs +++ b/Light.GuardClauses.SingleFile.cs @@ -2305,6 +2305,54 @@ public static ReadOnlyMemory MustBeBase64(this ReadOnlyMemory parame return parameter; } + /// + /// Ensures that is a non-abstract class, or otherwise throws an + /// . + /// + /// The type to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original type. + /// + /// Thrown when is not a class or is abstract. + /// + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static Type MustBeConcreteClass([NotNull][ValidatedNotNull] this Type? parameter, [CallerArgumentExpression("parameter")] string? parameterName = null, string? message = null) + { + parameter.MustNotBeNull(parameterName, message); + if (!(parameter.IsClass && !parameter.IsAbstract)) + { + Throw.MustBeConcreteClass(parameter, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that is a non-abstract class, or otherwise throws your custom exception. + /// + /// The type to be checked. + /// + /// The delegate that creates your custom exception. The original type is passed to this delegate. + /// + /// The original type. + /// + /// Your custom exception thrown when is null, is not a class, or is abstract. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static Type MustBeConcreteClass([NotNull][ValidatedNotNull] this Type? parameter, Func exceptionFactory) + { + if (parameter is null || !(parameter.IsClass && !parameter.IsAbstract)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } + /// /// Ensures that the string is a valid email address using the default email regular expression /// defined in , or otherwise throws an . @@ -12128,6 +12176,13 @@ public static void InvalidMinimumImmutableArrayLength(ImmutableArray param [DoesNotReturn] public static void MustBeAssignableTo(Type parameter, Type requiredType, string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"Values of type \"{parameter}\" must be assignable to variables of type \"{requiredType}\", but they are not.", parameterName); /// + /// Throws the default indicating that the specified type is not a concrete class, + /// using the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeConcreteClass(Type parameter, string? parameterName = null, string? message = null) => throw new ArgumentException(message ?? $"Type \"{parameter}\" must be a non-abstract class, but it is not.", parameterName); + /// /// Throws the default indicating that a comparable value must be greater /// than the given boundary value, using the optional parameter name and message. /// diff --git a/ai-plans/0171-concrete-class-assertion.md b/ai-plans/0171-concrete-class-assertion.md new file mode 100644 index 0000000..bf4f63a --- /dev/null +++ b/ai-plans/0171-concrete-class-assertion.md @@ -0,0 +1,41 @@ +# Concrete-Class Assertion + +## Rationale + +Parent issue #162 (point 4) identifies three BrilliantMessaging guards that require a handler type to be a class and not abstract. Callers currently have to combine reflection predicates with a generic assertion, which obscures the reusable intent and makes the failure contract inconsistent. + +Add `MustBeConcreteClass` as a fluent `Type` assertion that uses the reflection definition `Type.IsClass && !Type.IsAbstract`, remains available on `netstandard2.0`, and composes with `MustBeAssignableTo` when a handler must also satisfy a base-type or interface constraint. + +## Acceptance Criteria + +- [x] `type.MustBeConcreteClass(parameterName, message)` returns the original `Type` when `type.IsClass` is true and `type.IsAbstract` is false, and throws `ArgumentException` otherwise, identically on .NET Standard 2.0, .NET Standard 2.1, and .NET 10; the exception exposes the caller-captured parameter name and honors an optional custom message. +- [x] The default overload throws `ArgumentNullException` for a null type, attributing the failure to the caller-captured parameter name; no new public exception type is introduced. +- [x] A custom-exception-factory overload accepts `Func`, passes the original type to the factory, invokes it only when the type is null or is not a concrete class, and a null factory on a failing check throws `ArgumentNullException` via the existing `Throw.CustomException` convention. +- [x] Automated tests cover representative concrete classes and reflection edge cases accepted by the exact predicate; interfaces, abstract and static classes, value types, enums, and `void`; null input; return-value identity; parameter-name and custom-message propagation; the factory argument and concrete exception; no factory invocation on success; null-factory behavior; nullable-flow analysis; and composition with `MustBeAssignableTo`. +- [x] The source-export whitelist catalog and committed settings contain `MustBeConcreteClass`, and focused source-export tests cover retention of the assertion, its throw helper, and the one-argument custom-exception helper as well as trimming of the exception-factory overload when configured. +- [x] The committed .NET Standard 2.0 single-file distribution is regenerated with the assertion and validates for both supported source-export targets. +- [x] The type-relation assertion documentation lists `MustBeConcreteClass`, and the package release notes mention the new guard. +- [x] The complete solution restores and builds without warnings in Release configuration, and all automated tests pass on the pinned SDK. + +## Technical Details + +Add `Check.MustBeConcreteClass.cs` using the conventions of the other value-returning `Type` guards (aggressive inlining, JetBrains contract annotations, nullable-flow annotations, caller-argument-expression capture, and XML documentation). The exact public shape is: + +```csharp +public static Type MustBeConcreteClass( + [NotNull] [ValidatedNotNull] this Type? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null +); + +public static Type MustBeConcreteClass( + [NotNull] [ValidatedNotNull] this Type? parameter, + Func exceptionFactory +); +``` + +After validating null in the default overload, the assertion must test `parameter.IsClass && !parameter.IsAbstract`. Treat those reflection properties as the complete contract: do not inspect constructors, `ContainsGenericParameters`, visibility, or other instantiability concerns. Consequently, ordinary sealed and unsealed classes, delegates, arrays, and open or closed generic classes follow the BCL reflection flags, while static classes fail because their emitted type is abstract. + +Route default classification failures through a non-returning `Throw.MustBeConcreteClass` helper that constructs `ArgumentException` with the optional parameter name and a default message identifying the rejected type. The factory overload treats null and either failed predicate as validation failures and passes the original nullable value to `Throw.CustomException`. No microbenchmarks are required because the guard performs two constant-time reflection-property checks. + +Register `MustBeConcreteClass` in `AssertionWhitelist` and `settings.json`, add focused `SourceFileMergerWhitelistTests`, add `MustBeConcreteClassTests` under the type assertions, extend the value-returning assertion coverage in `Issue72NotNullAttributeTests`, update the type-relation table in `docs/assertion-overview.md`, update the package release notes, and regenerate `tools/source-export/Light.GuardClauses.SourceCodeTransformation/Light.GuardClauses.SingleFile.cs` through the committed source-export settings. diff --git a/docs/assertion-overview.md b/docs/assertion-overview.md index 61845aa..204911e 100644 --- a/docs/assertion-overview.md +++ b/docs/assertion-overview.md @@ -170,6 +170,7 @@ while preserving its concrete stream type, and all three guards support custom m | Assertion | Behavior | | --- | --- | | `MustBeAssignableTo` | Requires CLR assignability from a candidate type to a required base type or interface | +| `MustBeConcreteClass` | Requires a type whose reflection flags report a non-abstract class | | `IsEquivalentTypeTo` | Treats equal types and constructed-generic/definition pairs as equivalent | | `Implements`, `IsOrImplements` | Test interface implementation, optionally allowing equality | | `DerivesFrom`, `IsOrDerivesFrom` | Test base-class derivation, optionally allowing equality | @@ -177,8 +178,9 @@ while preserving its concrete stream type, and all three guards support custom m | `IsOpenConstructedGenericType` | Tests for a constructed generic type that still has open parameters | `MustBeAssignableTo` uses `requiredType.IsAssignableFrom(candidateType)` directly, so it covers interface -assignability, generic variance, and the BCL's open-generic behavior. The other relation methods provide comparer -overloads where applicable. +assignability, generic variance, and the BCL's open-generic behavior. `MustBeConcreteClass` uses +`type.IsClass && !type.IsAbstract` directly; delegates, arrays, and open generic classes therefore pass, while static +classes fail. The other relation methods provide comparer overloads where applicable. ## URI assertions diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4c4404a..7a9fee9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -20,7 +20,7 @@ Light.GuardClauses 15.1.0 -------------------------------- - - new assertions: MustBeAssignableTo, MustBeUri, ObjectDisposed + - new assertions: MustBeAssignableTo, MustBeConcreteClass, MustBeUri, ObjectDisposed - expanded MustBePositive support for sbyte, byte, short, ushort, uint, and ulong on all target frameworks diff --git a/src/Light.GuardClauses/Check.MustBeConcreteClass.cs b/src/Light.GuardClauses/Check.MustBeConcreteClass.cs new file mode 100644 index 0000000..443dfbd --- /dev/null +++ b/src/Light.GuardClauses/Check.MustBeConcreteClass.cs @@ -0,0 +1,66 @@ +using System; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Light.GuardClauses.ExceptionFactory; +using NotNullAttribute = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace Light.GuardClauses; + +public static partial class Check +{ + /// + /// Ensures that is a non-abstract class, or otherwise throws an + /// . + /// + /// The type to be checked. + /// The name of the parameter (optional). + /// The message that will be passed to the resulting exception (optional). + /// The original type. + /// + /// Thrown when is not a class or is abstract. + /// + /// Thrown when is null. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull")] + public static Type MustBeConcreteClass( + [NotNull] [ValidatedNotNull] this Type? parameter, + [CallerArgumentExpression("parameter")] string? parameterName = null, + string? message = null + ) + { + parameter.MustNotBeNull(parameterName, message); + + if (!(parameter.IsClass && !parameter.IsAbstract)) + { + Throw.MustBeConcreteClass(parameter, parameterName, message); + } + + return parameter; + } + + /// + /// Ensures that is a non-abstract class, or otherwise throws your custom exception. + /// + /// The type to be checked. + /// + /// The delegate that creates your custom exception. The original type is passed to this delegate. + /// + /// The original type. + /// + /// Your custom exception thrown when is null, is not a class, or is abstract. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + [ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")] + public static Type MustBeConcreteClass( + [NotNull] [ValidatedNotNull] this Type? parameter, + Func exceptionFactory + ) + { + if (parameter is null || !(parameter.IsClass && !parameter.IsAbstract)) + { + Throw.CustomException(exceptionFactory, parameter); + } + + return parameter; + } +} diff --git a/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeConcreteClass.cs b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeConcreteClass.cs new file mode 100644 index 0000000..5635295 --- /dev/null +++ b/src/Light.GuardClauses/ExceptionFactory/Throw.MustBeConcreteClass.cs @@ -0,0 +1,24 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Light.GuardClauses.ExceptionFactory; + +public static partial class Throw +{ + /// + /// Throws the default indicating that the specified type is not a concrete class, + /// using the optional parameter name and message. + /// + [ContractAnnotation("=> halt")] + [DoesNotReturn] + public static void MustBeConcreteClass( + Type parameter, + string? parameterName = null, + string? message = null + ) => + throw new ArgumentException( + message ?? $"Type \"{parameter}\" must be a non-abstract class, but it is not.", + parameterName + ); +} diff --git a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs index 1a91d73..1161e61 100644 --- a/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs +++ b/tests/Light.GuardClauses.SourceCodeTransformation.Tests/SourceFileMergerWhitelistTests.cs @@ -636,6 +636,51 @@ public static void MustBeAssignableToWhitelistTrimsExceptionFactoryOverload() sourceCode.Should().NotContain("public static void CustomException("); } + [Fact] + public static void MustBeConcreteClassWhitelistExportsGuardThrowHelperAndFactoryOnBothTargets() + { + using var temporaryDirectory = new TemporaryDirectory(); + var portableFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeConcreteClassPortable.cs"); + var modernFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeConcreteClassModern.cs"); + var whitelist = CreateWhitelist( + includedAssertions: [new ("MustBeConcreteClass", true)] + ); + + SourceFileMerger.CreateSingleSourceFile(CreateOptions(portableFile, whitelist)); + SourceFileMerger.CreateSingleSourceFile( + CreateOptions(modernFile, whitelist, SourceTargetFramework.Net10_0) + ); + + foreach (var sourceCode in new[] { File.ReadAllText(portableFile), File.ReadAllText(modernFile) }) + { + sourceCode.Should().Contain("public static Type MustBeConcreteClass("); + sourceCode.Should().Contain("public static void MustBeConcreteClass("); + sourceCode.Should().Contain("Func exceptionFactory"); + sourceCode.Should().Contain("public static void CustomException("); + sourceCode.Should().NotContain("public static Type MustBeAssignableTo("); + } + } + + [Fact] + public static void MustBeConcreteClassWhitelistTrimsExceptionFactoryOverload() + { + using var temporaryDirectory = new TemporaryDirectory(); + var targetFile = Path.Combine(temporaryDirectory.DirectoryPath, "MustBeConcreteClassWithoutFactory.cs"); + + SourceFileMerger.CreateSingleSourceFile( + CreateOptions( + targetFile, + CreateWhitelist(includedAssertions: [new ("MustBeConcreteClass", false)]) + ) + ); + var sourceCode = File.ReadAllText(targetFile); + + sourceCode.Should().Contain("public static Type MustBeConcreteClass("); + sourceCode.Should().Contain("public static void MustBeConcreteClass("); + sourceCode.Should().NotContain("Func exceptionFactory"); + sourceCode.Should().NotContain("public static void CustomException("); + } + [Fact] public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories() { diff --git a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs index 484ce97..b61f305 100644 --- a/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs +++ b/tests/Light.GuardClauses.Tests/Issues/Issue72NotNullAttributeTests.cs @@ -1090,6 +1090,26 @@ public static void CheckMustBeAssignableTo() } } + [Fact] + public static void CheckMustBeConcreteClass() + { + TestMustBeConcreteClass(typeof(MemoryStream)).Should().Be(typeof(MemoryStream)); + TestMustBeConcreteClassWithDelegate(typeof(MemoryStream)).Should().Be(typeof(MemoryStream)); + return; + + static Type TestMustBeConcreteClass(Type? type) + { + type.MustBeConcreteClass(); + return type; + } + + static Type TestMustBeConcreteClassWithDelegate(Type? type) + { + type.MustBeConcreteClass(_ => new Exception()); + return type; + } + } + [Fact] public static void CheckMustBeAbsoluteUri() { diff --git a/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeConcreteClassTests.cs b/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeConcreteClassTests.cs new file mode 100644 index 0000000..345565c --- /dev/null +++ b/tests/Light.GuardClauses.Tests/TypeAssertions/MustBeConcreteClassTests.cs @@ -0,0 +1,167 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using FluentAssertions; +using Xunit; + +namespace Light.GuardClauses.Tests.TypeAssertions; + +public static class MustBeConcreteClassTests +{ + [Theory(DisplayName = "MustBeConcreteClass must return types accepted by IsClass && !IsAbstract.")] + [MemberData(nameof(ConcreteClassTypes))] + public static void ConcreteClass(Type type) + { + var result = type.MustBeConcreteClass(); + + result.Should().BeSameAs(type); + } + + public static readonly TheoryData ConcreteClassTypes = + new () + { + typeof(SampleConcreteClass), + typeof(SealedConcreteClass), + typeof(SampleDelegate), + typeof(string[]), + typeof(List<>), + typeof(List), + }; + + [Theory(DisplayName = "MustBeConcreteClass must reject types not accepted by IsClass && !IsAbstract.")] + [MemberData(nameof(NonConcreteClassTypes))] + public static void NonConcreteClass(Type type) + { + Action act = () => type.MustBeConcreteClass(); + + var exception = act.Should().Throw().Which; + exception.ParamName.Should().Be(nameof(type)); + exception.Message.Should().Contain($"\"{type}\""); + exception.Message.Should().Contain("non-abstract class"); + } + + public static readonly TheoryData NonConcreteClassTypes = + new () + { + typeof(IDisposable), + typeof(AbstractClass), + typeof(StaticClass), + typeof(int), + typeof(DayOfWeek), + typeof(void), + }; + + [Fact] + public static void NullType() + { + Type? type = null; + + Action act = () => type.MustBeConcreteClass(); + + act.Should().Throw() + .WithParameterName(nameof(type)); + } + + [Fact] + public static void ExplicitParameterName() + { + Action act = () => typeof(IDisposable).MustBeConcreteClass("handlerType"); + + act.Should().Throw() + .WithParameterName("handlerType"); + } + + [Fact] + public static void CustomMessage() => + Test.CustomMessage( + message => typeof(IDisposable).MustBeConcreteClass(message: message) + ); + + [Theory] + [MemberData(nameof(FactoryFailureData))] + public static void FactoryReceivesOriginalTypeAndItsExceptionIsThrown(Type? type, Type? expectedFactoryArgument) + { + var expectedException = new InvalidOperationException("Custom concrete-class failure"); + Type? capturedType = typeof(void); + + Exception ExceptionFactory(Type? originalType) + { + capturedType = originalType; + return expectedException; + } + + Action act = () => type.MustBeConcreteClass(ExceptionFactory); + + act.Should().Throw() + .Which.Should().BeSameAs(expectedException); + capturedType.Should().BeSameAs(expectedFactoryArgument); + } + + public static readonly TheoryData FactoryFailureData = + new () + { + { typeof(IDisposable), typeof(IDisposable) }, + { typeof(AbstractClass), typeof(AbstractClass) }, + { null, null }, + }; + + [Fact] + public static void FactoryIsNotInvokedOnSuccess() + { + var type = typeof(SampleConcreteClass); + var wasInvoked = false; + + var result = type.MustBeConcreteClass( + _ => + { + wasInvoked = true; + return new Exception(); + } + ); + + result.Should().BeSameAs(type); + wasInvoked.Should().BeFalse(); + } + + [Fact] + public static void NullFactoryIsIgnoredOnSuccess() + { + var type = typeof(SampleConcreteClass); + + var result = type.MustBeConcreteClass((Func) null!); + + result.Should().BeSameAs(type); + } + + [Fact] + public static void NullFactoryThrowsArgumentNullExceptionOnFailure() + { + Action act = () => typeof(IDisposable).MustBeConcreteClass((Func) null!); + + act.Should().Throw() + .WithParameterName("exceptionFactory"); + } + + [Fact] + public static void ComposesWithMustBeAssignableTo() + { + var type = typeof(MemoryStream); + + var result = type.MustBeConcreteClass() + .MustBeAssignableTo(typeof(Stream)); + + result.Should().BeSameAs(type); + } + + private class SampleConcreteClass; + + private sealed class SealedConcreteClass; + + private abstract class AbstractClass; + + private static class StaticClass; + + private delegate void SampleDelegate(); +} diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs index 685bb87..9c53a87 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/AssertionWhitelist.cs @@ -109,6 +109,8 @@ public sealed class AssertionWhitelist public AssertionEntry MustBeAssignableTo { get; init; } = new(); + public AssertionEntry MustBeConcreteClass { get; init; } = new(); + public AssertionEntry MustBeEmailAddress { get; init; } = new(); public AssertionEntry MustBeFileExtension { get; init; } = new(); diff --git a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json index 4aa7a05..6bb5dd9 100644 --- a/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json +++ b/tools/source-export/Light.GuardClauses.SourceCodeTransformation/settings.json @@ -68,6 +68,7 @@ "MustBeAbsoluteUri": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeApproximately": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeAssignableTo": { "Include": true, "IncludeExceptionFactoryOverload": true }, + "MustBeConcreteClass": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeEmailAddress": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeFileExtension": { "Include": true, "IncludeExceptionFactoryOverload": true }, "MustBeFinite": { "Include": true, "IncludeExceptionFactoryOverload": true },