Files
go-omni/i18n/plural.go

197 lines
5.2 KiB
Go

package i18n
import (
_ "embed"
"encoding/json"
"fmt"
"math/big"
"regexp"
"strconv"
"strings"
"golang.org/x/text/language"
)
// PluralRulesVersion pins both runtimes to the same Unicode data. Changing it
// is a presentation compatibility change, independent of message format Version.
const PluralRulesVersion = "cldr-46.1"
//go:embed cldr/plurals.json
var pluralData []byte
//go:embed cldr/LICENSE
var unicodeLicense string
type pluralRange struct{ lo, hi *big.Int }
type pluralCondition struct {
operand byte
modulus *big.Int
negate bool
ranges []pluralRange
}
type pluralRule [][]pluralCondition // OR of AND groups, per the CLDR grammar.
type pluralSet struct {
text map[string]string
rules map[string]pluralRule
}
var pluralConditionSyntax = regexp.MustCompile(`^([nivwftec])(?: % ([1-9][0-9]*))? (!=|=) ([0-9]+(?:\.\.[0-9]+)?(?:,\s*[0-9]+(?:\.\.[0-9]+)?)*)$`)
var cardinalRules = loadCardinalRules()
func bigDecimal(s string) *big.Int {
if s == "" {
return new(big.Int)
}
n, ok := new(big.Int).SetString(s, 10)
if !ok {
panic("i18n: invalid embedded plural integer")
}
return n
}
func parsePluralRule(text string) (pluralRule, error) {
var result pluralRule
if len(text) > 2048 {
return nil, fmt.Errorf("plural rule too large")
}
for _, or := range strings.Split(text, " or ") {
var group []pluralCondition
for _, and := range strings.Split(or, " and ") {
parts := pluralConditionSyntax.FindStringSubmatch(strings.TrimSpace(and))
if parts == nil {
return nil, fmt.Errorf("invalid CLDR plural condition")
}
c := pluralCondition{operand: parts[1][0], negate: parts[3] == "!="}
if parts[2] != "" {
c.modulus = bigDecimal(parts[2])
}
for _, r := range strings.Split(parts[4], ",") {
lo, hi, has := strings.Cut(strings.TrimSpace(r), "..")
if !has {
hi = lo
}
low, high := bigDecimal(lo), bigDecimal(hi)
if low.Cmp(high) > 0 {
return nil, fmt.Errorf("reversed plural range")
}
c.ranges = append(c.ranges, pluralRange{low, high})
}
group = append(group, c)
}
result = append(result, group)
}
return result, nil
}
func loadCardinalRules() map[string]pluralSet {
var data struct {
Supplemental struct {
Rules map[string]map[string]string `json:"plurals-type-cardinal"`
} `json:"supplemental"`
}
if err := json.Unmarshal(pluralData, &data); err != nil {
panic("i18n: invalid embedded CLDR data")
}
out := map[string]pluralSet{}
for locale, entries := range data.Supplemental.Rules {
set := pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
for name, raw := range entries {
category := strings.TrimPrefix(name, "pluralRule-count-")
text, _, _ := strings.Cut(raw, "@")
text = strings.TrimSpace(text)
if category == "other" {
continue
}
rule, err := parsePluralRule(text)
if err != nil {
panic("i18n: unsupported embedded CLDR rule for " + locale)
}
set.text[category] = text
set.rules[category] = rule
}
out[strings.ReplaceAll(locale, "_", "-")] = set
}
return out
}
func rulesFor(tag language.Tag) pluralSet {
for {
if rules, ok := cardinalRules[tag.String()]; ok {
return rules
}
if tag == language.Und {
break
}
tag = tag.Parent()
}
return pluralSet{text: map[string]string{}, rules: map[string]pluralRule{}}
}
// operands expands the canonical numeric string exactly, avoiding any float
// conversion for int64 and any browser rounding of tiny fractional operands.
func operands(text string) (map[byte]*big.Int, bool) {
text = strings.TrimPrefix(text, "-")
mantissa, exponent, has := strings.Cut(text, "e")
shift := 0
if has {
shift, _ = strconv.Atoi(exponent)
}
whole, fraction, _ := strings.Cut(mantissa, ".")
digits := whole + fraction
point := len(whole) + shift
switch {
case point <= 0:
whole = "0"
fraction = strings.Repeat("0", -point) + digits
case point >= len(digits):
whole = digits + strings.Repeat("0", point-len(digits))
fraction = ""
default:
whole = digits[:point]
fraction = digits[point:]
}
tail := strings.TrimRight(fraction, "0")
i, f := bigDecimal(whole), bigDecimal(fraction)
return map[byte]*big.Int{'n': i, 'i': i, 'v': big.NewInt(int64(len(fraction))), 'w': big.NewInt(int64(len(tail))), 'f': f, 't': bigDecimal(tail), 'c': new(big.Int), 'e': new(big.Int)}, f.Sign() != 0
}
func (rule pluralRule) matches(values map[byte]*big.Int, fractional bool) bool {
for _, group := range rule {
all := true
for _, condition := range group {
value := values[condition.operand]
if condition.modulus != nil {
value = new(big.Int).Mod(value, condition.modulus)
}
found := false
if condition.operand != 'n' || !fractional {
for _, r := range condition.ranges {
if value.Cmp(r.lo) >= 0 && value.Cmp(r.hi) <= 0 {
found = true
break
}
}
}
if condition.negate {
found = !found
}
if !found {
all = false
break
}
}
if all {
return true
}
}
return false
}
func category(tag language.Tag, text string) (string, error) {
values, fractional := operands(text)
rules := rulesFor(tag).rules
for _, category := range []string{"zero", "one", "two", "few", "many"} {
if rule, ok := rules[category]; ok && rule.matches(values, fractional) {
return category, nil
}
}
return "other", nil
}