< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Runtime.PowerShellOpenApiClassExporter
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Runtime/PowerShellOpenApiClassExporter.cs
Tag: Kestrun/Kestrun@ca54e35c77799b76774b3805b6f075cdbc0c5fbe
Line coverage
85%
Covered lines: 388
Uncovered lines: 67
Coverable lines: 455
Total lines: 1215
Line coverage: 85.2%
Branch coverage
77%
Covered branches: 239
Total branches: 308
Branch coverage: 77.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/12/2025 - 17:27:19 Line coverage: 91.2% (94/103) Branch coverage: 75% (42/56) Total lines: 313 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/18/2025 - 21:41:58 Line coverage: 91.4% (96/105) Branch coverage: 75.8% (44/58) Total lines: 317 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff01/02/2026 - 00:16:25 Line coverage: 34.4% (101/293) Branch coverage: 25.2% (48/190) Total lines: 791 Tag: Kestrun/Kestrun@8405dc23b786b9d436fba0d65fb80baa4171e1d001/02/2026 - 21:56:10 Line coverage: 92.7% (319/344) Branch coverage: 86.1% (193/224) Total lines: 913 Tag: Kestrun/Kestrun@f60326065ebb24cf70b241e459b37baf142e6ed601/14/2026 - 07:55:07 Line coverage: 92.7% (320/345) Branch coverage: 86.2% (195/226) Total lines: 916 Tag: Kestrun/Kestrun@13bd81d8920e7e63e39aafdd188e7d766641ad3501/17/2026 - 04:33:35 Line coverage: 92.2% (368/399) Branch coverage: 84.8% (229/270) Total lines: 1085 Tag: Kestrun/Kestrun@aca34ea8d284564e2f9f6616dc937668dce926ba01/21/2026 - 17:07:46 Line coverage: 85.2% (388/455) Branch coverage: 77.5% (239/308) Total lines: 1215 Tag: Kestrun/Kestrun@3f6f61710c7ef7d5953cab578fe699c1e5e01a36 12/12/2025 - 17:27:19 Line coverage: 91.2% (94/103) Branch coverage: 75% (42/56) Total lines: 313 Tag: Kestrun/Kestrun@826bf9dcf9db118c5de4c78a3259bce9549f0dcd12/18/2025 - 21:41:58 Line coverage: 91.4% (96/105) Branch coverage: 75.8% (44/58) Total lines: 317 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff01/02/2026 - 00:16:25 Line coverage: 34.4% (101/293) Branch coverage: 25.2% (48/190) Total lines: 791 Tag: Kestrun/Kestrun@8405dc23b786b9d436fba0d65fb80baa4171e1d001/02/2026 - 21:56:10 Line coverage: 92.7% (319/344) Branch coverage: 86.1% (193/224) Total lines: 913 Tag: Kestrun/Kestrun@f60326065ebb24cf70b241e459b37baf142e6ed601/14/2026 - 07:55:07 Line coverage: 92.7% (320/345) Branch coverage: 86.2% (195/226) Total lines: 916 Tag: Kestrun/Kestrun@13bd81d8920e7e63e39aafdd188e7d766641ad3501/17/2026 - 04:33:35 Line coverage: 92.2% (368/399) Branch coverage: 84.8% (229/270) Total lines: 1085 Tag: Kestrun/Kestrun@aca34ea8d284564e2f9f6616dc937668dce926ba01/21/2026 - 17:07:46 Line coverage: 85.2% (388/455) Branch coverage: 77.5% (239/308) Total lines: 1215 Tag: Kestrun/Kestrun@3f6f61710c7ef7d5953cab578fe699c1e5e01a36

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_ValidClassNames()100%11100%
ExportOpenApiClasses(...)50%22100%
ExportOpenApiClasses(...)95%202096.87%
AppendCallback(...)75%44100%
NormalizeBlankLines(...)100%66100%
BuildCallbackFunctionStub(...)100%1616100%
TryExtractParamInfo(...)75%8881.81%
ExtractPowerShellParamBlock(...)81.25%171685.71%
TryConsumeQuoted(...)54.16%962450%
ExtractBodyParameterName(...)92.85%141493.33%
ExtractParamNamesFromStrippedParamBlock(...)90%202095%
StripPowerShellAttributeBlocks(...)94.44%181893.93%
HasOpenApiComponentAttribute(...)100%11100%
AppendClass(...)100%66100%
AppendOpenApiXmlMetadataProperty(...)71.42%241463.33%
BuildXmlMetadataHashtable(...)0%600240%
EscapePowerShellString(...)100%210%
ToPowerShellTypeName(...)100%1212100%
GetNullableTypeName(...)100%22100%
GetOpenApiArrayWrapperTypeName(...)100%11100%
GetCollapsedOpenApiPrimitiveTypeName(...)100%66100%
GetEnumTypeName(...)100%22100%
GetPrimitiveTypeName(...)100%11100%
GetArrayTypeName(...)100%44100%
FormatComponentOrFallbackName(...)100%44100%
CollectExportableEnums(...)100%66100%
FindEnumsInType()56.25%251666.66%
AppendEnum(...)62.5%8890.9%
ResolveElementArrayType(...)90%101083.33%
.cctor()100%11100%
ResolvePrimitiveTypeName(...)100%44100%
TryGetOpenApiValueUnderlyingType(...)90%1010100%
TryGetArrayComponentElementType(...)62.5%181678.94%
TopologicalSortByPropertyDependencies(...)100%22100%
Visit(...)100%88100%
GetComponentDependencyType(...)100%66100%
WriteOpenApiTempScript(...)100%11100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Runtime/PowerShellOpenApiClassExporter.cs

