Skip to content

Commit 6e56b08

Browse files
Introduce TryToNumber methods for safe word-to-number conversion (#1582)
Co-authored-by: Claire Novotny <[email protected]>
1 parent 605ab13 commit 6e56b08

10 files changed

+215
-39
lines changed

readme.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,12 @@ If words from other languages are used, a `NotSupportedException` is thrown.
896896
"one hundred and five".ToNumber(new CultureInfo("en")) => 105
897897
"three thousand two hundred".ToNumber(new CultureInfo("en")) => 3200
898898

899+
if ("forty-two".TryToNumber(out var number, new CultureInfo("en")))
900+
Console.WriteLine(number); // 42
901+
902+
if (!"tenn".TryToNumber(out var invalid, new CultureInfo("en"), out var badWord))
903+
Console.WriteLine($"Unrecognized word: {badWord}"); // Unrecognized word: tenn
904+
899905
// Unsupported locales (throws NotSupportedException)
900906
"vingt".ToNumber(new CultureInfo("fr")) // French
901907
"veinte".ToNumber(new CultureInfo("es")) // Spanish

src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.Approve_Public_Api.DotNet10_0.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ namespace Humanizer
384384
public interface IWordsToNumberConverter
385385
{
386386
int Convert(string words);
387+
bool TryConvert(string words, out int parsedValue);
388+
bool TryConvert(string words, out int parsedValue, out string? unrecognizedNumber);
387389
}
388390
public class In
389391
{
@@ -1975,5 +1977,7 @@ namespace Humanizer
19751977
public static class WordsToNumberExtension
19761978
{
19771979
public static int ToNumber(this string words, System.Globalization.CultureInfo culture) { }
1980+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture) { }
1981+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture, out string? unrecognizedWord) { }
19781982
}
19791983
}

src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.Approve_Public_Api.DotNet8_0.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,8 @@ namespace Humanizer
383383
public interface IWordsToNumberConverter
384384
{
385385
int Convert(string words);
386+
bool TryConvert(string words, out int parsedValue);
387+
bool TryConvert(string words, out int parsedValue, out string? unrecognizedNumber);
386388
}
387389
public class In
388390
{
@@ -1974,5 +1976,7 @@ namespace Humanizer
19741976
public static class WordsToNumberExtension
19751977
{
19761978
public static int ToNumber(this string words, System.Globalization.CultureInfo culture) { }
1979+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture) { }
1980+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture, out string? unrecognizedWord) { }
19771981
}
19781982
}

src/Humanizer.Tests/ApiApprover/PublicApiApprovalTest.Approve_Public_Api.Net4_8.verified.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,8 @@ namespace Humanizer
343343
public interface IWordsToNumberConverter
344344
{
345345
int Convert(string words);
346+
bool TryConvert(string words, out int parsedValue);
347+
bool TryConvert(string words, out int parsedValue, out string? unrecognizedNumber);
346348
}
347349
public class In
348350
{
@@ -1311,5 +1313,7 @@ namespace Humanizer
13111313
public static class WordsToNumberExtension
13121314
{
13131315
public static int ToNumber(this string words, System.Globalization.CultureInfo culture) { }
1316+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture) { }
1317+
public static bool TryToNumber(this string words, out int parsedNumber, System.Globalization.CultureInfo culture, out string? unrecognizedWord) { }
13141318
}
13151319
}

src/Humanizer.Tests/WordsToNumberTests.cs

