-
Notifications
You must be signed in to change notification settings - Fork 2.5k
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
base: main
Are you sure you want to change the base?
Changes from 5 commits
e5c469a
e241508
7a1909c
4a48db7
5f3fa6a
cbdb269
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,242 @@ | ||
// 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" | ||
"reflect" | ||
"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 { | ||
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) | ||
defaultValue := extractDefaultValue(field.Tag, fieldType) | ||
|
||
for _, name := range field.Names { | ||
fieldDoc := FieldDoc{ | ||
Name: name.Name, | ||
Type: fieldType, | ||
Tag: fieldTag, | ||
DefaultValue: defaultValue, | ||
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) | ||
// Create a new token file set | ||
fset := token.NewFileSet() | ||
|
||
// Parse the Go source file | ||
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 | ||
ast.Inspect(node, func(n ast.Node) bool { | ||
// Check for type declarations | ||
genDecl, ok := n.(*ast.GenDecl) | ||
if !ok || genDecl.Tok != token.TYPE { | ||
return true | ||
} | ||
|
||
// Iterate over the type specifications | ||
for _, spec := range genDecl.Specs { | ||
typeSpec, ok := spec.(*ast.TypeSpec) | ||
if !ok { | ||
continue | ||
} | ||
|
||
// Check if the type is a struct | ||
structType, ok := typeSpec.Type.(*ast.StructType) | ||
if !ok { | ||
continue | ||
} | ||
|
||
structDoc := StructDoc{ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not every struct you encounter is a config There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are config structs only in config.go file? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not guaranteed. Top level configs usually are, but the nested subconfigs they contain could be anywhere. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we use any specific convention to identify config structs? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sadly, there is no required interface that configs implement. The factories typically have this function
so you could use it as an indication that Config struct is really an interesting config. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For this i am planning to use ast.Inspect two times one for filtering the config structs returned by above function and second for printing them |
||
Name: typeSpec.Name.Name, | ||
Comment: extractComment(genDecl.Doc), | ||
} | ||
|
||
// Iterate over the struct fields | ||
for _, field := range structType.Fields.List { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current loop iterates over field.Names without handling fields that are embedded (anonymous fields). Adding logic to process embedded fields could improve the robustness of the documentation generation. Copilot is powered by AI, so mistakes are possible. Review output carefully before use. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is correct observation |
||
structDoc.Fields = append(structDoc.Fields, processField(fset, field, structMap)...) | ||
} | ||
// Store processed struct in the map | ||
structMap[structDoc.Name] = structDoc | ||
|
||
} | ||
return false | ||
}) | ||
|
||
// Convert map to slice | ||
for _, structDoc := range structMap { | ||
structs = append(structs, structDoc) | ||
} | ||
return structs, 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 "" | ||
} | ||
|
||
// extractDefaultValue parses the tag to find a default value if specified. | ||
func extractDefaultValue(tag *ast.BasicLit, fieldType string) interface{} { | ||
if tag == nil { | ||
return nil | ||
} | ||
tagValue := extractTag(tag) | ||
structTag := reflect.StructTag(tagValue) | ||
defaultValueStr := structTag.Get("default") | ||
AnmolxSingh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if defaultValueStr == "" { | ||
return nil | ||
} | ||
|
||
// Convert the default value string to the appropriate type | ||
switch fieldType { | ||
case "string": | ||
return defaultValueStr | ||
case "int", "int8", "int16", "int32", "int64": | ||
var intValue int64 | ||
if _, err := fmt.Sscanf(defaultValueStr, "%d", &intValue); err != nil { | ||
return err | ||
} | ||
return intValue | ||
case "uint", "uint8", "uint16", "uint32", "uint64": | ||
var uintValue uint64 | ||
fmt.Sscanf(defaultValueStr, "%d", &uintValue) | ||
return uintValue | ||
case "float32", "float64": | ||
var floatValue float64 | ||
fmt.Sscanf(defaultValueStr, "%f", &floatValue) | ||
return floatValue | ||
case "bool": | ||
var boolValue bool | ||
fmt.Sscanf(defaultValueStr, "%t", &boolValue) | ||
return boolValue | ||
// Add more types as needed | ||
default: | ||
return nil | ||
} | ||
} |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like what?
There was a problem hiding this comment.
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 packageThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1