Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
34 changes: 32 additions & 2 deletions .github/workflows/benchmarks-baseline-vs-current.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,24 @@ jobs:

- name: Clone and Build ResultsComparer
run: |
git clone --depth 1 https://github.com/dotnet/performance
git clone --depth 1 https://github.com/dotnet/performance "$RUNNER_TEMP/performance"

{
echo '<configuration>'
echo ' <config>'
echo ' <add key="signatureValidationMode" value="accept" />'
echo ' </config>'
echo ' <packageSources>'
echo ' <clear />'
echo ' <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />'
echo ' <add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />'
echo ' </packageSources>'
echo '</configuration>'
} > "$RUNNER_TEMP/performance-nuget.config"
Comment thread
clairernovotny marked this conversation as resolved.

dotnet restore "$RUNNER_TEMP/performance/src/tools/ResultsComparer/ResultsComparer.csproj" \
-p:PERFLAB_TARGET_FRAMEWORKS=net10.0 \
--configfile "$RUNNER_TEMP/performance-nuget.config"

- name: Locate Benchmark Results
id: results
Expand Down Expand Up @@ -174,7 +191,20 @@ jobs:
tfm="${{ matrix.targetFramework }}"

echo "Comparing results for $tfm..."
dotnet run --project performance/src/tools/ResultsComparer/ResultsComparer.csproj -c Release -p:PERFLAB_TARGET_FRAMEWORKS=net10.0 -f net10.0 --base "${{ steps.results.outputs.baseline_results }}" --diff "${{ steps.results.outputs.current_results }}" --threshold "5%" > "comparisons/diff-$tfm.md" || true
compare_exit=0
dotnet run --no-restore --project "$RUNNER_TEMP/performance/src/tools/ResultsComparer/ResultsComparer.csproj" -c Release -p:PERFLAB_TARGET_FRAMEWORKS=net10.0 -f net10.0 --base "${{ steps.results.outputs.baseline_results }}" --diff "${{ steps.results.outputs.current_results }}" --threshold "5%" > "comparisons/diff-$tfm.md" || compare_exit=$?

if [ ! -s "comparisons/diff-$tfm.md" ] || grep -q "The build failed" "comparisons/diff-$tfm.md"; then
cat "comparisons/diff-$tfm.md" >&2 || true
if [ "$compare_exit" -ne 0 ]; then
exit "$compare_exit"
fi
exit 1
fi

if [ "$compare_exit" -ne 0 ]; then
echo "ResultsComparer exited with code $compare_exit; uploaded comparison report for inspection."
fi

- name: Append Comparison to Summary
run: |
Expand Down
12 changes: 12 additions & 0 deletions src/Benchmarks/FormatterBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ public string RomanianDateHumanize() =>
public string RussianTimeSpanHumanize() =>
russianSample.Humanize(culture: russianCulture);

[Benchmark(Description = "Russian TimeSpan Humanize multi-part")]
public string RussianTimeSpanHumanizeMultiPart() =>
russianSample.Humanize(precision: 3, culture: russianCulture);

[Benchmark(Description = "Russian TimeSpan Humanize zero")]
public string RussianTimeSpanHumanizeZero() =>
TimeSpan.Zero.Humanize(culture: russianCulture);

[Benchmark(Description = "Russian TimeSpan Humanize words")]
public string RussianTimeSpanHumanizeWords() =>
russianSample.Humanize(culture: russianCulture, toWords: true);

[Benchmark(Description = "Arabic DataUnitHumanize")]
public string ArabicDataUnitHumanize() =>
arabicFormatter.DataUnitHumanize(DataUnit.Gigabyte, 2, toSymbol: false);
Expand Down
12 changes: 12 additions & 0 deletions src/Benchmarks/MetricNumeralBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ public string ToMetricKilo() =>
public string ToMetricMega() =>
1000000.ToMetric();

[Benchmark(Description = "ToMetric boundary")]
public string ToMetricBoundary() =>
999500.ToMetric();

[Benchmark(Description = "ToMetric giga")]
public string ToMetricGiga() =>
int.MaxValue.ToMetric();

