diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2023-10-24 07:34:00 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2023-10-24 07:34:00 +0200 |
| commit | 435397de49adc9740bc67416166aba4d0be8ae2a (patch) | |
| tree | 1642dccae517840f3256f35e29e8d7d1802f700f | |
| parent | 18476c6cba2d5d2bb3ecf3ad136dacbf41d0360e (diff) | |
feat: Add cmds
| -rwxr-xr-x | .githooks/pre-commit | 13 | ||||
| -rw-r--r-- | .github/workflows/workflow.yml | 16 | ||||
| -rwxr-xr-x | ci-deps.sh | 9 | ||||
| -rw-r--r-- | cmd/cvet/check_command.go | 104 | ||||
| -rw-r--r-- | cmd/cvet/check_query.go | 25 | ||||
| -rw-r--r-- | cmd/cvet/code_smells.go | 64 | ||||
| -rw-r--r-- | cmd/cvet/logger.go | 71 | ||||
| -rw-r--r-- | cmd/cvet/main.go | 9 | ||||
| -rw-r--r-- | cmd/gettext/gettext.go | 164 | ||||
| -rw-r--r-- | cmd/gettext/gettext_test.go | 61 | ||||
| -rw-r--r-- | go.mod | 11 | ||||
| -rw-r--r-- | go.sum | 20 |
12 files changed, 559 insertions, 8 deletions
diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..92a43ad --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,13 @@ +#!/bin/sh +set -ev + +templ generate +gofmt -d -e -s . +go vet ./... +go run ./tools/cvet ./... +staticcheck ./... +gosec -quiet ./... +gocyclo -over 10 -ignore '.*_templ\.go' . +golangci-lint run +go test ./... +go mod tidy
\ No newline at end of file diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..090bc8f --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,16 @@ +name: Push action +on: push +jobs: + checks: + runs-on: ubuntu-latest + env: + GOPATH: /home/runner/work/go + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v4 + with: + go-version-file: go.mod + - name: Install additional dependencies + run: sh ./ci-deps.sh + - name: Run git hooks + run: ./.githooks/pre-commit
\ No newline at end of file diff --git a/ci-deps.sh b/ci-deps.sh new file mode 100755 index 0000000..89b37ab --- /dev/null +++ b/ci-deps.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -ev + +go install github.com/a-h/templ/cmd/templ@latest +go install github.com/fzipp/gocyclo/cmd/gocyclo@latest +go install honnef.co/go/tools/cmd/staticcheck@latest +go install github.com/securego/gosec/v2/cmd/gosec@latest +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest +go install github.com/a-h/templ/cmd/templ@latest
\ No newline at end of file diff --git a/cmd/cvet/check_command.go b/cmd/cvet/check_command.go new file mode 100644 index 0000000..47db8fa --- /dev/null +++ b/cmd/cvet/check_command.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "go/ast" + "go/token" + "strings" + + "github.com/samber/lo" + "golang.org/x/tools/go/analysis" +) + +func isCommandFunc(node *ast.FuncDecl) bool { + return strings.HasSuffix(node.Name.Name, "Command") +} + +func loggerCommandKeyValueArgFinder(pass *analysis.Pass, commandName string) func(arg ast.Expr) bool { + return func(arg ast.Expr) bool { + call, ok := arg.(*ast.CallExpr) + if !ok { + return false + } + firstArg, ok := call.Args[0].(*ast.BasicLit) + if !ok { + return false + } + if firstArg.Kind == token.STRING && firstArg.Value == `"command"` { + secondArg, ok := call.Args[1].(*ast.BasicLit) + if !ok { + pass.Reportf(arg.Pos(), "logger command name must be a literal") + return true + } + if secondArg.Kind != token.STRING { + pass.Reportf(arg.Pos(), "logger command name must be a string literal") + return true + } + + value := secondArg.Value[1 : len(secondArg.Value)-1] + if value != commandName { + pass.Reportf(arg.Pos(), fmt.Sprintf("logger command name must be function name %s, but found %s", commandName, value)) + return true + } + return true + } + + return false + } +} + +func checkStmtHaveNoRawSlog(pass *analysis.Pass, stmts []ast.Stmt) { + for _, stmt := range stmts[1:] { + switch stmt := stmt.(type) { + case *ast.ExprStmt: + if call, ok := stmt.X.(*ast.CallExpr); ok { + if selector, ok := call.Fun.(*ast.SelectorExpr); ok { + if isIdent(selector.X, "slog") { + pass.Reportf(stmt.Pos(), "logger must be used to log") + } + } + } + case *ast.IfStmt: + checkStmtHaveNoRawSlog(pass, stmt.Body.List) + } + } +} + +func checkCommandLogger(pass *analysis.Pass, node *ast.FuncDecl) { + commandName := node.Name.Name + firstStmt, ok := node.Body.List[0].(*ast.AssignStmt) + if !ok || !isIdent(firstStmt.Lhs[0], "logger") { + pass.Reportf(node.Pos(), "command first statement must be a logger creation") + return + } + + call, ok := firstStmt.Rhs[0].(*ast.CallExpr) + if !ok || !isSelector(call.Fun, "slog", "With") { + pass.Reportf(node.Pos(), "logger creation must use slog.With") + return + } + if !lo.ContainsBy(call.Args, loggerCommandKeyValueArgFinder(pass, commandName)) { + pass.Reportf(node.Pos(), "logger creation must contain command arg") + } + + checkStmtHaveNoRawSlog(pass, node.Body.List[1:]) +} + +func checkCommandFunc(pass *analysis.Pass, node *ast.FuncDecl) { + if node.Type.Results.NumFields() != 2 { + pass.Reportf(node.Pos(), "command function must have 2 return value") + } else { + if !isIdent(node.Type.Results.List[0].Type, "int") { + pass.Reportf(node.Pos(), "command function must return an int code as the first return value") + } + if !isIdent(node.Type.Results.List[1].Type, "error") { + pass.Reportf(node.Pos(), "command function must return an error as the second return value") + } + } + if lo.ContainsBy(node.Type.Params.List, func(field *ast.Field) bool { + return isSelector(field.Type, "auth", "User") + }) { + pass.Reportf(node.Pos(), "command function must not have an auth.User parameter") + } + checkCommandLogger(pass, node) +} diff --git a/cmd/cvet/check_query.go b/cmd/cvet/check_query.go new file mode 100644 index 0000000..bde3b9f --- /dev/null +++ b/cmd/cvet/check_query.go @@ -0,0 +1,25 @@ +package main + +import ( + "go/ast" + "strings" + + "golang.org/x/tools/go/analysis" +) + +func isQueryFunc(node *ast.FuncDecl) bool { + return strings.HasSuffix(node.Name.Name, "Query") +} + +func checkQueryFunc(pass *analysis.Pass, node *ast.FuncDecl) { + if node.Type.Results.NumFields() != 3 { + pass.Reportf(node.Pos(), "query function must have 3 return values") + } else { + if !isIdent(node.Type.Results.List[1].Type, "int") { + pass.Reportf(node.Pos(), "query function must return an int code as the second return value") + } + if !isIdent(node.Type.Results.List[2].Type, "error") { + pass.Reportf(node.Pos(), "query function must return an error as the third return value") + } + } +} diff --git a/cmd/cvet/code_smells.go b/cmd/cvet/code_smells.go new file mode 100644 index 0000000..f306d85 --- /dev/null +++ b/cmd/cvet/code_smells.go @@ -0,0 +1,64 @@ +package main + +import ( + "go/ast" + + "github.com/samber/lo" + "golang.org/x/tools/go/analysis" +) + +var codeSmellsAnalyzer = &analysis.Analyzer{ + Name: "logging", + Doc: "Logging best practices", + Run: run, +} + +func isIdent(node ast.Node, name string) bool { + if ident, ok := node.(*ast.Ident); ok { + return ident.Name == name + } + return false +} + +func isSelector(node ast.Node, left string, right string) bool { + if selector, ok := node.(*ast.SelectorExpr); ok { + return isIdent(selector.X, left) && isIdent(selector.Sel, right) + } + return false +} + +func visit(pass *analysis.Pass) func(node ast.Node) bool { + return func(node ast.Node) bool { + switch node := node.(type) { + case *ast.CallExpr: + checkLogUsage(pass, node) + case *ast.FuncDecl: + if isQueryFunc(node) { + checkQueryFunc(pass, node) + } + if isCommandFunc(node) { + checkCommandFunc(pass, node) + } + if field, ok := lo.Find(node.Type.Params.List, func(field *ast.Field) bool { + return isSelector(field.Type, "context", "Context") + }); ok { + if field != node.Type.Params.List[0] { + pass.Reportf(node.Pos(), "context.Context must be the first parameter") + } + if field.Names[0].Name != "ctx" { + pass.Reportf(node.Pos(), "context.Context parameter must be named ctx") + } + } + case *ast.BlockStmt: + checkLogBeforePanic(pass, node) + } + return true + } +} + +func run(pass *analysis.Pass) (interface{}, error) { + for _, file := range pass.Files { + ast.Inspect(file, visit(pass)) + } + return nil, nil //nolint:nilnil +} diff --git a/cmd/cvet/logger.go b/cmd/cvet/logger.go new file mode 100644 index 0000000..0852f38 --- /dev/null +++ b/cmd/cvet/logger.go @@ -0,0 +1,71 @@ +package main + +import ( + "go/ast" + + "golang.org/x/tools/go/analysis" +) + +func checkNotLog(pass *analysis.Pass, node *ast.ExprStmt) { + prevCall, ok := node.X.(*ast.CallExpr) + if !ok { + return + } + selector, ok := prevCall.Fun.(*ast.SelectorExpr) + if !ok { + return + } + switch { + case isIdent(selector.X, "slog"): + case isIdent(selector.X, "logger"): + pass.Reportf(node.Pos(), "no log before panic") + } +} + +func checkLogBeforePanic(pass *analysis.Pass, node *ast.BlockStmt) { + for i, stmt := range node.List { + expr, ok := stmt.(*ast.ExprStmt) + if !ok { + continue + } + call, ok := expr.X.(*ast.CallExpr) + if !ok { + continue + } + if i > 0 && isIdent(call.Fun, "panic") { + if prev, ok := node.List[i-1].(*ast.ExprStmt); ok { + checkNotLog(pass, prev) + } + } + } +} + +func checkLogWithArgs(node *ast.CallExpr, pass *analysis.Pass) { + for _, arg := range node.Args { + call, ok := arg.(*ast.CallExpr) + if !ok { + continue + } + if selector, ok := call.Fun.(*ast.SelectorExpr); ok { + ident, ok := selector.X.(*ast.Ident) + if ok && ident.Name != "slog" { + pass.Reportf(node.Pos(), "slog.With and logger.With must be called with a slog arg") + } + } else { + pass.Reportf(node.Pos(), "slog.With and logger.With must be called with a slog arg") + } + } +} + +func checkLogUsage(pass *analysis.Pass, node *ast.CallExpr) { + if selector, ok := node.Fun.(*ast.SelectorExpr); ok { + if ident, ok := selector.X.(*ast.Ident); ok { + if ident.Name == "log" { + pass.Reportf(node.Pos(), "found old log usage") + } + } + } + if isSelector(node.Fun, "slog", "With") || isSelector(node.Fun, "logger", "With") { + checkLogWithArgs(node, pass) + } +} diff --git a/cmd/cvet/main.go b/cmd/cvet/main.go new file mode 100644 index 0000000..540515f --- /dev/null +++ b/cmd/cvet/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "golang.org/x/tools/go/analysis/multichecker" +) + +func main() { + multichecker.Main(codeSmellsAnalyzer) +} diff --git a/cmd/gettext/gettext.go b/cmd/gettext/gettext.go new file mode 100644 index 0000000..7abb6e2 --- /dev/null +++ b/cmd/gettext/gettext.go @@ -0,0 +1,164 @@ +package main + +import ( + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/martinlehoux/kagamigo/core" + "golang.org/x/exp/slog" + "gopkg.in/yaml.v3" +) + +func isIdentifierRune(char rune) bool { + return (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '.' +} + +type ArgsParser struct { + argsCount int + currentBlock string + currentNesting int + currentStringLiteral bool +} + +func (p *ArgsParser) parseStringLiteral(char rune) { + switch char { + case '"': + p.currentStringLiteral = false + p.currentBlock = "" + default: + p.currentBlock += string(char) + } +} + +func (p *ArgsParser) parseNonStringLiteral(char rune) { + if isIdentifierRune(char) { + p.currentBlock += string(char) + return + } + switch char { + case ' ': + if p.currentBlock != "" { + if p.currentNesting == 0 { + p.argsCount++ + } + p.currentBlock = "" + } + case '(': + p.currentBlock = "" + p.currentNesting++ + case ')': + p.currentBlock = "" + p.currentNesting-- + p.argsCount++ + case '"': + p.currentStringLiteral = true + } +} + +func (p *ArgsParser) Parse(args string) { + for _, char := range args { + switch p.currentStringLiteral { + case true: + p.parseStringLiteral(char) + case false: + p.parseNonStringLiteral(char) + } + } +} + +func (p ArgsParser) ArgsCount() int { + if p.currentBlock != "" { + p.argsCount++ + } + return p.argsCount +} + +func extractKeys(content string) map[string]int { + extractedKeys := make(map[string]int, 0) + reg := regexp.MustCompile(`\{\{\s*call \$?\.T\s*"(\w+)"([\w.\s",:()]*)}}`) + matches := reg.FindAllStringSubmatch(content, -1) + for _, match := range matches { + key := match[1] + args := strings.TrimSpace(match[2]) + parser := ArgsParser{} + parser.Parse(args) + extractedKeys[key] = parser.ArgsCount() + } + return extractedKeys +} + +func extractAllKeys() map[string]int { + extractedKeys := make(map[string]int, 0) + + err := filepath.Walk("templates", func(path string, info fs.FileInfo, err error) error { + if !info.IsDir() && filepath.Ext(path) == ".html" { + content, err := os.ReadFile(path) + core.Expect(err, "error reading file") + for key, value := range extractKeys(string(content)) { + extractedKeys[key] = value + } + } + return nil + }) + core.Expect(err, "error walking templates directory") + + return extractedKeys +} + +func main() { + write := flag.Bool("write", false, "write new locales") + flag.Parse() + baseLogger := slog.Default() + langs := [...]string{"en-GB", "fr-FR"} + + extractedKeys := extractAllKeys() + baseLogger.Info("extracted keys from templates", slog.Int("count", len(extractedKeys))) + + for _, lang := range langs { + logger := baseLogger.With(slog.String("lang", lang)) + currentLocales := make(map[string]string, 0) + newLocales := make(map[string]string, 0) + + locales, err := os.ReadFile(filepath.Join("locales", lang, "index.yml")) + core.Expect(err, "error reading file") + core.Expect(yaml.Unmarshal(locales, ¤tLocales), "error unmarshalling yaml") + + correctLocales := 0 + for key, translation := range currentLocales { + currentArgsCount := strings.Count(translation, "%") + expectedArgsCount, ok := extractedKeys[key] + switch { + case !ok: + logger.Info(`found unused key`, slog.String("key", key)) + case currentArgsCount != expectedArgsCount: + logger.Info(`found translation with incorrect number of arguments`, slog.String("key", key), slog.Int("current", currentArgsCount), slog.Int("expected", expectedArgsCount)) + newLocales[key] = "" + case translation == "": + newLocales[key] = "" + default: + newLocales[key] = currentLocales[key] + correctLocales++ + } + } + + for key := range extractedKeys { + if _, ok := currentLocales[key]; !ok { + logger.Info(`found missing key`, slog.String("key", key)) + newLocales[key] = "" + } + } + + logger.Info("finished checking locales", slog.Int("count", len(newLocales)), slog.Int("correct", correctLocales), slog.String("completion", fmt.Sprintf("%d%%", correctLocales*100/len(extractedKeys)))) + + if *write { + content, err := yaml.Marshal(newLocales) + core.Expect(err, "error marshalling yaml") + core.Expect(os.WriteFile(filepath.Join("locales", lang, "index.yml"), content, 0600), "error writing file") + } + } +} diff --git a/cmd/gettext/gettext_test.go b/cmd/gettext/gettext_test.go new file mode 100644 index 0000000..f1947be --- /dev/null +++ b/cmd/gettext/gettext_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractKeys(t *testing.T) { + content := `{{ call .T "Hello" }}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 0, keys["Hello"]) +} + +func TestExtractKeysWithoutSpaces(t *testing.T) { + content := `{{call .T "Hello"}}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 0, keys["Hello"]) +} + +func TestExtractKeysWithDollar(t *testing.T) { + content := `{{ call $.T "Hello" }}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 0, keys["Hello"]) +} + +func TestExtractKeysWithOneArgs(t *testing.T) { + content := `{{ call .T "approveButton" .Username }}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 1, keys["approveButton"]) +} + +func TestExtractKeysWithSeveralArgs(t *testing.T) { + content := `{{ call .T "approveButton" .Username .Age .Email }}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 3, keys["approveButton"]) +} + +func TestExtractKeysWithComplexArgs(t *testing.T) { + content := `{{ call $.T "raceStart_chosen" (.StartAt.Format "Monday, January 2, 2006 at 15:04") }}` + + keys := extractKeys(content) + + assert.Len(t, keys, 1) + assert.Equal(t, 1, keys["raceStart_chosen"]) +} @@ -6,15 +6,20 @@ require ( github.com/a-h/templ v0.2.408 github.com/gofrs/uuid v4.4.0+incompatible github.com/kataras/i18n v0.0.8 + github.com/samber/lo v1.38.1 + github.com/stretchr/testify v1.8.4 golang.org/x/exp v0.0.0-20231006140011-7918f672742d + golang.org/x/tools v0.14.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.9.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.16.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -11,13 +11,23 @@ github.com/kataras/i18n v0.0.8 h1:thiDRqq4fN2sQOK5CwR5Yh1yT7swP6B3wnadILhqjhk= github.com/kataras/i18n v0.0.8/go.mod h1:M/yRAqQ3Y7z2oSpotxG/+nPrgsJLas6t0kEJfIJk98E= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= |
