gitlink-cli/internal/i18n/cmd/check/main.go

158 lines
3.7 KiB
Go

package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func main() {
fix := flag.Bool("fix", false, "format locale JSON files")
scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys")
flag.Parse()
problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if len(problems) > 0 {
for _, problem := range problems {
fmt.Fprintln(os.Stderr, problem.String())
}
os.Exit(1)
}
if err := checkLocaleFormat(*fix); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *scanCode {
if err := checkCodeReferences(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
fmt.Println("i18n messages are valid")
}
func checkLocaleFormat(fix bool) error {
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
if err != nil {
return err
}
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
return err
}
formatted, err := formatJSON(data)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if string(data) == string(formatted) {
continue
}
if fix {
if err := os.WriteFile(path, formatted, 0644); err != nil {
return err
}
continue
}
return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path)
}
return nil
}
func formatJSON(data []byte) ([]byte, error) {
var messages map[string]string
if err := json.Unmarshal(data, &messages); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(messages); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func checkCodeReferences() error {
loader := i18n.NewEmbedLoader()
base, err := loader.Load("en-US")
if err != nil {
return err
}
used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"})
if err != nil {
return err
}
var missing []string
for key := range used {
if _, ok := base[key]; !ok {
missing = append(missing, key)
}
}
sort.Strings(missing)
if len(missing) > 0 {
return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", "))
}
for _, item := range defaultUses {
fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item)
}
return nil
}
func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) {
keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`)
defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`)
used := map[string]struct{}{}
var defaultUses []string
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
text := string(data)
for _, match := range keyPattern.FindAllStringSubmatch(text, -1) {
used[match[1]] = struct{}{}
}
for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) {
line := 1 + strings.Count(text[:match[0]], "\n")
defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line))
}
return nil
})
if err != nil {
return nil, nil, err
}
}
sort.Strings(defaultUses)
return used, defaultUses, nil
}