[Benchmark(Description = "ToMetric formatted")]
public string ToMetricFormatted() =>
1230.ToMetric(MetricNumeralFormats.WithSpace | MetricNumeralFormats.UseName, 2);

[Benchmark(Description = "ToMetric milli")]
public string ToMetricMilli() =>
0.001.ToMetric();
Expand Down
21 changes: 19 additions & 2 deletions src/Humanizer/Localisation/Formatters/DefaultFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,23 @@ static string RenderPattern(string? pattern, string countValue, string secondary
.Trim();
}

static string JoinPhraseParts(params string?[] parts) =>
string.Join(" ", parts.Where(static part => !string.IsNullOrWhiteSpace(part)));
static string JoinPhraseParts(string? first, string? second, string? third, string? fourth)
{
var result = AppendPhrasePart(null, first);
result = AppendPhrasePart(result, second);
result = AppendPhrasePart(result, third);
return AppendPhrasePart(result, fourth) ?? string.Empty;
}

static string? AppendPhrasePart(string? result, string? part)
{
if (string.IsNullOrWhiteSpace(part))
{
return result;
}

return result is null
? part
: string.Concat(result, " ", part);
Comment thread
clairernovotny marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,46 +45,12 @@ public string Convert(TimeOnly time, ClockNotationRounding roundToNearestFive)
return profile.Midday;
}

var hourWords = ResolveHourExpression(hour);
var nextHourWords = ResolveHourExpression(hour + 1);
var rawMinuteWords = ResolveMinuteWords(normalizedMinutes);

// When a zero-filler is configured and minutes are 1-9, prepend the filler word
// so templates like "{hour} {minutes}" produce "et nul fem" instead of "et fem".
// When compactMinuteWords is active (CJK locales), omit the space between filler and word.
var minuteWords = normalizedMinutes is > 0 and < 10 && profile.ZeroFiller.Length > 0
? string.Concat(profile.ZeroFiller, profile.CompactMinuteWords ? "" : " ", rawMinuteWords)
: rawMinuteWords;

var reverseMinuteWords = normalizedMinutes > 0 ? ResolveMinuteWords(60 - normalizedMinutes) : "";

string halfMinuteWords;
if (normalizedMinutes < 30)
{
halfMinuteWords = ResolveMinuteWords(30 - normalizedMinutes);
}
else if (normalizedMinutes > 30)
{
halfMinuteWords = ResolveMinuteWords(normalizedMinutes - 30);
}
else
{
halfMinuteWords = "";
}

// Resolve the article for locales that have singular/plural articles (ca, es).
var article = ResolveArticle(hour);
var nextArticle = ResolveArticle(hour + 1);

// Pre-compute the day-period string for possible inline use via {dayPeriod} placeholder.
var dayPeriod = GetDayPeriod(hour);

// Check minute-bucket template first (exact 5-minute intervals).
var template = GetBucketTemplate(normalizedMinutes);
if (template.Length > 0)
{
var minuteSuffix = ResolveMinuteSuffixDirect(normalizedMinutes);
var result = ExpandTemplate(template, hourWords, nextHourWords, minuteWords, reverseMinuteWords, halfMinuteWords, article, nextArticle, minuteSuffix, dayPeriod);
var result = ExpandTemplate(template, hour, normalizedMinutes, minuteSuffix);
return ApplyDayPeriodIfNeeded(result, template, hour, normalizedMinutes);
}

Expand All @@ -95,19 +61,21 @@ public string Convert(TimeOnly time, ClockNotationRounding roundToNearestFive)
if (rangeTemplate.Length > 0)
{
var minuteSuffix = ResolveMinuteSuffixForRange(normalizedMinutes);
var result = ExpandTemplate(rangeTemplate, hourWords, nextHourWords, minuteWords, reverseMinuteWords, halfMinuteWords, article, nextArticle, minuteSuffix, dayPeriod);
var result = ExpandTemplate(rangeTemplate, hour, normalizedMinutes, minuteSuffix);
return ApplyDayPeriodIfNeeded(result, rangeTemplate, hour, normalizedMinutes);
}

// Fall to default template — use the actual minute count for suffix resolution.
if (profile.DefaultTemplate.Length > 0)
{
var minuteSuffix = ResolveMinuteSuffixDirect(normalizedMinutes);
var result = ExpandTemplate(profile.DefaultTemplate, hourWords, nextHourWords, minuteWords, reverseMinuteWords, halfMinuteWords, article, nextArticle, minuteSuffix, dayPeriod);
var result = ExpandTemplate(profile.DefaultTemplate, hour, normalizedMinutes, minuteSuffix);
return ApplyDayPeriodIfNeeded(result, profile.DefaultTemplate, hour, normalizedMinutes);
}

// Absolute fallback: "{hour} {minutes}".
var hourWords = ResolveHourExpression(hour);
var minuteWords = ResolveMinuteExpression(normalizedMinutes);
var fallback = minuteWords.Length > 0 ? hourWords + " " + minuteWords : hourWords;
return ApplyDayPeriod(fallback, hour, usesNextHour: false);
}
Expand Down Expand Up @@ -219,6 +187,18 @@ string ResolveMinuteWords(int minutes)
return words;
}

string ResolveMinuteExpression(int normalizedMinutes)
{
var rawMinuteWords = ResolveMinuteWords(normalizedMinutes);

// When a zero-filler is configured and minutes are 1-9, prepend the filler word
// so templates like "{hour} {minutes}" produce "et nul fem" instead of "et fem".
// When compactMinuteWords is active (CJK locales), omit the space between filler and word.
return normalizedMinutes is > 0 and < 10 && profile.ZeroFiller.Length > 0
? string.Concat(profile.ZeroFiller, profile.CompactMinuteWords ? "" : " ", rawMinuteWords)
: rawMinuteWords;
}

/// <summary>
/// Removes all spaces from <paramref name="input"/> using a stack buffer.
/// </summary>
Expand Down Expand Up @@ -410,16 +390,22 @@ string GetRangeTemplate(int minutes)
};
}

