Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Humanizer.Tests/StringHumanizeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
[InlineData("?", "")]
[InlineData("", "")]
[InlineData("JeNeParlePasFrançais", "Je ne parle pas français")]
[InlineData("LONGER_WORD", "LONGER WORD")] // Issue #1557: ALL-CAPS with separators should be humanized to separated words
[InlineData("HELLO", "HELLO")] // ALL-CAPS words without separators should be preserved as potential acronyms

Copilot AI Oct 9, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove trailing whitespace at the end of the comment line.

Copilot uses AI. Check for mistakes.
public void CanHumanizeStringInPascalCase(string input, string expectedResult) =>
Assert.Equal(expectedResult, input.Humanize());

Expand Down Expand Up @@ -62,6 +64,8 @@ public void CanHumanizeStringWithAcronyms(string input, string expectedValue) =>
[InlineData("MühldorferStraße23", "Mühldorfer Straße 23")]
[InlineData("mühldorfer_STRAẞE_23", "Mühldorfer STRAẞE 23")]
[InlineData("CAN RETURN TITLE CASE", "Can Return Title Case")]
[InlineData("LONGER_WORD", "Longer Word")] // Issue #1557: ALL-CAPS with separators should be transformed to title case
[InlineData("HELLO", "HELLO")] // ALL-CAPS words without separators should be preserved as potential acronyms
public void CanHumanizeIntoTitleCase(string input, string expectedResult) =>
Assert.Equal(expectedResult, input.Humanize(LetterCasing.Title));

Expand All @@ -78,6 +82,7 @@ public void CanHumanizeIntoLowerCase(string input, string expectedResult) =>
[InlineData("égoïste", "Égoïste")]
[InlineData("Normal; Normal and PascalCase", "Normal; normal and pascal case")]
[InlineData("I,and No One else", "I, and no one else")]
[InlineData("LONGER_WORD", "Longer word")] // Issue #1557: ALL-CAPS with separators should be transformed to sentence case
public void CanHumanizeIntoSentenceCase(string input, string expectedResult) =>
Assert.Equal(expectedResult, input.Humanize(LetterCasing.Sentence));

Expand Down
4 changes: 4 additions & 0 deletions src/Humanizer.Tests/TransformersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
[InlineData("Title Case", "Title Case")]
[InlineData("apostrophe's aren't capitalized", "Apostrophe's Aren't Capitalized")]
[InlineData("titles with, commas work too", "Titles With, Commas Work Too")]
[InlineData("LONGER_WORD", "Longer_word")] // Issue #1557: ALL-CAPS with separators should be transformed
[InlineData("HELLO", "HELLO")] // ALL-CAPS words without separators should be preserved as potential acronyms
public void TransformToTitleCase(string input, string expectedOutput) =>
Assert.Equal(expectedOutput, input.Transform(To.TitleCase));

Expand All @@ -25,6 +27,8 @@ public void TransformToLowerCase(string input, string expectedOutput) =>
[InlineData("lower case statement", "Lower case statement")]
[InlineData("Sentence casing", "Sentence casing")]
[InlineData("honors UPPER case", "Honors UPPER case")]
[InlineData("LONGER_WORD", "Longer_word")] // Issue #1557: ALL-CAPS with separators should be transformed
[InlineData("HELLO", "HELLO")] // ALL-CAPS words without separators should be preserved as potential acronyms
public void TransformToSentenceCase(string input, string expectedOutput) =>
Assert.Equal(expectedOutput, input.Transform(To.SentenceCase));

