Files

460 lines
15 KiB
Go

// Copyright 2025 The go-github AUTHORS. All rights reserved.
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package structfield is a custom linter to be used by
// golangci-lint to find instances where the Go field name
// of a struct does not match the JSON or URL tag name.
// It honors idiomatic Go initialisms and handles the
// special case of `Github` vs `GitHub` as agreed upon
// by the original author of the repo.
// It also checks that fields with "omitempty" tags are reference types
// for `json` struct tags and value types for `url` struct tags.
package structfield
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"log"
"reflect"
"regexp"
"strings"
"github.com/golangci/plugin-module-register/register"
"golang.org/x/tools/go/analysis"
)
func init() {
register.Plugin("structfield", New)
}
// StructFieldPlugin is a custom linter plugin for golangci-lint.
type StructFieldPlugin struct {
allowedTagNames map[string]bool
allowedTagTypes map[string]bool
}
// Settings is the configuration for the structfield linter.
type Settings struct {
AllowedTagNames []string `json:"allowed-tag-names" yaml:"allowed-tag-names"`
AllowedTagTypes []string `json:"allowed-tag-types" yaml:"allowed-tag-types"`
}
// New returns an analysis.Analyzer to use with golangci-lint.
func New(cfg any) (register.LinterPlugin, error) {
allowedTagNames := map[string]bool{}
allowedTagTypes := map[string]bool{}
if cfg != nil {
if settingsMap, ok := cfg.(map[string]any); ok {
if exceptionsRaw, ok := settingsMap["allowed-tag-names"]; ok {
if exceptionsList, ok := exceptionsRaw.([]any); ok {
for _, item := range exceptionsList {
if exception, ok := item.(string); ok {
allowedTagNames[exception] = true
}
}
}
}
if exceptionsRaw, ok := settingsMap["allowed-tag-types"]; ok {
if exceptionsList, ok := exceptionsRaw.([]any); ok {
for _, item := range exceptionsList {
if exception, ok := item.(string); ok {
allowedTagTypes[exception] = true
}
}
}
}
}
}
return &StructFieldPlugin{
allowedTagNames: allowedTagNames,
allowedTagTypes: allowedTagTypes,
}, nil
}
// BuildAnalyzers builds the analyzers for the StructFieldPlugin.
func (f *StructFieldPlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
return []*analysis.Analyzer{
{
Name: "structfield",
Doc: `Reports mismatches between Go field and JSON or URL tag names and types.
Note that the JSON or URL tag name is the source-of-truth and the Go field name needs to match it.
If the "json" tag contains "omitempty", then the Go field must be a reference type.
If the "url" tag contains "omitempty", then the Go field must be a value type (unless it is a timestamp).`,
Run: func(pass *analysis.Pass) (any, error) {
return run(pass, f.allowedTagNames, f.allowedTagTypes)
},
},
}, nil
}
// GetLoadMode returns the load mode for the StructFieldPlugin.
func (f *StructFieldPlugin) GetLoadMode() string {
return register.LoadModeSyntax
}
func run(pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) (any, error) {
for _, file := range pass.Files {
ast.Inspect(file, func(n ast.Node) bool {
if n == nil {
return false
}
t, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
structType, ok := t.Type.(*ast.StructType)
if !ok {
return true
}
// Check only exported
if !ast.IsExported(t.Name.Name) {
return true
}
for _, field := range structType.Fields.List {
if field.Tag == nil || len(field.Names) == 0 {
continue
}
processStructField(t.Name.Name, field, pass, allowedTagNames, allowedTagTypes)
}
return true
})
}
return nil, nil
}
func processStructField(structName string, field *ast.Field, pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) {
goField := field.Names[0]
tagValue := strings.Trim(field.Tag.Value, "`")
structTag := reflect.StructTag(tagValue)
processTag(structName, goField, field, structTag, "json", pass, allowedTagNames, allowedTagTypes)
processTag(structName, goField, field, structTag, "url", pass, allowedTagNames, allowedTagTypes)
}
func processTag(structName string, goField *ast.Ident, field *ast.Field, structTag reflect.StructTag, tagType string, pass *analysis.Pass, allowedTagNames, allowedTagTypes map[string]bool) {
tagName, ok := structTag.Lookup(tagType)
if !ok || tagName == "-" {
return
}
hasOmitEmpty := strings.Contains(tagName, ",omitempty")
hasOmitZero := strings.Contains(tagName, ",omitzero")
if hasOmitEmpty || hasOmitZero {
checkGoFieldType(structName, goField.Name, tagType, field, field.Pos(), pass, allowedTagTypes, hasOmitEmpty, hasOmitZero)
tagName = strings.ReplaceAll(tagName, ",omitzero", "")
tagName = strings.ReplaceAll(tagName, ",omitempty", "")
}
if tagType == "url" {
tagName = strings.ReplaceAll(tagName, ",comma", "")
}
checkGoFieldName(structName, goField.Name, tagType, tagName, goField.Pos(), pass, allowedTagNames)
}
func checkAndReportInvalidTypesForOmitzero(structName, tagType, goFieldName string, fieldType ast.Expr, tokenPos token.Pos, pass *analysis.Pass) bool {
switch ft := fieldType.(type) {
case *ast.StarExpr:
// Check for *[]T where T is builtin - should be []T
if arrType, ok := ft.X.(*ast.ArrayType); ok {
if ident, ok := arrType.Elt.(*ast.Ident); ok && isBuiltinType(ident.Name) {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]"+ident.Name, structName)
} else if starExpr, ok := arrType.Elt.(*ast.StarExpr); ok {
// Check for *[]*T - should be []*T
if ident, ok := starExpr.X.(*ast.Ident); ok {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]*"+ident.Name, structName)
}
} else {
checkStructArrayType(structName, goFieldName, arrType, tokenPos, pass)
}
return true
}
// Check for *int and other pointers to builtin types
if ident, ok := ft.X.(*ast.Ident); ok {
if isBuiltinType(ident.Name) {
const msg = `the %q field in struct %q uses "omitzero" with a primitive type; remove "omitzero" and use only "omitempty" for pointer primitive types"`
pass.Reportf(tokenPos, msg, goFieldName, structName)
return true
}
}
// Check for *map - should be map
if _, ok := ft.X.(*ast.MapType); ok {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, exprToString(ft.X), structName)
return true
}
return true
case *ast.MapType:
return true
case *ast.ArrayType:
if tagType == "url" {
const msg = "the %q field in struct %q uses unsupported omitzero tag for URL tags"
pass.Reportf(tokenPos, msg, goFieldName, structName)
return true
}
checkStructArrayType(structName, goFieldName, ft, tokenPos, pass)
return true
case *ast.Ident:
if obj := pass.TypesInfo.ObjectOf(ft); obj != nil {
switch obj.Type().Underlying().(type) {
case *types.Struct:
// For Struct - should be *Struct
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "*"+ft.Name, structName)
return true
case *types.Basic:
if tagType == "url" {
const msg = "the %q field in struct %q uses unsupported omitzero tag for URL tags"
pass.Reportf(tokenPos, msg, goFieldName, structName)
return true
}
// For Builtin - should not to be used with omitzero
const msg = `the %q field in struct %q uses "omitzero" with a primitive type; remove "omitzero", as it is only allowed with structs, maps, and slices`
pass.Reportf(tokenPos, msg, goFieldName, structName)
return true
}
}
case *ast.SelectorExpr:
return true
default:
log.Fatalf("unhandled type: %T", ft)
}
return false
}
func checkGoFieldName(structName, goFieldName, tagType, tagName string, tokenPos token.Pos, pass *analysis.Pass, allowedNames map[string]bool) {
fullName := structName + "." + goFieldName
if allowedNames[fullName] {
return
}
want, alternate := tagNameToPascal(tagName)
if goFieldName != want && goFieldName != alternate {
const msg = "change Go field name %q to %q for %v tag %q in struct %q"
pass.Reportf(tokenPos, msg, goFieldName, want, tagType, tagName, structName)
}
}
func checkGoFieldType(structName, goFieldName, tagType string, field *ast.Field, tokenPos token.Pos, pass *analysis.Pass, allowedTypes map[string]bool, omitempty, omitzero bool) {
if allowedTypes[structName+"."+goFieldName] {
return
}
switch {
case omitzero:
skipOmitzero := checkAndReportInvalidTypesForOmitzero(structName, tagType, goFieldName, field.Type, tokenPos, pass)
if !skipOmitzero {
const msg = `the %q field in struct %q uses "omitzero"; remove "omitzero", as it is only allowed with structs, maps, and slices`
pass.Reportf(tokenPos, msg, goFieldName, structName)
}
case omitempty:
if newFieldType, ok := checkAndReportInvalidTypes(structName, tagType, goFieldName, field.Type, tokenPos, pass); !ok {
const msg = `change the %q field type to %q in the struct %q because its %v tag uses "omitempty"`
pass.Reportf(tokenPos, msg, goFieldName, newFieldType, structName, tagType)
}
}
}
func checkAndReportInvalidTypes(structName, tagType, goFieldName string, fieldType ast.Expr, tokenPos token.Pos, pass *analysis.Pass) (newFieldType string, ok bool) {
switch ft := fieldType.(type) {
case *ast.StarExpr:
// Check for *[]T where T is builtin - should be []T
if arrType, ok := ft.X.(*ast.ArrayType); ok {
if ident, ok := arrType.Elt.(*ast.Ident); ok && isBuiltinType(ident.Name) {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]"+ident.Name, structName)
} else if starExpr, ok := arrType.Elt.(*ast.StarExpr); ok {
// Check for *[]*T - should be []*T
if ident, ok := starExpr.X.(*ast.Ident); ok {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]*"+ident.Name, structName)
}
} else {
checkStructArrayType(structName, goFieldName, arrType, tokenPos, pass)
}
}
// Check for *map - should be map
if _, ok := ft.X.(*ast.MapType); ok {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, exprToString(ft.X), structName)
}
if ident, ok := ft.X.(*ast.Ident); ok && tagType == "url" && isBuiltinType(ident.Name) {
// Remove the pointer for primitives in url tags
return ident.Name, false
}
return "", true
case *ast.MapType:
return "", true
case *ast.ArrayType:
checkStructArrayType(structName, goFieldName, ft, tokenPos, pass)
return "", true
case *ast.SelectorExpr:
// Check for json.RawMessage
if ident, ok := ft.X.(*ast.Ident); ok && ident.Name == "json" && ft.Sel.Name == "RawMessage" {
return "", true
}
case *ast.Ident:
// Check for `any` type
if ft.Name == "any" {
return "", true
}
if tagType == "url" && isBuiltinType(ft.Name) {
return "", true
}
default:
log.Fatalf("unhandled type: %T", ft)
}
newFieldType = "*" + exprToString(fieldType)
return newFieldType, false
}
func checkStructArrayType(structName, goFieldName string, arrType *ast.ArrayType, tokenPos token.Pos, pass *analysis.Pass) {
if starExpr, ok := arrType.Elt.(*ast.StarExpr); ok {
if ident, ok := starExpr.X.(*ast.Ident); ok && isBuiltinType(ident.Name) {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]"+ident.Name, structName)
}
return
}
if ident, ok := arrType.Elt.(*ast.Ident); ok && ident.Obj != nil {
if _, ok := ident.Obj.Decl.(*ast.TypeSpec).Type.(*ast.StructType); ok {
const msg = "change the %q field type to %q in the struct %q"
pass.Reportf(tokenPos, msg, goFieldName, "[]*"+ident.Name, structName)
}
}
}
func isBuiltinType(typeName string) bool {
return types.Universe.Lookup(typeName) != nil
}
func exprToString(e ast.Expr) string {
switch t := e.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
return exprToString(t.X) + "." + t.Sel.Name
case *ast.MapType:
return "map[" + exprToString(t.Key) + "]" + exprToString(t.Value)
default:
return fmt.Sprintf("%T", e)
}
}
func splitTag(jsonTagName string) []string {
jsonTagName = strings.TrimPrefix(jsonTagName, "$")
if strings.Contains(jsonTagName, "_") {
return strings.Split(jsonTagName, "_")
}
if strings.Contains(jsonTagName, "-") {
return strings.Split(jsonTagName, "-")
}
if strings.ToLower(jsonTagName) == jsonTagName { // single word
return []string{jsonTagName}
}
s := camelCaseRE.ReplaceAllString(jsonTagName, "$1 $2")
parts := strings.Fields(s)
for i, part := range parts {
parts[i] = strings.ToLower(part)
}
return parts
}
var camelCaseRE = regexp.MustCompile(`([a-z0-9])([A-Z])`)
func tagNameToPascal(tagName string) (want, alternate string) {
parts := splitTag(tagName)
alt := make([]string, len(parts))
for i, part := range parts {
alt[i] = part
if part == "" {
continue
}
upper := strings.ToUpper(part)
if initialisms[upper] {
parts[i] = upper
alt[i] = upper
} else if specialCase, ok := specialCases[upper]; ok {
parts[i] = specialCase
alt[i] = specialCase
} else if possibleAlternate, ok := possibleAlternates[upper]; ok {
parts[i] = possibleAlternate
alt[i] = strings.ToUpper(part[:1]) + part[1:]
} else {
parts[i] = strings.ToUpper(part[:1]) + part[1:]
alt[i] = parts[i]
}
}
return strings.Join(parts, ""), strings.Join(alt, "")
}
// Common Go initialisms that should be all caps.
var initialisms = map[string]bool{
"AI": true, "API": true, "ASCII": true, "AWS": true,
"CAA": true, "CAS": true, "CLI": true, "CNAME": true, "CPU": true,
"CSS": true, "CWE": true, "CVE": true, "CVSS": true,
"DN": true, "DNS": true,
"EOF": true, "EPSS": true,
"GB": true, "GHSA": true, "GPG": true, "GUID": true,
"HTML": true, "HTTP": true, "HTTPS": true,
"ID": true, "IDE": true, "IDP": true, "IP": true, "JIT": true,
"JSON": true,
"OIDC": true,
"LDAP": true, "LFS": true, "LHS": true, "LOC": true,
"MCP": true, "MD5": true, "MS": true, "MX": true,
"NPM": true, "NTP": true, "NVD": true,
"OID": true, "OS": true,
"PEM": true, "PR": true, "QPS": true,
"RAM": true, "RHS": true, "RPC": true,
"SAML": true, "SAS": true, "SBOM": true, "SCIM": true,
"SHA": true, "SHA1": true, "SHA256": true,
"SKU": true, "SLA": true, "SMTP": true, "SNMP": true,
"SPDX": true, "SPDXID": true, "SQL": true, "SSH": true,
"SSL": true, "SSO": true, "SVN": true,
"TCP": true, "TFVC": true, "TLS": true, "TTL": true,
"UDP": true, "UI": true, "UID": true, "UUID": true,
"URI": true, "URL": true, "UTF8": true,
"VCF": true, "VCS": true, "VM": true,
"XML": true, "XMPP": true, "XSRF": true, "XSS": true,
}
var specialCases = map[string]string{
"CPUS": "CPUs",
"CWES": "CWEs",
"JFROG": "JFrog",
"GRAPHQL": "GraphQL",
"HREF": "HRef",
"IDS": "IDs",
"IPS": "IPs",
"OAUTH": "OAuth",
"OPENAPI": "OpenAPI",
"URLS": "URLs",
}
var possibleAlternates = map[string]string{
"ORGANIZATION": "Org",
"ORGANIZATIONS": "Orgs",
"REPOSITORY": "Repo",
"REPOSITORIES": "Repos",
}