< Summary

Information
Class: Allyaria.Abstractions.Extensions.StringExtensions
Assembly: Allyaria.Abstractions
File(s): /home/runner/work/allyaria/allyaria/src/Allyaria.Abstractions/Extensions/StringExtensions.cs
Line coverage
100%
Covered lines: 221
Uncovered lines: 0
Coverable lines: 221
Total lines: 577
Line coverage: 100%
Branch coverage
100%
Covered branches: 92
Total branches: 92
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
Capitalize(...)100%66100%
CollapseSeparatorRegex(...)100%11100%
FromCamelCase(...)100%22100%
FromKebabCase(...)100%22100%
FromPascalCase(...)100%22100%
FromPrefixedCase(...)100%1010100%
FromSnakeCase(...)100%22100%
NormalizeAccents(...)100%66100%
OrDefaultIfEmpty(...)100%22100%
OrDefaultIfNull(...)100%22100%
OrNull(...)100%22100%
ReplaceAndCollapseSeparators(...)100%11100%
SplitConcatenated(...)100%1616100%
ToCamelCase(...)100%1616100%
ToCssName(...)100%22100%
ToKebabCase(...)100%22100%
ToPascalCase(...)100%88100%
ToSnakeCase(...)100%22100%
TryParseEnum(...)100%1010100%

File(s)

/home/runner/work/allyaria/allyaria/src/Allyaria.Abstractions/Extensions/StringExtensions.cs

