-
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
Open
AnmolxSingh
wants to merge
6
commits into
jaegertracing:main
Choose a base branch
from
AnmolxSingh:ast
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e5c469a
ast main file
AnmolxSingh e241508
license added
AnmolxSingh 7a1909c
Update cmd/config-doc-gen/main.go
AnmolxSingh 4a48db7
Update cmd/config-doc-gen/main.go
AnmolxSingh 5f3fa6a
recursive struc doc added
AnmolxSingh cbdb269
removed default tag
AnmolxSingh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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 "" | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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