222 lines
5.8 KiB
Go
222 lines
5.8 KiB
Go
package i18n
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
var paramName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`)
|
|
var integerText = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
|
|
|
|
type part struct{ text, param string }
|
|
type compiled struct {
|
|
parts []part
|
|
selectKey, pluralKey string
|
|
cases map[string]*compiled
|
|
}
|
|
type budget struct{ nodes, bytes int }
|
|
|
|
func compile(p Pattern, schema map[string]Kind, used map[string]bool, b *budget, depth int) (*compiled, error) {
|
|
b.nodes++
|
|
b.bytes += len(p.Text)
|
|
if depth > 24 || b.nodes > 100000 || b.bytes > 4<<20 || len(p.Text) > 64<<10 || !utf8.ValidString(p.Text) {
|
|
return nil, &Diagnostic{Kind: "resource", Problem: "pattern exceeds limits or contains invalid UTF-8"}
|
|
}
|
|
c := &compiled{selectKey: p.Select, pluralKey: p.Plural}
|
|
if p.Select != "" || p.Plural != "" {
|
|
if p.Select != "" && p.Plural != "" || p.Text != "" || p.Literal || len(p.Cases) == 0 || len(p.Cases) > 128 {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "expected one select/plural branch with cases"}
|
|
}
|
|
key := p.Select
|
|
if key == "" {
|
|
key = p.Plural
|
|
}
|
|
kind, ok := schema[key]
|
|
if !ok || p.Plural != "" && kind != Integer && kind != Number || p.Select != "" && kind != String && kind != Boolean {
|
|
return nil, &Diagnostic{Kind: "parameter", Param: key, Problem: "branch requires a declared parameter of the appropriate type"}
|
|
}
|
|
used[key] = true
|
|
if _, ok := p.Cases["other"]; !ok {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "branch requires other"}
|
|
}
|
|
c.cases = make(map[string]*compiled, len(p.Cases))
|
|
for _, name := range sortedKeys(p.Cases) {
|
|
if !validID(name) {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid case name"}
|
|
}
|
|
if p.Plural != "" && !pluralCase(name) {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "invalid plural category or exact integer case"}
|
|
}
|
|
child, err := compile(p.Cases[name], schema, used, b, depth+1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.cases[name] = child
|
|
}
|
|
return c, nil
|
|
}
|
|
if p.Cases != nil {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "cases require select or plural"}
|
|
}
|
|
if p.Literal {
|
|
c.parts = []part{{text: p.Text}}
|
|
return c, nil
|
|
}
|
|
var literal strings.Builder
|
|
flush := func() {
|
|
if literal.Len() > 0 {
|
|
c.parts = append(c.parts, part{text: literal.String()})
|
|
literal.Reset()
|
|
}
|
|
}
|
|
for i := 0; i < len(p.Text); {
|
|
ch := p.Text[i]
|
|
if ch != '{' && ch != '}' {
|
|
literal.WriteByte(ch)
|
|
i++
|
|
continue
|
|
}
|
|
if i+1 < len(p.Text) && p.Text[i+1] == ch {
|
|
literal.WriteByte(ch)
|
|
i += 2
|
|
continue
|
|
}
|
|
if ch == '}' {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "unescaped closing brace"}
|
|
}
|
|
end := strings.IndexByte(p.Text[i+1:], '}')
|
|
if end < 0 {
|
|
return nil, &Diagnostic{Kind: "pattern", Problem: "unclosed placeholder"}
|
|
}
|
|
name := p.Text[i+1 : i+1+end]
|
|
if _, ok := schema[name]; !ok {
|
|
return nil, &Diagnostic{Kind: "parameter", Param: name, Problem: "undeclared placeholder"}
|
|
}
|
|
used[name] = true
|
|
flush()
|
|
c.parts = append(c.parts, part{param: name})
|
|
i += end + 2
|
|
}
|
|
flush()
|
|
return c, nil
|
|
}
|
|
|
|
func pluralCase(s string) bool {
|
|
switch s {
|
|
case "zero", "one", "two", "few", "many", "other":
|
|
return true
|
|
}
|
|
if !strings.HasPrefix(s, "=") {
|
|
return false
|
|
}
|
|
n, err := strconv.ParseInt(s[1:], 10, 64)
|
|
return err == nil && strconv.FormatInt(n, 10) == s[1:]
|
|
}
|
|
|
|
func scalar(kind Kind, value any) (string, error) {
|
|
bad := func() (string, error) {
|
|
return "", &Diagnostic{Kind: "parameter", Problem: "wrong type, range, or non-finite value"}
|
|
}
|
|
if value == nil {
|
|
return bad()
|
|
}
|
|
v := reflect.ValueOf(value)
|
|
switch kind {
|
|
case String:
|
|
if v.Kind() == reflect.String && v.Type() != reflect.TypeOf(json.Number("")) && utf8.ValidString(v.String()) && len(v.String()) <= 64<<10 {
|
|
return v.String(), nil
|
|
}
|
|
case Boolean:
|
|
if v.Kind() == reflect.Bool {
|
|
return strconv.FormatBool(v.Bool()), nil
|
|
}
|
|
case Integer:
|
|
if n, ok := value.(json.Number); ok {
|
|
x, e := n.Int64()
|
|
if e == nil {
|
|
return strconv.FormatInt(x, 10), nil
|
|
}
|
|
return bad()
|
|
}
|
|
switch v.Kind() {
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return strconv.FormatInt(v.Int(), 10), nil
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
if v.Uint() <= math.MaxInt64 {
|
|
return strconv.FormatUint(v.Uint(), 10), nil
|
|
}
|
|
}
|
|
case Number:
|
|
var n float64
|
|
if j, ok := value.(json.Number); ok {
|
|
var e error
|
|
n, e = j.Float64()
|
|
if e != nil {
|
|
return bad()
|
|
}
|
|
} else {
|
|
switch v.Kind() {
|
|
case reflect.Float32, reflect.Float64:
|
|
n = v.Float()
|
|
default:
|
|
return bad()
|
|
}
|
|
}
|
|
if !math.IsNaN(n) && !math.IsInf(n, 0) {
|
|
if n == 0 {
|
|
return "0", nil
|
|
}
|
|
// ECMAScript's plain/scientific thresholds, shared with the browser runtime.
|
|
format := byte('f')
|
|
if math.Abs(n) < 1e-6 || math.Abs(n) >= 1e21 {
|
|
format = 'e'
|
|
}
|
|
s := strconv.FormatFloat(n, format, -1, 64)
|
|
s = strings.ReplaceAll(strings.ReplaceAll(s, "e-0", "e-"), "e+0", "e+")
|
|
return s, nil
|
|
}
|
|
}
|
|
return bad()
|
|
}
|
|
|
|
func (c *compiled) render(tag language.Tag, values map[string]string) (string, error) {
|
|
if c.cases != nil {
|
|
key := values[c.selectKey]
|
|
if c.pluralKey != "" {
|
|
n := values[c.pluralKey]
|
|
key = "=" + n
|
|
if _, ok := c.cases[key]; !ok {
|
|
var err error
|
|
key, err = category(tag, n)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
next := c.cases[key]
|
|
if next == nil {
|
|
next = c.cases["other"]
|
|
}
|
|
return next.render(tag, values)
|
|
}
|
|
var out strings.Builder
|
|
for _, p := range c.parts {
|
|
value := p.text
|
|
if p.param != "" {
|
|
value = values[p.param]
|
|
}
|
|
if out.Len()+len(value) > 1<<20 {
|
|
return "", &Diagnostic{Kind: "resource", Problem: "rendered message exceeds 1 MiB"}
|
|
}
|
|
out.WriteString(value)
|
|
}
|
|
return out.String(), nil
|
|
}
|