< Summary

Information
Class: NexusLabs.Needlr.Generators.OptionsDiscoveryHelper
Assembly: NexusLabs.Needlr.Generators
File(s): /actions-runner/_work/needlr/needlr/src/NexusLabs.Needlr.Generators/OptionsDiscoveryHelper.cs
Line coverage
90%
Covered lines: 187
Uncovered lines: 19
Coverable lines: 206
Total lines: 474
Line coverage: 90.7%
Branch coverage
83%
Covered branches: 146
Total branches: 174
Branch coverage: 83.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
DetectPositionalRecord(...)78.57%141496.55%
ExtractBindableProperties(...)100%2626100%
ExtractDataAnnotations(...)82.14%675684.9%
TryGetNestedProperties(...)83.33%66100%
FilterNestedOptions(...)100%1414100%
IsPrimaryConstructor(...)100%66100%
AnalyzeComplexType(...)91.66%121295.45%
IsValidationAttribute(...)0%2040%
IsDictionaryType(...)75%44100%
IsListType(...)100%88100%
IsBindableClass(...)68.18%272278.57%
FindTypeSymbol(...)50%22100%

File(s)

/actions-runner/_work/needlr/needlr/src/NexusLabs.Needlr.Generators/OptionsDiscoveryHelper.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using Microsoft.CodeAnalysis;
 5using Microsoft.CodeAnalysis.CSharp;
 6using Microsoft.CodeAnalysis.CSharp.Syntax;
 7using NexusLabs.Needlr.Generators.Models;
 8
 9namespace NexusLabs.Needlr.Generators;
 10
 11/// <summary>
 12/// Helper for discovering and analyzing options types, including positional
 13/// records, bindable properties, data annotations, and nested options filtering.
 14/// </summary>
 15internal static class OptionsDiscoveryHelper
 16{
 17    /// <summary>
 18    /// Detects whether a type is a positional record that needs a generated parameterless constructor.
 19    /// Returns null if not a positional record, or PositionalRecordInfo if it is.
 20    /// </summary>
 21    internal static PositionalRecordInfo? DetectPositionalRecord(
 22        INamedTypeSymbol typeSymbol,
 23        IReadOnlyList<OptionsPropertyInfo> bindableProperties)
 24    {
 25        // Must be a record
 17626        if (!typeSymbol.IsRecord)
 15927            return null;
 28
 29        // Check for primary constructor with parameters
 30        // Records with positional parameters have a primary constructor generated from the record declaration
 1731        var primaryCtor = typeSymbol.InstanceConstructors
 3732            .FirstOrDefault(c => c.Parameters.Length > 0 && IsPrimaryConstructor(c, typeSymbol));
 33
 1734        if (primaryCtor == null)
 335            return null;
 36
 37        // Check if the record has a parameterless constructor already
 38        // (user-defined or from record with init-only properties)
 1439        var hasParameterlessCtor = typeSymbol.InstanceConstructors
 4240            .Any(c => c.Parameters.Length == 0 && !c.IsImplicitlyDeclared);
 41
 1442        if (hasParameterlessCtor)
 043            return null; // Doesn't need generated constructor
 44
 45        // Check if partial
 1446        var isPartial = typeSymbol.DeclaringSyntaxReferences
 1447            .Select(r => r.GetSyntax())
 1448            .OfType<TypeDeclarationSyntax>()
 5449            .Any(s => s.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)));
 50
 51        // Extract constructor parameters
 1452        var parameters = primaryCtor.Parameters
 10053            .Select(p => new PositionalRecordParameter(
 10054                bindableProperties.First(property =>
 74255                    property.Name.Equals(p.Name, StringComparison.Ordinal) &&
 74256                    property.TypeName == p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)),
 10057                p.Type.IsValueType))
 1458            .ToList();
 59
 60        // Get namespace
 1461        var containingNamespace = typeSymbol.ContainingNamespace.IsGlobalNamespace
 1462            ? ""
 1463            : typeSymbol.ContainingNamespace.ToDisplayString();
 64
 1465        return new PositionalRecordInfo(
 1466            typeSymbol.Name,
 1467            containingNamespace,
 1468            isPartial,
 1469            parameters);
 70    }
 71
 72    /// <summary>
 73    /// Extracts bindable properties from an options type for AOT code generation.
 74    /// </summary>
 75    internal static IReadOnlyList<OptionsPropertyInfo> ExtractBindableProperties(INamedTypeSymbol typeSymbol, HashSet<st
 76    {
 22277        var properties = new List<OptionsPropertyInfo>();
 22278        visitedTypes ??= new HashSet<string>();
 79
 80        // Prevent infinite recursion for circular references
 22281        var typeFullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 22282        if (!visitedTypes.Add(typeFullName))
 83        {
 684            return properties; // Already visited - circular reference
 85        }
 86
 87        try
 88        {
 492289            foreach (var member in typeSymbol.GetMembers())
 90            {
 224591                if (member is not IPropertySymbol property)
 92                    continue;
 93
 94                // Skip static, indexers, readonly properties without init
 47195                if (property.IsStatic || property.IsIndexer)
 96                    continue;
 97
 98                // Must have a setter (set or init)
 47199                if (property.SetMethod == null)
 100                    continue;
 101
 102                // Check if it's init-only
 454103                var isInitOnly = property.SetMethod.IsInitOnly;
 104
 105                // Get nullability info
 454106                var isNullable = property.NullableAnnotation == NullableAnnotation.Annotated ||
 454107                                 (property.Type is INamedTypeSymbol namedType &&
 454108                                  namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T);
 109
 454110                var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 111
 112                // Check if it's an enum type
 454113                var isEnum = false;
 454114                string? enumTypeName = null;
 454115                var actualType = property.Type;
 116
 117                // For nullable types, get the underlying type
 454118                if (actualType is INamedTypeSymbol nullableType &&
 454119                    nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
 454120                    nullableType.TypeArguments.Length == 1)
 121                {
 29122                    actualType = nullableType.TypeArguments[0];
 123                }
 124
 454125                if (actualType.TypeKind == TypeKind.Enum)
 126                {
 35127                    isEnum = true;
 35128                    enumTypeName = actualType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 129                }
 130
 131                // Detect complex types
 454132                var (complexKind, elementTypeName, nestedProps) = AnalyzeComplexType(property.Type, visitedTypes);
 133
 134                // Extract DataAnnotation attributes
 454135                var dataAnnotations = ExtractDataAnnotations(property);
 136
 454137                properties.Add(new OptionsPropertyInfo(
 454138                    property.Name,
 454139                    typeName,
 454140                    isNullable,
 454141                    isInitOnly,
 454142                    isEnum,
 454143                    enumTypeName,
 454144                    complexKind,
 454145                    elementTypeName,
 454146                    nestedProps,
 454147                    dataAnnotations));
 148            }
 149
 216150            return properties;
 151        }
 152        finally
 153        {
 216154            visitedTypes.Remove(typeFullName);
 216155        }
 216156    }
 157
 158    /// <summary>
 159    /// Extracts DataAnnotation validation attributes from a property symbol.
 160    /// </summary>
 161    internal static IReadOnlyList<DataAnnotationInfo> ExtractDataAnnotations(IPropertySymbol property)
 162    {
 454163        var annotations = new List<DataAnnotationInfo>();
 164
 954165        foreach (var attr in property.GetAttributes())
 166        {
 23167            var attrClass = attr.AttributeClass;
 23168            if (attrClass == null) continue;
 169
 170            // Get the attribute type name - use ContainingNamespace + Name for reliable matching
 23171            var attrNamespace = attrClass.ContainingNamespace?.ToDisplayString() ?? "";
 23172            var attrTypeName = attrClass.Name;
 173
 174            // Only process System.ComponentModel.DataAnnotations attributes
 23175            if (attrNamespace != "System.ComponentModel.DataAnnotations")
 176                continue;
 177
 178            // Extract error message if present
 23179            string? errorMessage = null;
 51180            foreach (var namedArg in attr.NamedArguments)
 181            {
 3182                if (namedArg.Key == "ErrorMessage" && namedArg.Value.Value is string msg)
 183                {
 1184                    errorMessage = msg;
 1185                    break;
 186                }
 187            }
 188
 189            // Check for known DataAnnotation attributes
 23190            if (attrTypeName == "RequiredAttribute")
 191            {
 12192                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Required, errorMessage));
 193            }
 11194            else if (attrTypeName == "RangeAttribute")
 195            {
 12196                object? min = null, max = null;
 6197                if (attr.ConstructorArguments.Length >= 2)
 198                {
 6199                    min = attr.ConstructorArguments[0].Value;
 6200                    max = attr.ConstructorArguments[1].Value;
 201                }
 6202                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Range, errorMessage, min, max));
 203            }
 5204            else if (attrTypeName == "StringLengthAttribute")
 205            {
 2206                object? maxLen = null;
 2207                int? minLen = null;
 2208                if (attr.ConstructorArguments.Length >= 1)
 209                {
 2210                    maxLen = attr.ConstructorArguments[0].Value;
 211                }
 8212                foreach (var namedArg in attr.NamedArguments)
 213                {
 2214                    if (namedArg.Key == "MinimumLength" && namedArg.Value.Value is int ml)
 215                    {
 2216                        minLen = ml;
 217                    }
 218                }
 2219                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.StringLength, errorMessage, null, maxLen, null
 220            }
 3221            else if (attrTypeName == "MinLengthAttribute")
 222            {
 1223                int? minLen = null;
 1224                if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is int ml)
 225                {
 1226                    minLen = ml;
 227                }
 1228                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.MinLength, errorMessage, null, null, null, min
 229            }
 2230            else if (attrTypeName == "MaxLengthAttribute")
 231            {
 1232                object? maxLen = null;
 1233                if (attr.ConstructorArguments.Length >= 1)
 234                {
 1235                    maxLen = attr.ConstructorArguments[0].Value;
 236                }
 1237                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.MaxLength, errorMessage, null, maxLen));
 238            }
 1239            else if (attrTypeName == "RegularExpressionAttribute")
 240            {
 1241                string? pattern = null;
 1242                if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is string p)
 243                {
 1244                    pattern = p;
 245                }
 1246                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.RegularExpression, errorMessage, null, null, p
 247            }
 0248            else if (attrTypeName == "EmailAddressAttribute")
 249            {
 0250                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.EmailAddress, errorMessage));
 251            }
 0252            else if (attrTypeName == "PhoneAttribute")
 253            {
 0254                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Phone, errorMessage));
 255            }
 0256            else if (attrTypeName == "UrlAttribute")
 257            {
 0258                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Url, errorMessage));
 259            }
 0260            else if (IsValidationAttribute(attrClass))
 261            {
 262                // Unsupported validation attribute
 0263                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Unsupported, errorMessage));
 264            }
 265        }
 266
 454267        return annotations;
 268    }
 269
 270    /// <summary>
 271    /// Attempts to extract nested properties from a type if it is a bindable class.
 272    /// </summary>
 273    internal static IReadOnlyList<OptionsPropertyInfo>? TryGetNestedProperties(ITypeSymbol elementType, HashSet<string> 
 274    {
 50275        if (elementType is INamedTypeSymbol namedElement && IsBindableClass(namedElement))
 276        {
 15277            var props = ExtractBindableProperties(namedElement, visitedTypes);
 15278            return props.Count > 0 ? props : null;
 279        }
 35280        return null;
 281    }
 282
 283    /// <summary>
 284    /// Filters out nested options types that are used as properties in other options types.
 285    /// These should not be registered separately - they are bound as part of their parent.
 286    /// </summary>
 287    internal static List<DiscoveredOptions> FilterNestedOptions(List<DiscoveredOptions> options, Compilation compilation
 288    {
 289        // Build a set of all options type names
 63290        var optionsTypeNames = new HashSet<string>(options.Select(o => o.TypeName));
 291
 292        // Find all options types that are used as properties in other options types
 19293        var nestedTypeNames = new HashSet<string>();
 294
 126295        foreach (var opt in options)
 296        {
 297            // Find the type symbol for this options type
 44298            var typeSymbol = FindTypeSymbol(compilation, opt.TypeName);
 44299            if (typeSymbol == null)
 300                continue;
 301
 302            // Check all properties of this type
 640303            foreach (var member in typeSymbol.GetMembers())
 304            {
 276305                if (member is not IPropertySymbol property)
 306                    continue;
 307
 308                // Skip non-class property types (primitives, structs, etc.)
 56309                if (property.Type is not INamedTypeSymbol propertyType)
 310                    continue;
 311
 56312                if (propertyType.TypeKind != TypeKind.Class)
 313                    continue;
 314
 315                // Get the fully qualified name of the property type
 42316                var propertyTypeName = TypeDiscoveryHelper.GetFullyQualifiedName(propertyType);
 317
 318                // If this property type is also an [Options] type, mark it as nested
 42319                if (optionsTypeNames.Contains(propertyTypeName))
 320                {
 9321                    nestedTypeNames.Add(propertyTypeName);
 322                }
 323            }
 324        }
 325
 326        // Return only root options (those not used as properties in other options)
 63327        return options.Where(o => !nestedTypeNames.Contains(o.TypeName)).ToList();
 328    }
 329
 330    private static bool IsPrimaryConstructor(IMethodSymbol ctor, INamedTypeSymbol recordType)
 331    {
 332        // For positional records, the primary constructor parameters correspond to auto-properties
 333        // Check if each parameter has a matching property
 237334        foreach (var param in ctor.Parameters)
 335        {
 103336            var hasMatchingProperty = recordType.GetMembers()
 103337                .OfType<IPropertySymbol>()
 954338                .Any(p => p.Name.Equals(param.Name, StringComparison.Ordinal) &&
 954339                         SymbolEqualityComparer.Default.Equals(p.Type, param.Type));
 340
 103341            if (!hasMatchingProperty)
 3342                return false;
 343        }
 344
 14345        return true;
 346    }
 347
 348    private static (ComplexTypeKind Kind, string? ElementTypeName, IReadOnlyList<OptionsPropertyInfo>? NestedProperties)
 349        ITypeSymbol typeSymbol,
 350        HashSet<string> visitedTypes)
 351    {
 352        // Check for array
 454353        if (typeSymbol is IArrayTypeSymbol arrayType)
 354        {
 15355            var elementType = arrayType.ElementType;
 15356            var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 15357            var nestedProps = TryGetNestedProperties(elementType, visitedTypes);
 15358            return (ComplexTypeKind.Array, elementTypeName, nestedProps);
 359        }
 360
 439361        if (typeSymbol is not INamedTypeSymbol namedType)
 362        {
 0363            return (ComplexTypeKind.None, null, null);
 364        }
 365
 366        // Check for Dictionary<string, T>
 439367        if (IsDictionaryType(namedType))
 368        {
 16369            var valueType = namedType.TypeArguments[1];
 16370            var valueTypeName = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 16371            var nestedProps = TryGetNestedProperties(valueType, visitedTypes);
 16372            return (ComplexTypeKind.Dictionary, valueTypeName, nestedProps);
 373        }
 374
 375        // Check for List<T>, IList<T>, ICollection<T>, IEnumerable<T>
 423376        if (IsListType(namedType))
 377        {
 19378            var elementType = namedType.TypeArguments[0];
 19379            var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 19380            var nestedProps = TryGetNestedProperties(elementType, visitedTypes);
 19381            return (ComplexTypeKind.List, elementTypeName, nestedProps);
 382        }
 383
 384        // Check for nested object (class with bindable properties)
 404385        if (IsBindableClass(namedType))
 386        {
 31387            var nestedProps = ExtractBindableProperties(namedType, visitedTypes);
 31388            if (nestedProps.Count > 0)
 389            {
 23390                return (ComplexTypeKind.NestedObject, null, nestedProps);
 391            }
 392        }
 393
 381394        return (ComplexTypeKind.None, null, null);
 395    }
 396
 397    private static bool IsValidationAttribute(INamedTypeSymbol attrClass)
 398    {
 399        // Check if this inherits from ValidationAttribute
 0400        var current = attrClass.BaseType;
 0401        while (current != null)
 402        {
 0403            if (current.ToDisplayString() == "System.ComponentModel.DataAnnotations.ValidationAttribute")
 0404                return true;
 0405            current = current.BaseType;
 406        }
 0407        return false;
 408    }
 409
 410    private static bool IsDictionaryType(INamedTypeSymbol type)
 411    {
 412        // Check for Dictionary<TKey, TValue> or IDictionary<TKey, TValue>
 439413        if (type.TypeArguments.Length != 2)
 423414            return false;
 415
 16416        var typeName = type.OriginalDefinition.ToDisplayString();
 16417        return typeName == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
 16418               typeName == "System.Collections.Generic.IDictionary<TKey, TValue>";
 419    }
 420
 421    private static bool IsListType(INamedTypeSymbol type)
 422    {
 423423        if (type.TypeArguments.Length != 1)
 375424            return false;
 425
 48426        var typeName = type.OriginalDefinition.ToDisplayString();
 48427        return typeName == "System.Collections.Generic.List<T>" ||
 48428               typeName == "System.Collections.Generic.IList<T>" ||
 48429               typeName == "System.Collections.Generic.ICollection<T>" ||
 48430               typeName == "System.Collections.Generic.IEnumerable<T>";
 431    }
 432
 433    private static bool IsBindableClass(INamedTypeSymbol type)
 434    {
 435        // Must be a class or struct, not abstract, not a system type
 454436        if (type.TypeKind != TypeKind.Class && type.TypeKind != TypeKind.Struct)
 37437            return false;
 438
 417439        if (type.IsAbstract)
 4440            return false;
 441
 442        // Skip system types and primitives
 413443        var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
 413444        if (ns.StartsWith("System"))
 445        {
 446            // Skip known non-bindable System namespaces
 367447            if (ns == "System" || ns.StartsWith("System.Collections") || ns.StartsWith("System.Threading"))
 367448                return false;
 449        }
 450
 451        // Must have a parameterless constructor (explicit or implicit)
 452        // Note: Classes without any explicit constructors have an implicit parameterless constructor
 92453        var hasExplicitConstructors = type.InstanceConstructors.Any(c => !c.IsImplicitlyDeclared);
 46454        if (hasExplicitConstructors)
 455        {
 0456            var hasParameterlessCtor = type.InstanceConstructors
 0457                .Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility == Accessibility.Public);
 0458            return hasParameterlessCtor;
 459        }
 460
 461        // No explicit constructors means implicit parameterless constructor exists
 46462        return true;
 463    }
 464
 465    private static INamedTypeSymbol? FindTypeSymbol(Compilation compilation, string fullyQualifiedName)
 466    {
 467        // Strip global:: prefix if present
 44468        var typeName = fullyQualifiedName.StartsWith("global::")
 44469            ? fullyQualifiedName.Substring(8)
 44470            : fullyQualifiedName;
 471
 44472        return compilation.GetTypeByMetadataName(typeName);
 473    }
 474}