#LineLine coverage
 1using System.Reflection;
 2using System.Text;
 3
 4namespace Kestrun.Runtime;
 5
 6/// <summary>
 7/// Exports OpenAPI component classes as PowerShell class definitions.
 8/// </summary>
 9public static class PowerShellOpenApiClassExporter
 10{
 11    /// <summary>
 12    /// Holds valid class names to be used as type in the OpenAPI function definitions.
 13    /// </summary>
 1814    public static List<string> ValidClassNames { get; } = [];
 15
 16    /// <summary>
 17    /// Exports OpenAPI component classes found in loaded assemblies
 18    /// as PowerShell class definitions.
 19    /// </summary>
 20    /// <param name="userCallbacks">Optional user-defined functions to include in the export.</param>
 21    /// <returns>The path to the temporary PowerShell script containing the class definitions.</returns>
 22    public static string ExportOpenApiClasses(Dictionary<string, string>? userCallbacks)
 23    {
 5924        var assemblies = AppDomain.CurrentDomain.GetAssemblies()
 1836925           .Where(a => a.FullName is not null &&
 1836926                    a.FullName.Contains("PowerShell Class Assembly"))
 5927           .ToArray();
 5928        return ExportOpenApiClasses(assemblies: assemblies, userCallbacks: userCallbacks);
 29    }
 30
 31    /// <summary>
 32    /// Exports OpenAPI component classes found in the specified assemblies
 33    /// as PowerShell class definitions
 34    /// </summary>
 35    /// <param name="assemblies">The assemblies to scan for OpenAPI component classes.</param>
 36    ///  <param name="userCallbacks"> Optional user-defined functions to include in the export.</param>
 37    /// <returns>The path to the temporary PowerShell script containing the class definitions.</returns>
 38    public static string ExportOpenApiClasses(Assembly[] assemblies, Dictionary<string, string>? userCallbacks)
 39    {
 40        // 1. Collect all component classes
 6341        var componentTypes = assemblies
 242            .SelectMany(a => a.GetTypes())
 100843            .Where(t => t.IsClass && !t.IsAbstract)
 6344            .Where(HasOpenApiComponentAttribute)
 6345            .ToList();
 46
 47        // Collect any enums required by the component graph.
 48        // If a class property uses an enum type constraint, that enum must exist in the session
 49        // before the class definition is parsed.
 6350        var enumTypes = CollectExportableEnums(componentTypes)
 151            .OrderBy(t => t.Name, StringComparer.Ordinal)
 6352            .ToList();
 53
 54        // For quick lookup when choosing type names
 6355        var componentSet = new HashSet<Type>(componentTypes);
 56
 57        // 2. Topologically sort by "uses other component as property type"
 6358        var sorted = TopologicalSortByPropertyDependencies(componentTypes, componentSet);
 6359        var hasCallbacks = userCallbacks is not null && userCallbacks.Count > 0;
 60
 61        // nothing to export
 6362        if (sorted.Count == 0 && !hasCallbacks)
 63        {
 5964            return string.Empty;
 65        }
 66
 67        // 3. Emit PowerShell classes (and optional callback functions)
 468        var sb = new StringBuilder();
 69
 470        if (enumTypes.Count > 0)
 71        {
 172            _ = sb.AppendLine("# ================================================");
 173            _ = sb.AppendLine("#   Kestrun OpenAPI Autogenerated Enum Definitions");
 174            _ = sb.AppendLine("# ================================================");
 175            _ = sb.AppendLine();
 76
 477            foreach (var enumType in enumTypes)
 78            {
 179                AppendEnum(enumType, sb);
 180                _ = sb.AppendLine(); // blank line between enums
 81            }
 82        }
 83
 2484        foreach (var type in sorted)
 85        {
 86            // Skip types without full name (should not happen)
 887            if (type.FullName is null)
 88            {
 89                continue;
 90            }
 891            if (ValidClassNames.Contains(type.FullName))
 92            {
 93                // Already registered remove old entry
 094                _ = ValidClassNames.Remove(type.FullName);
 95            }
 96            // Register valid class name
 897            ValidClassNames.Add(type.FullName);
 98            // Emit class definition
 899            AppendClass(type, componentSet, sb);
 8100            _ = sb.AppendLine(); // blank line between classes
 101        }
 102
 4103        if (hasCallbacks)
 104        {
 2105            AppendCallback(sb, userCallbacks);
 106        }
 107        // 4. Write to temp script file
 4108        return WriteOpenApiTempScript(sb.ToString());
 109    }
 110
 111    /// <summary>
 112    /// Appends user-defined callback functions to the PowerShell script.
 113    /// </summary>
 114    /// <param name="sb"> The StringBuilder to append the callback functions to. </param>
 115    /// <param name="userCallbacks"> The dictionary of user-defined callback functions, where the key is the function na
 116    private static void AppendCallback(StringBuilder sb, Dictionary<string, string>? userCallbacks)
 117    {
 2118        _ = sb.AppendLine("# ================================================");
 2119        _ = sb.AppendLine("#   Kestrun User Callback Functions");
 2120        _ = sb.AppendLine("# ================================================");
 2121        _ = sb.AppendLine();
 122
 13123        foreach (var kvp in userCallbacks!.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
 124        {
 3125            var name = kvp.Key;
 3126            var definition = kvp.Value ?? string.Empty;
 127
 128            // Emit a standardized callback function wrapper:
 129            // - keeps parameter type constraints
 130            // - strips OpenAPI/Parameter attributes
 131            // - builds $params and calls $Context.Response.AddCallbackParameters(...)
 3132            var functionScript = BuildCallbackFunctionStub(name, definition);
 3133            var normalized = NormalizeBlankLines(functionScript);
 3134            _ = sb.AppendLine(normalized);
 3135            _ = sb.AppendLine();
 136        }
 2137    }
 138
 139    /// <summary>
 140    /// Normalizes blank lines in the provided PowerShell script.
 141    /// </summary>
 142    /// <param name="script">The PowerShell script as a string.</param>
 143    /// <returns>A string with normalized blank lines.</returns>
 144    private static string NormalizeBlankLines(string script)
 145    {
 6146        if (string.IsNullOrWhiteSpace(script))
 147        {
 1148            return string.Empty;
 149        }
 150
 151        // Normalize newlines first
 5152        script = script.Replace("\r\n", "\n").Replace("\r", "\n");
 153
 5154        var lines = script.Split('\n');
 5155        var sb = new StringBuilder(script.Length);
 156
 174157        for (var idx = 0; idx < lines.Length; idx++)
 158        {
 82159            var line = lines[idx].TrimEnd();
 82160            var isBlank = string.IsNullOrWhiteSpace(line);
 161
 162            // For callback function export we want compact output:
 163            // drop ALL whitespace-only lines (attribute stripping leaves many single blank lines).
 82164            if (!isBlank)
 165            {
 72166                _ = sb.AppendLine(line);
 167            }
 168        }
 169
 170        // Trim trailing newlines
 5171        return sb.ToString().TrimEnd();
 172    }
 173
 174    /// <summary>
 175    /// Builds a PowerShell function stub for a user-defined callback function.
 176    /// </summary>
 177    /// <param name="functionName"> The name of the callback function. </param>
 178    /// <param name="definition"> The PowerShell function definition as a string. </param>
 179    /// <returns>A string containing the standardized PowerShell function stub.</returns>
 180    private static string BuildCallbackFunctionStub(string functionName, string definition)
 181    {
 3182        var (paramBlock, paramNames, bodyParamName) = TryExtractParamInfo(definition);
 183
 184        // Fall back to a no-param function if we can't parse anything.
 3185        var strippedParamBlock = StripPowerShellAttributeBlocks(paramBlock);
 3186        strippedParamBlock = NormalizeBlankLines(strippedParamBlock);
 187
 188        // Ensure we always have a param(...) block for consistent output.
 3189        if (string.IsNullOrWhiteSpace(strippedParamBlock))
 190        {
 1191            strippedParamBlock = "param()";
 1192            paramNames = [];
 193        }
 194
 3195        var sb = new StringBuilder();
 3196        _ = sb.AppendLine($"function {functionName} {{");
 197
 198        // Normalize indentation:
 199        // - "param(" line: 4 spaces
 200        // - parameter lines: 8 spaces
 201        // - closing ")": 4 spaces
 22202        foreach (var rawLine in strippedParamBlock.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'))
 203        {
 8204            var l = rawLine.Trim();
 8205            if (l.Length == 0)
 206            {
 207                continue;
 208            }
 209
 8210            if (l.Equals(")", StringComparison.Ordinal))
 211            {
 2212                _ = sb.Append("    ").AppendLine(l);
 2213                continue;
 214            }
 215
 6216            if (l.StartsWith("param", StringComparison.OrdinalIgnoreCase))
 217            {
 3218                _ = sb.Append("    ").AppendLine(l);
 3219                continue;
 220            }
 221
 3222            _ = sb.Append("        ").AppendLine(l);
 223        }
 224
 3225        _ = sb.AppendLine("    $FunctionName = $MyInvocation.MyCommand.Name");
 3226        _ = sb.AppendLine("    if ($null -eq $Context -or $null -eq $Context.Response) {");
 3227        _ = sb.AppendLine("        if (Test-KrLogger) {");
 3228        _ = sb.AppendLine("            Write-KrLog -Level Warning -Message '{function} must be called inside a route scr
 3229        _ = sb.AppendLine("        } else {");
 3230        _ = sb.AppendLine("            Write-Warning -Message \"$FunctionName must be called inside a route script with 
 3231        _ = sb.AppendLine("        }");
 3232        _ = sb.AppendLine("        return");
 3233        _ = sb.AppendLine("    }");
 3234        _ = sb.AppendLine("    Write-KrLog -Level Information -Message 'Defined callback function {CallbackFunction}' -V
 3235        _ = sb.AppendLine("    $params = [System.Collections.Generic.Dictionary[string, object]]::new()");
 236
 12237        foreach (var p in paramNames)
 238        {
 239            // Use the exact casing captured from the param block; dictionary keys are case-insensitive in C#.
 3240            _ = sb.AppendLine($"    $params['{p}'] = ${p}");
 241        }
 242
 3243        _ = sb.AppendLine(bodyParamName is { Length: > 0 }
 3244            ? $"    $bodyParameterName = '{bodyParamName}'"
 3245            : "    $bodyParameterName = $null");
 246
 3247        _ = sb.AppendLine();
 3248        _ = sb.AppendLine("    $Context.Response.AddCallbackParameters(");
 3249        _ = sb.AppendLine("        $MyInvocation.MyCommand.Name,");
 3250        _ = sb.AppendLine("        $bodyParameterName,");
 3251        _ = sb.AppendLine("        $params)");
 3252        _ = sb.AppendLine("}");
 253
 3254        return sb.ToString();
 255    }
 256
 257    private static (string ParamBlock, List<string> ParamNames, string? BodyParamName) TryExtractParamInfo(string defini
 258    {
 3259        if (string.IsNullOrWhiteSpace(definition))
 260        {
 0261            return (string.Empty, [], null);
 262        }
 263
 264        // Try to isolate the param(...) block from a FunctionInfo.Definition string.
 3265        var paramBlock = ExtractPowerShellParamBlock(definition);
 3266        if (string.IsNullOrWhiteSpace(paramBlock))
 267        {
 1268            return (string.Empty, [], null);
 269        }
 270
 271        // Identify the request body parameter name (prefer OpenApiRequestBody attribute if present)
 272        // Example: [OpenApiRequestBody(...)] [PaymentStatusChangedEvent]$Body
 2273        var bodyParamName = ExtractBodyParameterName(paramBlock);
 274
 275        // Strip attribute blocks so we keep only type constraints + $paramName
 2276        var stripped = StripPowerShellAttributeBlocks(paramBlock);
 2277        var paramNames = ExtractParamNamesFromStrippedParamBlock(stripped);
 278
 279        // If we didn't find OpenApiRequestBody, default to Body if present.
 3280        if (string.IsNullOrWhiteSpace(bodyParamName) && paramNames.Any(p => string.Equals(p, "Body", StringComparison.Or
 281        {
 0282            bodyParamName = paramNames.First(p => string.Equals(p, "Body", StringComparison.OrdinalIgnoreCase));
 283        }
 284
 2285        return (paramBlock, paramNames, bodyParamName);
 286    }
 287
 288    /// <summary>
 289    /// States for scanning PowerShell script for quoted segments.
 290    /// </summary>
 291    private enum ScanState
 292    {
 293        /// <summary>
 294        /// Normal scanning state (not inside quotes).
 295        /// </summary>
 296        Normal,
 297        /// <summary>
 298        /// Inside single-quoted string segment.
 299        /// </summary>
 300        SingleQuoted,
 301        /// <summary>
 302        /// Inside double-quoted string segment.
 303        /// </summary>
 304        DoubleQuoted
 305    }
 306
 307    /// <summary>
 308    /// Extracts the parameter block from a PowerShell function definition.
 309    /// </summary>
 310    /// <param name="definition"> The PowerShell function definition string. </param>
 311    /// <returns>The parameter block string including the 'param(...)' syntax; or an empty string if not found.</returns
 312    private static string ExtractPowerShellParamBlock(string definition)
 313    {
 3314        if (string.IsNullOrEmpty(definition))
 315        {
 0316            return string.Empty;
 317        }
 318
 3319        var idx = definition.IndexOf("param", StringComparison.OrdinalIgnoreCase);
 3320        if (idx < 0)
 321        {
 0322            return string.Empty;
 323        }
 324
 3325        var open = definition.IndexOf('(', idx);
 3326        if (open < 0)
 327        {
 1328            return string.Empty;
 329        }
 330
 2331        var depth = 0;
 2332        var state = ScanState.Normal;
 333
 534334        for (var i = open; i < definition.Length; i++)
 335        {
 267336            if (TryConsumeQuoted(definition, ref i, ref state))
 337            {
 338                continue;
 339            }
 340
 243341            var ch = definition[i];
 342
 243343            if (ch == '(')
 344            {
 5345                depth++;
 5346                continue;
 347            }
 348
 238349            if (ch == ')')
 350            {
 5351                depth--;
 5352                if (depth == 0)
 353                {
 2354                    return definition.Substring(idx, i - idx + 1);
 355                }
 356            }
 357        }
 358
 0359        return string.Empty;
 360    }
 361
 362    /// <summary>
 363    /// Tries to consume a quoted segment in the PowerShell script.
 364    /// </summary>
 365    /// <param name="s"> The input string to scan. </param>
 366    /// <param name="i"> The current index in the string, passed by reference and updated as the quoted segment is consu
 367    /// <param name="state"> The current scanning state, passed by reference and updated based on quote handling. </para
 368    /// <returns>True if a quoted segment was consumed; otherwise, false.</returns>
 369    private static bool TryConsumeQuoted(string s, ref int i, ref ScanState state)
 370    {
 267371        var ch = s[i];
 372
 373        // Enter quote states
 267374        if (state == ScanState.Normal)
 375        {
 249376            if (ch == '\'') { state = ScanState.SingleQuoted; return true; }
 243377            if (ch == '"') { state = ScanState.DoubleQuoted; return true; }
 243378            return false;
 379        }
 380
 381        // Inside single quotes: '' is an escaped single quote
 22382        if (state == ScanState.SingleQuoted)
 383        {
 22384            if (ch == '\'' && i + 1 < s.Length && s[i + 1] == '\'')
 385            {
 0386                i++; // consume second '
 0387                return true;
 388            }
 389
 22390            if (ch == '\'')
 391            {
 2392                state = ScanState.Normal;
 393            }
 394
 22395            return true;
 396        }
 397
 398        // Inside double quotes: backtick escapes the next char
 0399        if (state == ScanState.DoubleQuoted)
 400        {
 0401            if (ch == '`' && i + 1 < s.Length)
 402            {
 0403                i++; // skip escaped char
 0404                return true;
 405            }
 406
 0407            if (ch == '"')
 408            {
 0409                state = ScanState.Normal;
 410            }
 411
 0412            return true;
 413        }
 414
 0415        return false;
 416    }
 417
 418    /// <summary>
 419    /// Extracts the name of the body parameter from the parameter block, if annotated with [OpenApiRequestBody].
 420    /// </summary>
 421    /// <param name="paramBlock"> The parameter block string to search within. </param>
 422    /// <returns>The name of the body parameter if found; otherwise, null.</returns>
 423    private static string? ExtractBodyParameterName(string paramBlock)
 424    {
 425        // Very targeted heuristic: if [OpenApiRequestBody(...)] is present, pick the following $name.
 426        // This keeps the exporter decoupled from PowerShell AST dependencies.
 2427        var marker = "OpenApiRequestBody";
 2428        var idx = paramBlock.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
 2429        if (idx < 0)
 430        {
 1431            return null;
 432        }
 433
 434        // Search forward for '$' then capture identifier
 180435        for (var i = idx; i < paramBlock.Length; i++)
 436        {
 90437            if (paramBlock[i] != '$')
 438            {
 439                continue;
 440            }
 441
 1442            var start = i + 1;
 1443            var end = start;
 8444            while (end < paramBlock.Length)
 445            {
 8446                var ch = paramBlock[end];
 8447                if (!(char.IsLetterOrDigit(ch) || ch == '_'))
 448                {
 449                    break;
 450                }
 7451                end++;
 452            }
 453
 1454            if (end > start)
 455            {
 1456                return paramBlock[start..end];
 457            }
 458        }
 459
 0460        return null;
 461    }
 462
 463    private static List<string> ExtractParamNamesFromStrippedParamBlock(string strippedParamBlock)
 464    {
 465        // Parse variable names only from within param(...)
 466        // We expect declarations like: [string]$paymentId,
 2467        if (string.IsNullOrWhiteSpace(strippedParamBlock))
 468        {
 0469            return [];
 470        }
 471
 2472        var names = new List<string>();
 2473        var s = strippedParamBlock;
 474
 250475        for (var i = 0; i < s.Length; i++)
 476        {
 123477            if (s[i] != '$')
 478            {
 479                continue;
 480            }
 481
 3482            var start = i + 1;
 3483            var end = start;
 3484            if (start >= s.Length)
 485            {
 486                continue;
 487            }
 488
 3489            if (!(char.IsLetter(s[start]) || s[start] == '_'))
 490            {
 491                continue;
 492            }
 493
 3494            end++;
 21495            while (end < s.Length)
 496            {
 21497                var ch = s[end];
 21498                if (!(char.IsLetterOrDigit(ch) || ch == '_'))
 499                {
 500                    break;
 501                }
 18502                end++;
 503            }
 504
 3505            var name = s[start..end];
 3506            if (!names.Contains(name, StringComparer.OrdinalIgnoreCase))
 507            {
 3508                names.Add(name);
 509            }
 510
 3511            i = end - 1;
 512        }
 513
 2514        return names;
 515    }
 516
 517    private static string StripPowerShellAttributeBlocks(string script)
 518    {
 5519        if (string.IsNullOrWhiteSpace(script))
 520        {
 1521            return string.Empty;
 522        }
 523
 4524        var sb = new StringBuilder(script.Length);
 4525        var i = 0;
 224526        while (i < script.Length)
 527        {
 220528            var ch = script[i];
 220529            if (ch != '[')
 530            {
 208531                _ = sb.Append(ch);
 208532                i++;
 208533                continue;
 534            }
 535
 536            // Capture a full bracket block, handling nested [ ... ] (e.g. generic type constraints)
 12537            var start = i;
 12538            var depth = 0;
 12539            var j = i;
 346540            while (j < script.Length)
 541            {
 346542                var cj = script[j];
 346543                if (cj == '[')
 544                {
 12545                    depth++;
 546                }
 334547                else if (cj == ']')
 548                {
 12549                    depth--;
 12550                    if (depth == 0)
 551                    {
 12552                        j++; // include closing ']'
 12553                        break;
 554                    }
 555                }
 334556                j++;
 557            }
 558
 559            // If unbalanced, just emit the rest
 12560            if (depth != 0)
 561            {
 0562                _ = sb.Append(script.AsSpan(i));
 0563                break;
 564            }
 565
 12566            var block = script.AsSpan(start, j - start);
 567
 568            // Attribute blocks always include parentheses in our usage (e.g. [OpenApiPath(...)], [Parameter()]).
 569            // Keep type constraints like [string], [int], [MyType], [MyType[]], [List[string]].
 12570            if (block.IndexOf('(') >= 0)
 571            {
 6572                i = j;
 6573                continue;
 574            }
 575
 6576            _ = sb.Append(block);
 6577            i = j;
 578        }
 579
 4580        return sb.ToString();
 581    }
 582
 583    /// <summary>
 584    /// Determines if the specified type has an OpenAPI component attribute.
 585    /// </summary>
 586    /// <param name="t"></param>
 587    /// <returns></returns>
 588    private static bool HasOpenApiComponentAttribute(Type t)
 589    {
 631590        return t.GetCustomAttributes(inherit: true)
 733591                .Select(a => a.GetType().Name)
 631592                .Any(n =>
 1364593                    n.Contains("OpenApiSchemaComponent", StringComparison.OrdinalIgnoreCase));
 594    }
 595
 596    /// <summary>
 597    /// Appends the PowerShell class definition for the specified type to the StringBuilder.
 598    /// </summary>
 599    /// <param name="type"></param>
 600    /// <param name="componentSet"></param>
 601    /// <param name="sb"></param>
 602    private static void AppendClass(Type type, HashSet<Type> componentSet, StringBuilder sb)
 603    {
 604        // Detect base type (for parenting)
 8605        var baseType = type.BaseType;
 8606        var baseClause = string.Empty;
 607
 8608        if (baseType != null && baseType != typeof(object))
 609        {
 610            // Use PS-friendly type name for the base
 4611            var basePsName = ToPowerShellTypeName(baseType, componentSet, collapseToUnderlyingPrimitives: false);
 4612            baseClause = $" : {basePsName}";
 613        }
 8614        _ = sb.AppendLine("[NoRunspaceAffinity()]");
 8615        _ = sb.AppendLine($"class {type.Name}{baseClause} {{");
 616
 617        // Only properties *declared* on this type (no inherited ones)
 8618        var props = type.GetProperties(
 8619            BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
 620
 82621        foreach (var p in props)
 622        {
 33623            var psType = ToPowerShellTypeName(p.PropertyType, componentSet, collapseToUnderlyingPrimitives: true);
 33624            _ = sb.AppendLine($"    [{psType}]${p.Name}");
 625        }
 626
 627        // Add static XML metadata to guide XmlHelper without requiring PowerShell method invocation
 8628        AppendOpenApiXmlMetadataProperty(type, props, sb);
 629
 8630        _ = sb.AppendLine("}");
 8631    }
 632
 633    /// <summary>
 634    /// Appends a static hashtable property containing OpenApiXml metadata for the class and its properties.
 635    /// </summary>
 636    /// <remarks>
 637    /// This is emitted as a static property (not a PowerShell class method) so that C# reflection can read the
 638    /// metadata without requiring a PowerShell execution context bound to the current thread.
 639    /// </remarks>
 640    /// <param name="type">The type to extract OpenApiXml metadata from.</param>
 641    /// <param name="props">The properties of the type.</param>
 642    /// <param name="sb">The StringBuilder to append to.</param>
 643    private static void AppendOpenApiXmlMetadataProperty(Type type, PropertyInfo[] props, StringBuilder sb)
 644    {
 8645        _ = sb.AppendLine();
 8646        _ = sb.AppendLine("    # Static OpenApiXml metadata for this class");
 8647        _ = sb.AppendLine("    static [hashtable] $XmlMetadata = @{");
 8648        _ = sb.AppendLine("        ClassName = '" + type.Name + "'");
 649
 650        // Get class-level OpenApiXml attribute
 8651        var classXmlAttr = type.GetCustomAttributes(inherit: false)
 18652            .FirstOrDefault(a => a.GetType().Name == "OpenApiXmlAttribute");
 653
 8654        if (classXmlAttr != null)
 655        {
 0656            var classXml = BuildXmlMetadataHashtable(classXmlAttr, indent: 12);
 0657            if (!string.IsNullOrWhiteSpace(classXml))
 658            {
 0659                _ = sb.AppendLine("        ClassXml = @{");
 0660                _ = sb.AppendLine(classXml);
 0661                _ = sb.AppendLine("        }");
 662            }
 663        }
 664
 665        // Get property-level OpenApiXml attributes
 8666        if (props.Length > 0)
 667        {
 5668            _ = sb.AppendLine("        Properties = @{");
 5669            var hasAnyPropertyXml = false;
 670
 76671            foreach (var prop in props)
 672            {
 33673                var propXmlAttr = prop.GetCustomAttributes(inherit: false)
 37674                    .FirstOrDefault(a => a.GetType().Name == "OpenApiXmlAttribute");
 675
 33676                if (propXmlAttr != null)
 677                {
 0678                    var propXml = BuildXmlMetadataHashtable(propXmlAttr, indent: 16);
 0679                    if (!string.IsNullOrWhiteSpace(propXml))
 680                    {
 0681                        hasAnyPropertyXml = true;
 0682                        _ = sb.AppendLine($"            '{prop.Name}' = @{{");
 0683                        _ = sb.AppendLine(propXml);
 0684                        _ = sb.AppendLine("            }");
 685                    }
 686                }
 687            }
 688
 5689            if (!hasAnyPropertyXml)
 690            {
 5691                _ = sb.AppendLine("            # No property-level XML metadata");
 692            }
 693
 5694            _ = sb.AppendLine("        }");
 695        }
 696
 8697        _ = sb.AppendLine("    }");
 8698    }
 699
 700    /// <summary>
 701    /// Builds a PowerShell hashtable representation of OpenApiXml attribute properties.
 702    /// </summary>
 703    /// <param name="xmlAttr">The OpenApiXml attribute instance.</param>
 704    /// <param name="indent">Number of spaces to indent.</param>
 705    /// <returns>PowerShell hashtable string representation.</returns>
 706    private static string BuildXmlMetadataHashtable(object xmlAttr, int indent)
 707    {
 0708        var attrType = xmlAttr.GetType();
 0709        var sb = new StringBuilder();
 0710        var indentStr = new string(' ', indent);
 711
 712        // Extract properties using reflection
 0713        var nameProp = attrType.GetProperty("Name");
 0714        var namespaceProp = attrType.GetProperty("Namespace");
 0715        var prefixProp = attrType.GetProperty("Prefix");
 0716        var attributeProp = attrType.GetProperty("Attribute");
 0717        var wrappedProp = attrType.GetProperty("Wrapped");
 718
 0719        var name = nameProp?.GetValue(xmlAttr) as string;
 0720        var ns = namespaceProp?.GetValue(xmlAttr) as string;
 0721        var prefix = prefixProp?.GetValue(xmlAttr) as string;
 0722        var isAttribute = attributeProp?.GetValue(xmlAttr) is bool b && b;
 0723        var isWrapped = wrappedProp?.GetValue(xmlAttr) is bool w && w;
 724
 0725        if (!string.IsNullOrWhiteSpace(name))
 726        {
 0727            _ = sb.AppendLine($"{indentStr}Name = '{EscapePowerShellString(name)}'");
 728        }
 729
 0730        if (!string.IsNullOrWhiteSpace(ns))
 731        {
 0732            _ = sb.AppendLine($"{indentStr}Namespace = '{EscapePowerShellString(ns)}'");
 733        }
 734
 0735        if (!string.IsNullOrWhiteSpace(prefix))
 736        {
 0737            _ = sb.AppendLine($"{indentStr}Prefix = '{EscapePowerShellString(prefix)}'");
 738        }
 739
 0740        if (isAttribute)
 741        {
 0742            _ = sb.AppendLine($"{indentStr}Attribute = $true");
 743        }
 744
 0745        if (isWrapped)
 746        {
 0747            _ = sb.AppendLine($"{indentStr}Wrapped = $true");
 748        }
 749
 0750        return sb.ToString().TrimEnd();
 751    }
 752
 753    /// <summary>
 754    /// Escapes single quotes in a string for PowerShell string literals.
 755    /// </summary>
 756    /// <param name="str">The string to escape.</param>
 757    /// <returns>Escaped string safe for PowerShell single-quoted strings.</returns>
 0758    private static string EscapePowerShellString(string str) => str.Replace("'", "''");
 759
 760    /// <summary>
 761    /// Converts a .NET type to a PowerShell type name.
 762    /// </summary>
 763    /// <param name="t"></param>
 764    /// <param name="componentSet"></param>
 765    /// <param name="collapseToUnderlyingPrimitives">When true, types derived from OpenApiValue&lt;T&gt; are emitted as 
 766    /// <returns></returns>
 767    private static string ToPowerShellTypeName(Type t, HashSet<Type> componentSet, bool collapseToUnderlyingPrimitives)
 768    {
 48769        return GetNullableTypeName(t, componentSet, collapseToUnderlyingPrimitives)
 48770            ?? GetOpenApiArrayWrapperTypeName(t, componentSet, collapseToUnderlyingPrimitives)
 48771            ?? GetCollapsedOpenApiPrimitiveTypeName(t, componentSet, collapseToUnderlyingPrimitives)
 48772            ?? GetEnumTypeName(t)
 48773            ?? GetPrimitiveTypeName(t)
 48774            ?? GetArrayTypeName(t, componentSet, collapseToUnderlyingPrimitives)
 48775            ?? FormatComponentOrFallbackName(t, componentSet);
 776    }
 777
 778    /// <summary>
 779    /// Produces a PowerShell nullable type constraint (e.g. <c>Nullable[int]</c>) when the input is a <c>Nullable&lt;T&
 780    /// </summary>
 781    /// <param name="t">The CLR type to inspect.</param>
 782    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 783    /// <param name="collapseToUnderlyingPrimitives">Whether OpenAPI primitive wrapper types should be collapsed to prim
 784    /// <returns>The nullable type name, or <c>null</c> when <paramref name="t"/> is not nullable.</returns>
 785    private static string? GetNullableTypeName(Type t, HashSet<Type> componentSet, bool collapseToUnderlyingPrimitives)
 786    {
 48787        return Nullable.GetUnderlyingType(t) is Type underlying
 48788            ? $"Nullable[{ToPowerShellTypeName(underlying, componentSet, collapseToUnderlyingPrimitives)}]"
 48789            : null;
 790    }
 791
 792    /// <summary>
 793    /// Produces an element-array type constraint for OpenAPI schema component array wrapper types when appropriate.
 794    /// </summary>
 795    /// <param name="t">The CLR type to inspect.</param>
 796    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 797    /// <param name="collapseToUnderlyingPrimitives">Whether OpenAPI primitive wrapper types should be collapsed to prim
 798    /// <returns>The array wrapper type name, or <c>null</c> when <paramref name="t"/> is not an OpenAPI array wrapper t
 799    private static string? GetOpenApiArrayWrapperTypeName(Type t, HashSet<Type> componentSet, bool collapseToUnderlyingP
 46800        => ResolveElementArrayType(t, componentSet, collapseToUnderlyingPrimitives);
 801
 802    /// <summary>
 803    /// Produces the underlying primitive PowerShell type name for OpenAPI primitive wrapper types (e.g. OpenApiString/O
 804    /// </summary>
 805    /// <param name="t">The CLR type to inspect.</param>
 806    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 807    /// <param name="collapseToUnderlyingPrimitives">Whether collapsing is enabled.</param>
 808    /// <returns>The primitive name, or <c>null</c> when <paramref name="t"/> is not an OpenAPI wrapper type (or collaps
 809    /// <remarks>
 810    /// When <paramref name="collapseToUnderlyingPrimitives"/> is <c>true</c>,
 811    /// types derived from OpenApiValue&lt;T&gt; are emitted as their underlying primitive (e.g., string/double/bool/lon
 812    /// </remarks>
 813    private static string? GetCollapsedOpenApiPrimitiveTypeName(Type t, HashSet<Type> componentSet, bool collapseToUnder
 45814        => collapseToUnderlyingPrimitives
 45815           && TryGetOpenApiValueUnderlyingType(t, out var underlying)
 45816           && underlying is not null
 45817            ? ToPowerShellTypeName(underlying, componentSet, collapseToUnderlyingPrimitives)
 45818            : null;
 819
 820    /// <summary>
 821    /// Produces the simple name for enum types so PowerShell can bind against the emitted enum definition.
 822    /// </summary>
 823    /// <param name="t">The CLR type to inspect.</param>
 824    /// <returns>The enum name, or <c>null</c> when <paramref name="t"/> is not an enum.</returns>
 825    private static string? GetEnumTypeName(Type t)
 39826        => t.IsEnum ? t.Name : null;
 827
 828    /// <summary>
 829    /// Produces the PowerShell type name for well-known CLR primitives.
 830    /// </summary>
 831    /// <param name="t">The CLR type to inspect.</param>
 832    /// <returns>The primitive name, or <c>null</c> when no primitive mapping exists.</returns>
 833    private static string? GetPrimitiveTypeName(Type t)
 38834        => ResolvePrimitiveTypeName(t);
 835
 836    /// <summary>
 837    /// Produces a PowerShell element-array type constraint (e.g. <c>string[]</c>) for CLR array types.
 838    /// </summary>
 839    /// <param name="t">The CLR type to inspect.</param>
 840    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 841    /// <param name="collapseToUnderlyingPrimitives">Whether OpenAPI primitive wrapper types should be collapsed to prim
 842    /// <returns>The formatted array name, or <c>null</c> when <paramref name="t"/> is not an array.</returns>
 843    private static string? GetArrayTypeName(Type t, HashSet<Type> componentSet, bool collapseToUnderlyingPrimitives)
 8844        => t.IsArray && t.GetElementType() is Type elementType
 8845            ? $"{ToPowerShellTypeName(elementType, componentSet, collapseToUnderlyingPrimitives)}[]"
 8846            : null;
 847
 848    /// <summary>
 849    /// Formats a component type as its simple name or falls back to full name for other reference types.
 850    /// </summary>
 851    /// <param name="t">The CLR type to format.</param>
 852    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 853    /// <returns>A PowerShell-friendly type name.</returns>
 854    private static string FormatComponentOrFallbackName(Type t, HashSet<Type> componentSet)
 6855        => componentSet.Contains(t) || t.FullName is null
 6856            ? t.Name
 6857            : t.FullName;
 858
 859    /// <summary>
 860    /// Collects enums referenced by component properties so they can be emitted before class definitions.
 861    /// </summary>
 862    /// <param name="componentTypes">Component classes to scan.</param>
 863    /// <returns>A de-duplicated list of enums to export.</returns>
 864    private static IEnumerable<Type> CollectExportableEnums(IEnumerable<Type> componentTypes)
 865    {
 63866        var enums = new HashSet<Type>();
 867
 142868        foreach (var componentType in componentTypes)
 869        {
 82870            foreach (var p in componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Dec
 871            {
 68872                foreach (var enumType in FindEnumsInType(p.PropertyType))
 873                {
 1874                    _ = enums.Add(enumType);
 875                }
 876            }
 877        }
 878
 63879        return enums;
 880    }
 881
 882    /// <summary>
 883    /// Finds any enum types within a possibly wrapped type (nullable/array/generic).
 884    /// </summary>
 885    /// <param name="t">Type to inspect.</param>
 886    /// <returns>Zero or more enum types found.</returns>
 887    private static IEnumerable<Type> FindEnumsInType(Type t)
 888    {
 889        // Nullable<T>
 38890        if (Nullable.GetUnderlyingType(t) is Type underlying)
 891        {
 4892            foreach (var e in FindEnumsInType(underlying))
 893            {
 0894                yield return e;
 895            }
 2896            yield break;
 897        }
 898
 899        // Arrays
 36900        if (t.IsArray)
 901        {
 6902            foreach (var e in FindEnumsInType(t.GetElementType()!))
 903            {
 0904                yield return e;
 905            }
 3906            yield break;
 907        }
 908
 909        // Generic arguments
 33910        if (t.IsGenericType)
 911        {
 0912            foreach (var arg in t.GetGenericArguments())
 913            {
 0914                foreach (var e in FindEnumsInType(arg))
 915                {
 0916                    yield return e;
 917                }
 918            }
 919        }
 920
 33921        if (t.IsEnum)
 922        {
 1923            yield return t;
 924        }
 33925    }
 926
 927    /// <summary>
 928    /// Appends a PowerShell enum definition for the specified .NET enum type.
 929    /// </summary>
 930    /// <param name="enumType">Enum type to emit.</param>
 931    /// <param name="sb">Output StringBuilder.</param>
 932    private static void AppendEnum(Type enumType, StringBuilder sb)
 933    {
 1934        if (!enumType.IsEnum)
 935        {
 0936            return;
 937        }
 938
 1939        var underlying = Enum.GetUnderlyingType(enumType);
 1940        var psUnderlying = ResolvePrimitiveTypeName(underlying) ?? underlying.FullName ?? "int";
 941
 1942        _ = sb.AppendLine($"enum {enumType.Name} {{");
 943
 8944        foreach (var name in Enum.GetNames(enumType))
 945        {
 3946            var rawValue = Enum.Parse(enumType, name);
 3947            var numericValue = Convert.ChangeType(rawValue, underlying, provider: System.Globalization.CultureInfo.Invar
 948
 949            // Always emit explicit values to preserve non-sequential enums.
 3950            _ = sb.AppendLine($"    {name} = [{psUnderlying}]{numericValue}");
 951        }
 952
 1953        _ = sb.AppendLine("}");
 1954    }
 955
 956    /// <summary>
 957    /// Resolves the PowerShell type name for OpenAPI array wrapper components.
 958    /// </summary>
 959    /// <param name="t">The .NET type to resolve.</param>
 960    /// <param name="componentSet">The set of known OpenAPI component types.</param>
 961    /// <param name="collapseToUnderlyingPrimitives">When true, types derived from OpenApiValue&lt;T&gt; are emitted as 
 962    /// <returns>The PowerShell type name for the array element if applicable; otherwise, null.</returns>
 963    private static string? ResolveElementArrayType(Type t, HashSet<Type> componentSet, bool collapseToUnderlyingPrimitiv
 964    {
 965        // OpenAPI schema component array wrappers:
 966        // Some PowerShell OpenAPI schemas are modeled as a component class with Array=$true,
 967        // typically inheriting from the element schema type (e.g. EventDates : Date).
 968        // When referenced as a property type, we want the PowerShell type constraint to be
 969        // the element array (e.g. [Date[]]) instead of the wrapper class ([EventDates]).
 970        // IMPORTANT: this must run before OpenApiValue<T> collapsing so wrappers don't lose their array-ness.
 46971        if (collapseToUnderlyingPrimitives && componentSet.Contains(t) && TryGetArrayComponentElementType(t, out var ele
 972        {
 973            // Guard against pathological self-references.
 1974            if (elementType == t)
 975            {
 0976                return t.Name;
 977            }
 978
 1979            var elementPsName = ToPowerShellTypeName(elementType, componentSet, collapseToUnderlyingPrimitives);
 1980            return $"{elementPsName}[]";
 981        }
 45982        return null;
 983    }
 984
 985    // Mapping of .NET primitive types to PowerShell type names.
 1986    private static readonly Dictionary<Type, string> PrimitiveTypeAliases =
 1987         new()
 1988         {
 1989             [typeof(bool)] = "bool",
 1990             [typeof(byte)] = "byte",
 1991             [typeof(sbyte)] = "sbyte",
 1992             [typeof(short)] = "short",
 1993             [typeof(ushort)] = "ushort",
 1994             [typeof(int)] = "int",
 1995             [typeof(uint)] = "uint",
 1996             [typeof(long)] = "long",
 1997             [typeof(ulong)] = "ulong",
 1998             [typeof(float)] = "float",
 1999             [typeof(double)] = "double",
 11000             [typeof(decimal)] = "decimal",
 11001             [typeof(char)] = "char",
 11002             [typeof(string)] = "string",
 11003             [typeof(object)] = "object",
 11004             [typeof(DateTime)] = "datetime",
 11005             [typeof(Guid)] = "guid",
 11006             [typeof(byte[])] = "byte[]"
 11007         };
 1008
 1009    /// <summary>
 1010    /// Resolves the PowerShell type name for common .NET primitive types.
 1011    /// </summary>
 1012    /// <param name="t">The .NET type to resolve.</param>
 1013    /// <returns>The PowerShell type name if the type is a recognized primitive; otherwise, null.</returns>
 1014    private static string? ResolvePrimitiveTypeName(Type t)
 1015    {
 1016        // unwrap nullable if needed
 391017        t = Nullable.GetUnderlyingType(t) ?? t;
 1018
 391019        return PrimitiveTypeAliases.TryGetValue(t, out var alias) ? alias : null;
 1020    }
 1021
 1022    private static bool TryGetOpenApiValueUnderlyingType(Type t, out Type? underlyingType)
 1023    {
 411024        underlyingType = null;
 1025
 1026        // Walk base types looking for OpenApiScalar<T> (preferred) or OpenApiValue<T> (legacy)
 1027        // by name to avoid hard coupling.
 1028        // OpenApiScalar<T> lives in Kestrun.Annotations and is in the global namespace.
 411029        var current = t;
 1030
 1111031        while (current is not null && current != typeof(object))
 1032        {
 761033            if (current.IsGenericType)
 1034            {
 61035                var def = current.GetGenericTypeDefinition();
 61036                if (string.Equals(def.Name, "OpenApiScalar`1", StringComparison.Ordinal) ||
 61037                    string.Equals(def.Name, "OpenApiValue`1", StringComparison.Ordinal))
 1038                {
 61039                    underlyingType = current.GetGenericArguments()[0];
 61040                    return true;
 1041                }
 1042            }
 1043
 701044            current = current.BaseType;
 1045        }
 1046
 351047        return false;
 1048    }
 1049
 1050    private static bool TryGetArrayComponentElementType(Type componentType, out Type? elementType)
 1051    {
 41052        elementType = null;
 1053
 1054        // We don't take a hard dependency on the annotation type here; this exporter
 1055        // may reflect PowerShell-generated assemblies. We detect the attribute by name
 1056        // and then read common properties via reflection.
 41057        var attr = componentType
 41058            .GetCustomAttributes(inherit: false)
 81059            .FirstOrDefault(a => a.GetType().Name.Contains("OpenApiSchemaComponent", StringComparison.OrdinalIgnoreCase)
 1060
 41061        if (attr is null)
 1062        {
 01063            return false;
 1064        }
 1065
 41066        var attrType = attr.GetType();
 41067        var arrayProp = attrType.GetProperty("Array");
 41068        if (arrayProp?.GetValue(attr) is not bool isArray || !isArray)
 1069        {
 31070            return false;
 1071        }
 1072
 1073        // Prefer explicit ItemsType if provided.
 11074        var itemsTypeProp = attrType.GetProperty("ItemsType");
 11075        if (itemsTypeProp?.GetValue(attr) is Type itemsType)
 1076        {
 01077            elementType = itemsType;
 01078            return true;
 1079        }
 1080
 1081        // Common PowerShell pattern: wrapper inherits from element schema.
 11082        var baseType = componentType.BaseType;
 11083        if (baseType is not null && baseType != typeof(object))
 1084        {
 11085            elementType = baseType;
 11086            return true;
 1087        }
 1088
 01089        return false;
 1090    }
 1091
 1092    /// <summary>
 1093    /// Topologically sort types so that dependencies (property types)
 1094    /// appear before the types that reference them.
 1095    /// </summary>
 1096    /// <param name="types">The list of types to sort.</param>
 1097    /// <param name="componentSet">Set of component types for quick lookup.</param>
 1098    /// <returns>The sorted list of types.</returns>
 1099    private static List<Type> TopologicalSortByPropertyDependencies(
 1100        List<Type> types,
 1101        HashSet<Type> componentSet)
 1102    {
 631103        var result = new List<Type>();
 631104        var visited = new Dictionary<Type, bool>(); // false = temp-mark, true = perm-mark
 1105
 1421106        foreach (var t in types)
 1107        {
 81108            Visit(t, componentSet, visited, result);
 1109        }
 1110
 631111        return result;
 1112    }
 1113
 1114    /// <summary>
 1115    /// Visits the type and its dependencies recursively for topological sorting.
 1116    /// </summary>
 1117    /// <param name="t">Type to visit</param>
 1118    /// <param name="componentSet">Set of component types</param>
 1119    /// <param name="visited">Dictionary tracking visited types and their mark status</param>
 1120    /// <param name="result">List to accumulate the sorted types</param>
 1121    private static void Visit(
 1122     Type t,
 1123     HashSet<Type> componentSet,
 1124     Dictionary<Type, bool> visited,
 1125     List<Type> result)
 1126    {
 131127        if (visited.TryGetValue(t, out var perm))
 1128        {
 51129            if (!perm)
 1130            {
 1131                // cycle; ignore for now
 51132                return;
 1133            }
 1134            return;
 1135        }
 1136
 1137        // temp-mark
 81138        visited[t] = false;
 1139
 81140        var deps = new List<Type>();
 1141
 1142        // 1) Dependencies via property types (component properties)
 81143        var propDeps = t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
 401144                        .Select(p => GetComponentDependencyType(p.PropertyType, componentSet))
 401145                        .Where(dep => dep is not null)
 31146                        .Select(dep => dep!)
 81147                        .Distinct();
 1148
 81149        deps.AddRange(propDeps);
 1150
 1151        // 2) Dependency via base type (parenting)
 81152        var baseType = t.BaseType;
 81153        if (baseType != null && componentSet.Contains(baseType))
 1154        {
 21155            deps.Add(baseType);
 1156        }
 1157
 261158        foreach (var dep in deps.Distinct())
 1159        {
 51160            Visit(dep, componentSet, visited, result);
 1161        }
 1162
 1163        // perm-mark
 81164        visited[t] = true;
 81165        result.Add(t);
 81166    }
 1167
 1168    private static Type? GetComponentDependencyType(Type propertyType, HashSet<Type> componentSet)
 1169    {
 1170        // Unwrap Nullable
 401171        if (Nullable.GetUnderlyingType(propertyType) is Type underlying)
 1172        {
 21173            propertyType = underlying;
 1174        }
 1175
 1176        // Unwrap arrays
 401177        if (propertyType.IsArray)
 1178        {
 31179            propertyType = propertyType.GetElementType()!;
 1180        }
 1181
 401182        return componentSet.Contains(propertyType) ? propertyType : null;
 1183    }
 1184
 1185    /// <summary>
 1186    /// Writes the OpenAPI class definitions to a temporary PowerShell script file.
 1187    /// </summary>
 1188    /// <param name="openApiClasses">The OpenAPI class definitions as a string.</param>
 1189    /// <returns>The path to the temporary PowerShell script file.</returns>
 1190    public static string WriteOpenApiTempScript(string openApiClasses)
 1191    {
 1192        // Use a stable file name so multiple runspaces share the same script
 41193        var tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ps1");
 1194
 1195        // Ensure directory exists
 41196        _ = Directory.CreateDirectory(Path.GetDirectoryName(tempPath)!);
 1197
 1198        // Build content with header
 41199        var sb = new StringBuilder()
 41200        .AppendLine("# ================================================")
 41201        .AppendLine("#   Kestrun OpenAPI Autogenerated Class Definitions")
 41202        .AppendLine("#   DO NOT EDIT - generated at runtime")
 41203        .Append("#   Timestamp: ").Append(DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append('Z').AppendLine()
 41204        .AppendLine("# ================================================")
 41205        .AppendLine()
 41206        .AppendLine("[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSProvideCommentHelp', '')]")
 41207        .AppendLine("param()")
 41208        .AppendLine(openApiClasses);
 1209
 1210        // Save using UTF-8 without BOM
 41211        File.WriteAllText(tempPath, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
 1212
 41213        return tempPath;
 1214    }
 1215}

Methods/Properties

get_ValidClassNames()
ExportOpenApiClasses(System.Collections.Generic.Dictionary`2<System.String,System.String>)
ExportOpenApiClasses(System.Reflection.Assembly[],System.Collections.Generic.Dictionary`2<System.String,System.String>)
AppendCallback(System.Text.StringBuilder,System.Collections.Generic.Dictionary`2<System.String,System.String>)
NormalizeBlankLines(System.String)
BuildCallbackFunctionStub(System.String,System.String)
TryExtractParamInfo(System.String)
ExtractPowerShellParamBlock(System.String)
TryConsumeQuoted(System.String,System.Int32&,Kestrun.Runtime.PowerShellOpenApiClassExporter/ScanState&)
ExtractBodyParameterName(System.String)
ExtractParamNamesFromStrippedParamBlock(System.String)
StripPowerShellAttributeBlocks(System.String)
HasOpenApiComponentAttribute(System.Type)
AppendClass(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Text.StringBuilder)
AppendOpenApiXmlMetadataProperty(System.Type,System.Reflection.PropertyInfo[],System.Text.StringBuilder)
BuildXmlMetadataHashtable(System.Object,System.Int32)
EscapePowerShellString(System.String)
ToPowerShellTypeName(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
GetNullableTypeName(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
GetOpenApiArrayWrapperTypeName(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
GetCollapsedOpenApiPrimitiveTypeName(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
GetEnumTypeName(System.Type)
GetPrimitiveTypeName(System.Type)
GetArrayTypeName(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
FormatComponentOrFallbackName(System.Type,System.Collections.Generic.HashSet`1<System.Type>)
CollectExportableEnums(System.Collections.Generic.IEnumerable`1<System.Type>)
FindEnumsInType()
AppendEnum(System.Type,System.Text.StringBuilder)
ResolveElementArrayType(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Boolean)
.cctor()
ResolvePrimitiveTypeName(System.Type)
TryGetOpenApiValueUnderlyingType(System.Type,System.Type&)
TryGetArrayComponentElementType(System.Type,System.Type&)
TopologicalSortByPropertyDependencies(System.Collections.Generic.List`1<System.Type>,System.Collections.Generic.HashSet`1<System.Type>)
Visit(System.Type,System.Collections.Generic.HashSet`1<System.Type>,System.Collections.Generic.Dictionary`2<System.Type,System.Boolean>,System.Collections.Generic.List`1<System.Type>)
GetComponentDependencyType(System.Type,System.Collections.Generic.HashSet`1<System.Type>)
WriteOpenApiTempScript(System.String)