Skip to content

Instantly share code, notes, and snippets.

@konifar
Created December 11, 2023 00:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save konifar/65d7642b221e67ce1ab095b31b3654d1 to your computer and use it in GitHub Desktop.
Save konifar/65d7642b221e67ce1ab095b31b3654d1 to your computer and use it in GitHub Desktop.
package main
import (
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"log"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"sync/atomic"
"github.com/BurntSushi/toml"
)
var (
localeFileDir string
targetDir string
)
func init() {
flag.StringVar(&localeFileDir, "localeFileDir", ".", "Localeファイルのあるディレクトリ")
flag.StringVar(&targetDir, "targetDir", ".", "チェック対象のディレクトリ")
}
type ErrorInfo struct {
Key string
FileName string
LineNo int
Message string // エラーメッセージ
}
type Result struct {
errorInfos []ErrorInfo
err error
}
// Localeファイルからキーと値の一覧を取得
func readLocaleKeys(filename string) (map[string]string, error) {
keys := make(map[string]string)
_, err := toml.DecodeFile(filename, &keys)
if err != nil {
return nil, err
}
return keys, nil
}
// テンプレート文字列 {{}} のキー一覧を抽出する
func extractTemplateKeys(s string) []string {
re := regexp.MustCompile(`{{\.(.*?)}}`)
matches := re.FindAllStringSubmatch(s, -1)
keys := make([]string, len(matches))
for i, match := range matches {
if len(match) > 1 {
keys[i] = strings.TrimSpace(match[1])
}
}
return keys
}
// Goのコードを解析し、i18n.MustLocalize関数の第二引数がLocaleファイルのキーに存在するかをチェック
func checkLocalKeyWorker(filenames <-chan string, keys map[string]string, results chan<- Result) {
for filename := range filenames {
result := Result{}
var errorInfos []ErrorInfo
fileSet := token.NewFileSet()
node, err := parser.ParseFile(fileSet, filename, nil, parser.ParseComments)
if err != nil {
result.err = err
results <- result
}
// NOTE: 以下の条件で検査します
// 1. i18n.MustLocalize~関数を呼び出している(MustLocalize, MustLocalizeByLanguageCodeなど)
// 2. 第 2 引数が文字列で、Localeファイルに存在する
// 3. 第 3 引数がマップで、キーがLocaleファイルのテンプレート文字列のキーと一致する
ast.Inspect(node, func(n ast.Node) bool {
expr, _ := n.(*ast.CallExpr)
// 引数の数が3つより小さければスキップ
if expr == nil || len(expr.Args) < 3 {
return true
}
// 関数が MustLocalize~ でなければスキップ
indent, ok := expr.Fun.(*ast.SelectorExpr)
if !ok || !strings.HasPrefix(indent.Sel.Name, "MustLocalize") {
return true
}
// 第2引数が文字列でなければスキップ
secondArg, ok := expr.Args[1].(*ast.BasicLit)
if !ok || secondArg.Kind != token.STRING {
return true
}
// 第2引数の値がLocaleファイルに登録されているキーでなければエラーとしてerrorInfos に追加
key, _ := strconv.Unquote(secondArg.Value) // #nosec G104
position := fileSet.Position(expr.Pos())
message, ok := keys[key]
if !ok {
errorInfo := ErrorInfo{
Key: key,
FileName: position.Filename,
LineNo: position.Line,
Message: "キーが存在しません",
}
errorInfos = append(errorInfos, errorInfo)
return true
}
thirdArg := expr.Args[2]
templateKeys := extractTemplateKeys(message)
// テンプレート文字列のキーがない時
if len(templateKeys) == 0 {
// 第3引数がnilではなければエラーとして errorInfos に追加
paramsMap, ok := thirdArg.(*ast.CompositeLit)
if !ok && paramsMap != nil {
errorInfo := ErrorInfo{
Key: key,
FileName: position.Filename,
LineNo: position.Line,
Message: "第3引数はnilでなければなりません",
}
errorInfos = append(errorInfos, errorInfo)
return true
}
} else {
// テンプレート文字列のキーがある時
// 第3引数がmapではなければエラーとして errorInfos に追加
compositeLit, ok := thirdArg.(*ast.CompositeLit)
if !ok {
errorInfo := ErrorInfo{
Key: key,
FileName: position.Filename,
LineNo: position.Line,
Message: "第3引数はmapでなければなりません",
}
errorInfos = append(errorInfos, errorInfo)
return true
}
// テンプレート文字列のキーが3引数がmapのキーになければエラーとして errorInfos に追加
for _, elt := range compositeLit.Elts {
kvExpr, _ := elt.(*ast.KeyValueExpr) // #nosec G104
keyIdent, _ := kvExpr.Key.(*ast.BasicLit) // #nosec G104
paramKey, _ := strconv.Unquote(keyIdent.Value) // #nosec G104
if !contains(templateKeys, paramKey) {
errorInfo := ErrorInfo{
Key: key,
FileName: position.Filename,
LineNo: position.Line,
Message: fmt.Sprintf("第3引数のmapのキーがテンプレート文字列のキーと違います, paramsKey: %s, templateKeys: %v", paramKey, templateKeys),
}
errorInfos = append(errorInfos, errorInfo)
return true
}
}
}
log.Printf("[pass] key: %s, file: %s:%d\n", key, position.Filename, position.Line)
return true
})
result.errorInfos = errorInfos
results <- result
}
}
func contains(arr []string, str string) bool {
for _, a := range arr {
if a == str {
return true
}
}
return false
}
// go run scripts/i18n_key_checker/main.go -localeFileDir=helpers/i18n -targetDir=./
func main() {
flag.Parse()
log.Println("Start checking...")
log.Println("------------------------")
// Localeファイルからキーの一覧を読み込む
keys, err := readLocaleKeys(filepath.Join(localeFileDir, "ja.toml"))
if err != nil {
log.Fatal(err)
}
filePaths := make(chan string)
results := make(chan Result)
// NOTE: 検査チェッカーをつかえる CPU だけ起動
for i := 0; i < runtime.NumCPU(); i++ {
go checkLocalKeyWorker(filePaths, keys, results)
}
// 検査対象のファイルをワーカーに渡し終えたらチャネルを閉じる
inputDone := make(chan struct{})
var remainedCount int64
// NOTE: .go ファイルを検査対象として、ワーカーにチャネルで渡す
go func() {
// #nosec G104
_ = filepath.Walk(targetDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// .goファイルの場合のみ
if !info.IsDir() && filepath.Ext(path) == ".go" {
atomic.AddInt64(&remainedCount, 1)
filePaths <- path
}
return nil
})
close(inputDone)
close(filePaths)
}()
var errorInfos []ErrorInfo
for {
select {
case result := <-results:
if result.err != nil {
log.Fatal(result.err)
}
if len(result.errorInfos) > 0 {
errorInfos = append(errorInfos, result.errorInfos...)
}
atomic.AddInt64(&remainedCount, -1)
case <-inputDone:
if remainedCount == 0 {
log.Println("Finish checking...")
log.Println("------------------------")
if len(errorInfos) > 0 {
log.Printf("%d invalid keys/values are detected!\n", len(errorInfos))
for _, info := range errorInfos {
log.Printf("[fail] %s, key: %s, file: %s:%d\n", info.Message, info.Key, info.FileName, info.LineNo)
}
os.Exit(1)
}
log.Println("All keys are valid!")
return
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment