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 1/2] 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 From 38b359e3bf8d1f43c8ecd496f58ee60bfd304841 Mon Sep 17 00:00:00 2001 From: tvenclovas96_bigblackc <144885265+tvenclovas96@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:02:48 +0200 Subject: [PATCH 2/2] constraints with attribute --- NewType.Generator/AliasAttributeSource.cs | 24 +-- NewType.Generator/AliasCodeGenerator.cs | 155 +++++++++++------- NewType.Generator/AliasGenerator.cs | 60 ++++--- NewType.Generator/AliasModel.cs | 28 ++-- NewType.Generator/AliasModelExtractor.cs | 101 ++++++++---- .../AliasValidationAttributeSource.cs | 33 ++++ .../AnalyzerReleases.Unshipped.md | 3 +- NewType.Tests/Types.cs | 5 +- 8 files changed, 260 insertions(+), 149 deletions(-) create mode 100644 NewType.Generator/AliasValidationAttributeSource.cs diff --git a/NewType.Generator/AliasAttributeSource.cs b/NewType.Generator/AliasAttributeSource.cs index dc04e74..237f7b8 100644 --- a/NewType.Generator/AliasAttributeSource.cs +++ b/NewType.Generator/AliasAttributeSource.cs @@ -35,26 +35,7 @@ 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, @@ -74,9 +55,6 @@ 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 ff9b5a2..6fabeb5 100644 --- a/NewType.Generator/AliasCodeGenerator.cs +++ b/NewType.Generator/AliasCodeGenerator.cs @@ -20,7 +20,6 @@ internal class AliasCodeGenerator const string SingleIndent = " "; - public AliasCodeGenerator(AliasModel model) { _model = model; @@ -143,25 +142,27 @@ private void AppendConstructors() var memberIndent = GetMemberIndent(); // Constructor from aliased type (always emitted) - _sb.AppendLine($"{memberIndent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}."); + _sb.AppendLine( + $"{memberIndent}/// Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}."); AppendMethodImplAttribute(memberIndent); - - - if (_model.IncludeConstraints) + + + if (_model.ConstraintModel.UseConstraints) { _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(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( + $"{memberIndent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;"); } - + _sb.AppendLine(); // Forward constructors from the aliased type (conditionally) @@ -193,18 +194,22 @@ private void AppendImplicitOperators() // Implicit from aliased type to alias (T → Alias) if (!_model.SuppressImplicitWrap) { - _sb.AppendLine($"{indent}/// Implicitly converts from {_model.AliasedTypeMinimalName} to {_model.TypeName}."); + _sb.AppendLine( + $"{indent}/// Implicitly converts from {_model.AliasedTypeMinimalName} to {_model.TypeName}."); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static implicit operator {_model.TypeName}({_model.AliasedTypeFullName} value) => new {_model.TypeName}(value);"); + _sb.AppendLine( + $"{indent}public static implicit operator {_model.TypeName}({_model.AliasedTypeFullName} value) => new {_model.TypeName}(value);"); _sb.AppendLine(); } // Implicit from alias to aliased type (Alias → T) if (!_model.SuppressImplicitUnwrap) { - _sb.AppendLine($"{indent}/// Implicitly converts from {_model.TypeName} to {_model.AliasedTypeMinimalName}."); + _sb.AppendLine( + $"{indent}/// Implicitly converts from {_model.TypeName} to {_model.AliasedTypeMinimalName}."); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static implicit operator {_model.AliasedTypeFullName}({_model.TypeName} value) => value._value;"); + _sb.AppendLine( + $"{indent}public static implicit operator {_model.AliasedTypeFullName}({_model.TypeName} value) => value._value;"); _sb.AppendLine(); } } @@ -233,7 +238,8 @@ private void AppendBinaryOperators() var expr1 = WrapIfAlias(op.ReturnIsAliasedType, $"left._value {opSymbol} right._value"); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {_model.TypeName} right) => {expr1};"); + _sb.AppendLine( + $"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {_model.TypeName} right) => {expr1};"); _sb.AppendLine(); // Also generate alias op T for cross-type interop @@ -243,7 +249,8 @@ private void AppendBinaryOperators() // The implicit conversion to T already covers the T op alias case. var expr2 = WrapIfAlias(op.ReturnIsAliasedType, $"left._value {opSymbol} right"); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {_model.AliasedTypeFullName} right) => {expr2};"); + _sb.AppendLine( + $"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {_model.AliasedTypeFullName} right) => {expr2};"); _sb.AppendLine(); } // Operator with aliased type on left only — also emit T op Alias @@ -255,7 +262,8 @@ private void AppendBinaryOperators() var expr = WrapIfAlias(op.ReturnIsAliasedType, $"left._value {opSymbol} right"); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {op.RightTypeFullName} right) => {expr};"); + _sb.AppendLine( + $"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} left, {op.RightTypeFullName} right) => {expr};"); _sb.AppendLine(); } // Operator with aliased type on right only — also emit Alias op T @@ -267,7 +275,8 @@ private void AppendBinaryOperators() var expr = WrapIfAlias(op.ReturnIsAliasedType, $"left {opSymbol} right._value"); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({op.LeftTypeFullName} left, {_model.TypeName} right) => {expr};"); + _sb.AppendLine( + $"{indent}public static {returnTypeStr} operator {opSymbol}({op.LeftTypeFullName} left, {_model.TypeName} right) => {expr};"); _sb.AppendLine(); } } @@ -285,24 +294,28 @@ private void AppendBinaryOperators() if (IsShiftOperator(opName)) { AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, int right) => new {_model.TypeName}(left._value {opSymbol} right);"); + _sb.AppendLine( + $"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, int right) => new {_model.TypeName}(left._value {opSymbol} right);"); _sb.AppendLine(); } else { // Alias op Alias AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, {_model.TypeName} right) => new {_model.TypeName}(left._value {opSymbol} right._value);"); + _sb.AppendLine( + $"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, {_model.TypeName} right) => new {_model.TypeName}(left._value {opSymbol} right._value);"); _sb.AppendLine(); // Alias op T AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, {_model.AliasedTypeFullName} right) => new {_model.TypeName}(left._value {opSymbol} right);"); + _sb.AppendLine( + $"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} left, {_model.AliasedTypeFullName} right) => new {_model.TypeName}(left._value {opSymbol} right);"); _sb.AppendLine(); // T op Alias AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {_model.TypeName} operator {opSymbol}({_model.AliasedTypeFullName} left, {_model.TypeName} right) => new {_model.TypeName}(left {opSymbol} right._value);"); + _sb.AppendLine( + $"{indent}public static {_model.TypeName} operator {opSymbol}({_model.AliasedTypeFullName} left, {_model.TypeName} right) => new {_model.TypeName}(left {opSymbol} right._value);"); _sb.AppendLine(); } } @@ -326,7 +339,8 @@ private void AppendUnaryOperators() var expr = WrapIfAlias(op.ReturnIsAliasedType, $"{opSymbol}value._value"); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value) => {expr};"); + _sb.AppendLine( + $"{indent}public static {returnTypeStr} operator {opSymbol}({_model.TypeName} value) => {expr};"); _sb.AppendLine(); } @@ -340,7 +354,8 @@ private void AppendUnaryOperators() if (opSymbol == null) continue; AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} value) => new {_model.TypeName}({opSymbol}value._value);"); + _sb.AppendLine( + $"{indent}public static {_model.TypeName} operator {opSymbol}({_model.TypeName} value) => new {_model.TypeName}({opSymbol}value._value);"); _sb.AppendLine(); } } @@ -384,7 +399,8 @@ private void AppendComparisonOperators() foreach (var op in ops) { AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator {op}({_model.TypeName} left, {_model.TypeName} right) => left._value {op} right._value;"); + _sb.AppendLine( + $"{indent}public static bool operator {op}({_model.TypeName} left, {_model.TypeName} right) => left._value {op} right._value;"); _sb.AppendLine(); } @@ -396,15 +412,17 @@ private void AppendComparisonOperators() { var isRefType = !_model.AliasedTypeIsValueType; - string CompareExpr(string op) => isRefType - ? $"(left._value is null ? (right._value is null ? 0 : -1) : left._value.CompareTo(right._value)) {op} 0" - : $"left._value.CompareTo(right._value) {op} 0"; + string CompareExpr(string op) => + isRefType + ? $"(left._value is null ? (right._value is null ? 0 : -1) : left._value.CompareTo(right._value)) {op} 0" + : $"left._value.CompareTo(right._value) {op} 0"; string[] ops = ["<", ">", "<=", ">="]; foreach (var op in ops) { AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator {op}({_model.TypeName} left, {_model.TypeName} right) => {CompareExpr(op)};"); + _sb.AppendLine( + $"{indent}public static bool operator {op}({_model.TypeName} left, {_model.TypeName} right) => {CompareExpr(op)};"); _sb.AppendLine(); } } @@ -436,39 +454,46 @@ private void AppendEqualityMembers() // Object.Equals override _sb.AppendLine($"{indent}/// "); - _sb.AppendLine($"{indent}public override bool Equals(object? obj) => obj is {_model.TypeName} other && Equals(other);"); + _sb.AppendLine( + $"{indent}public override bool Equals(object? obj) => obj is {_model.TypeName} other && Equals(other);"); _sb.AppendLine(); if (_model.IsClass) { // Class types need null-safe equality operators AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator ==({_model.TypeName}? left, {_model.TypeName}? right) => ReferenceEquals(left, right) || (left is not null && left.Equals(right));"); + _sb.AppendLine( + $"{indent}public static bool operator ==({_model.TypeName}? left, {_model.TypeName}? right) => ReferenceEquals(left, right) || (left is not null && left.Equals(right));"); _sb.AppendLine(); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator !=({_model.TypeName}? left, {_model.TypeName}? right) => !(left == right);"); + _sb.AppendLine( + $"{indent}public static bool operator !=({_model.TypeName}? left, {_model.TypeName}? right) => !(left == right);"); _sb.AppendLine(); } else if (_model.HasNativeEqualityOperator) { AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator ==({_model.TypeName} left, {_model.TypeName} right) => left._value == right._value;"); + _sb.AppendLine( + $"{indent}public static bool operator ==({_model.TypeName} left, {_model.TypeName} right) => left._value == right._value;"); _sb.AppendLine(); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator !=({_model.TypeName} left, {_model.TypeName} right) => left._value != right._value;"); + _sb.AppendLine( + $"{indent}public static bool operator !=({_model.TypeName} left, {_model.TypeName} right) => left._value != right._value;"); _sb.AppendLine(); } else { // Fallback: route through Equals AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator ==({_model.TypeName} left, {_model.TypeName} right) => left.Equals(right);"); + _sb.AppendLine( + $"{indent}public static bool operator ==({_model.TypeName} left, {_model.TypeName} right) => left.Equals(right);"); _sb.AppendLine(); AppendMethodImplAttribute(indent); - _sb.AppendLine($"{indent}public static bool operator !=({_model.TypeName} left, {_model.TypeName} right) => !left.Equals(right);"); + _sb.AppendLine( + $"{indent}public static bool operator !=({_model.TypeName} left, {_model.TypeName} right) => !left.Equals(right);"); _sb.AppendLine(); } } @@ -513,7 +538,8 @@ private void AppendStaticMembers() if (member.IsProperty) { - _sb.AppendLine($"{indent}/// Forwards {_model.AliasedTypeMinimalName}.{member.Name}."); + _sb.AppendLine( + $"{indent}/// Forwards {_model.AliasedTypeMinimalName}.{member.Name}."); _sb.AppendLine($"{indent}public static {returnTypeStr} {member.Name}"); _sb.AppendLine($"{indent}{{"); AppendMethodImplAttribute($"{indent} "); @@ -523,7 +549,8 @@ private void AppendStaticMembers() } else if (member.IsReadonlyField) { - _sb.AppendLine($"{indent}/// Forwards {_model.AliasedTypeMinimalName}.{member.Name}."); + _sb.AppendLine( + $"{indent}/// Forwards {_model.AliasedTypeMinimalName}.{member.Name}."); _sb.AppendLine($"{indent}public static {returnTypeStr} {member.Name} => {valueExpr};"); _sb.AppendLine(); } @@ -537,7 +564,8 @@ private void AppendInstanceMembers() { var indent = GetMemberIndent(); - if (_model.InstanceFields.Length == 0 && _model.InstanceProperties.Length == 0 && _model.InstanceMethods.Length == 0) + if (_model.InstanceFields.Length == 0 && _model.InstanceProperties.Length == 0 + && _model.InstanceMethods.Length == 0) return; _sb.AppendLine($"{indent}#region Instance Members"); @@ -663,7 +691,8 @@ private void AppendToString() if (_model.ImplementsIFormattable) { _sb.AppendLine($"{indent}/// "); - _sb.AppendLine($"{indent}public string ToString(string? format, IFormatProvider? formatProvider) => _value.ToString(format, formatProvider);"); + _sb.AppendLine( + $"{indent}public string ToString(string? format, IFormatProvider? formatProvider) => _value.ToString(format, formatProvider);"); _sb.AppendLine(); } } @@ -687,22 +716,25 @@ private void AppendForwardedConstructor(string indent, ConstructorInfo ctor) _sb.AppendLine($"{indent}/// Forwards {_model.AliasedTypeMinimalName} constructor."); AppendMethodImplAttribute(indent); - if (_model.IncludeConstraints) + if (_model.ConstraintModel.UseConstraints) { 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});"); - + _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(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( + $"{indent}public {_model.TypeName}({parameters}) => _value = new {_model.AliasedTypeFullName}({arguments});"); } + _sb.AppendLine(); } @@ -727,21 +759,23 @@ private void AppendMethodImplAttribute(string indent) private void AppendConstraintChecker(string indent, string valueName) { - if (!_model.validValidationMethod) return; - - if (_model.DebugOnlyConstraints) + if (!_model.ConstraintModel.Valid) return; + + if (!_model.ConstraintModel.InRelease) _sb.AppendLine("#if DEBUG"); - + _sb.Append(indent).Append(indent).Append(indent) - .AppendLine($"if (!IsValid({valueName}))"); + .AppendLine($"if (!{_model.ConstraintModel.ValidationSymbolName}({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) + .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.ConstraintModel.InRelease) _sb.AppendLine("#endif"); + else + _sb.AppendLine(); } - + private static string FormatConstructorParameters(ConstructorInfo ctor) { return string.Join(", ", ctor.Parameters.Array.Select(p => @@ -779,7 +813,8 @@ private static string FormatConstructorArguments(ConstructorInfo ctor) })); } - private string GetMemberIndent() => string.IsNullOrEmpty(_model.Namespace) ? SingleIndent : $"{SingleIndent}{SingleIndent}"; + private string GetMemberIndent() => + string.IsNullOrEmpty(_model.Namespace) ? SingleIndent : $"{SingleIndent}{SingleIndent}"; private static string? GetOperatorSymbol(string operatorName) { @@ -887,4 +922,4 @@ SpecialType.System_Int64 or SpecialType.System_UInt64 or SpecialType.System_Single or SpecialType.System_Double or SpecialType.System_Decimal or SpecialType.System_Char; } -} +} \ No newline at end of file diff --git a/NewType.Generator/AliasGenerator.cs b/NewType.Generator/AliasGenerator.cs index a1e7379..893f7b7 100644 --- a/NewType.Generator/AliasGenerator.cs +++ b/NewType.Generator/AliasGenerator.cs @@ -12,23 +12,14 @@ 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)); + ctx.AddSource("newtypeConstraintAttribute.g.cs", + SourceText.From(ConstraintAttributeSource.Source, Encoding.UTF8)); }); // Pipeline for generic [newtype] attribute @@ -88,14 +79,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // 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 ExtractedOptions ExtractNamedArguments( AttributeData attributeData) { int options = DefaultOptions; - int constraintOptions = DefaultConstraintOptions; int methodImpl = DefaultMethodImplAggressiveInlining; foreach (var arg in attributeData.NamedArguments) @@ -105,39 +94,64 @@ private static ExtractedOptions ExtractNamedArguments( 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 new ExtractedOptions(options, constraintOptions, methodImpl); + return new ExtractedOptions(options, methodImpl); } private static void GenerateAliasCode( SourceProductionContext context, AliasModel model) { - if (model.IncludeConstraints && !model.validValidationMethod) + if (!model.ConstraintModel.Valid) { context.ReportDiagnostic( Diagnostic.Create( - MissingIsValidMethodDiagnostic, - model.Location, + ValidatorInvalidDiagnostic, model.ConstraintModel.LocationInfo?.ToLocation(), model.TypeName, - model.AliasedTypeFullName + model.ConstraintModel.ValidationSymbolName ?? "Method", + model.AliasedTypeMinimalName )); return; } - + + if (model.ConstraintModel.Multiple) + { + context.ReportDiagnostic( + Diagnostic.Create(ValidatorMultipleDiagnostic, model.LocationInfo?.ToLocation()) + ); + 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)); } + + private static readonly DiagnosticDescriptor ValidatorInvalidDiagnostic = + new( + id: "NEWTYPE001", + title: "Malformed validation method", + messageFormat: "Incorrectly formed validation method for type '{0}'. Expected signature: 'bool {1}({2})'.", + category: "Unknown", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + private static readonly DiagnosticDescriptor ValidatorMultipleDiagnostic = + new( + id: "NEWTYPE002", + title: "Multiple validators", + messageFormat: "Only a single validation method should be used", + category: "Unknown", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); } \ No newline at end of file diff --git a/NewType.Generator/AliasModel.cs b/NewType.Generator/AliasModel.cs index 7279467..231291c 100644 --- a/NewType.Generator/AliasModel.cs +++ b/NewType.Generator/AliasModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; namespace newtype.generator; @@ -16,10 +17,10 @@ internal record AliasModel( bool IsReadonly, bool IsClass, bool IsRecord, - + // Location for messages - Location? Location, - + LocationInfo? LocationInfo, + // Aliased type string AliasedTypeFullName, string AliasedTypeMinimalName, @@ -43,10 +44,7 @@ internal record AliasModel( bool SuppressImplicitUnwrap, bool SuppressConstructorForwarding, int MethodImplValue, - - bool IncludeConstraints, - bool DebugOnlyConstraints, - bool validValidationMethod, + ConstraintModel ConstraintModel, // Members EquatableArray BinaryOperators, @@ -56,12 +54,9 @@ internal record 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 ExtractedOptions(int Options, int MethodImpl); internal readonly record struct BinaryOperatorInfo( string Name, @@ -127,3 +122,12 @@ internal readonly record struct ConstructorParameterInfo( bool IsParams, string? DefaultValueLiteral ) : IEquatable; + +internal readonly record struct LocationInfo( + string FilePath, + TextSpan TextSpan, + LinePositionSpan LineSpan +) : IEquatable +{ + public Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan); +} \ No newline at end of file diff --git a/NewType.Generator/AliasModelExtractor.cs b/NewType.Generator/AliasModelExtractor.cs index 9660e07..e066c01 100644 --- a/NewType.Generator/AliasModelExtractor.cs +++ b/NewType.Generator/AliasModelExtractor.cs @@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; namespace newtype.generator; @@ -16,20 +17,18 @@ 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( + public static AliasModel? Extract( GeneratorAttributeSyntaxContext context, ITypeSymbol aliasedType, 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() : ""; + var namespaceName = ns is { IsGlobalNamespace: false } ? ns.ToDisplayString() : ""; var isReadonly = typeDecl.Modifiers.Any(SyntaxKind.ReadOnlyKeyword); var isClass = typeDecl is ClassDeclarationSyntax @@ -55,11 +54,9 @@ internal static class AliasModelExtractor var constructors = ExtractForwardableConstructors(typeSymbol, aliasedType); var typeDisplayString = typeSymbol.ToDisplayString(); - var validIsValid = HasValidIsValid(typeSymbol, aliasedType); - var useConstraints = (allOptions.ConstraintOptions & OptionsUseConstraints) != 0; - - + var constraintModel = ExtractValidationMethod(typeSymbol, aliasedType); + return new AliasModel( TypeName: typeName, Namespace: namespaceName, @@ -67,7 +64,7 @@ internal static class AliasModelExtractor IsReadonly: isReadonly, IsClass: isClass, IsRecord: isRecord, - Location: typeSymbol.Locations.FirstOrDefault(), + LocationInfo: ToLocationStruct(typeSymbol.Locations.FirstOrDefault()), AliasedTypeFullName: aliasedTypeFullName, AliasedTypeMinimalName: aliasedTypeMinimalName, AliasedTypeSpecialType: aliasedType.SpecialType, @@ -81,9 +78,7 @@ internal static class AliasModelExtractor SuppressImplicitUnwrap: (allOptions.Options & OptionsNoImplicitUnwrap) != 0, SuppressConstructorForwarding: (allOptions.Options & OptionsNoConstructorForwarding) != 0, MethodImplValue: allOptions.MethodImpl, - IncludeConstraints: useConstraints, - DebugOnlyConstraints: (allOptions.ConstraintOptions & OptionsConstraintsInRelease) == 0, // inverse - validValidationMethod: validIsValid, + ConstraintModel: constraintModel, BinaryOperators: binaryOperators, UnaryOperators: unaryOperators, StaticMembers: staticMembers, @@ -362,22 +357,52 @@ private static string GetConstructorSignature(IMethodSymbol ctor) })); } - private static bool HasValidIsValid(ITypeSymbol targetType, ITypeSymbol aliasedType) + private static ConstraintModel ExtractValidationMethod(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; + IMethodSymbol? validationMethod = null; + Location? location = null; + bool invalid = false; + bool multiple = false; + bool inRelease = false; + + foreach (var method in targetType.GetMembers().OfType()) + { + foreach (var attributeData in method.GetAttributes().Where(x => x is not null)) + { + if (attributeData.AttributeClass!.Name == ConstraintAttributeSource.AttributeName) + { + foreach (var arg in attributeData.NamedArguments) + { + if (arg.Key == "IncludeInRelease") + { + inRelease = (bool)arg.Value.Value!; + } + } + + // doesn't have to be static + var methodValid = method.ReturnType.SpecialType == SpecialType.System_Boolean && + method.Parameters.Length == 1 && + SymbolEqualityComparer.Default.Equals( + method.Parameters[0].Type, + aliasedType); + + invalid |= !methodValid; + + if (validationMethod == null) + { + validationMethod = method; + location = method.Locations[0]; + } + else + { + multiple = true; + } + } + } + } + + return new ConstraintModel(validationMethod?.Name, inRelease, !invalid, multiple, ToLocationStruct(location)); } private static string FormatDefaultValue(IParameterSymbol param) @@ -441,4 +466,24 @@ private static bool ImplementsInterface(ITypeSymbol type, string interfaceFullNa return type.AllInterfaces.Any(i => i.ToDisplayString() == interfaceFullName); } + + private static LocationInfo? ToLocationStruct(Location? location) => + location is not null && location.IsInSource ? + new LocationInfo( + location.SourceTree.FilePath, + location.SourceSpan, + new LinePositionSpan( + location.GetLineSpan().StartLinePosition, + location.GetLineSpan().EndLinePosition)) + :null; } + +internal record ConstraintModel( + string? ValidationSymbolName, + bool InRelease, + bool Valid, + bool Multiple, + LocationInfo? LocationInfo) +{ + public bool UseConstraints => ValidationSymbolName is not null && Valid && !Multiple; +}; \ No newline at end of file diff --git a/NewType.Generator/AliasValidationAttributeSource.cs b/NewType.Generator/AliasValidationAttributeSource.cs new file mode 100644 index 0000000..f24749c --- /dev/null +++ b/NewType.Generator/AliasValidationAttributeSource.cs @@ -0,0 +1,33 @@ +namespace newtype.generator; + +/// +/// Source text for the [Alias] attribute that gets injected into user compilations. +/// +internal static class ConstraintAttributeSource +{ + public const string AttributeName = "newtypeValidationAttribute"; + + public const string Source = """ + // + #nullable enable + + /// + /// Used to mark validation methods for aliased types that are called upon construction. + /// + namespace newtype + { + [global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = false, Inherited = false)] + [global::System.Diagnostics.Conditional("newtype_GENERATOR")] + internal sealed class newtypeValidationAttribute : global::System.Attribute + { + /// + /// Creates a new validation attribute. + /// + public newtypeValidationAttribute() { } + + /// Whether the validation method should remain in release or be stripped. + public bool IncludeInRelease { get; set; } + } + } + """; +} \ No newline at end of file diff --git a/NewType.Generator/AnalyzerReleases.Unshipped.md b/NewType.Generator/AnalyzerReleases.Unshipped.md index cf30ff2..20b0ef7 100644 --- a/NewType.Generator/AnalyzerReleases.Unshipped.md +++ b/NewType.Generator/AnalyzerReleases.Unshipped.md @@ -5,4 +5,5 @@ | Rule ID | Category | Severity | Notes | |------------|-----------|----------|----------------| -| NEWTYPE001 | `Unknown` | Error | AliasGenerator | \ No newline at end of file +| NEWTYPE001 | `Unknown` | Error | AliasGenerator | +| NEWTYPE002 | `Unknown` | Error | AliasGenerator | diff --git a/NewType.Tests/Types.cs b/NewType.Tests/Types.cs index 2049999..d9597f0 100644 --- a/NewType.Tests/Types.cs +++ b/NewType.Tests/Types.cs @@ -8,10 +8,11 @@ namespace newtype.tests; [newtype] public readonly partial struct Velocity; -[newtype(ConstraintOptions = NewtypeConstraintOptions.ReleaseEnabled)] +[newtype] public readonly partial struct Direction { - private static bool IsValid(Vector2 direction) + [newtypeValidation] + private static bool IsNormalized(Vector2 direction) { // enforce normalized const float epsilon = 0.0001f;