Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Auto-generate documentation for jaeger-v2 configuration structs via AST #6776

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions cmd/config-doc-gen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// Copyright (c) 2025 The Jaeger Authors.
// SPDX-License-Identifier: Apache-2.0

package main

import (
"bytes"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"log"
"os"
"path/filepath"
"strings"
)

// FieldDoc represents documentation for a struct field.
type FieldDoc struct {
Name string `json:"name"`
Type string `json:"type"`
Tag string `json:"tag,omitempty"`
DefaultValue interface{} `json:"default_value,omitempty"`
Comment string `json:"comment,omitempty"`
}

// StructDoc represents documentation for a struct.
type StructDoc struct {
Name string `json:"name"`
Fields []FieldDoc `json:"fields"`
Comment string `json:"comment,omitempty"`
}

func main() {
// List of target directories
targetDirs := []string{
"cmd/jaeger/internal/extension/jaegerquery",
"pkg/es/config",
}

var allStructs []StructDoc

// Iterate over each target directory
for _, dir := range targetDirs {
fmt.Printf("Parsing directory: %s\n", dir)
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are using file system access. Doesn't Go AST have other means of traversal? We have some components imported directly from OTEL, with their own configs, and they will not be easily accessible via local file system (they would be somewhere in Go cache).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that would require more dependencies to be added to go.mod

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like what?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

golang.org/x/tools/go/packages would be added to traverse AST based on package

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

if err != nil {
return err
}
// Process only Go source files
if !info.IsDir() && filepath.Ext(path) == ".go" {
structs, err := parseFile(path)
if err != nil {
log.Printf("Error parsing file %s: %v", path, err)
return err
}
allStructs = append(allStructs, structs...)
}
return nil
})
if err != nil {
log.Fatalf("Error walking the path %s: %v", dir, err)
}
}

// Serialize the collected struct documentation to JSON
outputFile, err := os.Create("struct_docs.json")
if err != nil {
log.Fatalf("Error creating output file: %v", err)
}
defer outputFile.Close()

encoder := json.NewEncoder(outputFile)
encoder.SetIndent("", " ")
if err := encoder.Encode(allStructs); err != nil {
log.Fatalf("Error encoding JSON: %v", err)
}

fmt.Println("Struct documentation has been written to struct_docs.json")
}

// processField processes a struct field and handles nested structs.
func processField(fset *token.FileSet, field *ast.Field, structs map[string]StructDoc) []FieldDoc {
var fieldDocs []FieldDoc
fieldType := exprToString(fset, field.Type)
fieldTag := extractTag(field.Tag)

for _, name := range field.Names {
fieldDoc := FieldDoc{
Name: name.Name,
Type: fieldType,
Tag: fieldTag,
Comment: extractComment(field.Doc),
}
fieldDocs = append(fieldDocs, fieldDoc)
}

// Handle nested struct
if ident, ok := field.Type.(*ast.Ident); ok && ident.Obj != nil {
if typeSpec, ok := ident.Obj.Decl.(*ast.TypeSpec); ok {
if structType, isStruct := typeSpec.Type.(*ast.StructType); isStruct {
nestedStructDoc := StructDoc{
Name: ident.Name,
Comment: extractComment(typeSpec.Doc),
}
for _, nestedField := range structType.Fields.List {
nestedStructDoc.Fields = append(nestedStructDoc.Fields, processField(fset, nestedField, structs)...)
}
structs[ident.Name] = nestedStructDoc
}
}
}

return fieldDocs
}

// parseFile parses a Go source file and extracts struct information.
func parseFile(filePath string) ([]StructDoc, error) {
var structs []StructDoc
structMap := make(map[string]StructDoc)
defaultValues := make(map[string]map[string]interface{}) // Store default values for structs

// Create a new token file set
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("failed to parse file %s: %w", filePath, err)
}

// Traverse the AST to find struct type declarations and functions
ast.Inspect(node, func(n ast.Node) bool {
switch n := n.(type) {
case *ast.GenDecl: // Handle struct declarations
if n.Tok != token.TYPE {
return true
}
for _, spec := range n.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}

structDoc := StructDoc{
Name: typeSpec.Name.Name,
Comment: extractComment(n.Doc),
}

for _, field := range structType.Fields.List {
structDoc.Fields = append(structDoc.Fields, processField(fset, field, structMap)...)
}

structMap[structDoc.Name] = structDoc
}

case *ast.FuncDecl: // Handle `createDefaultConfig()`
if n.Name.Name == "createDefaultConfig" {
structName, defaults := extractDefaultValues(fset, n)

if structName != "" {
defaultValues[structName] = defaults
}
}
}
return true
})

// Apply extracted defaults to structs
for structName, defaults := range defaultValues {
if structDoc, exists := structMap[structName]; exists {
for i, field := range structDoc.Fields {
if val, found := defaults[field.Name]; found {
structDoc.Fields[i].DefaultValue = val
}
}
structMap[structName] = structDoc
}
}

// Convert map to slice
for _, structDoc := range structMap {
structs = append(structs, structDoc)
}
return structs, nil
}

// function to extract default value from struct
func extractDefaultValues(fset *token.FileSet, fn *ast.FuncDecl) (string, map[string]interface{}) {
defaults := make(map[string]interface{})
var structName string

if fn.Body.List == nil {
return "", defaults
}

for _, stmt := range fn.Body.List {
retStmt, ok := stmt.(*ast.ReturnStmt)
if !ok || len(retStmt.Results) == 0 {
continue
}

unaryExpr, ok := retStmt.Results[0].(*ast.UnaryExpr)
if !ok {
continue
}

compLit, ok := unaryExpr.X.(*ast.CompositeLit)
if !ok {
continue
}

if ident, ok := compLit.Type.(*ast.Ident); ok {
structName = ident.Name
}

for _, elt := range compLit.Elts {
kvExpr, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}

// Pass a valid fset instead of nil
fieldName := exprToString(fset, kvExpr.Key)
defaultValue := extractValue(kvExpr.Value)
defaults[fieldName] = defaultValue
}
}

return structName, defaults
}

// function to extract value
func extractValue(expr ast.Expr) interface{} {
switch v := expr.(type) {
case *ast.BasicLit: // Handle basic types
switch v.Kind {
case token.STRING:
return strings.Trim(v.Value, `"`)
case token.INT:
var intValue int
fmt.Sscanf(v.Value, "%d", &intValue)
return intValue
case token.FLOAT:
var floatValue float64
fmt.Sscanf(v.Value, "%f", &floatValue)
return floatValue
default:
return v.Value
}
case *ast.Ident: // Handle boolean values
if v.Name == "true" {
return true
} else if v.Name == "false" {
return false
}
return v.Name // Could be a constant
case *ast.CallExpr: // Handle function calls
if fun, ok := v.Fun.(*ast.SelectorExpr); ok {
return fmt.Sprintf("function_call: %s.%s", fun.X, fun.Sel.Name)
} else if fun, ok := v.Fun.(*ast.Ident); ok {
return fmt.Sprintf("function_call: %s", fun.Name)
}
}
return nil
}

// extractComment retrieves the text from a CommentGroup.
func extractComment(cg *ast.CommentGroup) string {
if cg != nil {
return strings.TrimSpace(cg.Text())
}
return ""
}

// exprToString converts an ast.Expr to its string representation.
func exprToString(fset *token.FileSet, expr ast.Expr) string {
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, expr); err != nil {
return ""
}
return buf.String()
}

// extractTag extracts the tag value from a BasicLit.
func extractTag(tag *ast.BasicLit) string {
if tag != nil {
return strings.Trim(tag.Value, "`")
}
return ""
}
Loading