< Summary

Information
Class: NexusLabs.Needlr.Generators.ConstructorGenerationDiscoveryHelper
Assembly: NexusLabs.Needlr.Generators
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/ConstructorGenerationDiscoveryHelper.cs
Line coverage
93%
Covered lines: 100
Uncovered lines: 7
Coverable lines: 107
Total lines: 299
Line coverage: 93.4%
Branch coverage
88%
Covered branches: 81
Total branches: 92
Branch coverage: 88%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
TryGetEffectiveConstructorParameters(...)100%88100%
TryGetModel(...)85%404095.55%
IsCandidateClassDeclaration(...)100%22100%
TryCreateCanonicalModel(...)75%4483.33%
HasFieldMember(...)100%44100%
IsCanonicalFieldBearingDeclaration(...)92.85%141492.3%
ComparePosition(...)100%22100%
GetGenerateConstructorAttributeMode(...)90%101090.9%
GetParameterName(...)75%9875%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/ConstructorGenerationDiscoveryHelper.cs

#LineLine coverage
 1using System.Collections.Generic;
 2using System.Linq;
 3
 4using Microsoft.CodeAnalysis;
 5using Microsoft.CodeAnalysis.CSharp;
 6using Microsoft.CodeAnalysis.CSharp.Syntax;
 7
 8using NexusLabs.Needlr.Generators.Models;
 9using NexusLabs.Needlr.Roslyn.Shared;
 10
 11namespace NexusLabs.Needlr.Generators;
 12
 13/// <summary>
 14/// Discovers whether a type is eligible for generated-constructor generation
 15/// (<c>[GenerateConstructor]</c> or a positive field-level constructor guard trigger)
 16/// and builds the shared <see cref="GeneratedConstructorModel"/> that both source
 17/// emission and Needlr's type-registry constructor discovery consume.
 18/// </summary>
 19/// <remarks>
 20/// Symbol-level eligibility facts (attribute identity, eligible field set, positive
 21/// field-guard trigger, overall eligibility) are delegated to
 22/// <see cref="GeneratedConstructorEligibility"/> so that this generator, the SignalR
 23/// generator, and Needlr's analyzers agree on the same rules. This class owns only the
 24/// main-generator-specific emission model: guard resolution, parameter-name
 25/// normalization, and the <see cref="GeneratedConstructorModel"/> shape.
 26/// </remarks>
 27internal static class ConstructorGenerationDiscoveryHelper
 28{
 29    private const string GenerateConstructorAttributeName = "GenerateConstructorAttribute";
 30
 131    internal static readonly SymbolDisplayFormat NullableAwareFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMisc
 132        SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions | SymbolDisplayMiscellaneousOptions.IncludeNullabl
 33
 34    /// <summary>
 35    /// Builds the effective constructor-parameter list for a type that is eligible
 36    /// for generated-constructor generation, for use by Needlr's type-registry
 37    /// discovery. Returns <see langword="null"/> when the type is not eligible, in
 38    /// which case callers should fall back to symbol-based constructor discovery
 39    /// (which is correct both for types with a hand-written constructor and for
 40    /// referenced-assembly types whose generated constructor is already compiled).
 41    /// </summary>
 42    internal static IReadOnlyList<TypeDiscoveryHelper.ConstructorParameterInfo>? TryGetEffectiveConstructorParameters(IN
 43    {
 73645744        var model = TryGetModel(typeSymbol);
 73645745        if (model is null)
 73643946            return null;
 47
 48        // Only usable for Needlr's automatic DI discovery when every parameter is a
 49        // container-resolvable service type -- the same rule applied to hand-written
 50        // constructors via TypeDiscoveryHelper.IsInjectableParameterType. A
 51        // field-derived constructor with a string/value-type parameter (e.g. a
 52        // field-triggered guard on a plain string) is still a perfectly valid
 53        // generated constructor; it just isn't eligible for automatic registration
 54        // without a factory or explicit registration.
 8355        foreach (var field in GeneratedConstructorEligibility.GetEligibleConstructorFields(typeSymbol))
 56        {
 2557            if (!TypeDiscoveryHelper.IsInjectableParameterType(field.Type))
 358                return null;
 59        }
 60
 1561        var fields = model.Value.Fields;
 1562        var result = new TypeDiscoveryHelper.ConstructorParameterInfo[fields.Length];
 6863        for (var i = 0; i < fields.Length; i++)
 64        {
 1965            result[i] = new TypeDiscoveryHelper.ConstructorParameterInfo(
 1966                fields[i].ParameterTypeName,
 1967                serviceKey: null,
 1968                parameterName: fields[i].ParameterName);
 69        }
 70
 1571        return result;
 372    }
 73
 74    /// <summary>
 75    /// Builds the full generated-constructor model for a type, or <see langword="null"/>
 76    /// when the type is not eligible for constructor generation.
 77    /// </summary>
 78    internal static GeneratedConstructorModel? TryGetModel(INamedTypeSymbol typeSymbol)
 79    {
 228484680        if (typeSymbol.TypeKind != TypeKind.Class || typeSymbol.IsRecord)
 360781            return null;
 82
 83        // Nested types are unsupported: a nested type's enclosing instance (when the
 84        // outer type is non-static) is not something a generated constructor knows how
 85        // to obtain, so this feature is restricted to top-level types.
 228123986        if (typeSymbol.ContainingType != null)
 187            return null;
 88
 89        // Base types requiring constructor arguments are unsupported. A base type
 90        // with an accessible parameterless constructor (including the common case of no
 91        // explicit base constructors at all, e.g. BackgroundService) is fully supported
 92        // since the generated constructor relies on the implicit `: base()` call.
 228123893        if (typeSymbol.BaseType != null && typeSymbol.BaseType.SpecialType != SpecialType.System_Object &&
 228123894            !GeneratedConstructorEligibility.HasAccessibleParameterlessConstructor(typeSymbol.BaseType))
 95        {
 14091996            return null;
 97        }
 98
 214031999        var candidates = new List<(IFieldSymbol Field, ConstructorGuardModel[] Guards)>();
 4281338100        foreach (var field in GeneratedConstructorEligibility.GetEligibleConstructorFields(typeSymbol))
 101        {
 350102            candidates.Add((field, ConstructorGuardDiscoveryHelper.GetExplicitGuards(field)));
 103        }
 104
 2140319105        var hasPositiveFieldTrigger = GeneratedConstructorEligibility.HasPositiveFieldGuardTrigger(typeSymbol);
 2140319106        var classNullGuardMode = GetGenerateConstructorAttributeMode(typeSymbol);
 107
 2140319108        if (classNullGuardMode is null && !hasPositiveFieldTrigger)
 2140176109            return null;
 110
 143111        if (!GeneratedConstructorEligibility.IsDeclaredPartial(typeSymbol))
 1112            return null;
 113
 142114        if (GeneratedConstructorEligibility.HasExplicitInstanceConstructor(typeSymbol))
 1115            return null;
 116
 141117        if (candidates.Count == 0)
 0118            return null;
 119
 141120        var parameterNames = new HashSet<string>(System.StringComparer.Ordinal);
 141121        var fields = new EligibleConstructorField[candidates.Count];
 634122        for (var i = 0; i < candidates.Count; i++)
 123        {
 176124            var (field, guards) = candidates[i];
 176125            var parameterName = GetParameterName(field.Name);
 126
 127            // Parameter-name collisions after normalization are unsupported; skip
 128            // generation entirely rather than emit a broken constructor.
 176129            if (!parameterNames.Add(parameterName))
 0130                return null;
 131
 176132            var parameterTypeName = field.Type.ToDisplayString(NullableAwareFormat);
 176133            var isNonNullableReferenceType = field.Type.IsReferenceType && field.Type.NullableAnnotation == NullableAnno
 134
 176135            fields[i] = new EligibleConstructorField(field.Name, parameterName, parameterTypeName, isNonNullableReferenc
 136        }
 137
 141138        var typeParameterList = typeSymbol.TypeParameters.Length == 0
 141139            ? string.Empty
 147140            : "<" + string.Join(", ", typeSymbol.TypeParameters.Select(tp => tp.Name)) + ">";
 141
 141142        var containingNamespace = typeSymbol.ContainingNamespace is { IsGlobalNamespace: false }
 141143            ? typeSymbol.ContainingNamespace.ToDisplayString()
 141144            : string.Empty;
 145
 282146        var sourceFilePath = typeSymbol.Locations.FirstOrDefault(l => l.IsInSource)?.SourceTree?.FilePath;
 147
 141148        return new GeneratedConstructorModel(
 141149            containingNamespace,
 141150            typeSymbol.Name,
 141151            typeParameterList,
 141152            typeSymbol.TypeParameters.Length,
 141153            classNullGuardMode ?? GeneratedConstructorNullGuardMode.None,
 141154            fields,
 141155            sourceFilePath);
 156    }
 157
 158    /// <summary>
 159    /// The syntax-level predicate for the generator's per-type incremental pipeline:
 160    /// a cheap, semantic-model-free check for a top-level class declaration with at
 161    /// least one field member. Any class without a field can never be eligible (see
 162    /// <see cref="TryGetModel"/>), so this filters the overwhelming majority of
 163    /// classes in a typical compilation before a semantic model is ever touched.
 164    /// </summary>
 165    internal static bool IsCandidateClassDeclaration(SyntaxNode node)
 166    {
 3711167        return node is ClassDeclarationSyntax classDeclaration && HasFieldMember(classDeclaration);
 168    }
 169
 170    /// <summary>
 171    /// The transform for the generator's per-type incremental pipeline. Builds the
 172    /// full <see cref="GeneratedConstructorModel"/> for the syntax node's type,
 173    /// immediately converting the resolved <see cref="INamedTypeSymbol"/> into an
 174    /// equatable value model rather than passing the symbol itself further down the
 175    /// pipeline (symbols compare by reference across compilations and would defeat
 176    /// incremental caching).
 177    /// </summary>
 178    /// <remarks>
 179    /// For a partial type declared across multiple field-bearing declarations, this
 180    /// produces a model for exactly one of them -- the declaration earliest in
 181    /// deterministic (file path, then position) order -- and <see langword="null"/> for
 182    /// every other declaration of the same type, so the generator emits exactly one
 183    /// source file per type regardless of how many partial declarations carry fields.
 184    /// </remarks>
 185    internal static GeneratedConstructorModel? TryCreateCanonicalModel(GeneratorSyntaxContext context)
 186    {
 106187        var classDeclaration = (ClassDeclarationSyntax)context.Node;
 188
 106189        if (context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
 0190            return null;
 191
 106192        if (!IsCanonicalFieldBearingDeclaration(typeSymbol, classDeclaration))
 2193            return null;
 194
 104195        return TryGetModel(typeSymbol);
 196    }
 197
 198    private static bool HasFieldMember(ClassDeclarationSyntax classDeclaration)
 199    {
 999200        foreach (var member in classDeclaration.Members)
 201        {
 305202            if (member is FieldDeclarationSyntax)
 215203                return true;
 204        }
 205
 87206        return false;
 207    }
 208
 209    /// <summary>
 210    /// True when <paramref name="classDeclaration"/> is, among every field-bearing
 211    /// partial declaration of <paramref name="typeSymbol"/>, the one earliest in
 212    /// deterministic (file path, then span start) order. Comparing file path and span
 213    /// rather than syntax-node identity keeps this correct even when a declaration is
 214    /// re-parsed into a new (but structurally equivalent) node instance.
 215    /// </summary>
 216    private static bool IsCanonicalFieldBearingDeclaration(INamedTypeSymbol typeSymbol, ClassDeclarationSyntax classDecl
 217    {
 106218        string? canonicalFilePath = null;
 106219        var canonicalStart = 0;
 220
 432221        foreach (var syntaxRef in typeSymbol.DeclaringSyntaxReferences)
 222        {
 110223            if (syntaxRef.GetSyntax() is not ClassDeclarationSyntax candidate || !HasFieldMember(candidate))
 224                continue;
 225
 110226            var filePath = candidate.SyntaxTree.FilePath;
 110227            var start = candidate.SpanStart;
 228
 110229            if (canonicalFilePath is null ||
 110230                ComparePosition(filePath, start, canonicalFilePath, canonicalStart) < 0)
 231            {
 108232                canonicalFilePath = filePath;
 108233                canonicalStart = start;
 234            }
 235        }
 236
 106237        if (canonicalFilePath is null)
 0238            return false;
 239
 106240        return canonicalFilePath == classDeclaration.SyntaxTree.FilePath && canonicalStart == classDeclaration.SpanStart
 241    }
 242
 243    private static int ComparePosition(string filePathA, int startA, string filePathB, int startB)
 244    {
 4245        var pathCompare = string.CompareOrdinal(filePathA, filePathB);
 4246        return pathCompare != 0 ? pathCompare : startA.CompareTo(startB);
 247    }
 248
 249    private static GeneratedConstructorNullGuardMode? GetGenerateConstructorAttributeMode(INamedTypeSymbol typeSymbol)
 250    {
 12112930251        foreach (var attribute in typeSymbol.GetAttributes())
 252        {
 3916199253            var attrClass = attribute.AttributeClass;
 3916199254            if (attrClass is null)
 255                continue;
 256
 3916199257            if (!GeneratedConstructorEligibility.IsNeedlrGeneratorsAttribute(attrClass, GenerateConstructorAttributeName
 258                continue;
 259
 106260            if (attribute.ConstructorArguments.Length == 0)
 84261                return GeneratedConstructorNullGuardMode.None;
 262
 22263            var arg = attribute.ConstructorArguments[0];
 22264            if (arg.Value is int modeValue)
 22265                return (GeneratedConstructorNullGuardMode)modeValue;
 266
 0267            return GeneratedConstructorNullGuardMode.None;
 268        }
 269
 2140213270        return null;
 271    }
 272
 273    /// <summary>
 274    /// Normalizes a field name into a constructor parameter name (e.g. <c>_repository</c>
 275    /// -> <c>repository</c>), escaping C# keywords with <c>@</c>. Internal so
 276    /// <see cref="FactoryDiscoveryHelper"/> can derive the exact same parameter names
 277    /// for a generated constructor's fields when building factory call sites.
 278    /// </summary>
 279    internal static string GetParameterName(string fieldName)
 280    {
 333281        var name = fieldName;
 282
 333283        if (name.Length > 1 && name[0] == '_')
 284        {
 331285            name = name.Substring(1);
 286        }
 287
 333288        if (name.Length == 0)
 289        {
 0290            name = "value";
 291        }
 333292        else if (char.IsUpper(name[0]))
 293        {
 0294            name = char.ToLowerInvariant(name[0]) + name.Substring(1);
 295        }
 296
 333297        return GeneratorHelpers.EscapeIdentifier(name);
 298    }
 299}