Expand Down
3 changes: 2 additions & 1 deletion src/Humanizer/StringHumanizeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ static string FromPascalCase(string input)
public static string Humanize(this string input)
{
// if input is all capitals (e.g. an acronym) then return it without change
if (input.All(char.IsUpper))
// unless it contains separators which suggest it's a compound word that should be humanized
if (input.All(char.IsUpper) && !input.Contains('_') && !input.Contains('-'))
{
return input;
}
Expand Down
66 changes: 66 additions & 0 deletions src/Humanizer/Transformer/ToSentenceCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,72 @@ public string Transform(string input, CultureInfo? culture)

if (input.Length >= 1)
{
// If the input contains word separators and all letters are uppercase, transform it to sentence case
// This handles the issue #1557 case: "HYPEN-SEPARATOR" -> "Hypen-separator"
if ((input.Contains('_') || input.Contains('-')) &&
input.Where(char.IsLetter).Any() &&
input.Where(char.IsLetter).All(char.IsUpper))
{
return StringHumanizeExtensions.Concat(
culture.TextInfo.ToUpper(input[0]),
culture.TextInfo.ToLower(input[1..]).AsSpan());
}

// For multi-word strings (result of humanization), handle each word
if (input.Contains(' '))
{
var words = input.Split(' ');
var result = new StringBuilder();

// Check if this looks like it came from separator-based input (all words are ALL-CAPS)
bool allWordsAreCaps = words.Where(w => w.Any(char.IsLetter)).All(w => w.All(char.IsUpper));

for (int i = 0; i < words.Length; i++)
{
var word = words[i];
if (i > 0) result.Append(' ');

if (i == 0)
{
// First word: capitalize first letter, lowercase the rest (unless it's an acronym)
if (word.Length > 0)
{
// For the first word in sentence case
if (word.All(char.IsUpper) && word.Any(char.IsLetter) && !allWordsAreCaps)
{
// Preserve ALL-CAPS words as likely acronyms only if not all words are caps
result.Append(word);
}
else
{
result.Append(culture.TextInfo.ToUpper(word[0]));
if (word.Length > 1)
{
result.Append(culture.TextInfo.ToLower(word[1..]));
}
}
}
}
else
{
// Subsequent words: lowercase (unless it's an acronym)
if (word.All(char.IsUpper) && word.Any(char.IsLetter) && !allWordsAreCaps)
{
// Preserve ALL-CAPS words as likely acronyms only if not all words are caps
result.Append(word);
}
else
{
result.Append(culture.TextInfo.ToLower(word));
}
}
}

return result.ToString();
}

// Single word case
// If first character is already uppercase, leave as-is (handles normal case and acronyms)
if (char.IsUpper(input[0]))
{
return input;
Expand Down
45 changes: 42 additions & 3 deletions src/Humanizer/Transformer/ToTitleCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@
var matches = Regex.Matches(input);
var builder = new StringBuilder(input);
var textInfo = culture.TextInfo;

// Check if this appears to be a multi-word result from humanizing separator-based input
bool isMultiWordFromSeparators = ContainsMultipleAllCapsWords(input);

foreach (Match word in matches)
{
var value = word.Value;
if (AllCapitals(value) || IsArticleOrConjunctionOrPreposition(value))
if ((AllCapitals(value, isMultiWordFromSeparators)) || IsArticleOrConjunctionOrPreposition(value))
{
continue;
}
Expand All @@ -33,7 +37,7 @@
.Remove(index, replacement.Length)
.Insert(index, replacement);

static bool AllCapitals(string input)
static bool AllCapitals(string input, bool isMultiWordFromSeparators = false)
{
foreach (var ch in input)
{
Expand All @@ -43,7 +47,42 @@
}
}

return true;
// If this appears to be from separator-based input, be more restrictive about preserving acronyms
if (isMultiWordFromSeparators)
{
// Only preserve very short words like "I", "IT", "TV"
return input.Length <= 3 && !input.Contains('_') && !input.Contains('-');
}

// For single words or mixed contexts, preserve potential acronyms more generously
// This preserves "HELLO", "STRAẞE", "ALLCAPS" etc.
return !input.Contains('_') && !input.Contains('-');
}

static bool ContainsMultipleAllCapsWords(string input)
{
// Check if input contains multiple ALL-CAPS words separated by spaces
// This suggests it came from humanizing separator-based input like "LONGER_WORD" → "LONGER WORD"
var words = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length < 2) return false;

int allCapsCount = 0;
int totalWordCount = 0;
foreach (var word in words)
{
if (word.Any(char.IsLetter))
{
totalWordCount++;
if (word.All(char.IsUpper))
{
allCapsCount++;
}
}
}
Comment on lines +71 to +81

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

// Only consider it "from separators" if ALL or most words are ALL-CAPS
// This catches "LONGER WORD" but not "Title humanization Honors ALLCAPS"
Comment thread
clairernovotny marked this conversation as resolved.
return allCapsCount >= 2 && allCapsCount >= totalWordCount * 0.75;
}

private static bool IsArticleOrConjunctionOrPreposition(string word) =>
Expand Down