#LineLine coverage
 1namespace Allyaria.Abstractions.Extensions;
 2
 3/// <summary>
 4/// Provides extension methods for working with <see cref="string" /> values, including PascalCase ⇄ spaced-words
 5/// conversion, culture-aware capitalization, diacritic (accent) removal, and case-style conversions (camel/snake/kebab)
 6/// </summary>
 7/// <remarks>All methods are null/whitespace-safe and return <see cref="string.Empty" /> when appropriate.</remarks>
 8public static class StringExtensions
 9{
 10    /// <summary>
 11    /// Regular expression used to validate camelCase identifiers: must start with a lowercase ASCII letter and contain 
 12    /// ASCII letters and digits.
 13    /// </summary>
 14    /// <remarks>Pattern: <c>^[a-z][A-Za-z0-9]*$</c></remarks>
 515    private static readonly Regex CamelCaseIdentifierRegex = new(
 516        pattern: "^[a-z][A-Za-z0-9]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant
 517    );
 18
 19    /// <summary>
 20    /// Regular expression used to validate kebab-case identifiers: must start with an ASCII letter and then contain onl
 21    /// letters, digits, or hyphens.
 22    /// </summary>
 23    /// <remarks>Pattern: <c>^[A-Za-z][A-Za-z0-9-]*$</c></remarks>
 524    private static readonly Regex KebabCaseIdentifierRegex = new(
 525        pattern: "^[A-Za-z][A-Za-z0-9-]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant
 526    );
 27
 28    /// <summary>
 29    /// Regular expression used to validate PascalCase identifiers: must start with an uppercase ASCII letter and contai
 30    /// ASCII letters and digits.
 31    /// </summary>
 32    /// <remarks>Pattern: <c>^[A-Z][A-Za-z0-9]*$</c></remarks>
 533    private static readonly Regex PascalCaseIdentifierRegex = new(
 534        pattern: "^[A-Z][A-Za-z0-9]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant
 535    );
 36
 37    /// <summary>
 38    /// Regular expression used to validate snake_case identifiers: must start with an ASCII letter and then contain onl
 39    /// letters, digits, or underscores.
 40    /// </summary>
 41    /// <remarks>Pattern: <c>^[A-Za-z][A-Za-z0-9_]*$</c></remarks>
 542    private static readonly Regex SnakeCaseIdentifierRegex = new(
 543        pattern: "^[A-Za-z][A-Za-z0-9_]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant
 544    );
 45
 46    /// <summary>
 47    /// Produces a word whose first character is uppercase and remaining characters are lowercase, honoring the specifie
 48    /// culture's casing rules.
 49    /// </summary>
 50    public static string Capitalize(this string? word, CultureInfo? culture = null)
 51    {
 33852        if (string.IsNullOrEmpty(value: word))
 53        {
 454            return string.Empty;
 55        }
 56
 33457        var ci = culture ?? CultureInfo.InvariantCulture;
 58
 33459        return word.Length == 1
 33460            ? char.ToUpper(c: word[index: 0], culture: ci).ToString(provider: ci)
 33461            : char.ToUpper(c: word[index: 0], culture: ci) + word[1..].ToLower(culture: ci);
 62    }
 63
 64    /// <summary>
 65    /// Builds a compiled regular expression that matches one or more occurrences of the supplied separator character, f
 66    /// collapsing into a single instance.
 67    /// </summary>
 68    /// <remarks>Example: for <c>'-'</c>, the effective pattern is <c>"-+"</c>; for <c>'_'</c>, <c>"_+"</c>.</remarks>
 69    private static Regex CollapseSeparatorRegex(char replaceChar)
 21270        => new(
 21271            pattern: $"[{Regex.Escape(str: replaceChar.ToString())}]+",
 21272            options: RegexOptions.Compiled | RegexOptions.CultureInvariant
 21273        );
 74
 75    /// <summary>Converts a camelCase identifier into a human-readable string with spaces.</summary>
 76    /// <exception cref="AryArgumentException">Thrown when the trimmed input is not a valid camelCase identifier.</excep
 77    public static string FromCamelCase(this string? value)
 78    {
 3779        if (string.IsNullOrWhiteSpace(value: value))
 80        {
 681            return string.Empty;
 82        }
 83
 3184        var text = value.Trim();
 3185        var argName = nameof(value);
 86
 3187        AryGuard.Check(
 3188            condition: CamelCaseIdentifierRegex.IsMatch(input: text),
 3189            argName: argName,
 3190            message:
 3191            $"{argName} must be a camelCase identifier (start with a lowercase letter; letters and digits only)"
 3192        );
 93
 2594        return SplitConcatenated(value: text);
 95    }
 96
 97    /// <summary>Converts a kebab-case identifier into a human-readable string with spaces.</summary>
 98    /// <exception cref="AryArgumentException">Thrown when the trimmed input is not a valid kebab-case identifier.</exce
 99    public static string FromKebabCase(this string? value)
 100    {
 226101        if (string.IsNullOrWhiteSpace(value: value))
 102        {
 6103            return string.Empty;
 104        }
 105
 220106        var text = value.Trim();
 220107        var argName = nameof(value);
 108
 220109        AryGuard.Check(
 220110            condition: KebabCaseIdentifierRegex.IsMatch(input: text),
 220111            argName: argName,
 220112            message:
 220113            $"{argName} must be a kebab-case identifier (start with a letter; letters, digits, and hyphens only)"
 220114        );
 115
 198116        return ReplaceAndCollapseSeparators(value: text, replaceChar: '-');
 117    }
 118
 119    /// <summary>Converts a PascalCase identifier into a human-readable string with spaces.</summary>
 120    /// <exception cref="AryArgumentException">Thrown when the trimmed input is not a valid PascalCase identifier.</exce
 121    public static string FromPascalCase(this string? value)
 122    {
 53123        if (string.IsNullOrWhiteSpace(value: value))
 124        {
 8125            return string.Empty;
 126        }
 127
 45128        var text = value.Trim();
 45129        var argName = nameof(value);
 130
 45131        AryGuard.Check(
 45132            condition: PascalCaseIdentifierRegex.IsMatch(input: text),
 45133            argName: argName,
 45134            message:
 45135            $"{argName} must be a PascalCase identifier (start with an uppercase letter; letters and digits only)"
 45136        );
 137
 39138        return SplitConcatenated(value: text);
 139    }
 140
 141    /// <summary>
 142    /// Attempts to detect the naming convention of an identifier that may include an optional leading prefix (<c>_</c> 
 143    /// one/more <c>-</c>) and converts it into a human-readable string.
 144    /// </summary>
 145    /// <exception cref="AryArgumentException">
 146    /// Thrown when the trimmed input cannot be classified as PascalCase, camelCase, snake_case, or kebab-case.
 147    /// </exception>
 148    public static string FromPrefixedCase(this string? value)
 149    {
 110150        if (string.IsNullOrWhiteSpace(value: value))
 151        {
 10152            return string.Empty;
 153        }
 154
 100155        var core = value.Trim().TrimStart('_', '-');
 100156        var argName = nameof(value);
 157
 100158        AryGuard.Check(
 100159            condition: core.Length is not 0,
 100160            argName: argName,
 100161            message: $"{argName} cannot be reduced to a valid identifier."
 100162        );
 163
 96164        if (PascalCaseIdentifierRegex.IsMatch(input: core))
 165        {
 13166            return FromPascalCase(value: core);
 167        }
 168
 83169        if (CamelCaseIdentifierRegex.IsMatch(input: core))
 170        {
 21171            return FromCamelCase(value: core);
 172        }
 173
 62174        if (SnakeCaseIdentifierRegex.IsMatch(input: core))
 175        {
 2176            return FromSnakeCase(value: core);
 177        }
 178
 60179        if (KebabCaseIdentifierRegex.IsMatch(input: core))
 180        {
 57181            return FromKebabCase(value: core);
 182        }
 183
 3184        throw new AryArgumentException(
 3185            message:
 3186            "Input must be PascalCase, camelCase, snake_case, or kebab-case (with optional leading '_' or '-').",
 3187            argName: nameof(value),
 3188            argValue: value
 3189        );
 190    }
 191
 192    /// <summary>Converts a snake_case identifier into a human-readable string with spaces.</summary>
 193    /// <exception cref="AryArgumentException">Thrown when the trimmed input is not a valid snake_case identifier.</exce
 194    public static string FromSnakeCase(this string? value)
 195    {
 18196        if (string.IsNullOrWhiteSpace(value: value))
 197        {
 6198            return string.Empty;
 199        }
 200
 12201        var text = value.Trim();
 12202        var argName = nameof(value);
 203
 12204        AryGuard.Check(
 12205            condition: SnakeCaseIdentifierRegex.IsMatch(input: text),
 12206            argName: argName,
 12207            message:
 12208            $"{argName} must be a snake_case identifier (start with a letter; letters, digits, and underscores only)"
 12209        );
 210
 6211        return ReplaceAndCollapseSeparators(value: text, replaceChar: '_');
 212    }
 213
 214    /// <summary>Removes diacritic marks (accents) from a string.</summary>
 215    public static string NormalizeAccents(this string? value)
 216    {
 370217        if (string.IsNullOrWhiteSpace(value: value))
 218        {
 4219            return string.Empty;
 220        }
 221
 366222        var normalized = value.Normalize(normalizationForm: NormalizationForm.FormD);
 366223        var sb = new StringBuilder(capacity: normalized.Length);
 224
 4552225        foreach (var c in normalized)
 226        {
 1910227            if (CharUnicodeInfo.GetUnicodeCategory(ch: c) != UnicodeCategory.NonSpacingMark)
 228            {
 1882229                sb.Append(value: c);
 230            }
 231        }
 232
 366233        return sb.ToString().Normalize(normalizationForm: NormalizationForm.FormC);
 234    }
 235
 236    /// <summary>
 237    /// Returns the provided string if it is not <c>null</c>, empty, or white space; otherwise, returns the specified de
 238    /// value, or <see cref="string.Empty" /> if no default is provided.
 239    /// </summary>
 240    /// <param name="value">The string to evaluate.</param>
 241    /// <param name="defaultValue">
 242    /// The value to return when <paramref name="value" /> is <c>null</c>, empty, or white space. If omitted,
 243    /// <see cref="string.Empty" /> is used.
 244    /// </param>
 245    /// <returns>
 246    /// <paramref name="value" /> when it is not <c>null</c>, empty, or white space; otherwise,
 247    /// <paramref name="defaultValue" />.
 248    /// </returns>
 249    public static string OrDefaultIfEmpty(this string? value, string defaultValue = "")
 1361413250        => string.IsNullOrWhiteSpace(value: value)
 1361413251            ? defaultValue
 1361413252            : value;
 253
 254    /// <summary>
 255    /// Returns the provided string if it is not <c>null</c>; otherwise, returns the specified default value, or
 256    /// <see cref="string.Empty" /> if no default is provided.
 257    /// </summary>
 258    /// <param name="value">The string to evaluate.</param>
 259    /// <param name="defaultValue">
 260    /// The value to return when <paramref name="value" /> is <c>null</c>. If omitted, <see cref="string.Empty" /> is us
 261    /// </param>
 262    /// <returns><paramref name="value" /> when it is not <c>null</c>; otherwise, <paramref name="defaultValue" />.</ret
 6263    public static string OrDefaultIfNull(this string? value, string defaultValue = "") => value ?? defaultValue;
 264
 265    /// <summary>
 266    /// Returns <c>null</c> when the specified string is <c>null</c>, empty, or consists only of white-space; otherwise 
 267    /// the original string.
 268    /// </summary>
 269    /// <param name="value">The input string to evaluate.</param>
 270    /// <returns>
 271    /// <c>null</c> if <paramref name="value" /> is <c>null</c>, empty, or white-space; otherwise the original
 272    /// <paramref name="value" />.
 273    /// </returns>
 274    public static string? OrNull(this string? value)
 58275        => string.IsNullOrWhiteSpace(value: value)
 58276            ? null
 58277            : value;
 278
 279    /// <summary>
 280    /// Replaces all occurrences of a specified separator character in the input string with spaces, collapsing multiple
 281    /// consecutive separators into a single space.
 282    /// </summary>
 283    private static string ReplaceAndCollapseSeparators(string value, char replaceChar)
 284    {
 204285        var text = value.Trim();
 286
 204287        return CollapseSeparatorRegex(replaceChar: replaceChar).Replace(input: text, replacement: " ").Trim();
 288    }
 289
 290    /// <summary>
 291    /// Inserts spaces at inferred word boundaries within a concatenated identifier, preserving acronyms (e.g.,
 292    /// <c>HTTPRequest</c> → <c>HTTP Request</c>).
 293    /// </summary>
 294    private static string SplitConcatenated(string value)
 295    {
 66296        var sb = new StringBuilder(capacity: value.Length + 8);
 297
 1150298        for (var i = 0; i < value.Length; i++)
 299        {
 509300            var c = value[index: i];
 301
 509302            if (char.IsUpper(c: c) && i > 0)
 303            {
 35304                var prev = value[index: i - 1];
 305
 306                // Insert a space at boundaries:
 307                // 1) Prev is lowercase or digit (…aA… or …1A…)
 308                // 2) Current is uppercase and next is lowercase (…HTTPs… → "HTTP s")
 35309                if (!char.IsUpper(c: prev) ||
 35310                    (i + 1 < value.Length && char.IsLower(c: value[index: i + 1])))
 311                {
 29312                    if (sb.Length > 0 && sb[^1] != ' ')
 313                    {
 29314                        sb.Append(value: ' ');
 315                    }
 316                }
 317            }
 318
 509319            sb.Append(value: c);
 320        }
 321
 66322        return sb.ToString().Trim();
 323    }
 324
 325    /// <summary>Converts a string into camelCase form (first letter lowercased, subsequent words capitalized).</summary
 326    public static string ToCamelCase(this string? value)
 327    {
 18328        if (string.IsNullOrWhiteSpace(value: value))
 329        {
 10330            return string.Empty;
 331        }
 332
 8333        var text = value.Trim();
 334
 335        // If there is no whitespace, infer token boundaries from concatenated identifiers (e.g., "XMLHttpRequest" -> "X
 8336        var tokenSource = text.IndexOfAny(
 8337            anyOf:
 8338            [
 8339                ' ',
 8340                '\t',
 8341                '\r',
 8342                '\n'
 8343            ]
 8344        ) >= 0
 8345            ? text
 8346            : SplitConcatenated(value: text);
 347
 8348        var words = tokenSource.Split(
 8349            separator:
 8350            [
 8351                ' ',
 8352                '\t',
 8353                '\r',
 8354                '\n'
 8355            ], options: StringSplitOptions.RemoveEmptyEntries
 8356        );
 357
 8358        var sb = new StringBuilder(capacity: text.Length);
 359
 48360        for (var i = 0; i < words.Length; i++)
 361        {
 16362            var w = words[i].NormalizeAccents().Trim();
 363
 16364            if (w.Length == 0)
 365            {
 366                continue;
 367            }
 368
 14369            if (i == 0)
 370            {
 371                // First token: if it's an acronym, lower it fully; otherwise lower first char and the rest.
 6372                if (w.All(predicate: char.IsUpper))
 373                {
 4374                    sb.Append(value: w.ToLowerInvariant());
 375                }
 376                else
 377                {
 2378                    sb.Append(value: char.ToLower(c: w[index: 0], culture: CultureInfo.InvariantCulture));
 379
 2380                    if (w.Length > 1)
 381                    {
 2382                        sb.Append(value: w[1..].ToLower(culture: CultureInfo.InvariantCulture));
 383                    }
 384                }
 385            }
 386            else
 387            {
 388                // Subsequent tokens: TitleCase the lower-cased token (acronyms like HTTP -> Http).
 8389                var lower = w.ToLower(culture: CultureInfo.InvariantCulture);
 8390                sb.Append(value: char.ToUpper(c: lower[index: 0], culture: CultureInfo.InvariantCulture));
 391
 8392                if (lower.Length > 1)
 393                {
 8394                    sb.Append(value: lower[1..]);
 395                }
 396            }
 397        }
 398
 8399        return sb.ToString();
 400    }
 401
 402    /// <summary>Converts the current string into a normalized CSS-compatible name.</summary>
 403    /// <param name="name">The string to normalize into a CSS-compatible identifier.</param>
 404    /// <returns>A lowercase, hyphenated CSS-friendly name (e.g., <c>"Font_Size"</c> → <c>"font-size"</c>).</returns>
 405    public static string ToCssName(this string? name)
 333406        => Regex.Replace(
 333407                input: (name ?? string.Empty).Replace(oldChar: '_', newChar: '-'), pattern: @"[\s-]+", replacement: "-"
 333408            ).Trim(trimChar: '-')
 333409            .ToLowerInvariant();
 410
 411    /// <summary>Converts a string into kebab-case form (words separated by hyphens, lowercased).</summary>
 412    public static string ToKebabCase(this string? value)
 413    {
 8414        if (string.IsNullOrWhiteSpace(value: value))
 415        {
 4416            return string.Empty;
 417        }
 418
 4419        var cleaned = value.Trim().NormalizeAccents();
 420
 4421        var withHyphens = Regex.Replace(
 4422            input: cleaned,
 4423            pattern: "\\s+",
 4424            replacement: "-",
 4425            options: RegexOptions.CultureInvariant
 4426        );
 427
 4428        var collapsed = CollapseSeparatorRegex(replaceChar: '-').Replace(input: withHyphens, replacement: "-");
 429
 4430        return collapsed.ToLowerInvariant();
 431    }
 432
 433    /// <summary>Converts a string into PascalCase, preserving acronyms.</summary>
 434    public static string ToPascalCase(this string? value)
 435    {
 153436        if (string.IsNullOrWhiteSpace(value: value))
 437        {
 8438            return string.Empty;
 439        }
 440
 145441        var words = value.Split(
 145442            separator:
 145443            [
 145444                ' ',
 145445                '\t',
 145446                '\r',
 145447                '\n'
 145448            ], options: StringSplitOptions.RemoveEmptyEntries
 145449        );
 450
 145451        var sb = new StringBuilder(capacity: value.Length);
 452
 962453        foreach (var word in words)
 454        {
 336455            var normalized = word.NormalizeAccents().Trim();
 456
 336457            if (normalized.Length == 0)
 458            {
 459                continue;
 460            }
 461
 334462            sb.Append(
 334463                value: normalized.All(predicate: char.IsUpper)
 334464                    ? normalized
 334465                    : Capitalize(word: normalized, culture: CultureInfo.InvariantCulture)
 334466            );
 467        }
 468
 145469        return sb.ToString();
 470    }
 471
 472    /// <summary>Converts a string into snake_case form (words separated by underscores, lowercased).</summary>
 473    public static string ToSnakeCase(this string? value)
 474    {
 8475        if (string.IsNullOrWhiteSpace(value: value))
 476        {
 4477            return string.Empty;
 478        }
 479
 4480        var cleaned = value.Trim().NormalizeAccents();
 481
 4482        var withUnderscores = Regex.Replace(
 4483            input: cleaned,
 4484            pattern: "\\s+",
 4485            replacement: "_",
 4486            options: RegexOptions.CultureInvariant
 4487        );
 488
 4489        var collapsed = CollapseSeparatorRegex(replaceChar: '_').Replace(input: withUnderscores, replacement: "_");
 490
 4491        return collapsed.ToLowerInvariant();
 492    }
 493
 494    /// <summary>
 495    /// Attempts to parse the specified string into an enumeration value of type <typeparamref name="TEnum" />.
 496    /// </summary>
 497    /// <typeparam name="TEnum">
 498    /// The target enumeration type to parse. Must be a struct that implements
 499    /// <see cref="System.Enum" />.
 500    /// </typeparam>
 501    /// <param name="value">
 502    /// The input string value to parse into an enumeration constant. May be <see langword="null" /> or whitespace.
 503    /// </param>
 504    /// <param name="result">
 505    /// When this method returns, contains the parsed enumeration value if successful; otherwise the default value for
 506    /// <typeparamref name="TEnum" />.
 507    /// </param>
 508    /// <returns>
 509    /// <see langword="true" /> if the input string could be parsed into a valid enumeration value; otherwise,
 510    /// <see langword="false" />.
 511    /// </returns>
 512    /// <remarks>
 513    ///     <para>Parsing occurs in multiple passes:</para>
 514    ///     <list type="number">
 515    ///         <item>Trims and checks for null or whitespace.</item>
 516    ///         <item>
 517    ///         Attempts a direct parse using <see cref="Enum.TryParse{TEnum}(string, bool, out TEnum)" /> with
 518    ///         case-insensitive comparison.
 519    ///         </item>
 520    ///         <item>Normalizes the value by converting from kebab-case to PascalCase and attempts to parse again.</ite
 521    ///         <item>
 522    ///         Matches against any <see cref="System.ComponentModel.DescriptionAttribute" /> description values defined
 523    ///         enumeration members.
 524    ///         </item>
 525    ///     </list>
 526    ///     <para>
 527    ///     This method never throws exceptions; it provides a safe way to attempt parsing user or external input to an 
 528    ///     </para>
 529    /// </remarks>
 530    public static bool TryParseEnum<TEnum>(this string? value, out TEnum result)
 531        where TEnum : struct, Enum
 532    {
 368533        if (string.IsNullOrWhiteSpace(value: value))
 534        {
 58535            result = default(TEnum);
 536
 58537            return false;
 538        }
 539
 310540        var trimmed = value.Trim();
 541
 310542        if (Enum.TryParse(value: trimmed, ignoreCase: true, result: out result))
 543        {
 157544            return true;
 545        }
 546
 547        try
 548        {
 549            // Attempt to normalize the enum.
 550            // Could fail on the FromKebabCase() call, but that's okay.
 153551            var normalized = trimmed.FromKebabCase().ToPascalCase();
 552
 137553            if (Enum.TryParse(value: normalized, ignoreCase: true, result: out result))
 554            {
 55555                return true;
 556            }
 82557        }
 16558        catch
 559        {
 560            // Ignore error.
 16561        }
 562
 1416563        foreach (var item in Enum.GetValues(enumType: typeof(TEnum)).Cast<TEnum>())
 564        {
 618565            if (string.Equals(a: item.GetDescription(), b: trimmed, comparisonType: StringComparison.OrdinalIgnoreCase))
 566            {
 16567                result = item;
 568
 16569                return true;
 570            }
 571        }
 572
 82573        result = default(TEnum);
 574
 82575        return false;
 71576    }
 577}