< Summary

Information
Class: NexusLabs.Needlr.Generators.GeneratorHelpers
Assembly: NexusLabs.Needlr.Generators
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Generators/GeneratorHelpers.cs
Line coverage
90%
Covered lines: 117
Uncovered lines: 13
Coverable lines: 130
Total lines: 383
Line coverage: 90%
Branch coverage
81%
Covered branches: 91
Total branches: 112
Branch coverage: 81.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

File(s)

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

#LineLine coverage
 1// Copyright (c) NexusLabs. All rights reserved.
 2// Licensed under the MIT License.
 3
 4using System;
 5using System.Collections.Generic;
 6using System.Linq;
 7using System.Text;
 8
 9using Microsoft.CodeAnalysis.CSharp;
 10
 11namespace NexusLabs.Needlr.Generators;
 12
 13/// <summary>
 14/// Utility methods for source code generation.
 15/// </summary>
 16internal static class GeneratorHelpers
 17{
 18    /// <summary>
 19    /// Formats an integer for emission into generated source or JSON.
 20    /// </summary>
 21    /// <remarks>
 22    /// String interpolation uses the ambient culture, and several locales render a
 23    /// negative number with U+2212 MINUS SIGN, which is not valid C#. Every number that
 24    /// reaches generated output must go through this method. A generator cannot pin the
 25    /// ambient culture instead: <c>RS1035</c> bans analyzers from touching
 26    /// <c>CultureInfo.CurrentCulture</c>.
 27    /// </remarks>
 28    public static string Literal(int value)
 29    {
 27373430        return value.ToString(System.Globalization.CultureInfo.InvariantCulture);
 31    }
 32
 33    /// <summary>
 34    /// Sanitizes an assembly name to be a valid C# identifier for use in namespaces.
 35    /// </summary>
 36    public static string SanitizeIdentifier(string name)
 37    {
 10189038        if (string.IsNullOrEmpty(name))
 039            return "Generated";
 40
 10189041        var sb = new StringBuilder(name.Length);
 517008242        foreach (var c in name)
 43        {
 248315144            if (char.IsLetterOrDigit(c) || c == '_')
 45            {
 228506846                sb.Append(c);
 47            }
 19808348            else if (c == '.' || c == '-' || c == ' ')
 49            {
 50                // Keep dots for namespace segments, replace dashes/spaces with underscores
 19808351                sb.Append(c == '.' ? '.' : '_');
 52            }
 53            // Skip other characters
 54        }
 55
 10189056        var result = sb.ToString();
 57
 58        // Ensure each segment doesn't start with a digit
 10189059        var segments = result.Split('.');
 80372660        for (int i = 0; i < segments.Length; i++)
 61        {
 29997362            if (segments[i].Length > 0 && char.IsDigit(segments[i][0]))
 63            {
 064                segments[i] = "_" + segments[i];
 65            }
 66        }
 67
 40186368        return string.Join(".", segments.Where(s => s.Length > 0));
 69    }
 70
 71    /// <summary>
 72    /// Escapes a C# keyword or contextual keyword for use as an identifier.
 73    /// </summary>
 74    public static string EscapeIdentifier(string name)
 75    {
 96176        return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None ||
 96177            SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None
 96178                ? "@" + name
 96179                : name;
 80    }
 81
 82    /// <summary>
 83    /// Escapes a string for use in a regular C# string literal.
 84    /// </summary>
 85    public static string EscapeStringLiteral(string value)
 86    {
 102485387        if (string.IsNullOrEmpty(value))
 10988            return string.Empty;
 102474489        return value.Replace("\\", "\\\\").Replace("\"", "\\\"");
 90    }
 91
 92    /// <summary>
 93    /// Escapes a string for use in a verbatim C# string literal.
 94    /// </summary>
 95    public static string EscapeVerbatimStringLiteral(string value)
 96    {
 54597        if (string.IsNullOrEmpty(value))
 098            return string.Empty;
 99        // In verbatim strings, only double-quotes need escaping (by doubling them)
 545100        return value.Replace("\"", "\"\"");
 101    }
 102
 103    /// <summary>
 104    /// Escapes content for use in XML documentation.
 105    /// </summary>
 106    public static string EscapeXmlContent(string content)
 107    {
 108        // The content from GetDocumentationCommentXml() is already parsed,
 109        // so entities like &lt; are already decoded. We need to re-encode them.
 7110        return content
 7111            .Replace("&", "&amp;")
 7112            .Replace("<", "&lt;")
 7113            .Replace(">", "&gt;")
 7114            .Replace("\"", "&quot;")
 7115            .Replace("'", "&apos;");
 116    }
 117
 118    /// <summary>
 119    /// Gets the simple type name from a fully qualified name.
 120    /// "global::System.String" -> "String"
 121    /// </summary>
 122    public static string GetSimpleTypeName(string fullyQualifiedName)
 123    {
 0124        var parts = fullyQualifiedName.Split('.');
 0125        return parts[parts.Length - 1];
 126    }
 127
 128    /// <summary>
 129    /// Converts a name to camelCase, removing leading 'I' for interfaces.
 130    /// </summary>
 131    public static string ToCamelCase(string name)
 132    {
 44133        if (string.IsNullOrEmpty(name))
 0134            return name;
 135
 136        // Remove leading 'I' for interfaces
 44137        if (name.Length > 1 && name[0] == 'I' && char.IsUpper(name[1]))
 0138            name = name.Substring(1);
 139
 44140        return char.ToLowerInvariant(name[0]) + name.Substring(1);
 141    }
 142
 143    /// <summary>
 144    /// Strips the "global::" prefix from a type name if present.
 145    /// </summary>
 146    public static string StripGlobalPrefix(string name)
 147    {
 18501148        return name.StartsWith("global::", StringComparison.Ordinal)
 18501149            ? name.Substring(8)
 18501150            : name;
 151    }
 152
 153    /// <summary>
 154    /// Gets the short type name from a fully qualified name.
 155    /// Removes global:: prefix and namespace.
 156    /// </summary>
 157    public static string GetShortTypeName(string fullyQualifiedTypeName)
 158    {
 12178814159        var name = fullyQualifiedTypeName;
 12178814160        if (name.StartsWith("global::", StringComparison.Ordinal))
 12087921161            name = name.Substring(8);
 162
 163        // For generic types, find the last dot before the generic type parameter
 164        // e.g., "Microsoft.Extensions.Options.IOptions<Foo.Bar>" -> find dot before "IOptions"
 12178814165        var genericStart = name.IndexOf('<');
 166
 12178814167        if (genericStart < 0)
 168        {
 169            // Non-generic type: just find the last dot
 12172002170            var lastDot = name.LastIndexOf('.');
 12172002171            return lastDot >= 0 ? name.Substring(lastDot + 1) : name;
 172        }
 173
 174        // Generic type: shorten the outer type and recursively shorten type arguments
 6812175        var lastDot2 = name.LastIndexOf('.', genericStart - 1);
 6812176        var outerType = lastDot2 >= 0 ? name.Substring(lastDot2 + 1, genericStart - lastDot2 - 1) : name.Substring(0, ge
 177
 178        // Extract and shorten the type arguments
 6812179        var genericEnd = name.LastIndexOf('>');
 6812180        if (genericEnd > genericStart)
 181        {
 6812182            var typeArgsStr = name.Substring(genericStart + 1, genericEnd - genericStart - 1);
 6812183            var shortenedArgs = ShortenGenericTypeArgs(typeArgsStr);
 6812184            return $"{outerType}<{shortenedArgs}>";
 185        }
 186
 0187        return outerType;
 188    }
 189
 190    private static string ShortenGenericTypeArgs(string typeArgsStr)
 191    {
 192        // Handle multiple type arguments and nested generics
 6812193        var result = new System.Text.StringBuilder();
 6812194        var depth = 0;
 6812195        var start = 0;
 196
 435754197        for (int i = 0; i < typeArgsStr.Length; i++)
 198        {
 211065199            var c = typeArgsStr[i];
 212137200            if (c == '<') depth++;
 211065201            else if (c == '>') depth--;
 208921202            else if (c == ',' && depth == 0)
 203            {
 204                // Found a top-level comma, process this argument
 1440205                var arg = typeArgsStr.Substring(start, i - start).Trim();
 1442206                if (result.Length > 0) result.Append(", ");
 1440207                result.Append(GetShortTypeName(arg));
 1440208                start = i + 1;
 209            }
 210        }
 211
 212        // Process the last (or only) argument
 6812213        var lastArg = typeArgsStr.Substring(start).Trim();
 8250214        if (result.Length > 0) result.Append(", ");
 6812215        result.Append(GetShortTypeName(lastArg));
 216
 6812217        return result.ToString();
 218    }
 219
 220    /// <summary>
 221    /// Gets the proxy type name for an intercepted service.
 222    /// </summary>
 223    public static string GetProxyTypeName(string fullyQualifiedTypeName)
 224    {
 28225        var shortName = GetShortTypeName(fullyQualifiedTypeName);
 28226        return $"{shortName}_InterceptorProxy";
 227    }
 228
 229    /// <summary>
 230    /// Extracts the generic type argument from a generic type name.
 231    /// E.g., "Task&lt;string&gt;" -> "string"
 232    /// </summary>
 233    public static string ExtractGenericTypeArgument(string genericTypeName)
 234    {
 1235        var openBracket = genericTypeName.IndexOf('<');
 1236        var closeBracket = genericTypeName.LastIndexOf('>');
 1237        if (openBracket >= 0 && closeBracket > openBracket)
 238        {
 1239            return genericTypeName.Substring(openBracket + 1, closeBracket - openBracket - 1);
 240        }
 0241        return "object";
 242    }
 243
 244    /// <summary>
 245    /// Gets the base name of a generic type (without type arguments).
 246    /// E.g., "IHandler&lt;Order&gt;" -> "IHandler"
 247    /// </summary>
 248    public static string GetGenericBaseName(string typeName)
 249    {
 21250        var angleBracketIndex = typeName.IndexOf('<');
 21251        return angleBracketIndex >= 0 ? typeName.Substring(0, angleBracketIndex) : typeName;
 252    }
 253
 254    /// <summary>
 255    /// Creates a closed generic type name from an open generic decorator and a closed interface.
 256    /// For example: LoggingDecorator{T} + IHandler{Order} = LoggingDecorator{Order}
 257    /// </summary>
 258    public static string CreateClosedGenericType(string openDecoratorTypeName, string closedInterfaceName, string openIn
 259    {
 7260        var closedArgs = ExtractGenericArguments(closedInterfaceName);
 7261        var openDecoratorBaseName = GetGenericBaseName(openDecoratorTypeName);
 262
 7263        if (closedArgs.Length == 0)
 0264            return openDecoratorTypeName;
 265
 7266        return $"{openDecoratorBaseName}<{string.Join(", ", closedArgs)}>";
 267    }
 268
 269    /// <summary>
 270    /// Extracts the generic type arguments from a closed generic type name.
 271    /// For example: "IHandler{Order, Payment}" returns ["Order", "Payment"]
 272    /// </summary>
 273    public static string[] ExtractGenericArguments(string typeName)
 274    {
 7275        var angleBracketIndex = typeName.IndexOf('<');
 7276        if (angleBracketIndex < 0)
 0277            return Array.Empty<string>();
 278
 7279        var argsStart = angleBracketIndex + 1;
 7280        var argsEnd = typeName.LastIndexOf('>');
 7281        if (argsEnd <= argsStart)
 0282            return Array.Empty<string>();
 283
 7284        var argsString = typeName.Substring(argsStart, argsEnd - argsStart);
 285
 286        // Handle nested generics by parsing with bracket depth tracking
 7287        var args = new List<string>();
 7288        var depth = 0;
 7289        var start = 0;
 290
 474291        for (int i = 0; i < argsString.Length; i++)
 292        {
 230293            var c = argsString[i];
 230294            if (c == '<') depth++;
 230295            else if (c == '>') depth--;
 230296            else if (c == ',' && depth == 0)
 297            {
 1298                args.Add(argsString.Substring(start, i - start).Trim());
 1299                start = i + 1;
 300            }
 301        }
 302
 303        // Add the last argument
 7304        if (start < argsString.Length)
 7305            args.Add(argsString.Substring(start).Trim());
 306
 7307        return args.ToArray();
 308    }
 309
 310    /// <summary>
 311    /// Converts a type name to a valid Mermaid node ID.
 312    /// </summary>
 313    public static string GetMermaidNodeId(string typeName)
 314    {
 69300315        return SanitizeMermaidId(GetShortTypeName(typeName));
 316    }
 317
 318    /// <summary>
 319    /// Converts arbitrary text into a valid Mermaid identifier by replacing every character
 320    /// that is not an ASCII letter, digit, or underscore.
 321    /// </summary>
 322    public static string SanitizeMermaidId(string value)
 323    {
 69490324        if (string.IsNullOrEmpty(value))
 0325            return "_";
 326
 69490327        var sb = new StringBuilder(value.Length);
 2476042328        foreach (var c in value)
 329        {
 1168531330            var isSafe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
 1168531331            sb.Append(isSafe ? c : '_');
 332        }
 333
 69490334        return sb.ToString();
 335    }
 336
 337    /// <summary>
 338    /// Escapes text used inside a quoted Mermaid node or subgraph label.
 339    /// </summary>
 340    public static string EscapeMermaidLabel(string label)
 341    {
 190342        return label
 190343            .Replace("\"", "#quot;")
 190344            .Replace("\r", " ")
 190345            .Replace("\n", " ");
 346    }
 347
 348    /// <summary>
 349    /// Escapes text used inside a Markdown pipe table cell so the table structure is preserved.
 350    /// </summary>
 351    public static string EscapeMarkdownTableCell(string value)
 352    {
 8353        return value
 8354            .Replace("|", "\\|")
 8355            .Replace("\r", " ")
 8356            .Replace("\n", " ");
 357    }
 358
 359    /// <summary>
 360    /// Calculates a percentage, handling division by zero.
 361    /// </summary>
 362    public static int Percentage(int count, int total)
 363    {
 381364        if (total == 0) return 0;
 381365        return (int)Math.Round(100.0 * count / total);
 366    }
 367
 368    /// <summary>
 369    /// Extracts the namespace portion from a fully-qualified type name.
 370    /// Strips the leading <c>global::</c> prefix if present.
 371    /// </summary>
 372    public static string GetNamespaceFromTypeName(string fullyQualifiedName)
 373    {
 12374        var name = fullyQualifiedName;
 12375        if (name.StartsWith("global::"))
 376        {
 12377            name = name.Substring(8);
 378        }
 379
 12380        var lastDot = name.LastIndexOf('.');
 12381        return lastDot >= 0 ? name.Substring(0, lastDot) : string.Empty;
 382    }
 383}