Lines changed: 106 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,39 +34,116 @@ public class WordsToNumberTests_US
3434
[InlineData("negative one hundred and five", -105)]
3535
[InlineData("negative twenty-first", -21)]
3636
public void ToNumber_US(string words, int expectedNumber) => Assert.Equal(expectedNumber, words.ToNumber(CultureInfo.CurrentCulture));
37+
38+
[Theory]
39+
[InlineData("zero", 0, null)]
40+
[InlineData("one", 1, null)]
41+
[InlineData("minus five", -5, null)]
42+
[InlineData("eleven", 11, null)]
43+
[InlineData("ninety five", 95, null)]
44+
[InlineData("hundred five", 105, null)]
45+
[InlineData("one hundred ninety six", 196, null)]
46+
[InlineData("minus one hundred and five", -105, null)]
47+
[InlineData("seventeenth", 17, null)]
48+
[InlineData("thirtieth", 30, null)]
49+
[InlineData("twenty-seventh", 27, null)]
50+
[InlineData("thirty-first", 31, null)]
51+
[InlineData("minus twenty-first", -21, null)]
52+
[InlineData("two thousand twenty three", 2023, null)]
53+
[InlineData("one million two hundred thirty four thousand five hundred sixty seven", 1234567, null)]
54+
[InlineData("one hundred and third", 103, null)]
55+
[InlineData("two hundred and first", 201, null)]
56+
[InlineData("five thousand and ninth", 5009, null)]
57+
[InlineData("17th", 17, null)]
58+
[InlineData("31st", 31, null)]
59+
[InlineData("100th", 100, null)]
60+
[InlineData("203rd", 203, null)]
61+
[InlineData("minus 21st", -21, null)]
62+
[InlineData("negative five", -5, null)]
63+
[InlineData("negative one hundred and five", -105, null)]
64+
[InlineData("negative twenty-first", -21, null)]
65+
public void TryToNumber_ValidInput_US(string words, int expectedNumber, string? expectedUnrecognizedWord)
66+
{
67+
Assert.True(words.TryToNumber(out var parsedNumber, CultureInfo.CurrentCulture, out var unrecognizedWord));
68+
Assert.Equal(unrecognizedWord, expectedUnrecognizedWord);
69+
Assert.Equal(expectedNumber, parsedNumber);
70+
}
71+
72+
[Theory]
73+
[InlineData("twenty nine hello", 0, "hello")]
74+
[InlineData("mister three", 0, "mister")]
75+
[InlineData("tenn", 0, "tenn")]
76+
[InlineData("twenty sveen", 0, "sveen")]
77+
[InlineData("minus fift five", 0, "fift")]
78+
[InlineData("sixty two j", 0, "j")]
79+
[InlineData("two hundred , ninetyy sevennn", 0, "ninetyy")]
80+
[InlineData("invalidinput", 0, "invalidinput")]
81+
[InlineData("30rmd", 0, "30rmd")]
82+
[InlineData("negative energy", 0, "energy")]
83+
public void TryToNumber_InvalidInput_US(string words, int expectedNumber, string? expectedUnrecognizedWord)
84+
{
85+
Assert.False(words.TryToNumber(out var parsedNumber, CultureInfo.CurrentCulture, out var unrecognizedWord));
86+
Assert.Equal(unrecognizedWord, expectedUnrecognizedWord);
87+
Assert.Equal(expectedNumber, parsedNumber);
88+
}
89+
3790
}
3891

