Skip to content

Commit 42a4e93

Browse files
committed
Rewrite and more tests, and keep fully backwards compatible
1 parent 8ccfde7 commit 42a4e93

File tree

2 files changed

+76
-29
lines changed

2 files changed

+76
-29
lines changed

src/Compiler/Microsoft.AspNetCore.Razor.Language/test/HtmlConventionsTest.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33

44
#nullable disable
55

6+
using System;
7+
using System.Text.RegularExpressions;
68
using Xunit;
79

810
namespace Microsoft.AspNetCore.Razor.Language;
911

1012
public class HtmlConventionsTest
1113
{
12-
public static TheoryData HtmlConversionData
14+
public static TheoryData<string, string> HtmlConversionData
1315
{
1416
get
1517
{
@@ -21,12 +23,21 @@ public static TheoryData HtmlConversionData
2123
{ "CAPSOnOUTSIDE", "caps-on-outside" },
2224
{ "ALLCAPS", "allcaps" },
2325
{ "One1Two2Three3", "one1-two2-three3" },
24-
{ "ONE1TWO2THREE3", "one1-two2-three3" },
25-
{ "First_Second_ThirdHi", "first_second_third-hi" }
26+
{ "ONE1TWO2THREE3", "one1two2three3" },
27+
{ "First_Second_ThirdHi", "first_second_third-hi" },
28+
{ "One123Two234Three345", "one123-two234-three345" },
29+
{ "ONE123TWO234THREE345", "one123two234three345" },
30+
{ "1TWO2THREE3", "1two2three3" },
31+
{ "alllowercase", "alllowercase" },
2632
};
2733
}
2834
}
2935

36+
private static readonly Regex OldHtmlCaseRegex = new Regex(
37+
"(?<!^)((?<=[a-zA-Z0-9])[A-Z][a-z])|((?<=[a-z])[A-Z])",
38+
RegexOptions.None,
39+
TimeSpan.FromMilliseconds(500));
40+
3041
[Theory]
3142
[MemberData(nameof(HtmlConversionData))]
3243
public void ToHtmlCase_ReturnsExpectedConversions(string input, string expectedOutput)
@@ -36,5 +47,9 @@ public void ToHtmlCase_ReturnsExpectedConversions(string input, string expectedO
3647

3748
// Assert
3849
Assert.Equal(expectedOutput, output);
50+
51+
// Assure backwards compatibility with regex
52+
var regexResult = OldHtmlCaseRegex.Replace(input, "-$1$2").ToLowerInvariant();
53+
Assert.Equal(regexResult, output);
3954
}
4055
}

src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/HtmlConventions.cs

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
#nullable disable
5-
64
using System;
5+
using System.Diagnostics;
6+
using System.Diagnostics.CodeAnalysis;
77
using Microsoft.AspNetCore.Razor.PooledObjects;
88

99
namespace Microsoft.AspNetCore.Razor.Language;
@@ -38,49 +38,81 @@ internal static bool IsInvalidNonWhitespaceHtmlCharacters(char testChar)
3838
/// ONE1TWO2THREE3 => one1two2three3
3939
/// First_Second_ThirdHi => first_second_third-hi
4040
/// </example>
41-
public static string ToHtmlCase(string name)
41+
public static string ToHtmlCase(string input)
4242
{
43-
if (string.IsNullOrEmpty(name))
43+
if (string.IsNullOrEmpty(input))
4444
{
45-
return name;
45+
return input;
4646
}
4747

48-
var input = name.AsSpan();
48+
return TryGetKebabCaseString(input, out var result)
49+
? result
50+
: input;
51+
}
4952

50-
using var _ = StringBuilderPool.GetPooledObject(out var result);
51-
result.SetCapacityIfLarger(input.Length + 5);
53+
private static bool TryGetKebabCaseString(ReadOnlySpan<char> input, [NotNullWhen(true)] out string? result)
54+
{
55+
using var _ = StringBuilderPool.GetPooledObject(out var builder);
5256

53-
// It's slightly faster to use a foreach and index manually, than to use a for 🤷‍
57+
var allLower = true;
5458
var i = 0;
55-
foreach (var c in name)
59+
foreach (var c in input)
5660
{
5761
if (char.IsUpper(c))
5862
{
59-
// Insert hyphen if:
60-
// - Not the first character, and
61-
// - Previous is lowercase or digit, or
62-
// - Previous is uppercase and next is lowercase (e.g. CAPSOn → caps-on)
63-
if (i > 0)
63+
allLower = false;
64+
65+
if (ShouldInsertHyphenBeforeUppercase(input, i))
6466
{
65-
var prev = input[i - 1];
66-
var prevIsLowerOrDigit = char.IsLower(prev) || char.IsDigit(prev);
67-
var prevIsUpper = char.IsUpper(prev);
68-
var nextIsLower = (i + 1 < input.Length) && char.IsLower(input[i + 1]);
69-
if (prevIsLowerOrDigit || (prevIsUpper && nextIsLower))
70-
{
71-
result.Append('-');
72-
}
67+
builder.Append('-');
7368
}
74-
result.Append(char.ToLowerInvariant(c));
69+
70+
builder.Append(char.ToLowerInvariant(c));
7571
}
7672
else
7773
{
78-
result.Append(c);
74+
builder.Append(c);
7975
}
8076

8177
i++;
8278
}
8379

84-
return result.ToString();
80+
if (allLower)
81+
{
82+
// If the input is all lowercase, we don't need to realize the builder,
83+
// it will just be cleared when the pooled object is disposed.
84+
result = null;
85+
return false;
86+
}
87+
88+
result = builder.ToString();
89+
return true;
90+
}
91+
92+
private static bool ShouldInsertHyphenBeforeUppercase(ReadOnlySpan<char> input, int i)
93+
{
94+
Debug.Assert(char.IsUpper(input[i]));
95+
96+
if (i == 0)
97+
{
98+
// First character is uppercase, no hyphen needed (e.g. This → this)
99+
return false;
100+
}
101+
102+
var prev = input[i - 1];
103+
if (char.IsLower(prev))
104+
{
105+
// Lowercase followed by uppercase (e.g. someThing → some-thing)
106+
return true;
107+
}
108+
109+
if ((char.IsUpper(prev) || char.IsDigit(prev)) &&
110+
(i + 1 < input.Length) && char.IsLower(input[i + 1]))
111+
{
112+
// Uppercase or digit followed by lowercase (e.g. CAPSOn → caps-on)
113+
return true; ;
114+
}
115+
116+
return false;
85117
}
86118
}

0 commit comments

Comments
 (0)