Skip to content
Open
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
26 changes: 24 additions & 2 deletions NewType.Generator/AliasAttributeSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,26 @@ internal enum NewtypeOptions
/// <summary>Suppress implicit conversions and constructor forwarding.</summary>
Opaque = NoImplicitConversions | NoConstructorForwarding,
}


/// <summary>
/// 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
/// </summary>
[global::System.Flags]
internal enum NewtypeConstraintOptions
{
/// <summary>Constraints disabled(default).</summary>
Disabled = 0,
/// <summary>Enable constraints. Debug builds only by default</summary>
Enabled = 1,
/// <summary>Include constraint code in release builds, if constraints are enabled</summary>
IncludeInRelease = 2,

/// <summary>Enable constraints and include in release builds.</summary>
ReleaseEnabled = Enabled | IncludeInRelease,
}

/// <summary>
/// Marks a partial type as a type alias for the specified type.
/// The source generator will generate implicit conversions, operator forwarding,
Expand All @@ -54,7 +73,10 @@ public newtypeAttribute() { }

/// <summary>Controls which features the generator emits.</summary>
public NewtypeOptions Options { get; set; }


/// <summary>Controls which constraint features the generator emits.</summary>
public NewtypeConstraintOptions ConstraintOptions { get; set; }

/// <summary>
/// Overrides the MethodImplOptions applied to generated members.
/// Default is <see cref="global::System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining"/>.
Expand Down
65 changes: 58 additions & 7 deletions NewType.Generator/AliasCodeGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -137,20 +140,36 @@ private void AppendField()

private void AppendConstructors()
{
var indent = GetMemberIndent();
var memberIndent = GetMemberIndent();

// Constructor from aliased type (always emitted)
_sb.AppendLine($"{indent}/// <summary>Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}.</summary>");
AppendMethodImplAttribute(indent);
_sb.AppendLine($"{indent}public {_model.TypeName}({_model.AliasedTypeFullName} value) => _value = value;");
_sb.AppendLine($"{memberIndent}/// <summary>Creates a new {_model.TypeName} from a {_model.AliasedTypeMinimalName}.</summary>");
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;");
}
Comment on lines +150 to +163
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent indentation in generated constructor body.

The _value = value; assignment (line 156) uses three SingleIndent levels hardcoded, but memberIndent already accounts for namespace depth. When a namespace is present, memberIndent is 8 spaces, and the body should be indented one level deeper (12 spaces). However, SingleIndent * 3 always produces 12 spaces regardless of namespace presence.

For types without a namespace, the member indent is 4 spaces, so body lines should be 8 spaces, but this code emits 12.

Proposed fix
         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();
+            _sb.AppendLine($"{memberIndent}{{");
+
+            var bodyIndent = memberIndent + SingleIndent;
+            AppendConstraintChecker(bodyIndent, "value");
+            _sb.AppendLine($"{bodyIndent}_value = value;");
+
+            _sb.AppendLine($"{memberIndent}}}");
         }
🤖 Prompt for AI Agents
In `@NewType.Generator/AliasCodeGenerator.cs` around lines 150 - 163, The
constructor body indentation is inconsistent: in AliasCodeGenerator.cs when
_model.IncludeConstraints is true you append the assignment using three
SingleIndent units which ignores memberIndent and mis-indents types without
namespaces; change the code that writes the body (the AppendConstraintChecker
call and the line writing "_value = value;") to build indentation relative to
memberIndent (e.g., append memberIndent then one SingleIndent before body lines)
instead of using SingleIndent repeated three times so the body is always
indented exactly one level deeper than memberIndent; keep
AppendConstraintChecker("SingleIndent", "value") usage but ensure the subsequent
"_value = value;" write uses memberIndent + SingleIndent.


_sb.AppendLine();

// Forward constructors from the aliased type (conditionally)
if (!_model.SuppressConstructorForwarding)
{
foreach (var ctor in _model.ForwardedConstructors)
{
AppendForwardedConstructor(indent, ctor);
AppendForwardedConstructor(memberIndent, ctor);
}
}
}
Expand Down Expand Up @@ -668,7 +687,22 @@ private void AppendForwardedConstructor(string indent, ConstructorInfo ctor)

_sb.AppendLine($"{indent}/// <summary>Forwards {_model.AliasedTypeMinimalName} constructor.</summary>");
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});");
}
Comment on lines +690 to +705
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same indentation issue in forwarded constructor.

The forwarded constructor has the same hardcoded indentation problem. SingleIndent * 3 doesn't adapt to the actual member indent level.

Proposed fix
     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();
+        _sb.AppendLine($"{indent}{{");
+        var bodyIndent = indent + SingleIndent;
+        _sb.AppendLine($"{bodyIndent}var {valueName} = new {_model.AliasedTypeFullName}({arguments});");
+
+        AppendConstraintChecker(bodyIndent, valueName);
+        _sb.AppendLine($"{bodyIndent}_value = {valueName};");
+
+        _sb.AppendLine($"{indent}}}");
     }