3992
[UseCulture("en-GB")]
4093
public class WordsToNumberTests_GB
4194
{
4295
[Theory]
43-
[InlineData("zero", 0)]
44-
[InlineData("one", 1)]
45-
[InlineData("minus five", -5)]
46-
[InlineData("eleven", 11)]
47-
[InlineData("ninety-five", 95)]
48-
[InlineData("hundred and five", 105)]
49-
[InlineData("one hundred and ninety-six", 196)]
50-
[InlineData("minus one hundred and five", -105)]
51-
[InlineData("seventeenth", 17)]
52-
[InlineData("thirtieth", 30)]
53-
[InlineData("twenty-seventh", 27)]
54-
[InlineData("thirty-first", 31)]
55-
[InlineData("minus twenty-first", -21)]
56-
[InlineData("two thousand and twenty-three", 2023)]
57-
[InlineData("one million, two hundred and thirty-four thousand, five hundred and sixty-seven", 1234567)]
58-
[InlineData("one hundred and third", 103)]
59-
[InlineData("two hundred and first", 201)]
60-
[InlineData("five thousand and ninth", 5009)]
61-
[InlineData("17th", 17)]
62-
[InlineData("31st", 31)]
63-
[InlineData("100th", 100)]
64-
[InlineData("203rd", 203)]
65-
[InlineData("minus 21st", -21)]
66-
[InlineData("negative five", -5)]
67-
[InlineData("negative one hundred and five", -105)]
68-
[InlineData("negative twenty-first", -21)]
69-
public void ToNumber_GB(string words, int expectedNumber) => Assert.Equal(expectedNumber, words.ToNumber(CultureInfo.CurrentCulture));
96+
[InlineData("zero", 0, null)]
97+
[InlineData("one", 1, null)]
98+
[InlineData("minus five", -5, null)]
99+
[InlineData("eleven", 11, null)]
100+
[InlineData("ninety five", 95, null)]
101+
[InlineData("hundred five", 105, null)]
102+
[InlineData("one hundred ninety six", 196, null)]
103+
[InlineData("minus one hundred and five", -105, null)]
104+
[InlineData("seventeenth", 17, null)]
105+
[InlineData("thirtieth", 30, null)]
106+
[InlineData("twenty-seventh", 27, null)]
107+
[InlineData("thirty-first", 31, null)]
108+
[InlineData("minus twenty-first", -21, null)]
109+
[InlineData("two thousand twenty three", 2023, null)]
110+
[InlineData("one million two hundred thirty four thousand five hundred sixty seven", 1234567, null)]
111+
[InlineData("one hundred and third", 103, null)]
112+
[InlineData("two hundred and first", 201, null)]
113+
[InlineData("five thousand and ninth", 5009, null)]
114+
[InlineData("17th", 17, null)]
115+
[InlineData("31st", 31, null)]
116+
[InlineData("100th", 100, null)]
117+
[InlineData("203rd", 203, null)]
118+
[InlineData("minus 21st", -21, null)]
119+
[InlineData("negative five", -5, null)]
120+
[InlineData("negative one hundred and five", -105, null)]
121+
[InlineData("negative twenty-first", -21, null)]
122+
public void TryToNumber_ValidInput_GB(string words, int expectedNumber, string? expectedUnrecognizedWord)
123+
{
124+
Assert.True(words.TryToNumber(out var parsedNumber, CultureInfo.CurrentCulture, out var unrecognizedWord));
125+
Assert.Equal(unrecognizedWord, expectedUnrecognizedWord);
126+
Assert.Equal(expectedNumber, parsedNumber);
127+
}
128+
129+
[Theory]
130+
[InlineData("twenty nine hello", 0, "hello")]
131+
[InlineData("mister three", 0, "mister")]
132+
[InlineData("tenn", 0, "tenn")]
133+
[InlineData("twenty sveen", 0, "sveen")]
134+
[InlineData("minus fift five", 0, "fift")]
135+
[InlineData("sixty two j", 0, "j")]
136+
[InlineData("two hundred , ninetyy sevennn", 0, "ninetyy")]
137+
[InlineData("invalidinput", 0, "invalidinput")]
138+
[InlineData("30rmd", 0, "30rmd")]
139+
[InlineData("negative energy", 0, "energy")]
140+
public void TryToNumber_InvalidInput_GB(string words, int expectedNumber, string? expectedUnrecognizedWord)
141+
{
142+
Assert.False(words.TryToNumber(out var parsedNumber, CultureInfo.CurrentCulture, out var unrecognizedWord));
143+
Assert.Equal(unrecognizedWord, expectedUnrecognizedWord);
144+
Assert.Equal(expectedNumber, parsedNumber);
145+
}
146+
70147
}
71148
public class WordsToNumberTests_NonEnglish
72149
{
@@ -82,4 +159,6 @@ public void ThrowsForNonEnglishWords(string cultureName, string word)
82159
Assert.Contains($"'{culture.TwoLetterISOLanguageName}'", ex.Message);
83160
}
84161
}
162+
163+
85164
}

