| | | 1 | | namespace 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> |
| | | 8 | | public 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> |
| | 5 | 15 | | private static readonly Regex CamelCaseIdentifierRegex = new( |
| | 5 | 16 | | pattern: "^[a-z][A-Za-z0-9]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant |
| | 5 | 17 | | ); |
| | | 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> |
| | 5 | 24 | | private static readonly Regex KebabCaseIdentifierRegex = new( |
| | 5 | 25 | | pattern: "^[A-Za-z][A-Za-z0-9-]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant |
| | 5 | 26 | | ); |
| | | 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> |
| | 5 | 33 | | private static readonly Regex PascalCaseIdentifierRegex = new( |
| | 5 | 34 | | pattern: "^[A-Z][A-Za-z0-9]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant |
| | 5 | 35 | | ); |
| | | 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> |
| | 5 | 42 | | private static readonly Regex SnakeCaseIdentifierRegex = new( |
| | 5 | 43 | | pattern: "^[A-Za-z][A-Za-z0-9_]*$", options: RegexOptions.Compiled | RegexOptions.CultureInvariant |
| | 5 | 44 | | ); |
| | | 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 | | { |
| | 338 | 52 | | if (string.IsNullOrEmpty(value: word)) |
| | | 53 | | { |
| | 4 | 54 | | return string.Empty; |
| | | 55 | | } |
| | | 56 | | |
| | 334 | 57 | | var ci = culture ?? CultureInfo.InvariantCulture; |
| | | 58 | | |
| | 334 | 59 | | return word.Length == 1 |
| | 334 | 60 | | ? char.ToUpper(c: word[index: 0], culture: ci).ToString(provider: ci) |
| | 334 | 61 | | : 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) |
| | 212 | 70 | | => new( |
| | 212 | 71 | | pattern: $"[{Regex.Escape(str: replaceChar.ToString())}]+", |
| | 212 | 72 | | options: RegexOptions.Compiled | RegexOptions.CultureInvariant |
| | 212 | 73 | | ); |
| | | 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 | | { |
| | 37 | 79 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 80 | | { |
| | 6 | 81 | | return string.Empty; |
| | | 82 | | } |
| | | 83 | | |
| | 31 | 84 | | var text = value.Trim(); |
| | 31 | 85 | | var argName = nameof(value); |
| | | 86 | | |
| | 31 | 87 | | AryGuard.Check( |
| | 31 | 88 | | condition: CamelCaseIdentifierRegex.IsMatch(input: text), |
| | 31 | 89 | | argName: argName, |
| | 31 | 90 | | message: |
| | 31 | 91 | | $"{argName} must be a camelCase identifier (start with a lowercase letter; letters and digits only)" |
| | 31 | 92 | | ); |
| | | 93 | | |
| | 25 | 94 | | 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 | | { |
| | 226 | 101 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 102 | | { |
| | 6 | 103 | | return string.Empty; |
| | | 104 | | } |
| | | 105 | | |
| | 220 | 106 | | var text = value.Trim(); |
| | 220 | 107 | | var argName = nameof(value); |
| | | 108 | | |
| | 220 | 109 | | AryGuard.Check( |
| | 220 | 110 | | condition: KebabCaseIdentifierRegex.IsMatch(input: text), |
| | 220 | 111 | | argName: argName, |
| | 220 | 112 | | message: |
| | 220 | 113 | | $"{argName} must be a kebab-case identifier (start with a letter; letters, digits, and hyphens only)" |
| | 220 | 114 | | ); |
| | | 115 | | |
| | 198 | 116 | | 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 | | { |
| | 53 | 123 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 124 | | { |
| | 8 | 125 | | return string.Empty; |
| | | 126 | | } |
| | | 127 | | |
| | 45 | 128 | | var text = value.Trim(); |
| | 45 | 129 | | var argName = nameof(value); |
| | | 130 | | |
| | 45 | 131 | | AryGuard.Check( |
| | 45 | 132 | | condition: PascalCaseIdentifierRegex.IsMatch(input: text), |
| | 45 | 133 | | argName: argName, |
| | 45 | 134 | | message: |
| | 45 | 135 | | $"{argName} must be a PascalCase identifier (start with an uppercase letter; letters and digits only)" |
| | 45 | 136 | | ); |
| | | 137 | | |
| | 39 | 138 | | 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 | | { |
| | 110 | 150 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 151 | | { |
| | 10 | 152 | | return string.Empty; |
| | | 153 | | } |
| | | 154 | | |
| | 100 | 155 | | var core = value.Trim().TrimStart('_', '-'); |
| | 100 | 156 | | var argName = nameof(value); |
| | | 157 | | |
| | 100 | 158 | | AryGuard.Check( |
| | 100 | 159 | | condition: core.Length is not 0, |
| | 100 | 160 | | argName: argName, |
| | 100 | 161 | | message: $"{argName} cannot be reduced to a valid identifier." |
| | 100 | 162 | | ); |
| | | 163 | | |
| | 96 | 164 | | if (PascalCaseIdentifierRegex.IsMatch(input: core)) |
| | | 165 | | { |
| | 13 | 166 | | return FromPascalCase(value: core); |
| | | 167 | | } |
| | | 168 | | |
| | 83 | 169 | | if (CamelCaseIdentifierRegex.IsMatch(input: core)) |
| | | 170 | | { |
| | 21 | 171 | | return FromCamelCase(value: core); |
| | | 172 | | } |
| | | 173 | | |
| | 62 | 174 | | if (SnakeCaseIdentifierRegex.IsMatch(input: core)) |
| | | 175 | | { |
| | 2 | 176 | | return FromSnakeCase(value: core); |
| | | 177 | | } |
| | | 178 | | |
| | 60 | 179 | | if (KebabCaseIdentifierRegex.IsMatch(input: core)) |
| | | 180 | | { |
| | 57 | 181 | | return FromKebabCase(value: core); |
| | | 182 | | } |
| | | 183 | | |
| | 3 | 184 | | throw new AryArgumentException( |
| | 3 | 185 | | message: |
| | 3 | 186 | | "Input must be PascalCase, camelCase, snake_case, or kebab-case (with optional leading '_' or '-').", |
| | 3 | 187 | | argName: nameof(value), |
| | 3 | 188 | | argValue: value |
| | 3 | 189 | | ); |
| | | 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 | | { |
| | 18 | 196 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 197 | | { |
| | 6 | 198 | | return string.Empty; |
| | | 199 | | } |
| | | 200 | | |
| | 12 | 201 | | var text = value.Trim(); |
| | 12 | 202 | | var argName = nameof(value); |
| | | 203 | | |
| | 12 | 204 | | AryGuard.Check( |
| | 12 | 205 | | condition: SnakeCaseIdentifierRegex.IsMatch(input: text), |
| | 12 | 206 | | argName: argName, |
| | 12 | 207 | | message: |
| | 12 | 208 | | $"{argName} must be a snake_case identifier (start with a letter; letters, digits, and underscores only)" |
| | 12 | 209 | | ); |
| | | 210 | | |
| | 6 | 211 | | 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 | | { |
| | 370 | 217 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 218 | | { |
| | 4 | 219 | | return string.Empty; |
| | | 220 | | } |
| | | 221 | | |
| | 366 | 222 | | var normalized = value.Normalize(normalizationForm: NormalizationForm.FormD); |
| | 366 | 223 | | var sb = new StringBuilder(capacity: normalized.Length); |
| | | 224 | | |
| | 4552 | 225 | | foreach (var c in normalized) |
| | | 226 | | { |
| | 1910 | 227 | | if (CharUnicodeInfo.GetUnicodeCategory(ch: c) != UnicodeCategory.NonSpacingMark) |
| | | 228 | | { |
| | 1882 | 229 | | sb.Append(value: c); |
| | | 230 | | } |
| | | 231 | | } |
| | | 232 | | |
| | 366 | 233 | | 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 = "") |
| | 1361413 | 250 | | => string.IsNullOrWhiteSpace(value: value) |
| | 1361413 | 251 | | ? defaultValue |
| | 1361413 | 252 | | : 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 |
| | 6 | 263 | | 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) |
| | 58 | 275 | | => string.IsNullOrWhiteSpace(value: value) |
| | 58 | 276 | | ? null |
| | 58 | 277 | | : 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 | | { |
| | 204 | 285 | | var text = value.Trim(); |
| | | 286 | | |
| | 204 | 287 | | 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 | | { |
| | 66 | 296 | | var sb = new StringBuilder(capacity: value.Length + 8); |
| | | 297 | | |
| | 1150 | 298 | | for (var i = 0; i < value.Length; i++) |
| | | 299 | | { |
| | 509 | 300 | | var c = value[index: i]; |
| | | 301 | | |
| | 509 | 302 | | if (char.IsUpper(c: c) && i > 0) |
| | | 303 | | { |
| | 35 | 304 | | 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") |
| | 35 | 309 | | if (!char.IsUpper(c: prev) || |
| | 35 | 310 | | (i + 1 < value.Length && char.IsLower(c: value[index: i + 1]))) |
| | | 311 | | { |
| | 29 | 312 | | if (sb.Length > 0 && sb[^1] != ' ') |
| | | 313 | | { |
| | 29 | 314 | | sb.Append(value: ' '); |
| | | 315 | | } |
| | | 316 | | } |
| | | 317 | | } |
| | | 318 | | |
| | 509 | 319 | | sb.Append(value: c); |
| | | 320 | | } |
| | | 321 | | |
| | 66 | 322 | | 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 | | { |
| | 18 | 328 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 329 | | { |
| | 10 | 330 | | return string.Empty; |
| | | 331 | | } |
| | | 332 | | |
| | 8 | 333 | | var text = value.Trim(); |
| | | 334 | | |
| | | 335 | | // If there is no whitespace, infer token boundaries from concatenated identifiers (e.g., "XMLHttpRequest" -> "X |
| | 8 | 336 | | var tokenSource = text.IndexOfAny( |
| | 8 | 337 | | anyOf: |
| | 8 | 338 | | [ |
| | 8 | 339 | | ' ', |
| | 8 | 340 | | '\t', |
| | 8 | 341 | | '\r', |
| | 8 | 342 | | '\n' |
| | 8 | 343 | | ] |
| | 8 | 344 | | ) >= 0 |
| | 8 | 345 | | ? text |
| | 8 | 346 | | : SplitConcatenated(value: text); |
| | | 347 | | |
| | 8 | 348 | | var words = tokenSource.Split( |
| | 8 | 349 | | separator: |
| | 8 | 350 | | [ |
| | 8 | 351 | | ' ', |
| | 8 | 352 | | '\t', |
| | 8 | 353 | | '\r', |
| | 8 | 354 | | '\n' |
| | 8 | 355 | | ], options: StringSplitOptions.RemoveEmptyEntries |
| | 8 | 356 | | ); |
| | | 357 | | |
| | 8 | 358 | | var sb = new StringBuilder(capacity: text.Length); |
| | | 359 | | |
| | 48 | 360 | | for (var i = 0; i < words.Length; i++) |
| | | 361 | | { |
| | 16 | 362 | | var w = words[i].NormalizeAccents().Trim(); |
| | | 363 | | |
| | 16 | 364 | | if (w.Length == 0) |
| | | 365 | | { |
| | | 366 | | continue; |
| | | 367 | | } |
| | | 368 | | |
| | 14 | 369 | | if (i == 0) |
| | | 370 | | { |
| | | 371 | | // First token: if it's an acronym, lower it fully; otherwise lower first char and the rest. |
| | 6 | 372 | | if (w.All(predicate: char.IsUpper)) |
| | | 373 | | { |
| | 4 | 374 | | sb.Append(value: w.ToLowerInvariant()); |
| | | 375 | | } |
| | | 376 | | else |
| | | 377 | | { |
| | 2 | 378 | | sb.Append(value: char.ToLower(c: w[index: 0], culture: CultureInfo.InvariantCulture)); |
| | | 379 | | |
| | 2 | 380 | | if (w.Length > 1) |
| | | 381 | | { |
| | 2 | 382 | | 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). |
| | 8 | 389 | | var lower = w.ToLower(culture: CultureInfo.InvariantCulture); |
| | 8 | 390 | | sb.Append(value: char.ToUpper(c: lower[index: 0], culture: CultureInfo.InvariantCulture)); |
| | | 391 | | |
| | 8 | 392 | | if (lower.Length > 1) |
| | | 393 | | { |
| | 8 | 394 | | sb.Append(value: lower[1..]); |
| | | 395 | | } |
| | | 396 | | } |
| | | 397 | | } |
| | | 398 | | |
| | 8 | 399 | | 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) |
| | 333 | 406 | | => Regex.Replace( |
| | 333 | 407 | | input: (name ?? string.Empty).Replace(oldChar: '_', newChar: '-'), pattern: @"[\s-]+", replacement: "-" |
| | 333 | 408 | | ).Trim(trimChar: '-') |
| | 333 | 409 | | .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 | | { |
| | 8 | 414 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 415 | | { |
| | 4 | 416 | | return string.Empty; |
| | | 417 | | } |
| | | 418 | | |
| | 4 | 419 | | var cleaned = value.Trim().NormalizeAccents(); |
| | | 420 | | |
| | 4 | 421 | | var withHyphens = Regex.Replace( |
| | 4 | 422 | | input: cleaned, |
| | 4 | 423 | | pattern: "\\s+", |
| | 4 | 424 | | replacement: "-", |
| | 4 | 425 | | options: RegexOptions.CultureInvariant |
| | 4 | 426 | | ); |
| | | 427 | | |
| | 4 | 428 | | var collapsed = CollapseSeparatorRegex(replaceChar: '-').Replace(input: withHyphens, replacement: "-"); |
| | | 429 | | |
| | 4 | 430 | | return collapsed.ToLowerInvariant(); |
| | | 431 | | } |
| | | 432 | | |
| | | 433 | | /// <summary>Converts a string into PascalCase, preserving acronyms.</summary> |
| | | 434 | | public static string ToPascalCase(this string? value) |
| | | 435 | | { |
| | 153 | 436 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 437 | | { |
| | 8 | 438 | | return string.Empty; |
| | | 439 | | } |
| | | 440 | | |
| | 145 | 441 | | var words = value.Split( |
| | 145 | 442 | | separator: |
| | 145 | 443 | | [ |
| | 145 | 444 | | ' ', |
| | 145 | 445 | | '\t', |
| | 145 | 446 | | '\r', |
| | 145 | 447 | | '\n' |
| | 145 | 448 | | ], options: StringSplitOptions.RemoveEmptyEntries |
| | 145 | 449 | | ); |
| | | 450 | | |
| | 145 | 451 | | var sb = new StringBuilder(capacity: value.Length); |
| | | 452 | | |
| | 962 | 453 | | foreach (var word in words) |
| | | 454 | | { |
| | 336 | 455 | | var normalized = word.NormalizeAccents().Trim(); |
| | | 456 | | |
| | 336 | 457 | | if (normalized.Length == 0) |
| | | 458 | | { |
| | | 459 | | continue; |
| | | 460 | | } |
| | | 461 | | |
| | 334 | 462 | | sb.Append( |
| | 334 | 463 | | value: normalized.All(predicate: char.IsUpper) |
| | 334 | 464 | | ? normalized |
| | 334 | 465 | | : Capitalize(word: normalized, culture: CultureInfo.InvariantCulture) |
| | 334 | 466 | | ); |
| | | 467 | | } |
| | | 468 | | |
| | 145 | 469 | | 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 | | { |
| | 8 | 475 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 476 | | { |
| | 4 | 477 | | return string.Empty; |
| | | 478 | | } |
| | | 479 | | |
| | 4 | 480 | | var cleaned = value.Trim().NormalizeAccents(); |
| | | 481 | | |
| | 4 | 482 | | var withUnderscores = Regex.Replace( |
| | 4 | 483 | | input: cleaned, |
| | 4 | 484 | | pattern: "\\s+", |
| | 4 | 485 | | replacement: "_", |
| | 4 | 486 | | options: RegexOptions.CultureInvariant |
| | 4 | 487 | | ); |
| | | 488 | | |
| | 4 | 489 | | var collapsed = CollapseSeparatorRegex(replaceChar: '_').Replace(input: withUnderscores, replacement: "_"); |
| | | 490 | | |
| | 4 | 491 | | 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 | | { |
| | 368 | 533 | | if (string.IsNullOrWhiteSpace(value: value)) |
| | | 534 | | { |
| | 58 | 535 | | result = default(TEnum); |
| | | 536 | | |
| | 58 | 537 | | return false; |
| | | 538 | | } |
| | | 539 | | |
| | 310 | 540 | | var trimmed = value.Trim(); |
| | | 541 | | |
| | 310 | 542 | | if (Enum.TryParse(value: trimmed, ignoreCase: true, result: out result)) |
| | | 543 | | { |
| | 157 | 544 | | return true; |
| | | 545 | | } |
| | | 546 | | |
| | | 547 | | try |
| | | 548 | | { |
| | | 549 | | // Attempt to normalize the enum. |
| | | 550 | | // Could fail on the FromKebabCase() call, but that's okay. |
| | 153 | 551 | | var normalized = trimmed.FromKebabCase().ToPascalCase(); |
| | | 552 | | |
| | 137 | 553 | | if (Enum.TryParse(value: normalized, ignoreCase: true, result: out result)) |
| | | 554 | | { |
| | 55 | 555 | | return true; |
| | | 556 | | } |
| | 82 | 557 | | } |
| | 16 | 558 | | catch |
| | | 559 | | { |
| | | 560 | | // Ignore error. |
| | 16 | 561 | | } |
| | | 562 | | |
| | 1416 | 563 | | foreach (var item in Enum.GetValues(enumType: typeof(TEnum)).Cast<TEnum>()) |
| | | 564 | | { |
| | 618 | 565 | | if (string.Equals(a: item.GetDescription(), b: trimmed, comparisonType: StringComparison.OrdinalIgnoreCase)) |
| | | 566 | | { |
| | 16 | 567 | | result = item; |
| | | 568 | | |
| | 16 | 569 | | return true; |
| | | 570 | | } |
| | | 571 | | } |
| | | 572 | | |
| | 82 | 573 | | result = default(TEnum); |
| | | 574 | | |
| | 82 | 575 | | return false; |
| | 71 | 576 | | } |
| | | 577 | | } |