Files
go-omni/i18n/pattern.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
}