src/Humanizer/Localisation/WordsToNumber/DefaultWordsToNumberConverter.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@ internal class DefaultWordsToNumberConverter : GenderlessWordsToNumberConverter
99
public DefaultWordsToNumberConverter(CultureInfo culture) => cultureInfo = culture;
1010

1111
public override int Convert(string words)
12+
{
13+
TryConvert(words, out var parsedValue);
14+
15+
return parsedValue;
16+
}
17+
public override bool TryConvert(string words, out int parsedValue) => TryConvert(words, out parsedValue, out _);
18+
19+
public override bool TryConvert(string words, out int parsedValue, out string? unrecognizedWord)
1220
{
1321
if (cultureInfo.TwoLetterISOLanguageName == "en")
1422
{
15-
return new EnglishWordsToNumberConverter().Convert(words);
23+
return new EnglishWordsToNumberConverter().TryConvert(words, out parsedValue, out unrecognizedWord);
1624
}
1725
throw new NotSupportedException($"Words-to-number conversion is not supported for '{cultureInfo.TwoLetterISOLanguageName}'.");
1826
}
27+
1928
}

src/Humanizer/Localisation/WordsToNumber/EnglishWordsToNumberConverter.cs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,24 @@ internal class EnglishWordsToNumberConverter : GenderlessWordsToNumberConverter
3232
};
3333

3434
public override int Convert(string words)
35+
{
36+
if (!TryConvert(words, out var result, out var unrecognizedword))
37+
throw new ArgumentException($"Unrecognized number word: {unrecognizedword}");
38+
39+
return result;
40+
}
41+
42+
public override bool TryConvert(string words, out int result) => TryConvert(words, out result, out _);
43+
44+
public override bool TryConvert(string words, out int parsedValue, out string? unrecognizedWord)
3545
{
3646
if (string.IsNullOrWhiteSpace(words))
3747
throw new ArgumentException("Input words cannot be empty.");
3848

49+
unrecognizedWord = null;
50+
3951
words = words.Replace(",", "")
40-
.Replace(" and ", " ")
52+
.Replace(" and ", " ")
4153
.ToLowerInvariant()
4254
.Trim();
4355

@@ -50,19 +62,33 @@ public override int Convert(string words)
5062
words = words.Replace("-", " ");
5163

5264
if (int.TryParse(words, out var numericValue))
53-
return isNegative ? -numericValue : numericValue;
65+
{
66+
parsedValue = isNegative ? -numericValue : numericValue;
67+
return true;
68+
}
5469

5570
if (OrdinalsMap.TryGetValue(words, out var ordinalValue))
56-
return isNegative ? -ordinalValue : ordinalValue;
71+
{
72+
parsedValue = isNegative ? -ordinalValue : ordinalValue;
73+
return true;
74+
}
75+
if (TryConvertWordsToNumber(words, out var numberValue, out var unrecognizedNumberWord))
76+
{
77+
parsedValue = isNegative ? -numberValue : numberValue;
78+
return true;
79+
}
5780

58-
return isNegative ? -ConvertWordsToNumber(words) : ConvertWordsToNumber(words);
81+
unrecognizedWord = unrecognizedNumberWord;
82+
parsedValue = default;
83+
return false;
5984
}
6085

61-
62-
private int ConvertWordsToNumber(string words)
86+
private bool TryConvertWordsToNumber(string words, out int result, out string? unrecognizedWord)
6387
{
6488
var wordsArray = words.Split(' ', StringSplitOptions.RemoveEmptyEntries);
65-
int result = 0, current = 0;
89+
result = 0;
90+
unrecognizedWord = null;
91+
var current = 0;
6692
var hasOrdinal = false;
6793

6894
foreach (var word in wordsArray)
@@ -75,10 +101,14 @@ private int ConvertWordsToNumber(string words)
75101
}
76102

