Skip to content

Implement pluralisable strings#6734

Merged
bdach merged 5 commits into
ppy:masterfrom
smoogipoo:pluralisable-string-2
May 8, 2026
Merged

Implement pluralisable strings#6734
bdach merged 5 commits into
ppy:masterfrom
smoogipoo:pluralisable-string-2

Conversation

@smoogipoo

Copy link
Copy Markdown
Contributor

Supersedes / closes #6733
Supersedes / closes #4918

Used tests + some implementation from first PR above, with my proposal.

Example in-game usage:

osu.Game.Resources.Localisation.Web.CommonStrings.CountBadges("5").ToQuantity(5);

Named it ToQuantity() to match humanizer.

@smoogipoo smoogipoo force-pushed the pluralisable-string-2 branch from 4253cee to 9d838b2 Compare April 18, 2026 07:30
@smoogipoo smoogipoo requested a review from bdach April 18, 2026 07:30
@smoogipoo

Copy link
Copy Markdown
Contributor Author

@bdach See if you find this more digestible. I'm certain it's the correct way to go, save for better documentation/commenting/naming/etc - let me know if this is inadequate as is (was a 20 minute implementation).

@bdach

bdach commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

See if you find this more digestible.

If you want my honest thoughts...

Before I even look at any of these PRs in terms of the code inside, the first metric I'm going to use when evaluating a change like this is to look at the usage code. And...

Example in-game usage:

osu.Game.Resources.Localisation.Web.CommonStrings.CountBadges("5").ToQuantity(5);

This kinda sucks? It sucks for two reasons.

  1. Why is it required to input 5 twice, once as a number and once as a string? (This is possibly fixable by rewriting the .ToQuantity() helper, although it does require making some assumptions as to how pluralisable strings work, in that they should probably only ever be allowed to have a designated placeholder for the primary quantity indicator. Whether that assumption holds for existing web-side strings, I don't know; maybe you do know that it doesn't hold and that's why this was written this way.)
  2. The second, worse one is: Why do you have to know to .ToQuantity() this string to begin with? This is a burden on both localisers and code reviewers since it is not readily apparent whether a LocalisableString usage needs to get .ToQuantity() called on it or not to work correct. Now is it simple and obvious to check? Sure, it is. That said, the best APIs are the ones that are literally impossible to misuse. This isn't that. What would be that, is if osu.Game.Resources already returned a PluralisableString and you just never need to worry about it.

Now I have no immediate answers to either of these criticisms. If I had them, I'd have PR'd this sort of change myself ages ago, and I'm pretty sure I had at least one go at this before hitting these exact walls and bailing since I wasn't sure I could pull it off. Maybe you can bring a counterexample of existing web strings that make your path clearly the only viable one, given how confident you appear to be in saying it's the "correct way to go", but right now I don't see it so I really do wonder if this is the best that can be done.

Also I do note that both of the predecessor PRs suffer from issue (1) above, as far as I can tell - but in theory not (2). I say "in theory" because none of the previous authors have actually tried to follow up.

@smoogipoo

smoogipoo commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

Why is it required to input 5 twice, once as a number and once as a string?

  • What if the "quantity" is a non-integer value like 5.1?
  • What if you don't want to display 5.1 but want to format it as 5.10? Maybe you want to put a ± infront of it, maybe you want to display a leading number of digits for fixed-size/fixed-length text.

I can imagine use cases where you don't necessarily want the same number to become an argument with whatever formatting the framework so chooses to use.

although it does require making some assumptions as to how pluralisable strings work

Yes indeed, I didn't want to do this.

in that they should probably only ever be allowed to have a designated placeholder for the primary quantity indicator

Emphasis mine. I think it's important to understand this limits the potential scope of localisation. You'd no longer able to cleanly compose strings for example.

Simple example but let's say the pluralisable string was {0} beer (+{1})|{0} beers (+{1}), that is it'd display to the user as "you picked up 5 beers (+2)". How would you compose this string?


As for (2), making it return a pluralisable string can always be done as a followup. None of the code here would change as far as I can tell.

The question is detecting when exactly that's the case, and I'm not sure that can be done safely without potentially mangling strings, so I'm not going to attempt it for the time being. I mean, have a look at how the strings are actually defined: https://github.com/ppy/osu-web/blob/21a7e87a2eeb504668a0877cd6b3a258d2e8e144/resources/lang/en/common.php#L69-L87 There's no indication of "this is a string that's pluralisable" other than it having a delimiter in the string - you can't even count on it having a :count_delimited var name.

Some strings (e.g. second_short_unit) don't have a count at all, so you have to sort of inject a virtual quantity parameter into the method opportunistically. There's many trials to face down this path.

@bdach

bdach commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

What if the "quantity" is a non-integer value like 5.1?

This is a concern that I don't see in scope because the grammar rules can change yet again for decimals so it just wouldn't work anyhow without further nudging of the pluralisation rules. Counterexample: in Polish, the proper grammatical form for some nouns when decimals come into play is a completely different one than any of the integral-plural variants:

  • 1 kot (1 cat)
  • 2 koty (2 cats)
  • 5 kotów (5 cats)
  • 0.7 kota (0.7... of a cat. please don't cancel me for this example. it's just the first one that came to mind. just treat it as a statistic or something.)

Whether this could be eventually supported is up for debate. I'd say we don't want to. My mindset was to basically chain framework to the scope of pluralisation as done in laravel and move no further, because I don't want to find myself suddenly maintaining a localisation library and needing to research how fractional plural noun forms work in like Swedish or something.

What if you don't want to display 5.1 but want to format it as 5.10? Maybe you want to put a ± infront of it, maybe you want to display a leading number of digits for fixed-size/fixed-length text.

This is fair. I expect it to be the 5% case, but it is real.

I was hoping that to cover the 95% case, maybe a convenience helper could be provided. Something along the lines of

        public static PluralisableString ToQuantity(this Func<LocalisableString, LocalisableString> strTemplate, int quantity, char separator = '|')
            => new PluralisableString(strTemplate(quantity.ToLocalisableString()), quantity, separator);

With the end goal of writing something like this:

                var t = CommonStrings.CountMinutes.ToQuantity((int)difference.TotalSeconds);

But compiler says no. You'd either have to wrap the prefix into a Func<LocalisableString, LocalisableString> or make it a full static method instead of an extension. At which point, not sure there's a point.

Maybe could be done with further support osu-resources-side (to expose overloads of relevant methods that return the Func). If there's even a good heuristic that could do it.

Some strings (e.g. second_short_unit) don't have a count at all, so you have to sort of inject a virtual quantity parameter into the method opportunistically. There's many trials to face down this path.

Yeah this is also bad for my best case scenario.

All which to say, I'm like 80% sold on this being the-best-that-can-be-done. That said I won't do a proper review until Monday.

@smoogipoo

smoogipoo commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

We could do the following because after all we have some static helpers:

diff --git a/osu.Framework/Localisation/LocalisableString.cs b/osu.Framework/Localisation/LocalisableString.cs
index be2dde5fd3..0df7b1e375 100644
--- a/osu.Framework/Localisation/LocalisableString.cs
+++ b/osu.Framework/Localisation/LocalisableString.cs
@@ -4,6 +4,7 @@
 using System;
 using System.Diagnostics.CodeAnalysis;
 using JetBrains.Annotations;
+using osu.Framework.Extensions.LocalisationExtensions;
 
 namespace osu.Framework.Localisation
 {
@@ -47,6 +48,8 @@ public LocalisableString(ILocalisableStringData data)
         /// <param name="interpolation">The interpolated string containing format and arguments.</param>
         public static LocalisableString Interpolate(FormattableString interpolation) => new LocalisableFormattableString(interpolation);
 
+        public static LocalisableString Quantity(Func<LocalisableString, LocalisableString> strTemplate, int quantity) => strTemplate(quantity.ToLocalisableString()).ToQuantity(quantity);
+
         /// <summary>
         /// Indicates whether the specified <see cref="LocalisableString"/> is <see langword="null"/> or an empty <see langword="string"/>.
         /// </summary>

Usage would then be:

LocalisableString.Quantity(osu.Game.Resources.Localisation.Web.CommonStrings.CountBadges, 5);

That said, I'm still against this because I believe it leads localisation into more potentially unexpected results - if you ever use this in a context where that parameter is not a quantity, things are going to mess up. I highly prefer being explicit about it. I'll grit my teeth if that's what it takes to get this over the line.

@smoogipoo

smoogipoo commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

I took a bit of time to implement pluralisable strings in osu-localisation-analyser to see how it'd look: ppy/osu-localisation-analyser#67

Full diff of osu-resources: https://gist.github.com/smoogipoo/5d03d91d1e4984ed66b76e1908eb1748
Haven't actually tested if any of this compiles at this point, just wanted a point of reference to call out anything that would be very obviously wrong after generation.

Again I want to say that this can be done at any point in time and I don't feel any rush in doing this.

@smoogipoo smoogipoo changed the title Implement pluralisable localisation strings Implement pluralisable strings Apr 18, 2026
@pull-request-size pull-request-size Bot added size/XL and removed size/L labels May 7, 2026

@bdach bdach left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pushed minor cleanups, docs & attributions. Seems okay. @smoogipoo plz double-check.

@peppy peppy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just a quick check, looks okay to me!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants