From 7faa613eafd3aef90497748dc36a67279a0da77a Mon Sep 17 00:00:00 2001 From: tvenclovas96_bigblackc <144885265+tvenclovas96@users.noreply.github.com> Date: Sun, 8 Feb 2026 23:17:40 +0200 Subject: [PATCH] constraints --- NewType.Generator/AliasAttributeSource.cs | 26 +++++++- NewType.Generator/AliasCodeGenerator.cs | 65 ++++++++++++++++-- NewType.Generator/AliasGenerator.cs | 66 ++++++++++++++----- NewType.Generator/AliasModel.cs | 19 ++++-- NewType.Generator/AliasModelExtractor.cs | 43 +++++++++--- NewType.Generator/AnalyzerReleases.Shipped.md | 3 + .../AnalyzerReleases.Unshipped.md | 8 +++ NewType.Generator/NewType.Generator.csproj | 4 ++ NewType.Tests/ConstraintValidationTests.cs | 62 +++++++++++++++++ NewType.Tests/Types.cs | 14 +++- 10 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 NewType.Generator/AnalyzerReleases.Shipped.md create mode 100644 NewType.Generator/AnalyzerReleases.Unshipped.md create mode 100644 NewType.Tests/ConstraintValidationTests.cs diff --git a/NewType.Generator/AliasAttributeSource.cs b/NewType.Generator/AliasAttributeSource.cs index debf22e..dc04e74 100644 --- a/NewType.Generator/AliasAttributeSource.cs +++ b/NewType.Generator/AliasAttributeSource.cs @@ -35,7 +35,26 @@ internal enum NewtypeOptions /// Suppress implicit conversions and constructor forwarding. Opaque = NoImplicitConversions | NoConstructorForwarding, } - + + /// + /// Controls which constraint-related features the newtype generator emits. If enabled, + /// will automatically call a user-defined 'bool IsValid(AliasedType value)' method on the newtype + /// to verify it is valid + /// + [global::System.Flags] + internal enum NewtypeConstraintOptions + { + /// Constraints disabled(default). + Disabled = 0, + /// Enable constraints. Debug builds only by default + Enabled = 1, + /// Include constraint code in release builds, if constraints are enabled + IncludeInRelease = 2, + + /// Enable constraints and include in release builds. + ReleaseEnabled = Enabled | IncludeInRelease, + } + /// /// Marks a partial type as a type alias for the specified type. /// The source generator will generate implicit conversions, operator forwarding, @@ -54,7 +73,10 @@ public newtypeAttribute() { } /// Controls which features the generator emits. public NewtypeOptions Options { get; set; } - + + /// Controls which constraint features the generator emits. + public NewtypeConstraintOptions ConstraintOptions { get; set; } + /// /// Overrides the MethodImplOptions applied to generated members. /// Default is . diff --git a/NewType.Generator/AliasCodeGenerator.cs b/NewType.Generator/AliasCodeGenerator.cs index cc75f93..ff9b5a2 100644 --- a/NewType.Generator/AliasCodeGenerator.cs +++ b/NewType.Generator/AliasCodeGenerator.cs @@ -18,6 +18,9 @@ internal class AliasCodeGenerator private readonly AliasModel _model; private readonly StringBuilder _sb = new(); + const string SingleIndent = " "; + + public AliasCodeGenerator(AliasModel model) { _model = model; @@ -137,12 +140,28 @@ private void AppendField() private void AppendConstructors() { - var indent = GetMemberIndent(); + var memberIndent = GetMemberIndent(); // Constructor from aliased type (always emitted) - _sb.AppendLine($"{indent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}."); - AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;"); + _sb.AppendLine($"{memberIndent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}."); + AppendMethodImplAttribute(memberIndent); + + + if (_model.IncludeConstraints) + { + _sb.AppendLine($"{memberIndent}public {_model.TypeName}({_model.AliasedTypeFullName} value)"); + _sb.Append(memberIndent).Append('{').AppendLine(); + + AppendConstraintChecker(SingleIndent, "value"); + _sb.Append(SingleIndent).Append(SingleIndent).Append(SingleIndent).AppendLine("_value = value;"); + + _sb.Append(memberIndent).Append('}').AppendLine(); + } + else + { + _sb.AppendLine($"{memberIndent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;"); + } + _sb.AppendLine(); // Forward constructors from the aliased type (conditionally) @@ -150,7 +169,7 @@ private void AppendConstructors() { foreach (var ctor in _model.ForwardedConstructors) { - AppendForwardedConstructor(indent, ctor); + AppendForwardedConstructor(memberIndent, ctor); } } } @@ -668,7 +687,22 @@ private void AppendForwardedConstructor(string indent, ConstructorInfo ctor) _sb.AppendLine($"{indent}/// Forwards {_model.AliasedTypeMinimalName} constructor."); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public {_model.TypeName}({parameters}) => _value = new {_model.AliasedTypeFullName}({arguments});"); + if (_model.IncludeConstraints) + { + const string valueName = "newValue"; + _sb.AppendLine($"{indent}public {_model.TypeName}({parameters})"); + _sb.Append(indent).Append('{').AppendLine(); + _sb.Append(indent).Append(SingleIndent).AppendLine($"var {valueName} = new {_model.AliasedTypeFullName}({arguments});"); + + AppendConstraintChecker(SingleIndent, valueName); + _sb.Append(SingleIndent).Append(SingleIndent).Append(SingleIndent).AppendLine($"_value = {valueName};"); + + _sb.Append(indent).Append('}').AppendLine(); + } + else + { + _sb.AppendLine($"{indent}public {_model.TypeName}({parameters}) => _value = new {_model.AliasedTypeFullName}({arguments});"); + } _sb.AppendLine(); } @@ -691,6 +725,23 @@ private void AppendMethodImplAttribute(string indent) _sb.AppendLine(line); } + private void AppendConstraintChecker(string indent, string valueName) + { + if (!_model.validValidationMethod) return; + + if (_model.DebugOnlyConstraints) + _sb.AppendLine("#if DEBUG"); + + _sb.Append(indent).Append(indent).Append(indent) + .AppendLine($"if (!IsValid({valueName}))"); + _sb.Append(indent).Append(indent).Append(indent).Append(indent) + .AppendLine($"throw new InvalidOperationException($\"Failed validation check when trying to create '{_model.TypeName}' with '{_model.AliasedTypeMinimalName}' value: {{{valueName}}}\");"); // we heard you like interpolation + + + if (_model.DebugOnlyConstraints) + _sb.AppendLine("#endif"); + } + private static string FormatConstructorParameters(ConstructorInfo ctor) { return string.Join(", ", ctor.Parameters.Array.Select(p => @@ -728,7 +779,7 @@ private static string FormatConstructorArguments(ConstructorInfo ctor) })); } - private string GetMemberIndent() => string.IsNullOrEmpty(_model.Namespace) ? " " : " "; + private string GetMemberIndent() => string.IsNullOrEmpty(_model.Namespace) ? SingleIndent : $"{SingleIndent}{SingleIndent}"; private static string? GetOperatorSymbol(string operatorName) { diff --git a/NewType.Generator/AliasGenerator.cs b/NewType.Generator/AliasGenerator.cs index 684aa30..a1e7379 100644 --- a/NewType.Generator/AliasGenerator.cs +++ b/NewType.Generator/AliasGenerator.cs @@ -12,30 +12,42 @@ namespace newtype.generator; [Generator(LanguageNames.CSharp)] public class AliasGenerator : IIncrementalGenerator { + private static readonly DiagnosticDescriptor MissingIsValidMethodDiagnostic = + new DiagnosticDescriptor( + id: "NEWTYPE001", + title: "Missing validation method", + messageFormat: $"Type '{{0}}' uses constraints but does not define a compatible validation method. Expected signature: 'bool {AliasModel.ConstraintValidationMethodSymbol}({{1}})'.", + category: "Unknown", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A constraint-enabled wrapped type must define a validation method." + ); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Register the attribute source - context.RegisterPostInitializationOutput(ctx => { ctx.AddSource("newtypeAttribute.g.cs", SourceText.From(NewtypeAttributeSource.Source, Encoding.UTF8)); }); + context.RegisterPostInitializationOutput(ctx => + { + ctx.AddSource("newtypeAttribute.g.cs", SourceText.From(NewtypeAttributeSource.Source, Encoding.UTF8)); + }); // Pipeline for generic [newtype] attribute - var genericPipeline = context.SyntaxProvider + IncrementalValuesProvider genericPipeline = context.SyntaxProvider .ForAttributeWithMetadataName( "newtype.newtypeAttribute`1", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, _) => ExtractGenericModel(ctx)) - .Where(static model => model is not null) - .Select(static (model, _) => model!.Value); + .Where(static model => model is not null)!; context.RegisterSourceOutput(genericPipeline, static (spc, model) => GenerateAliasCode(spc, model)); // Pipeline for non-generic [newtype(typeof(T))] attribute - var nonGenericPipeline = context.SyntaxProvider + IncrementalValuesProvider nonGenericPipeline = context.SyntaxProvider .ForAttributeWithMetadataName( "newtype.newtypeAttribute", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, _) => ExtractNonGenericModel(ctx)) - .Where(static model => model is not null) - .Select(static (model, _) => model!.Value); + .Where(static model => model is not null)!; context.RegisterSourceOutput(nonGenericPipeline, static (spc, model) => GenerateAliasCode(spc, model)); } @@ -45,14 +57,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) foreach (var attributeData in context.Attributes) { var attributeClass = attributeData.AttributeClass; - if (attributeClass is {IsGenericType: true} && + if (attributeClass is { IsGenericType: true } && attributeClass.TypeArguments.Length == 1) { var aliasedType = attributeClass.TypeArguments[0]; - var (options, methodImpl) = ExtractNamedArguments(attributeData); - return AliasModelExtractor.Extract(context, aliasedType, options, methodImpl); + var options = ExtractNamedArguments(attributeData); + return AliasModelExtractor.Extract(context, aliasedType, options); } } + return null; } @@ -63,21 +76,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (attributeData.ConstructorArguments.Length > 0 && attributeData.ConstructorArguments[0].Value is ITypeSymbol aliasedType) { - var (options, methodImpl) = ExtractNamedArguments(attributeData); - return AliasModelExtractor.Extract(context, aliasedType, options, methodImpl); + var options = ExtractNamedArguments(attributeData); + + return AliasModelExtractor.Extract(context, aliasedType, options); } } + return null; } // Mirrors MethodImplOptions.AggressiveInlining — the generator can't reference // the injected NewtypeOptions enum, and we want readable defaults. private const int DefaultOptions = 0; + private const int DefaultConstraintOptions = 0; private const int DefaultMethodImplAggressiveInlining = 256; - private static (int options, int methodImpl) ExtractNamedArguments(AttributeData attributeData) + private static ExtractedOptions ExtractNamedArguments( + AttributeData attributeData) { int options = DefaultOptions; + int constraintOptions = DefaultConstraintOptions; int methodImpl = DefaultMethodImplAggressiveInlining; foreach (var arg in attributeData.NamedArguments) @@ -87,23 +105,39 @@ private static (int options, int methodImpl) ExtractNamedArguments(AttributeData case "Options": options = (int)arg.Value.Value!; break; + case "ConstraintOptions": + constraintOptions = (int)arg.Value.Value!; + break; case "MethodImpl": methodImpl = (int)arg.Value.Value!; break; } } - return (options, methodImpl); + return new ExtractedOptions(options, constraintOptions, methodImpl); } private static void GenerateAliasCode( SourceProductionContext context, AliasModel model) { + if (model.IncludeConstraints && !model.validValidationMethod) + { + context.ReportDiagnostic( + Diagnostic.Create( + MissingIsValidMethodDiagnostic, + model.Location, + model.TypeName, + model.AliasedTypeFullName + )); + + return; + } + var generator = new AliasCodeGenerator(model); var source = generator.Generate(); - + var fileName = $"{model.TypeDisplayString.Replace(".", "_").Replace("<", "_").Replace(">", "_")}.g.cs"; context.AddSource(fileName, SourceText.From(source, Encoding.UTF8)); } -} +} \ No newline at end of file diff --git a/NewType.Generator/AliasModel.cs b/NewType.Generator/AliasModel.cs index 2584186..7279467 100644 --- a/NewType.Generator/AliasModel.cs +++ b/NewType.Generator/AliasModel.cs @@ -8,7 +8,7 @@ namespace newtype.generator; /// Fully-extracted, equatable model representing a newtype alias. /// Contains only strings, bools, plain enums, and EquatableArrays — no Roslyn symbols. /// -internal readonly record struct AliasModel( +internal record AliasModel( // Type being declared string TypeName, string Namespace, @@ -16,8 +16,10 @@ internal readonly record struct AliasModel( bool IsReadonly, bool IsClass, bool IsRecord, - bool IsRecordStruct, - + + // Location for messages + Location? Location, + // Aliased type string AliasedTypeFullName, string AliasedTypeMinimalName, @@ -41,6 +43,10 @@ internal readonly record struct AliasModel( bool SuppressImplicitUnwrap, bool SuppressConstructorForwarding, int MethodImplValue, + + bool IncludeConstraints, + bool DebugOnlyConstraints, + bool validValidationMethod, // Members EquatableArray BinaryOperators, @@ -50,7 +56,12 @@ internal readonly record struct AliasModel( EquatableArray InstanceProperties, EquatableArray InstanceMethods, EquatableArray ForwardedConstructors -); +) +{ + public const string ConstraintValidationMethodSymbol = "IsValid"; +}; + +internal readonly record struct ExtractedOptions(int Options, int ConstraintOptions, int MethodImpl); internal readonly record struct BinaryOperatorInfo( string Name, diff --git a/NewType.Generator/AliasModelExtractor.cs b/NewType.Generator/AliasModelExtractor.cs index da34af1..9660e07 100644 --- a/NewType.Generator/AliasModelExtractor.cs +++ b/NewType.Generator/AliasModelExtractor.cs @@ -16,16 +16,17 @@ internal static class AliasModelExtractor private const int OptionsNoImplicitWrap = 1; private const int OptionsNoImplicitUnwrap = 2; private const int OptionsNoConstructorForwarding = 4; + private const int OptionsUseConstraints = 1; + private const int OptionsConstraintsInRelease = 2; public static AliasModel? Extract( GeneratorAttributeSyntaxContext context, ITypeSymbol aliasedType, - int options, - int methodImpl) + ExtractedOptions allOptions) { var typeDecl = (TypeDeclarationSyntax)context.TargetNode; var typeSymbol = (INamedTypeSymbol)context.TargetSymbol; - + var typeName = typeSymbol.Name; var ns = typeSymbol.ContainingNamespace; var namespaceName = ns is {IsGlobalNamespace: false} ? ns.ToDisplayString() : ""; @@ -35,7 +36,6 @@ internal static class AliasModelExtractor || (typeDecl is RecordDeclarationSyntax rds && !rds.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)); var isRecord = typeDecl is RecordDeclarationSyntax; - var isRecordStruct = isRecord && !isClass; var aliasedTypeFullName = aliasedType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var aliasedTypeMinimalName = aliasedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat); @@ -55,7 +55,11 @@ internal static class AliasModelExtractor var constructors = ExtractForwardableConstructors(typeSymbol, aliasedType); var typeDisplayString = typeSymbol.ToDisplayString(); + var validIsValid = HasValidIsValid(typeSymbol, aliasedType); + var useConstraints = (allOptions.ConstraintOptions & OptionsUseConstraints) != 0; + + return new AliasModel( TypeName: typeName, Namespace: namespaceName, @@ -63,7 +67,7 @@ internal static class AliasModelExtractor IsReadonly: isReadonly, IsClass: isClass, IsRecord: isRecord, - IsRecordStruct: isRecordStruct, + Location: typeSymbol.Locations.FirstOrDefault(), AliasedTypeFullName: aliasedTypeFullName, AliasedTypeMinimalName: aliasedTypeMinimalName, AliasedTypeSpecialType: aliasedType.SpecialType, @@ -73,10 +77,13 @@ internal static class AliasModelExtractor HasNativeEqualityOperator: hasNativeEquality, TypeDisplayString: typeDisplayString, HasStaticMemberCandidates: hasStaticMemberCandidates, - SuppressImplicitWrap: (options & OptionsNoImplicitWrap) != 0, - SuppressImplicitUnwrap: (options & OptionsNoImplicitUnwrap) != 0, - SuppressConstructorForwarding: (options & OptionsNoConstructorForwarding) != 0, - MethodImplValue: methodImpl, + SuppressImplicitWrap: (allOptions.Options & OptionsNoImplicitWrap) != 0, + SuppressImplicitUnwrap: (allOptions.Options & OptionsNoImplicitUnwrap) != 0, + SuppressConstructorForwarding: (allOptions.Options & OptionsNoConstructorForwarding) != 0, + MethodImplValue: allOptions.MethodImpl, + IncludeConstraints: useConstraints, + DebugOnlyConstraints: (allOptions.ConstraintOptions & OptionsConstraintsInRelease) == 0, // inverse + validValidationMethod: validIsValid, BinaryOperators: binaryOperators, UnaryOperators: unaryOperators, StaticMembers: staticMembers, @@ -355,6 +362,24 @@ private static string GetConstructorSignature(IMethodSymbol ctor) })); } + private static bool HasValidIsValid(ITypeSymbol targetType, ITypeSymbol aliasedType) + { + // does not have to be static, even it is more "hygienic" + // seems like it would a be a little annoying to enforce if not strictly needed + IMethodSymbol? isValidMethod = + targetType + .GetMembers(AliasModel.ConstraintValidationMethodSymbol) + .OfType() + .FirstOrDefault(m => + m.ReturnType.SpecialType == SpecialType.System_Boolean && + m.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals( + m.Parameters[0].Type, + aliasedType)); + + return isValidMethod != null; + } + private static string FormatDefaultValue(IParameterSymbol param) { var value = param.ExplicitDefaultValue; diff --git a/NewType.Generator/AnalyzerReleases.Shipped.md b/NewType.Generator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..60b59dd --- /dev/null +++ b/NewType.Generator/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/NewType.Generator/AnalyzerReleases.Unshipped.md b/NewType.Generator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..cf30ff2 --- /dev/null +++ b/NewType.Generator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +| Rule ID | Category | Severity | Notes | +|------------|-----------|----------|----------------| +| NEWTYPE001 | `Unknown` | Error | AliasGenerator | \ No newline at end of file diff --git a/NewType.Generator/NewType.Generator.csproj b/NewType.Generator/NewType.Generator.csproj index 60e8b63..4de361b 100644 --- a/NewType.Generator/NewType.Generator.csproj +++ b/NewType.Generator/NewType.Generator.csproj @@ -49,5 +49,9 @@ + + + + diff --git a/NewType.Tests/ConstraintValidationTests.cs b/NewType.Tests/ConstraintValidationTests.cs new file mode 100644 index 0000000..e1296b0 --- /dev/null +++ b/NewType.Tests/ConstraintValidationTests.cs @@ -0,0 +1,62 @@ +using System.Numerics; +using newtype.tests; +using Xunit; + +public class ConstraintValidationTests +{ + [Fact] + public void Direction_Valid() + { + //doesn't throw + var dir = new Direction(new Vector2(0, 1)); + } + + [Fact] + public void Direction_ValidImplicit() + { + //doesn't throw + Direction dir = new Vector2(0, 1); + } + + [Fact] + public void Direction_Valid_Forwarded() + { + //doesn't throw + var dir = new Direction(0, 1); + } + + [Fact] + public void Direction_CreateInvalid_Throws() + { + Assert.Throws(() => new Direction(new Vector2(999, 999))); + } + + [Fact] + public void Direction_CreateInvalidImplicit_Throws() + { + Assert.Throws(() => (Direction)new Vector2(999, 999)); + } + + [Fact] + public void Direction_CreateInvalidForwarded_Throws() + { + Assert.Throws(() => new Direction(999, 999)); + } + + [Fact] + public void Direction_ForwardedOperation_ValidResult() + { + var dir1 = new Direction(new Vector2(0, 1f)); + var dir2 = new Direction(new Vector2(0, 1f)); + var dir3 = dir1 * dir2; + } + + [Fact] + public void Direction_ForwardedOperation_InvalidResult_Throws() + { + var dir1 = new Direction(new Vector2(0, 1f)); + var dir2 = new Direction(new Vector2(0, 1f)); + + Assert.Throws(() => dir1 + dir2); + } +} \ No newline at end of file diff --git a/NewType.Tests/Types.cs b/NewType.Tests/Types.cs index 7f3673a..2049999 100644 --- a/NewType.Tests/Types.cs +++ b/NewType.Tests/Types.cs @@ -8,6 +8,17 @@ namespace newtype.tests; [newtype] public readonly partial struct Velocity; +[newtype(ConstraintOptions = NewtypeConstraintOptions.ReleaseEnabled)] +public readonly partial struct Direction +{ + private static bool IsValid(Vector2 direction) + { + // enforce normalized + const float epsilon = 0.0001f; + return Math.Abs(direction.LengthSquared() - 1f) < epsilon; + } +} + [newtype] public readonly partial struct Scale; @@ -103,6 +114,5 @@ public partial class DisplayName; // "Extending Your Types" example — custom cross-type operator public readonly partial struct Position { - public static Position operator +(Position p, Velocity v) - => new(p.Value + v.Value); + public static Position operator +(Position p, Velocity v) => new(p.Value + v.Value); } \ No newline at end of file