77103
if (!NumbersMap.TryGetValue(word, out var value))
78-
throw new ArgumentException($"Unrecognized number word: {word}");
104+
{
105+
unrecognizedWord = word;
106+
return false;
107+
}
79108

80109
if (value == 100)
81110
current = (current == 0 ? 1 : current) * 100;
111+
82112
else if (value >= 1000)
83113
{
84114
result += (current == 0 ? 1 : current) * value;
@@ -88,6 +118,9 @@ private int ConvertWordsToNumber(string words)
88118
current += value;
89119
}
90120

91-
return hasOrdinal ? result : result + current;
121+
if (!hasOrdinal)
122+
result += current;
123+
124+
return true;
92125
}
93126
}

src/Humanizer/Localisation/WordsToNumber/GenderlessWordsToNumberConverter.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ namespace Humanizer;
33
internal abstract class GenderlessWordsToNumberConverter : IWordsToNumberConverter
44
{
55
public abstract int Convert(string words);
6+
public abstract bool TryConvert(string words, out int parsedValue);
7+
public abstract bool TryConvert(string words, out int parsedValue, out string? unrecognizedNumber);
68
}

src/Humanizer/Localisation/WordsToNumber/IWordsToNumberConverter.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,8 @@ namespace Humanizer;
22

33
public interface IWordsToNumberConverter
44
{
5+
bool TryConvert(string words, out int parsedValue);
6+
bool TryConvert(string words, out int parsedValue, out string? unrecognizedNumber);
7+
58
int Convert(string words);
69
}
Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,44 @@
11
using System.Globalization;
2-
using Humanizer;
2+
33
namespace Humanizer
44
{
55
/// <summary>
66
/// Transform humanized string to number; e.g. one => 1
77
/// </summary>
88
public static class WordsToNumberExtension
99
{
10-
public static int ToNumber(this string words, CultureInfo culture) => Configurator.GetWordsToNumberConverter(culture).Convert(words);
10+
/// <summary>
11+
/// Throws an exception if any word is not recognized.
12+
/// </summary>
13+
/// <param name="words">The spelled-out number (e.g., "three hundred twenty-one").</param>
14+
/// <param name="culture">The culture used for parsing (e.g., en-US).</param>
15+
/// <returns>The integer value represented by the words.</returns>
16+
/// <exception cref="FormatException">Thrown if the input contains unrecognized words.</exception>
17+
public static int ToNumber(this string words, CultureInfo culture)
18+
=> Configurator.GetWordsToNumberConverter(culture).Convert(words);
19+
20+
/// <summary>
21+
/// Returns false if the input is not a valid spelled-out number.
22+
/// </summary>
23+
/// <param name="words">The spelled-out number (e.g., "forty-two").</param>
24+
/// <param name="parsedNumber">The parsed integer result if successful; otherwise 0.</param>
25+
/// <param name="culture">The culture used for parsing.</param>
26+
/// <returns>True if conversion was successful; otherwise false.</returns>
27+
public static bool TryToNumber(this string words, out int parsedNumber, CultureInfo culture)
28+
=> Configurator.GetWordsToNumberConverter(culture).TryConvert(words, out parsedNumber);
29+
30+
/// <summary>
31+
/// Returns false if any word is unrecognized, and provides the first invalid word.
32+
/// </summary>
33+
/// <param name="words">The spelled-out number (e.g., "one thousand one").</param>
34+
/// <param name="parsedNumber">The parsed integer result if successful; otherwise 0.</param>
35+
/// <param name="culture">The culture used for parsing.</param>
36+
/// <param name="unrecognizedWord">
37+
/// The first unrecognized word found in the input; null if all words are valid.
38+
/// </param>
39+
/// <returns>True if conversion was successful; otherwise false.</returns>
40+
public static bool TryToNumber(this string words, out int parsedNumber, CultureInfo culture, out string? unrecognizedWord)
41+
=> Configurator.GetWordsToNumberConverter(culture).TryConvert(words, out parsedNumber, out unrecognizedWord);
42+
1143
}
1244
}

0 commit comments

Comments
 (0)