< Summary

Information
Class: NexusLabs.Needlr.Generators.OptionsDiscoveryHelper
Assembly: NexusLabs.Needlr.Generators
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/OptionsDiscoveryHelper.cs
Line coverage
90%
Covered lines: 180
Uncovered lines: 19
Coverable lines: 199
Total lines: 461
Line coverage: 90.4%
Branch coverage
83%
Covered branches: 144
Total branches: 172
Branch coverage: 83.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
DetectPositionalRecord(...)75%121296%
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)

/home/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(INamedTypeSymbol typeSymbol)
 22    {
 23        // Must be a record
 16724        if (!typeSymbol.IsRecord)
 15525            return null;
 26
 27        // Check for primary constructor with parameters
 28        // Records with positional parameters have a primary constructor generated from the record declaration
 1229        var primaryCtor = typeSymbol.InstanceConstructors
 2730            .FirstOrDefault(c => c.Parameters.Length > 0 && IsPrimaryConstructor(c, typeSymbol));
 31
 1232        if (primaryCtor == null)
 333            return null;
 34
 35        // Check if the record has a parameterless constructor already
 36        // (user-defined or from record with init-only properties)
 937        var hasParameterlessCtor = typeSymbol.InstanceConstructors
 2738            .Any(c => c.Parameters.Length == 0 && !c.IsImplicitlyDeclared);
 39
 940        if (hasParameterlessCtor)
 041            return null; // Doesn't need generated constructor
 42
 43        // Check if partial
 944        var isPartial = typeSymbol.DeclaringSyntaxReferences
 945            .Select(r => r.GetSyntax())
 946            .OfType<TypeDeclarationSyntax>()
 3447            .Any(s => s.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)));
 48
 49        // Extract constructor parameters
 950        var parameters = primaryCtor.Parameters
 2351            .Select(p => new PositionalRecordParameter(p.Name, p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualified
 952            .ToList();
 53
 54        // Get namespace
 955        var containingNamespace = typeSymbol.ContainingNamespace.IsGlobalNamespace
 956            ? ""
 957            : typeSymbol.ContainingNamespace.ToDisplayString();
 58
 959        return new PositionalRecordInfo(
 960            typeSymbol.Name,
 961            containingNamespace,
 962            isPartial,
 963            parameters);
 64    }
 65
 66    /// <summary>
 67    /// Extracts bindable properties from an options type for AOT code generation.
 68    /// </summary>
 69    internal static IReadOnlyList<OptionsPropertyInfo> ExtractBindableProperties(INamedTypeSymbol typeSymbol, HashSet<st
 70    {
 19571        var properties = new List<OptionsPropertyInfo>();
 19572        visitedTypes ??= new HashSet<string>();
 73
 74        // Prevent infinite recursion for circular references
 19575        var typeFullName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 19576        if (!visitedTypes.Add(typeFullName))
 77        {
 678            return properties; // Already visited - circular reference
 79        }
 80
 329481        foreach (var member in typeSymbol.GetMembers())
 82        {
 145883            if (member is not IPropertySymbol property)
 84                continue;
 85
 86            // Skip static, indexers, readonly properties without init
 29187            if (property.IsStatic || property.IsIndexer)
 88                continue;
 89
 90            // Must have a setter (set or init)
 29191            if (property.SetMethod == null)
 92                continue;
 93
 94            // Check if it's init-only
 27995            var isInitOnly = property.SetMethod.IsInitOnly;
 96
 97            // Get nullability info
 27998            var isNullable = property.NullableAnnotation == NullableAnnotation.Annotated ||
 27999                             (property.Type is INamedTypeSymbol namedType &&
 279100                              namedType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T);
 101
 279102            var typeName = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 103
 104            // Check if it's an enum type
 279105            var isEnum = false;
 279106            string? enumTypeName = null;
 279107            var actualType = property.Type;
 108
 109            // For nullable types, get the underlying type
 279110            if (actualType is INamedTypeSymbol nullableType &&
 279111                nullableType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T &&
 279112                nullableType.TypeArguments.Length == 1)
 113            {
 4114                actualType = nullableType.TypeArguments[0];
 115            }
 116
 279117            if (actualType.TypeKind == TypeKind.Enum)
 118            {
 15119                isEnum = true;
 15120                enumTypeName = actualType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 121            }
 122
 123            // Detect complex types
 279124            var (complexKind, elementTypeName, nestedProps) = AnalyzeComplexType(property.Type, visitedTypes);
 125
 126            // Extract DataAnnotation attributes
 279127            var dataAnnotations = ExtractDataAnnotations(property);
 128
 279129            properties.Add(new OptionsPropertyInfo(
 279130                property.Name,
 279131                typeName,
 279132                isNullable,
 279133                isInitOnly,
 279134                isEnum,
 279135                enumTypeName,
 279136                complexKind,
 279137                elementTypeName,
 279138                nestedProps,
 279139                dataAnnotations));
 140        }
 141
 189142        return properties;
 143    }
 144
 145    /// <summary>
 146    /// Extracts DataAnnotation validation attributes from a property symbol.
 147    /// </summary>
 148    internal static IReadOnlyList<DataAnnotationInfo> ExtractDataAnnotations(IPropertySymbol property)
 149    {
 279150        var annotations = new List<DataAnnotationInfo>();
 151
 604152        foreach (var attr in property.GetAttributes())
 153        {
 23154            var attrClass = attr.AttributeClass;
 23155            if (attrClass == null) continue;
 156
 157            // Get the attribute type name - use ContainingNamespace + Name for reliable matching
 23158            var attrNamespace = attrClass.ContainingNamespace?.ToDisplayString() ?? "";
 23159            var attrTypeName = attrClass.Name;
 160
 161            // Only process System.ComponentModel.DataAnnotations attributes
 23162            if (attrNamespace != "System.ComponentModel.DataAnnotations")
 163                continue;
 164
 165            // Extract error message if present
 23166            string? errorMessage = null;
 51167            foreach (var namedArg in attr.NamedArguments)
 168            {
 3169                if (namedArg.Key == "ErrorMessage" && namedArg.Value.Value is string msg)
 170                {
 1171                    errorMessage = msg;
 1172                    break;
 173                }
 174            }
 175
 176            // Check for known DataAnnotation attributes
 23177            if (attrTypeName == "RequiredAttribute")
 178            {
 12179                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Required, errorMessage));
 180            }
 11181            else if (attrTypeName == "RangeAttribute")
 182            {
 12183                object? min = null, max = null;
 6184                if (attr.ConstructorArguments.Length >= 2)
 185                {
 6186                    min = attr.ConstructorArguments[0].Value;
 6187                    max = attr.ConstructorArguments[1].Value;
 188                }
 6189                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Range, errorMessage, min, max));
 190            }
 5191            else if (attrTypeName == "StringLengthAttribute")
 192            {
 2193                object? maxLen = null;
 2194                int? minLen = null;
 2195                if (attr.ConstructorArguments.Length >= 1)
 196                {
 2197                    maxLen = attr.ConstructorArguments[0].Value;
 198                }
 8199                foreach (var namedArg in attr.NamedArguments)
 200                {
 2201                    if (namedArg.Key == "MinimumLength" && namedArg.Value.Value is int ml)
 202                    {
 2203                        minLen = ml;
 204                    }
 205                }
 2206                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.StringLength, errorMessage, null, maxLen, null
 207            }
 3208            else if (attrTypeName == "MinLengthAttribute")
 209            {
 1210                int? minLen = null;
 1211                if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is int ml)
 212                {
 1213                    minLen = ml;
 214                }
 1215                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.MinLength, errorMessage, null, null, null, min
 216            }
 2217            else if (attrTypeName == "MaxLengthAttribute")
 218            {
 1219                object? maxLen = null;
 1220                if (attr.ConstructorArguments.Length >= 1)
 221                {
 1222                    maxLen = attr.ConstructorArguments[0].Value;
 223                }
 1224                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.MaxLength, errorMessage, null, maxLen));
 225            }
 1226            else if (attrTypeName == "RegularExpressionAttribute")
 227            {
 1228                string? pattern = null;
 1229                if (attr.ConstructorArguments.Length >= 1 && attr.ConstructorArguments[0].Value is string p)
 230                {
 1231                    pattern = p;
 232                }
 1233                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.RegularExpression, errorMessage, null, null, p
 234            }
 0235            else if (attrTypeName == "EmailAddressAttribute")
 236            {
 0237                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.EmailAddress, errorMessage));
 238            }
 0239            else if (attrTypeName == "PhoneAttribute")
 240            {
 0241                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Phone, errorMessage));
 242            }
 0243            else if (attrTypeName == "UrlAttribute")
 244            {
 0245                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Url, errorMessage));
 246            }
 0247            else if (IsValidationAttribute(attrClass))
 248            {
 249                // Unsupported validation attribute
 0250                annotations.Add(new DataAnnotationInfo(DataAnnotationKind.Unsupported, errorMessage));
 251            }
 252        }
 253
 279254        return annotations;
 255    }
 256
 257    /// <summary>
 258    /// Attempts to extract nested properties from a type if it is a bindable class.
 259    /// </summary>
 260    internal static IReadOnlyList<OptionsPropertyInfo>? TryGetNestedProperties(ITypeSymbol elementType, HashSet<string> 
 261    {
 14262        if (elementType is INamedTypeSymbol namedElement && IsBindableClass(namedElement))
 263        {
 3264            var props = ExtractBindableProperties(namedElement, visitedTypes);
 3265            return props.Count > 0 ? props : null;
 266        }
 11267        return null;
 268    }
 269
 270    /// <summary>
 271    /// Filters out nested options types that are used as properties in other options types.
 272    /// These should not be registered separately - they are bound as part of their parent.
 273    /// </summary>
 274    internal static List<DiscoveredOptions> FilterNestedOptions(List<DiscoveredOptions> options, Compilation compilation
 275    {
 276        // Build a set of all options type names
 63277        var optionsTypeNames = new HashSet<string>(options.Select(o => o.TypeName));
 278
 279        // Find all options types that are used as properties in other options types
 19280        var nestedTypeNames = new HashSet<string>();
 281
 126282        foreach (var opt in options)
 283        {
 284            // Find the type symbol for this options type
 44285            var typeSymbol = FindTypeSymbol(compilation, opt.TypeName);
 44286            if (typeSymbol == null)
 287                continue;
 288
 289            // Check all properties of this type
 640290            foreach (var member in typeSymbol.GetMembers())
 291            {
 276292                if (member is not IPropertySymbol property)
 293                    continue;
 294
 295                // Skip non-class property types (primitives, structs, etc.)
 56296                if (property.Type is not INamedTypeSymbol propertyType)
 297                    continue;
 298
 56299                if (propertyType.TypeKind != TypeKind.Class)
 300                    continue;
 301
 302                // Get the fully qualified name of the property type
 42303                var propertyTypeName = TypeDiscoveryHelper.GetFullyQualifiedName(propertyType);
 304
 305                // If this property type is also an [Options] type, mark it as nested
 42306                if (optionsTypeNames.Contains(propertyTypeName))
 307                {
 9308                    nestedTypeNames.Add(propertyTypeName);
 309                }
 310            }
 311        }
 312
 313        // Return only root options (those not used as properties in other options)
 63314        return options.Where(o => !nestedTypeNames.Contains(o.TypeName)).ToList();
 315    }
 316
 317    private static bool IsPrimaryConstructor(IMethodSymbol ctor, INamedTypeSymbol recordType)
 318    {
 319        // For positional records, the primary constructor parameters correspond to auto-properties
 320        // Check if each parameter has a matching property
 73321        foreach (var param in ctor.Parameters)
 322        {
 26323            var hasMatchingProperty = recordType.GetMembers()
 26324                .OfType<IPropertySymbol>()
 101325                .Any(p => p.Name.Equals(param.Name, StringComparison.Ordinal) &&
 101326                         SymbolEqualityComparer.Default.Equals(p.Type, param.Type));
 327
 26328            if (!hasMatchingProperty)
 3329                return false;
 330        }
 331
 9332        return true;
 333    }
 334
 335    private static (ComplexTypeKind Kind, string? ElementTypeName, IReadOnlyList<OptionsPropertyInfo>? NestedProperties)
 336        ITypeSymbol typeSymbol,
 337        HashSet<string> visitedTypes)
 338    {
 339        // Check for array
 279340        if (typeSymbol is IArrayTypeSymbol arrayType)
 341        {
 3342            var elementType = arrayType.ElementType;
 3343            var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 3344            var nestedProps = TryGetNestedProperties(elementType, visitedTypes);
 3345            return (ComplexTypeKind.Array, elementTypeName, nestedProps);
 346        }
 347
 276348        if (typeSymbol is not INamedTypeSymbol namedType)
 349        {
 0350            return (ComplexTypeKind.None, null, null);
 351        }
 352
 353        // Check for Dictionary<string, T>
 276354        if (IsDictionaryType(namedType))
 355        {
 4356            var valueType = namedType.TypeArguments[1];
 4357            var valueTypeName = valueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 4358            var nestedProps = TryGetNestedProperties(valueType, visitedTypes);
 4359            return (ComplexTypeKind.Dictionary, valueTypeName, nestedProps);
 360        }
 361
 362        // Check for List<T>, IList<T>, ICollection<T>, IEnumerable<T>
 272363        if (IsListType(namedType))
 364        {
 7365            var elementType = namedType.TypeArguments[0];
 7366            var elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
 7367            var nestedProps = TryGetNestedProperties(elementType, visitedTypes);
 7368            return (ComplexTypeKind.List, elementTypeName, nestedProps);
 369        }
 370
 371        // Check for nested object (class with bindable properties)
 265372        if (IsBindableClass(namedType))
 373        {
 25374            var nestedProps = ExtractBindableProperties(namedType, visitedTypes);
 25375            if (nestedProps.Count > 0)
 376            {
 19377                return (ComplexTypeKind.NestedObject, null, nestedProps);
 378            }
 379        }
 380
 246381        return (ComplexTypeKind.None, null, null);
 382    }
 383
 384    private static bool IsValidationAttribute(INamedTypeSymbol attrClass)
 385    {
 386        // Check if this inherits from ValidationAttribute
 0387        var current = attrClass.BaseType;
 0388        while (current != null)
 389        {
 0390            if (current.ToDisplayString() == "System.ComponentModel.DataAnnotations.ValidationAttribute")
 0391                return true;
 0392            current = current.BaseType;
 393        }
 0394        return false;
 395    }
 396
 397    private static bool IsDictionaryType(INamedTypeSymbol type)
 398    {
 399        // Check for Dictionary<TKey, TValue> or IDictionary<TKey, TValue>
 276400        if (type.TypeArguments.Length != 2)
 272401            return false;
 402
 4403        var typeName = type.OriginalDefinition.ToDisplayString();
 4404        return typeName == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
 4405               typeName == "System.Collections.Generic.IDictionary<TKey, TValue>";
 406    }
 407
 408    private static bool IsListType(INamedTypeSymbol type)
 409    {
 272410        if (type.TypeArguments.Length != 1)
 261411            return false;
 412
 11413        var typeName = type.OriginalDefinition.ToDisplayString();
 11414        return typeName == "System.Collections.Generic.List<T>" ||
 11415               typeName == "System.Collections.Generic.IList<T>" ||
 11416               typeName == "System.Collections.Generic.ICollection<T>" ||
 11417               typeName == "System.Collections.Generic.IEnumerable<T>";
 418    }
 419
 420    private static bool IsBindableClass(INamedTypeSymbol type)
 421    {
 422        // Must be a class or struct, not abstract, not a system type
 279423        if (type.TypeKind != TypeKind.Class && type.TypeKind != TypeKind.Struct)
 17424            return false;
 425
 262426        if (type.IsAbstract)
 4427            return false;
 428
 429        // Skip system types and primitives
 258430        var ns = type.ContainingNamespace?.ToDisplayString() ?? "";
 258431        if (ns.StartsWith("System"))
 432        {
 433            // Skip known non-bindable System namespaces
 230434            if (ns == "System" || ns.StartsWith("System.Collections") || ns.StartsWith("System.Threading"))
 230435                return false;
 436        }
 437
 438        // Must have a parameterless constructor (explicit or implicit)
 439        // Note: Classes without any explicit constructors have an implicit parameterless constructor
 56440        var hasExplicitConstructors = type.InstanceConstructors.Any(c => !c.IsImplicitlyDeclared);
 28441        if (hasExplicitConstructors)
 442        {
 0443            var hasParameterlessCtor = type.InstanceConstructors
 0444                .Any(c => c.Parameters.Length == 0 && c.DeclaredAccessibility == Accessibility.Public);
 0445            return hasParameterlessCtor;
 446        }
 447
 448        // No explicit constructors means implicit parameterless constructor exists
 28449        return true;
 450    }
 451
 452    private static INamedTypeSymbol? FindTypeSymbol(Compilation compilation, string fullyQualifiedName)
 453    {
 454        // Strip global:: prefix if present
 44455        var typeName = fullyQualifiedName.StartsWith("global::")
 44456            ? fullyQualifiedName.Substring(8)
 44457            : fullyQualifiedName;
 458
 44459        return compilation.GetTypeByMetadataName(typeName);
 460    }
 461}