string ExpandTemplate(
string template, string hour, string nextHour,
string minutes, string minutesReverse, string minutesFromHalf,
string article, string nextArticle, string minuteSuffix, string dayPeriod)
string ExpandTemplate(string template, int hour, int normalizedMinutes, string minuteSuffix)
{
var hourWords = template.Contains("{hour}") ? ResolveHourExpression(hour) : "";
var nextHourWords = template.Contains("{nextHour}") ? ResolveHourExpression(hour + 1) : "";
var minuteWords = template.Contains("{minutes}") ? ResolveMinuteExpression(normalizedMinutes) : "";
var reverseMinuteWords = template.Contains("{minutesReverse}") && normalizedMinutes > 0 ? ResolveMinuteWords(60 - normalizedMinutes) : "";
var halfMinuteWords = template.Contains("{minutesFromHalf}") ? ResolveHalfMinuteWords(normalizedMinutes) : "";
var article = template.Contains("{article}") ? ResolveArticle(hour) : "";
var nextArticle = template.Contains("{nextArticle}") ? ResolveArticle(hour + 1) : "";
var dayPeriod = template.Contains("{dayPeriod}") ? GetDayPeriod(hour) : "";

Comment thread
clairernovotny marked this conversation as resolved.
Outdated
// Single-pass expansion: scan the template once, resolve placeholders inline,
// skip double spaces, and trim — producing only the final return string.
var maxLen = template.Length
+ hour.Length + nextHour.Length + minutes.Length
+ minutesReverse.Length + minutesFromHalf.Length
+ hourWords.Length + nextHourWords.Length + minuteWords.Length
+ reverseMinuteWords.Length + halfMinuteWords.Length
+ article.Length + nextArticle.Length + minuteSuffix.Length + dayPeriod.Length;

Span<char> buf = stackalloc char[maxLen];
Expand All @@ -440,7 +426,7 @@ string ExpandTemplate(
var name = template.AsSpan(i + 1, close - i - 1);
var replacement = ResolveTemplatePlaceholder(
name, template, close + 1,
hour, nextHour, minutes, minutesReverse, minutesFromHalf,
hourWords, nextHourWords, minuteWords, reverseMinuteWords, halfMinuteWords,
article, nextArticle, minuteSuffix, dayPeriod);

replacement.CopyTo(buf[pos..]);
Expand All @@ -459,6 +445,21 @@ string ExpandTemplate(
return new string(result);
}

string ResolveHalfMinuteWords(int normalizedMinutes)
{
if (normalizedMinutes < 30)
{
return ResolveMinuteWords(30 - normalizedMinutes);
}

if (normalizedMinutes > 30)
{
return ResolveMinuteWords(normalizedMinutes - 30);
}

return "";
}

static void AppendChar(Span<char> buf, ref int pos, char c)
{
// Collapse double spaces inline so no post-processing pass is needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -971,27 +971,33 @@ static string NormalizeLowercaseRemovePeriods(string words)
// This profile stays allocation-free when the input is already lowercase and only trimmed
// whitespace needs to be preserved.
var needsNormalization = false;
var sawHyphen = false;
var needsBuilderNormalization = false;
var previousWasSpace = false;

foreach (var current in source)
{
if (current is ',' or '.')
{
needsNormalization = true;
needsBuilderNormalization = true;
break;
}

if (current == '-')
{
needsNormalization = true;
break;
sawHyphen = true;
previousWasSpace = false;
continue;
}

if (char.IsWhiteSpace(current))
{
if (current != ' ' || previousWasSpace)
{
needsNormalization = true;
needsBuilderNormalization = true;
break;
}

Expand All @@ -1002,6 +1008,7 @@ static string NormalizeLowercaseRemovePeriods(string words)
if (char.IsUpper(current))
{
needsNormalization = true;
needsBuilderNormalization = true;
break;
}

Expand All @@ -1013,9 +1020,39 @@ static string NormalizeLowercaseRemovePeriods(string words)
return source.Length == words.Length ? words : source.ToString();
}

if (sawHyphen && !needsBuilderNormalization && CanReplaceHyphensWithSpaces(source))
{
return source.Length == words.Length
? words.Replace('-', ' ')
: source.ToString().Replace('-', ' ');
}
Comment thread
clairernovotny marked this conversation as resolved.

return NormalizeWithBuilder(words, TokenMapNormalizationProfile.LowercaseRemovePeriods);
}

static bool CanReplaceHyphensWithSpaces(ReadOnlySpan<char> source)
{
for (var i = 0; i < source.Length; i++)
{
if (source[i] != '-')
{
continue;
}

if (i == 0 ||
i == source.Length - 1 ||
source[i - 1] == '-' ||
source[i + 1] == '-' ||
source[i - 1] == ' ' ||
source[i + 1] == ' ')
{
return false;
}
}
Comment thread
clairernovotny marked this conversation as resolved.

return true;
Comment thread
clairernovotny marked this conversation as resolved.
}

/// <summary>
/// Lowercases text while treating some punctuation as token separators.
/// </summary>
Expand Down
39 changes: 37 additions & 2 deletions src/Humanizer/MetricNumeralExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,43 @@ public static double FromMetric(this string input)
/// </code>
/// </example>
/// <returns>A valid Metric representation</returns>
public static string ToMetric(this int input, MetricNumeralFormats? formats = null, int? decimals = null) =>
((double)input).ToMetric(formats, decimals);
public static string ToMetric(this int input, MetricNumeralFormats? formats = null, int? decimals = null)
{
if (!formats.HasValue && !decimals.HasValue)
{
return BuildDefaultRepresentation(input);
}

return ((double)input).ToMetric(formats, decimals);
}

static string BuildDefaultRepresentation(int input)
{
if (input == 0)
{
return input.ToString();
}

var absolute = Math.Abs((long)input);
var nfi = LocaleNumberFormattingOverrides.GetFormattingNumberFormat(CultureInfo.CurrentCulture);

if (absolute < 1_000)
{
return input.ToString(nfi);
}

if (absolute < 1_000_000)
{
return (input / 1_000d).ToString("G15", nfi) + "k";
}

if (absolute < 1_000_000_000)
{
return (input / 1_000_000d).ToString("G15", nfi) + "M";
}

return (input / 1_000_000_000d).ToString("G15", nfi) + "G";
}

/// <summary>
/// Converts a number into a valid and Human-readable Metric representation.
Expand Down
Loading
Loading