package phrasebook import ( "fmt" "io/fs" "path" "regexp" "sort" "strconv" "strings" "unicode" "github.com/goccy/go-yaml" ) // Catalog is an immutable-after-load set of namespaced translation entries. type Catalog struct { // locale -> full key (vendor.plugin::group.dot.path) -> entry messages map[string]map[string]entry // namespace (plugin ID) -> owning plugin ID namespaces map[string]string } type entry struct { text string plurals map[string]string pipes []pipePart } type pipePart struct { exact *float64 lo *float64 hi *float64 text string cat string } var cldrNames = map[string]struct{}{ "zero": {}, "one": {}, "two": {}, "few": {}, "many": {}, "other": {}, } var cldrOrder = []string{"zero", "one", "two", "few", "many", "other"} // NewCatalog returns an empty translation catalog. func NewCatalog() *Catalog { return &Catalog{ messages: make(map[string]map[string]entry), namespaces: make(map[string]string), } } // Load walks fsys for lang//.yaml files, flattens nested YAML // maps into vendor.plugin::group.dot.path keys, and merges them into c. func (c *Catalog) Load(pluginID string, fsys fs.FS) error { if c == nil { return fmt.Errorf("phrasebook: catalog is nil") } if pluginID == "" { return fmt.Errorf("phrasebook: plugin id is empty") } if fsys == nil { return nil } if owner, ok := c.namespaces[pluginID]; ok { return catalogErrorf(pluginID, "", "", "duplicate namespace owner %q", owner) } if c.messages == nil { c.messages = make(map[string]map[string]entry) } if c.namespaces == nil { c.namespaces = make(map[string]string) } var files []string err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } files = append(files, path.Clean(p)) return nil }) if err != nil { return catalogErrorf(pluginID, "", "", "walk lang fs: %s", err.Error()) } sort.Strings(files) for _, file := range files { locale, group, err := parseLangPath(file) if err != nil { return catalogErrorf(pluginID, file, "", "%s", err.Error()) } raw, err := fs.ReadFile(fsys, file) if err != nil { return catalogErrorf(pluginID, file, "", "read: %s", err.Error()) } if err := c.loadFile(pluginID, file, locale, group, raw); err != nil { return err } } c.namespaces[pluginID] = pluginID return nil } func (c *Catalog) loadFile(pluginID, file, locale, group string, raw []byte) error { var decoded any if err := yaml.Unmarshal(raw, &decoded); err != nil { return catalogErrorf(pluginID, file, "", "parse: %s", err.Error()) } if decoded == nil { return nil } root, err := asStringMap(decoded) if err != nil { return catalogErrorf(pluginID, file, "", "%s", err.Error()) } prefix := pluginID + "::" + group return flatten(c, pluginID, file, locale, prefix, root) } func flatten(c *Catalog, pluginID, file, locale, prefix string, v any) error { switch val := v.(type) { case string: return c.putString(pluginID, file, locale, prefix, val) case map[string]any: if len(val) == 0 { return nil } if isPluralMap(val) { return c.putPluralMap(pluginID, file, locale, prefix, val) } keys := make([]string, 0, len(val)) for k := range val { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { next := joinKey(prefix, k) if err := flatten(c, pluginID, file, locale, next, val[k]); err != nil { return err } } return nil case map[any]any: converted, err := anyMapToStringMap(val) if err != nil { return catalogErrorf(pluginID, file, prefix, "%s", err.Error()) } return flatten(c, pluginID, file, locale, prefix, converted) case nil: return catalogErrorf(pluginID, file, prefix, "non-string leaf (null)") default: return catalogErrorf(pluginID, file, prefix, "non-string leaf (%T)", v) } } func isPluralMap(m map[string]any) bool { if len(m) == 0 { return false } for k := range m { if _, ok := cldrNames[k]; !ok { return false } } return true } func (c *Catalog) putString(pluginID, file, locale, key, text string) error { e := entry{text: text} if strings.Contains(text, "|") { parts, err := parsePipe(text, locale) if err != nil { return catalogErrorf(pluginID, file, key, "%s", err.Error()) } e.pipes = parts } return c.put(pluginID, file, locale, key, e) } func (c *Catalog) putPluralMap(pluginID, file, locale, key string, raw map[string]any) error { if _, ok := raw["other"]; !ok { return catalogErrorf(pluginID, file, key, "plural map requires other") } allowed, err := cldrCategorySet(locale) if err != nil { return catalogErrorf(pluginID, file, key, "%s", err.Error()) } plurals := make(map[string]string, len(raw)) keys := make([]string, 0, len(raw)) for k := range raw { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if _, ok := allowed[k]; !ok { return catalogErrorf(pluginID, file, key, "invalid plural category %q for locale %s", k, locale) } s, ok := raw[k].(string) if !ok { return catalogErrorf(pluginID, file, joinKey(key, k), "non-string leaf (%T)", raw[k]) } plurals[k] = s } return c.put(pluginID, file, locale, key, entry{plurals: plurals}) } func (c *Catalog) put(pluginID, file, locale, key string, e entry) error { if key == "" || strings.HasSuffix(key, "::") || strings.HasSuffix(key, ".") { return catalogErrorf(pluginID, file, key, "empty key") } byKey := c.messages[locale] if byKey == nil { byKey = make(map[string]entry) c.messages[locale] = byKey } if _, exists := byKey[key]; exists { return catalogErrorf(pluginID, file, key, "duplicate key") } byKey[key] = e return nil } func (c *Catalog) lookup(locale, key string) (entry, bool) { if c == nil { return entry{}, false } byKey := c.messages[locale] if byKey == nil { return entry{}, false } e, ok := byKey[key] return e, ok } func parseLangPath(p string) (locale, group string, err error) { p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/")) p = strings.TrimPrefix(p, "/") if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") { return "", "", fmt.Errorf("malformed path") } parts := strings.Split(p, "/") if len(parts) != 3 || parts[0] != "lang" { return "", "", fmt.Errorf("malformed path") } locale = parts[1] base := parts[2] if !strings.HasSuffix(base, ".yaml") { return "", "", fmt.Errorf("malformed path") } group = strings.TrimSuffix(base, ".yaml") if !validLocale(locale) || !validGroup(group) { return "", "", fmt.Errorf("malformed path") } return locale, group, nil } func validLocale(s string) bool { if s == "" || s == "." || s == ".." { return false } for i, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) { continue } if (r == '-' || r == '_') && i > 0 && i < len(s)-1 { continue } return false } return true } func validGroup(s string) bool { if s == "" || s == "." || s == ".." { return false } for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' { continue } return false } return true } func joinKey(prefix, key string) string { if prefix == "" { return key } if key == "" { return prefix } return prefix + "." + key } func asStringMap(v any) (map[string]any, error) { switch val := v.(type) { case map[string]any: return val, nil case map[any]any: return anyMapToStringMap(val) default: return nil, fmt.Errorf("root must be a mapping") } } func anyMapToStringMap(v map[any]any) (map[string]any, error) { out := make(map[string]any, len(v)) for k, val := range v { sk, ok := k.(string) if !ok { return nil, fmt.Errorf("non-string key (%T)", k) } out[sk] = val } return out, nil } var ( pipeExact = regexp.MustCompile(`^\{(-?\d+)\}\s*(.*)$`) pipeRange = regexp.MustCompile(`^\[(-?\d+),(-?\d+|\*)\]\s*(.*)$`) ) func parsePipe(text, locale string) ([]pipePart, error) { raw := strings.Split(text, "|") if len(raw) < 2 { return nil, fmt.Errorf("malformed pipe") } cats, _, err := cldrCategories(locale) if err != nil { return nil, err } parts := make([]pipePart, 0, len(raw)) var forms []pipePart for _, seg := range raw { seg = strings.TrimSpace(seg) if seg == "" { return nil, fmt.Errorf("malformed pipe") } part, err := parsePipeSegment(seg) if err != nil { return nil, err } parts = append(parts, part) if part.exact == nil && part.lo == nil { forms = append(forms, part) } } if len(forms) > 0 && len(forms) != len(cats) { return nil, fmt.Errorf("incomplete pipe") } fi := 0 for i := range parts { if parts[i].exact == nil && parts[i].lo == nil { parts[i].cat = cats[fi] fi++ } } return parts, nil } func parsePipeSegment(seg string) (pipePart, error) { if strings.HasPrefix(seg, "{") { m := pipeExact.FindStringSubmatch(seg) if m == nil { return pipePart{}, fmt.Errorf("malformed pipe") } n, err := strconv.ParseFloat(m[1], 64) if err != nil { return pipePart{}, fmt.Errorf("malformed pipe") } return pipePart{exact: &n, text: m[2]}, nil } if strings.HasPrefix(seg, "[") { m := pipeRange.FindStringSubmatch(seg) if m == nil { return pipePart{}, fmt.Errorf("malformed pipe") } lo, err := strconv.ParseFloat(m[1], 64) if err != nil { return pipePart{}, fmt.Errorf("malformed pipe") } part := pipePart{lo: &lo, text: m[3]} if m[2] == "*" { return part, nil } hi, err := strconv.ParseFloat(m[2], 64) if err != nil { return pipePart{}, fmt.Errorf("malformed pipe") } if lo > hi { return pipePart{}, fmt.Errorf("malformed pipe") } part.hi = &hi return part, nil } return pipePart{text: seg}, nil } func catalogErrorf(pluginID, file, key, format string, args ...any) error { msg := fmt.Sprintf(format, args...) switch { case key != "": return fmt.Errorf("phrasebook: plugin %s file %s key %s: %s", pluginID, file, key, msg) case file != "": return fmt.Errorf("phrasebook: plugin %s file %s: %s", pluginID, file, msg) default: return fmt.Errorf("phrasebook: plugin %s: %s", pluginID, msg) } }