🤖 Prompt for AI Agents
In `@NewType.Generator/AliasCodeGenerator.cs` around lines 690 - 705, The
forwarded constructor uses hardcoded SingleIndent repetitions which don't
respect the current member indentation; change both branches to build
indentation from the existing indent variable plus SingleIndent(s) instead of
appending SingleIndent three times. Specifically, in the block that calls
AppendConstraintChecker and sets _value, replace the
_sb.Append(SingleIndent).Append(SingleIndent).Append(SingleIndent)... call with
something that starts from indent and then appends the needed SingleIndent(s)
(for example _sb.Append(indent).Append(SingleIndent).Append(SingleIndent) before
writing the "_value" assignment), and in the else branch replace the single-line
AppendLine with a properly indented multi-line emission that uses indent +
SingleIndent(s) so the forwarded constructor matches the same member-indent
level; reference AppendConstraintChecker, _sb, indent, SingleIndent,
_model.TypeName and _model.AliasedTypeFullName to locate the lines to change.

_sb.AppendLine();
}

Expand All @@ -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");
}
Comment on lines +728 to +743
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

AppendConstraintChecker uses incorrect indentation approach.

The method receives an indent parameter but then uses indent * 3 hardcoded (via three .Append(indent) calls), which doesn't align with the actual nesting level. The caller should pass the correct body indent, and this method should use it directly.

Proposed fix
-    private void AppendConstraintChecker(string indent, string valueName)
+    private void AppendConstraintChecker(string bodyIndent, 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}}}\");");
+        _sb.AppendLine($"{bodyIndent}if (!IsValid({valueName}))");
+        _sb.AppendLine($"{bodyIndent}{SingleIndent}throw new InvalidOperationException($\"Failed validation check when trying to create '{_model.TypeName}' with '{_model.AliasedTypeMinimalName}' value: {{{valueName}}}\");");
         
         if (_model.DebugOnlyConstraints)
             _sb.AppendLine("#endif");
     }
🤖 Prompt for AI Agents
In `@NewType.Generator/AliasCodeGenerator.cs` around lines 728 - 743,
AppendConstraintChecker currently hardcodes triple indentation by calling
.Append(indent) three times; instead use the passed-in indent as the correct
"body" indent: emit the if line with a single .Append(indent).AppendLine($"if
(!IsValid({valueName}))"), and emit the throw line with the body indent plus one
more indent (e.g. .Append(indent).Append(indent).AppendLine(...)) so nesting is
correct; keep the `#if` DEBUG guard logic and the same exception text referencing
_model.TypeName and _model.AliasedTypeMinimalName and write to _sb as before.


private static string FormatConstructorParameters(ConstructorInfo ctor)
{
return string.Join(", ", ctor.Parameters.Array.Select(p =>
Expand Down Expand Up @@ -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)
{
Expand Down
66 changes: 50 additions & 16 deletions NewType.Generator/AliasGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>] attribute
var genericPipeline = context.SyntaxProvider
IncrementalValuesProvider<AliasModel> 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<AliasModel> 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));
}
Expand All @@ -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;
}

Expand All @@ -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)
Expand All @@ -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));
}
}
}
19 changes: 15 additions & 4 deletions NewType.Generator/AliasModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ namespace newtype.generator;
/// Fully-extracted, equatable model representing a newtype alias.
/// Contains only strings, bools, plain enums, and EquatableArrays — no Roslyn symbols.
/// </summary>
internal readonly record struct AliasModel(
internal record AliasModel(
// Type being declared
string TypeName,
string Namespace,
Accessibility DeclaredAccessibility,
bool IsReadonly,
bool IsClass,
bool IsRecord,
bool IsRecordStruct,


// Location for messages
Location? Location,

// Aliased type
string AliasedTypeFullName,
string AliasedTypeMinimalName,
Expand All @@ -41,6 +43,10 @@ internal readonly record struct AliasModel(
bool SuppressImplicitUnwrap,
bool SuppressConstructorForwarding,
int MethodImplValue,

bool IncludeConstraints,
bool DebugOnlyConstraints,
bool validValidationMethod,

// Members
EquatableArray<BinaryOperatorInfo> BinaryOperators,
Expand All @@ -50,7 +56,12 @@ internal readonly record struct AliasModel(
EquatableArray<InstancePropertyInfo> InstanceProperties,
EquatableArray<InstanceMethodInfo> InstanceMethods,
EquatableArray<ConstructorInfo> ForwardedConstructors
);
)
{
public const string ConstraintValidationMethodSymbol = "IsValid";
};

internal readonly record struct ExtractedOptions(int Options, int ConstraintOptions, int MethodImpl);

internal readonly record struct BinaryOperatorInfo(
string Name,
Expand Down
Loading