syft/internal/capabilities/generate/app_config_discovery.go
Alex Goodman 0dd906b071 fix linting
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2025-10-28 10:38:24 -04:00

477 lines
12 KiB
Go

package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"reflect"
"sort"
"strings"
)
// AppConfigField represents an application-level configuration field for catalogers
type AppConfigField struct {
Key string // e.g., "golang.search-local-mod-cache-licenses"
Description string // extracted from DescribeFields() method
DefaultValue interface{} // extracted from Default*() functions
}
// DiscoverAppConfigs discovers all application-level cataloger configuration fields
// from the options package
func DiscoverAppConfigs(repoRoot string) ([]AppConfigField, error) {
optionsDir := filepath.Join(repoRoot, "cmd", "syft", "internal", "options")
// parse all .go files in the options directory to extract configuration fields
configs := []AppConfigField{}
// define the config files we want to parse with their top-level keys
configFiles := map[string]string{
"dotnet.go": "dotnet",
"golang.go": "golang",
"java.go": "java",
"javascript.go": "javascript",
"linux_kernel.go": "linux-kernel",
"nix.go": "nix",
"python.go": "python",
}
for filename, topLevelKey := range configFiles {
filePath := filepath.Join(optionsDir, filename)
fields, err := extractAppConfigFields(filePath, topLevelKey)
if err != nil {
return nil, fmt.Errorf("failed to extract config from %s: %w", filename, err)
}
configs = append(configs, fields...)
}
// sort by key for consistent output
sort.Slice(configs, func(i, j int) bool {
return configs[i].Key < configs[j].Key
})
return configs, nil
}
// extractAppConfigFields extracts config fields from an options file
func extractAppConfigFields(filePath, topLevelKey string) ([]AppConfigField, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, err
}
var configs []AppConfigField
// find the main config struct (not nested ones)
configStruct, descriptions := findAppConfigStructAndDescriptions(f, topLevelKey)
if configStruct == nil {
return nil, fmt.Errorf("no config struct found in %s", filePath)
}
// extract default values from the default function
defaults := extractAppDefaultValues(f)
// build config fields from struct fields
for _, field := range configStruct.Fields.List {
// extract yaml tag to get the field key
yamlKey := extractYAMLTag(field)
if yamlKey == "" || yamlKey == "-" {
continue
}
var fieldName string
if len(field.Names) > 0 {
fieldName = field.Names[0].Name
} else {
continue
}
// build full key path
fullKey := topLevelKey + "." + yamlKey
// handle nested structs (e.g., golang.MainModuleVersion)
if isNestedStruct(field.Type) {
nestedConfigs := extractNestedAppConfigs(f, fullKey, fieldName, field.Type, descriptions, defaults)
configs = append(configs, nestedConfigs...)
continue
}
// get description from DescribeFields
description := descriptions[fieldName]
// get default value
defaultValue := defaults[fieldName]
configs = append(configs, AppConfigField{
Key: fullKey,
Description: description,
DefaultValue: defaultValue,
})
}
return configs, nil
}
// findAppConfigStructAndDescriptions finds the main config struct and extracts field descriptions
// from the DescribeFields method
func findAppConfigStructAndDescriptions(f *ast.File, topLevelKey string) (*ast.StructType, map[string]string) {
expectedName := determineExpectedConfigName(topLevelKey)
configStruct := findConfigStruct(f, expectedName)
descriptions := extractDescriptionsFromDescribeFields(f)
return configStruct, descriptions
}
// determineExpectedConfigName maps the top-level key to the expected config struct name
func determineExpectedConfigName(topLevelKey string) string {
// handle special cases first
switch topLevelKey {
case "linux-kernel":
return "linuxKernelConfig"
case "javascript":
return "javaScriptConfig"
default:
return topLevelKey + "Config"
}
}
// findConfigStruct searches for the config struct with the expected name in the AST
func findConfigStruct(f *ast.File, expectedName string) *ast.StructType {
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
if typeSpec.Name.Name == expectedName {
return structType
}
}
}
return nil
}
// extractDescriptionsFromDescribeFields extracts field descriptions from the DescribeFields method
func extractDescriptionsFromDescribeFields(f *ast.File) map[string]string {
descriptions := make(map[string]string)
for _, decl := range f.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if !ok || funcDecl.Name.Name != "DescribeFields" {
continue
}
// extract descriptions from descriptions.Add calls
ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
callExpr, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// check if this is a descriptions.Add call
selector, ok := callExpr.Fun.(*ast.SelectorExpr)
if !ok || selector.Sel.Name != "Add" {
return true
}
// first argument should be a field reference (&o.FieldName or &o.Parent.FieldName)
if len(callExpr.Args) < 2 {
return true
}
fieldPath := extractFieldPathFromRef(callExpr.Args[0])
if fieldPath == "" {
return true
}
// second argument is the description string
description := extractStringLiteral(callExpr.Args[1])
if description != "" {
description = cleanDescription(description)
descriptions[fieldPath] = description
}
return true
})
}
return descriptions
}
// extractNestedAppConfigs handles nested config structs like golang.MainModuleVersion
func extractNestedAppConfigs(f *ast.File, parentKey, parentFieldName string, fieldType ast.Expr, descriptions map[string]string, defaults map[string]interface{}) []AppConfigField {
var configs []AppConfigField
// find the nested struct type
var nestedStructName string
switch t := fieldType.(type) {
case *ast.Ident:
nestedStructName = t.Name
default:
return nil
}
// find the struct definition
var nestedStruct *ast.StructType
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok || typeSpec.Name.Name != nestedStructName {
continue
}
var structOk bool
nestedStruct, structOk = typeSpec.Type.(*ast.StructType)
if structOk {
break
}
}
}
if nestedStruct == nil {
return nil
}
// extract fields from nested struct
for _, field := range nestedStruct.Fields.List {
yamlKey := extractYAMLTag(field)
if yamlKey == "" || yamlKey == "-" {
continue
}
var fieldName string
if len(field.Names) > 0 {
fieldName = field.Names[0].Name
} else {
continue
}
fullKey := parentKey + "." + yamlKey
// get description using the nested path (e.g., "MainModuleVersion.FromLDFlags")
nestedPath := parentFieldName + "." + fieldName
description := descriptions[nestedPath]
// try to get default value from nested defaults
var defaultValue interface{}
if nestedDefaults, ok := defaults[parentFieldName].(map[string]interface{}); ok {
defaultValue = nestedDefaults[fieldName]
}
configs = append(configs, AppConfigField{
Key: fullKey,
Description: description,
DefaultValue: defaultValue,
})
}
return configs
}
// extractAppDefaultValues extracts default values from the default*Config function
func extractAppDefaultValues(f *ast.File) map[string]interface{} {
defaults := make(map[string]interface{})
for _, decl := range f.Decls {
funcDecl, ok := decl.(*ast.FuncDecl)
if !ok || !strings.HasPrefix(funcDecl.Name.Name, "default") {
continue
}
// look for return statements that construct the config struct
ast.Inspect(funcDecl.Body, func(n ast.Node) bool {
// look for return statements
returnStmt, ok := n.(*ast.ReturnStmt)
if !ok || len(returnStmt.Results) == 0 {
return true
}
// check if returning a struct literal
compositeLit, ok := returnStmt.Results[0].(*ast.CompositeLit)
if !ok {
return true
}
// extract field values from the composite literal
for _, elt := range compositeLit.Elts {
kvExpr, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
// get field name
ident, ok := kvExpr.Key.(*ast.Ident)
if !ok {
continue
}
fieldName := ident.Name
// extract the value
value := extractAppValue(kvExpr.Value)
if value != nil {
defaults[fieldName] = value
}
}
return true
})
}
return defaults
}
// extractAppValue extracts a Go value from an AST expression
func extractAppValue(expr ast.Expr) interface{} {
switch v := expr.(type) {
case *ast.BasicLit:
// string, int, bool literals
switch v.Kind {
case token.STRING:
return strings.Trim(v.Value, `"`)
case token.INT:
return v.Value
case token.FLOAT:
return v.Value
}
case *ast.Ident:
// boolean values
if v.Name == "true" {
return true
}
if v.Name == "false" {
return false
}
if v.Name == "nil" {
return nil
}
case *ast.CompositeLit:
// nested struct literal
nested := make(map[string]interface{})
for _, elt := range v.Elts {
kvExpr, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
ident, ok := kvExpr.Key.(*ast.Ident)
if !ok {
continue
}
nested[ident.Name] = extractAppValue(kvExpr.Value)
}
if len(nested) > 0 {
return nested
}
}
return nil
}
// extractYAMLTag extracts the yaml tag value from a struct field
func extractYAMLTag(field *ast.Field) string {
if field.Tag == nil {
return ""
}
tag := strings.Trim(field.Tag.Value, "`")
tags := reflect.StructTag(tag)
yamlTag := tags.Get("yaml")
// handle tags like "field-name,omitempty"
if idx := strings.Index(yamlTag, ","); idx != -1 {
yamlTag = yamlTag[:idx]
}
return yamlTag
}
// extractFieldPathFromRef extracts field path from & o.FieldName or &o.Parent.FieldName expression
func extractFieldPathFromRef(expr ast.Expr) string {
unaryExpr, ok := expr.(*ast.UnaryExpr)
if !ok {
return ""
}
// handle nested field references like &o.MainModuleVersion.FromLDFlags
var parts []string
current := unaryExpr.X
for {
selectorExpr, ok := current.(*ast.SelectorExpr)
if !ok {
break
}
// add this selector to the path
parts = append([]string{selectorExpr.Sel.Name}, parts...)
// move to the next level
current = selectorExpr.X
}
// join the parts with dots to create the full path
// e.g., ["MainModuleVersion", "FromLDFlags"] -> "MainModuleVersion.FromLDFlags"
if len(parts) > 0 {
return strings.Join(parts, ".")
}
return ""
}
// extractStringLiteral extracts a string value from a BasicLit node
func extractStringLiteral(expr ast.Expr) string {
lit, ok := expr.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return ""
}
// remove backticks or quotes
value := strings.Trim(lit.Value, "`\"")
return value
}
// cleanDescription cleans up multi-line descriptions
func cleanDescription(desc string) string {
// replace multiple whitespace with single space
desc = strings.Join(strings.Fields(desc), " ")
return strings.TrimSpace(desc)
}
// isNestedStruct checks if a field type is a nested struct (not a pointer or basic type)
func isNestedStruct(expr ast.Expr) bool {
switch t := expr.(type) {
case *ast.Ident:
// check if it's a struct type (not a basic type)
// basic types would be: string, int, bool, etc.
basicTypes := map[string]bool{
"string": true, "int": true, "int8": true, "int16": true, "int32": true, "int64": true,
"uint": true, "uint8": true, "uint16": true, "uint32": true, "uint64": true,
"float32": true, "float64": true, "bool": true, "byte": true, "rune": true,
}
return !basicTypes[t.Name]
case *ast.StarExpr:
// pointer types are not nested structs for our purposes
return false
case *ast.ArrayType, *ast.MapType:
return false
default:
return false
}
}