< Summary

Information
Class: NexusLabs.Needlr.Generators.CodeGen.OptionsCodeGenerator
Assembly: NexusLabs.Needlr.Generators
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/CodeGen/OptionsCodeGenerator.cs
Line coverage
84%
Covered lines: 205
Uncovered lines: 37
Coverable lines: 242
Total lines: 418
Line coverage: 84.7%
Branch coverage
57%
Covered branches: 128
Total branches: 223
Branch coverage: 57.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
GenerateOptionsValidatorsSource(...)100%2020100%
GenerateDataAnnotationsValidatorsSource(...)100%1010100%
GenerateDataAnnotationValidation(...)60%683570.12%
EscapeString(...)100%11100%
EscapeVerbatimString(...)100%11100%
GeneratePositionalRecordConstructorsSource(...)100%88100%
GetDefaultValueForType(...)46%410115044%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/CodeGen/OptionsCodeGenerator.cs

#LineLine coverage
 1// Copyright (c) NexusLabs. All rights reserved.
 2// Licensed under the MIT License.
 3
 4using System.Collections.Generic;
 5using System.Text;
 6
 7using NexusLabs.Needlr.Generators.Models;
 8
 9namespace NexusLabs.Needlr.Generators.CodeGen;
 10
 11/// <summary>
 12/// Generates IValidateOptions implementations for options classes with validation.
 13/// </summary>
 14internal static class OptionsCodeGenerator
 15{
 16    internal static string GenerateOptionsValidatorsSource(IReadOnlyList<DiscoveredOptions> optionsWithValidators, strin
 17    {
 1818        var builder = new StringBuilder();
 1819        var safeAssemblyName = GeneratorHelpers.SanitizeIdentifier(assemblyName);
 20
 1821        breadcrumbs.WriteFileHeader(builder, assemblyName, "Needlr Generated Options Validators");
 1822        builder.AppendLine("#nullable enable");
 1823        builder.AppendLine();
 1824        builder.AppendLine("using System.Collections.Generic;");
 1825        builder.AppendLine("using System.Linq;");
 1826        builder.AppendLine();
 1827        builder.AppendLine("using Microsoft.Extensions.Options;");
 1828        builder.AppendLine();
 1829        builder.AppendLine("using NexusLabs.Needlr.Generators;");
 1830        builder.AppendLine();
 1831        builder.AppendLine($"namespace {safeAssemblyName}.Generated;");
 1832        builder.AppendLine();
 33
 34        // Generate validator class for each options type with a validator method
 7235        foreach (var opt in optionsWithValidators)
 36        {
 1837            if (!opt.HasValidatorMethod || opt.ValidatorMethod == null)
 38                continue;
 39
 1840            var shortTypeName = GeneratorHelpers.GetShortTypeName(opt.TypeName);
 1841            var validatorClassName = shortTypeName + "Validator";
 42
 43            // Determine which type has the validator method
 1844            var validatorTargetType = opt.HasExternalValidator ? opt.ValidatorTypeName! : opt.TypeName;
 45
 1846            builder.AppendLine("/// <summary>");
 1847            builder.AppendLine($"/// Generated validator for <see cref=\"{opt.TypeName}\"/>.");
 1848            if (opt.HasExternalValidator)
 49            {
 650                builder.AppendLine($"/// Uses external validator <see cref=\"{validatorTargetType}\"/>.");
 51            }
 52            else
 53            {
 1254                builder.AppendLine("/// Calls the validation method on the options instance.");
 55            }
 1856            builder.AppendLine("/// </summary>");
 1857            builder.AppendLine("[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"NexusLabs.Needlr.Generators\",
 1858            builder.AppendLine($"public sealed class {validatorClassName} : IValidateOptions<{opt.TypeName}>");
 1859            builder.AppendLine("{");
 60
 1861            if (opt.HasExternalValidator && !opt.ValidatorMethod.Value.IsStatic)
 62            {
 63                // External validator needs to be injected for instance methods
 464                builder.AppendLine($"    private readonly {validatorTargetType} _validator;");
 465                builder.AppendLine();
 466                builder.AppendLine($"    public {validatorClassName}({validatorTargetType} validator)");
 467                builder.AppendLine("    {");
 468                builder.AppendLine("        _validator = validator;");
 469                builder.AppendLine("    }");
 470                builder.AppendLine();
 71            }
 72
 1873            builder.AppendLine($"    public ValidateOptionsResult Validate(string? name, {opt.TypeName} options)");
 1874            builder.AppendLine("    {");
 1875            builder.AppendLine("        var errors = new List<string>();");
 76
 77            // Generate the foreach to iterate errors
 78            string validationCall;
 1879            if (opt.HasExternalValidator)
 80            {
 681                if (opt.ValidatorMethod.Value.IsStatic)
 82                {
 83                    // Static method on external type: ExternalValidator.ValidateMethod(options)
 284                    validationCall = $"{validatorTargetType}.{opt.ValidatorMethod.Value.MethodName}(options)";
 85                }
 86                else
 87                {
 88                    // Instance method on external type: _validator.ValidateMethod(options)
 489                    validationCall = $"_validator.{opt.ValidatorMethod.Value.MethodName}(options)";
 90                }
 91            }
 1292            else if (opt.ValidatorMethod.Value.IsStatic)
 93            {
 94                // Static method on options type: OptionsType.ValidateMethod(options)
 195                validationCall = $"{opt.TypeName}.{opt.ValidatorMethod.Value.MethodName}(options)";
 96            }
 97            else
 98            {
 99                // Instance method on options type: options.ValidateMethod()
 11100                validationCall = $"options.{opt.ValidatorMethod.Value.MethodName}()";
 101            }
 102
 18103            builder.AppendLine($"        foreach (var error in {validationCall})");
 18104            builder.AppendLine("        {");
 18105            builder.AppendLine("            // Support both string and ValidationError (ValidationError.ToString() retur
 18106            builder.AppendLine("            var errorMessage = error?.ToString() ?? string.Empty;");
 18107            builder.AppendLine("            if (!string.IsNullOrEmpty(errorMessage))");
 18108            builder.AppendLine("            {");
 18109            builder.AppendLine("                errors.Add(errorMessage);");
 18110            builder.AppendLine("            }");
 18111            builder.AppendLine("        }");
 18112            builder.AppendLine();
 18113            builder.AppendLine("        if (errors.Count > 0)");
 18114            builder.AppendLine("        {");
 18115            builder.AppendLine($"            return ValidateOptionsResult.Fail(errors);");
 18116            builder.AppendLine("        }");
 18117            builder.AppendLine();
 18118            builder.AppendLine("        return ValidateOptionsResult.Success;");
 18119            builder.AppendLine("    }");
 18120            builder.AppendLine("}");
 18121            builder.AppendLine();
 122        }
 123
 18124        return builder.ToString();
 125    }
 126
 127    /// <summary>
 128    /// Generates IValidateOptions implementations for options classes with DataAnnotation attributes.
 129    /// This enables AOT-compatible validation without reflection.
 130    /// </summary>
 131    internal static string GenerateDataAnnotationsValidatorsSource(
 132        IReadOnlyList<DiscoveredOptions> optionsWithDataAnnotations,
 133        string assemblyName,
 134        BreadcrumbWriter breadcrumbs,
 135        string? projectDirectory)
 136    {
 18137        var builder = new StringBuilder();
 18138        var safeAssemblyName = GeneratorHelpers.SanitizeIdentifier(assemblyName);
 139
 18140        breadcrumbs.WriteFileHeader(builder, assemblyName, "Needlr Generated DataAnnotations Validators");
 18141        builder.AppendLine("#nullable enable");
 18142        builder.AppendLine();
 18143        builder.AppendLine("using System.Collections.Generic;");
 18144        builder.AppendLine("using System.Text.RegularExpressions;");
 18145        builder.AppendLine();
 18146        builder.AppendLine("using Microsoft.Extensions.Options;");
 18147        builder.AppendLine();
 18148        builder.AppendLine($"namespace {safeAssemblyName}.Generated;");
 18149        builder.AppendLine();
 150
 74151        foreach (var opt in optionsWithDataAnnotations)
 152        {
 19153            if (!opt.HasDataAnnotations)
 154                continue;
 155
 19156            var shortTypeName = GeneratorHelpers.GetShortTypeName(opt.TypeName);
 19157            var validatorClassName = shortTypeName + "DataAnnotationsValidator";
 158
 19159            builder.AppendLine("/// <summary>");
 19160            builder.AppendLine($"/// Generated DataAnnotations validator for <see cref=\"{opt.TypeName}\"/>.");
 19161            builder.AppendLine("/// Validates DataAnnotation attributes without reflection (AOT-compatible).");
 19162            builder.AppendLine("/// </summary>");
 19163            builder.AppendLine("[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"NexusLabs.Needlr.Generators\",
 19164            builder.AppendLine($"public sealed class {validatorClassName} : IValidateOptions<{opt.TypeName}>");
 19165            builder.AppendLine("{");
 19166            builder.AppendLine($"    public ValidateOptionsResult Validate(string? name, {opt.TypeName} options)");
 19167            builder.AppendLine("    {");
 19168            builder.AppendLine("        var errors = new List<string>();");
 19169            builder.AppendLine();
 170
 171            // Generate validation for each property with DataAnnotations
 82172            foreach (var prop in opt.Properties)
 173            {
 22174                if (!prop.HasDataAnnotations)
 175                    continue;
 176
 90177                foreach (var annotation in prop.DataAnnotations)
 178                {
 23179                    GenerateDataAnnotationValidation(builder, prop, annotation);
 180                }
 181            }
 182
 19183            builder.AppendLine("        if (errors.Count > 0)");
 19184            builder.AppendLine("        {");
 19185            builder.AppendLine("            return ValidateOptionsResult.Fail(errors);");
 19186            builder.AppendLine("        }");
 19187            builder.AppendLine();
 19188            builder.AppendLine("        return ValidateOptionsResult.Success;");
 19189            builder.AppendLine("    }");
 19190            builder.AppendLine("}");
 19191            builder.AppendLine();
 192        }
 193
 18194        return builder.ToString();
 195    }
 196
 197    private static void GenerateDataAnnotationValidation(StringBuilder builder, OptionsPropertyInfo prop, DataAnnotation
 198    {
 23199        var propName = prop.Name;
 23200        var errorMsg = annotation.ErrorMessage;
 201
 23202        switch (annotation.Kind)
 203        {
 204            case DataAnnotationKind.Required:
 12205                var requiredError = errorMsg ?? $"The {propName} field is required.";
 12206                if (prop.TypeName.Contains("string"))
 207                {
 12208                    builder.AppendLine($"        if (string.IsNullOrEmpty(options.{propName}))");
 209                }
 210                else
 211                {
 0212                    builder.AppendLine($"        if (options.{propName} == null)");
 213                }
 12214                builder.AppendLine("        {");
 12215                builder.AppendLine($"            errors.Add(\"{EscapeString(requiredError)}\");");
 12216                builder.AppendLine("        }");
 12217                builder.AppendLine();
 12218                break;
 219
 220            case DataAnnotationKind.Range:
 6221                var min = annotation.Minimum;
 6222                var max = annotation.Maximum;
 6223                var rangeError = errorMsg ?? $"The field {propName} must be between {min} and {max}.";
 6224                builder.AppendLine($"        if (options.{propName} < {min} || options.{propName} > {max})");
 6225                builder.AppendLine("        {");
 6226                builder.AppendLine($"            errors.Add(\"{EscapeString(rangeError)}\");");
 6227                builder.AppendLine("        }");
 6228                builder.AppendLine();
 6229                break;
 230
 231            case DataAnnotationKind.StringLength:
 2232                var maxLen = annotation.Maximum;
 2233                var minLen = annotation.MinimumLength ?? 0;
 2234                var strLenError = errorMsg ?? $"The field {propName} must be a string with a maximum length of {maxLen}.
 2235                if (minLen > 0)
 236                {
 2237                    builder.AppendLine($"        if (options.{propName}?.Length < {minLen} || options.{propName}?.Length
 238                }
 239                else
 240                {
 0241                    builder.AppendLine($"        if (options.{propName}?.Length > {maxLen})");
 242                }
 2243                builder.AppendLine("        {");
 2244                builder.AppendLine($"            errors.Add(\"{EscapeString(strLenError)}\");");
 2245                builder.AppendLine("        }");
 2246                builder.AppendLine();
 2247                break;
 248
 249            case DataAnnotationKind.MinLength:
 1250                var minLenVal = annotation.MinimumLength ?? 0;
 1251                var minLenError = errorMsg ?? $"The field {propName} must have a minimum length of {minLenVal}.";
 1252                builder.AppendLine($"        if (options.{propName}?.Length < {minLenVal})");
 1253                builder.AppendLine("        {");
 1254                builder.AppendLine($"            errors.Add(\"{EscapeString(minLenError)}\");");
 1255                builder.AppendLine("        }");
 1256                builder.AppendLine();
 1257                break;
 258
 259            case DataAnnotationKind.MaxLength:
 1260                var maxLenVal = annotation.Maximum;
 1261                var maxLenError = errorMsg ?? $"The field {propName} must have a maximum length of {maxLenVal}.";
 1262                builder.AppendLine($"        if (options.{propName}?.Length > {maxLenVal})");
 1263                builder.AppendLine("        {");
 1264                builder.AppendLine($"            errors.Add(\"{EscapeString(maxLenError)}\");");
 1265                builder.AppendLine("        }");
 1266                builder.AppendLine();
 1267                break;
 268
 269            case DataAnnotationKind.RegularExpression:
 1270                var pattern = annotation.Pattern ?? "";
 1271                var regexError = errorMsg ?? $"The field {propName} must match the regular expression '{pattern}'.";
 272                // Use verbatim string for pattern
 1273                builder.AppendLine($"        if (options.{propName} != null && !Regex.IsMatch(options.{propName}, @\"{Es
 1274                builder.AppendLine("        {");
 1275                builder.AppendLine($"            errors.Add(\"{EscapeString(regexError)}\");");
 1276                builder.AppendLine("        }");
 1277                builder.AppendLine();
 1278                break;
 279
 280            case DataAnnotationKind.EmailAddress:
 0281                var emailError = errorMsg ?? $"The {propName} field is not a valid e-mail address.";
 0282                builder.AppendLine($"        if (!string.IsNullOrEmpty(options.{propName}) && !Regex.IsMatch(options.{pr
 0283                builder.AppendLine("        {");
 0284                builder.AppendLine($"            errors.Add(\"{EscapeString(emailError)}\");");
 0285                builder.AppendLine("        }");
 0286                builder.AppendLine();
 0287                break;
 288
 289            case DataAnnotationKind.Url:
 0290                var urlError = errorMsg ?? $"The {propName} field is not a valid fully-qualified http, https, or ftp URL
 0291                builder.AppendLine($"        if (!string.IsNullOrEmpty(options.{propName}) && !global::System.Uri.TryCre
 0292                builder.AppendLine("        {");
 0293                builder.AppendLine($"            errors.Add(\"{EscapeString(urlError)}\");");
 0294                builder.AppendLine("        }");
 0295                builder.AppendLine();
 0296                break;
 297
 298            case DataAnnotationKind.Phone:
 0299                var phoneError = errorMsg ?? $"The {propName} field is not a valid phone number.";
 0300                builder.AppendLine($"        if (!string.IsNullOrEmpty(options.{propName}) && !Regex.IsMatch(options.{pr
 0301                builder.AppendLine("        {");
 0302                builder.AppendLine($"            errors.Add(\"{EscapeString(phoneError)}\");");
 0303                builder.AppendLine("        }");
 0304                builder.AppendLine();
 305                break;
 306
 307            case DataAnnotationKind.Unsupported:
 308                // Skip unsupported - will be handled by analyzer
 309                break;
 310        }
 0311    }
 312
 313    private static string EscapeString(string s)
 314    {
 23315        return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
 316    }
 317
 318    private static string EscapeVerbatimString(string s)
 319    {
 1320        return s.Replace("\"", "\"\"");
 321    }
 322
 323    /// <summary>
 324    /// Generates parameterless constructors for partial positional records with [Options].
 325    /// This enables configuration binding which requires a parameterless constructor.
 326    /// </summary>
 327    internal static string GeneratePositionalRecordConstructorsSource(
 328        IReadOnlyList<DiscoveredOptions> optionsNeedingConstructors,
 329        string assemblyName,
 330        BreadcrumbWriter breadcrumbs,
 331        string? projectDirectory)
 332    {
 7333        var builder = new StringBuilder();
 334
 7335        breadcrumbs.WriteFileHeader(builder, assemblyName, "Needlr Generated Options Constructors");
 7336        builder.AppendLine("#nullable enable");
 7337        builder.AppendLine();
 338
 339        // Group by namespace for cleaner output
 7340        var byNamespace = optionsNeedingConstructors
 7341            .Where(o => o.PositionalRecordInfo != null)
 7342            .GroupBy(o => o.PositionalRecordInfo!.Value.ContainingNamespace)
 14343            .OrderBy(g => g.Key);
 344
 28345        foreach (var namespaceGroup in byNamespace)
 346        {
 7347            var namespaceName = namespaceGroup.Key;
 348
 7349            if (!string.IsNullOrEmpty(namespaceName))
 350            {
 7351                builder.AppendLine($"namespace {namespaceName};");
 7352                builder.AppendLine();
 353            }
 354
 28355            foreach (var opt in namespaceGroup)
 356            {
 7357                var info = opt.PositionalRecordInfo!.Value;
 358
 7359                builder.AppendLine("/// <summary>");
 7360                builder.AppendLine($"/// Generated parameterless constructor for configuration binding.");
 7361                builder.AppendLine($"/// Chains to primary constructor with default values.");
 7362                builder.AppendLine("/// </summary>");
 7363                builder.AppendLine($"[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"NexusLabs.Needlr.Generato
 7364                builder.AppendLine($"public partial record {info.ShortTypeName}");
 7365                builder.AppendLine("{");
 366
 367                // Build the constructor call with default values for each parameter
 7368                var defaultArgs = new List<string>();
 52369                foreach (var param in info.Parameters)
 370                {
 19371                    var defaultValue = GetDefaultValueForType(param.TypeName);
 19372                    defaultArgs.Add(defaultValue);
 373                }
 374
 7375                var argsString = string.Join(", ", defaultArgs);
 7376                builder.AppendLine($"    public {info.ShortTypeName}() : this({argsString}) {{ }}");
 7377                builder.AppendLine("}");
 7378                builder.AppendLine();
 379            }
 380        }
 381
 7382        return builder.ToString();
 383    }
 384
 385    /// <summary>
 386    /// Gets the default value expression for a given type.
 387    /// </summary>
 388    private static string GetDefaultValueForType(string fullyQualifiedTypeName)
 389    {
 390        // Handle common types with user-friendly defaults
 19391        return fullyQualifiedTypeName switch
 19392        {
 7393            "global::System.String" or "string" => "string.Empty",
 4394            "global::System.Boolean" or "bool" => "default",
 6395            "global::System.Int32" or "int" => "default",
 0396            "global::System.Int64" or "long" => "default",
 0397            "global::System.Int16" or "short" => "default",
 0398            "global::System.Byte" or "byte" => "default",
 0399            "global::System.SByte" or "sbyte" => "default",
 0400            "global::System.UInt32" or "uint" => "default",
 0401            "global::System.UInt64" or "ulong" => "default",
 0402            "global::System.UInt16" or "ushort" => "default",
 0403            "global::System.Single" or "float" => "default",
 1404            "global::System.Double" or "double" => "default",
 0405            "global::System.Decimal" or "decimal" => "default",
 0406            "global::System.Char" or "char" => "default",
 0407            "global::System.DateTime" => "default",
 0408            "global::System.DateTimeOffset" => "default",
 0409            "global::System.TimeSpan" => "default",
 0410            "global::System.Guid" => "default",
 19411            // For nullable types and reference types, use default (which gives null for reference types)
 19412            // For other value types, use default
 1413            _ when fullyQualifiedTypeName.EndsWith("?") => "default",
 1414            _ => "default!"  // Reference types need null-forgiving operator
 19415        };
 416    }
 417}
 418