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
55 changes: 55 additions & 0 deletions Light.GuardClauses.SingleFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2305,6 +2305,54 @@ public static ReadOnlyMemory<char> MustBeBase64(this ReadOnlyMemory<char> parame
return parameter;
}

/// <summary>
/// Ensures that <paramref name = "parameter"/> is a non-abstract class, or otherwise throws an
/// <see cref = "ArgumentException"/>.
/// </summary>
/// <param name = "parameter">The type 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>
/// <returns>The original type.</returns>
/// <exception cref = "ArgumentException">
/// Thrown when <paramref name = "parameter"/> is not a class or is abstract.
/// </exception>
/// <exception cref = "ArgumentNullException">Thrown when <paramref name = "parameter"/> is null.</exception>
[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;
}

/// <summary>
/// Ensures that <paramref name = "parameter"/> is a non-abstract class, or otherwise throws your custom exception.
/// </summary>
/// <param name = "parameter">The type to be checked.</param>
/// <param name = "exceptionFactory">
/// The delegate that creates your custom exception. The original type is passed to this delegate.
/// </param>
/// <returns>The original type.</returns>
/// <exception cref = "Exception">
/// Your custom exception thrown when <paramref name = "parameter"/> is null, is not a class, or is abstract.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")]
public static Type MustBeConcreteClass([NotNull][ValidatedNotNull] this Type? parameter, Func<Type?, Exception> exceptionFactory)
{
if (parameter is null || !(parameter.IsClass && !parameter.IsAbstract))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}

/// <summary>
/// Ensures that the string is a valid email address using the default email regular expression
/// defined in <see cref = "RegularExpressions.EmailRegex"/>, or otherwise throws an <see cref = "InvalidEmailAddressException"/>.
Expand Down Expand Up @@ -12128,6 +12176,13 @@ public static void InvalidMinimumImmutableArrayLength<T>(ImmutableArray<T> 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);
/// <summary>
/// Throws the default <see cref = "ArgumentException"/> indicating that the specified type is not a concrete class,
/// using the optional parameter name and message.
/// </summary>
[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);
/// <summary>
/// Throws the default <see cref = "ArgumentOutOfRangeException"/> indicating that a comparable value must be greater
/// than the given boundary value, using the optional parameter name and message.
/// </summary>
Expand Down
41 changes: 41 additions & 0 deletions ai-plans/0171-concrete-class-assertion.md
Original file line number Diff line number Diff line change
@@ -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<Type?, Exception>`, 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<Type?, Exception> 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.
6 changes: 4 additions & 2 deletions docs/assertion-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,17 @@ 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 |
| `InheritsFrom`, `IsOrInheritsFrom` | Test derivation or interface implementation, optionally allowing equality |
| `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

Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -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
</PackageReleaseNotes>
</PropertyGroup>
Expand Down
66 changes: 66 additions & 0 deletions src/Light.GuardClauses/Check.MustBeConcreteClass.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Ensures that <paramref name="parameter" /> is a non-abstract class, or otherwise throws an
/// <see cref="ArgumentException" />.
/// </summary>
/// <param name="parameter">The type 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>
/// <returns>The original type.</returns>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="parameter" /> is not a class or is abstract.
/// </exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="parameter" /> is null.</exception>
[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;
}

/// <summary>
/// Ensures that <paramref name="parameter" /> is a non-abstract class, or otherwise throws your custom exception.
/// </summary>
/// <param name="parameter">The type to be checked.</param>
/// <param name="exceptionFactory">
/// The delegate that creates your custom exception. The original type is passed to this delegate.
/// </param>
/// <returns>The original type.</returns>
/// <exception cref="Exception">
/// Your custom exception thrown when <paramref name="parameter" /> is null, is not a class, or is abstract.
/// </exception>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[ContractAnnotation("parameter:null => halt; parameter:notnull => notnull; exceptionFactory:null => halt")]
public static Type MustBeConcreteClass(
[NotNull] [ValidatedNotNull] this Type? parameter,
Func<Type?, Exception> exceptionFactory
)
{
if (parameter is null || !(parameter.IsClass && !parameter.IsAbstract))
{
Throw.CustomException(exceptionFactory, parameter);
}

return parameter;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;

namespace Light.GuardClauses.ExceptionFactory;

public static partial class Throw
{
/// <summary>
/// Throws the default <see cref="ArgumentException" /> indicating that the specified type is not a concrete class,
/// using the optional parameter name and message.
/// </summary>
[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
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,51 @@ public static void MustBeAssignableToWhitelistTrimsExceptionFactoryOverload()
sourceCode.Should().NotContain("public static void CustomException<T1, T2>(");
}

[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<Type?, Exception> exceptionFactory");
sourceCode.Should().Contain("public static void CustomException<T>(");
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<Type?, Exception> exceptionFactory");
sourceCode.Should().NotContain("public static void CustomException<T>(");
}

[Fact]
public static void MustBeUriWhitelistExportsGuardThrowHelperExceptionAndFactories()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading