197 lines
5.2 KiB
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
|
|
}
|