// Package docgen turns the configuration structs into reference documentation. // // Generated rather than written, because a hand-written reference is a promise // that someone will remember. This project added six settings in a fortnight — // store, servers, lease_ttl, thread and memory retention, min_disk_gb — and // every one of them reached an operator only through a narrative document that // happened to mention it. A reference nobody maintains is worse than none: it // is the same list, quietly missing the setting you came to look up. // // Derived from the source, so the only way for it to be wrong is for the code // to be wrong. A test regenerates it and fails when it has drifted, which is // what keeps that true after everyone has forgotten this exists. package docgen //go:generate go run ./gen import ( "fmt" "go/ast" "go/parser" "go/token" "reflect" "sort" "strings" ) // Field is one configuration key. type Field struct { Key string Type string Doc string // Nested names the struct this key expands into, by its ID // (config.Auth), empty for a leaf. It is what lets the reference say // "see Auth" rather than printing the same twenty keys under four // parents. Nested string } // Block is a struct that appears in configuration. type Block struct { // Package and Name are where the struct is declared: what tells // tlsconf.Config from the agent's Config. Package string Name string // Title is what it is called in the reference and on the screens: the // name alone where no other block shares it, the package's name with it // where one does. Title string Doc string Fields []Field } // ID is the block's package and name, unique where Name is not. func (b Block) ID() string { return b.Package + "." + b.Name } // Collect reads koanf-tagged structs out of Go source files. func Collect(files []string) ([]Block, error) { fset := token.NewFileSet() var blocks []Block seen := map[string]bool{} for _, path := range files { f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) if err != nil { return nil, fmt.Errorf("parsing %s: %w", path, err) } ast.Inspect(f, func(n ast.Node) bool { ts, ok := n.(*ast.TypeSpec) if !ok { return true } st, ok := ts.Type.(*ast.StructType) if !ok { return true } b, ok := blockOf(f.Name.Name, ts, st) if !ok || seen[b.ID()] { return true } seen[b.ID()] = true blocks = append(blocks, b) return true }) } // Named by their own name unless another block has it too. Keyed by // name alone, tlsconf.Config, engines.Config and supervisor.Config were // each taken for the agent's Config and dropped: the reference had no // TLS settings at all. count := map[string]int{} for _, b := range blocks { count[b.Name]++ } for i := range blocks { blocks[i].Title = blocks[i].Name if count[blocks[i].Name] > 1 { blocks[i].Title = blocks[i].ID() } } sort.Slice(blocks, func(i, j int) bool { return blocks[i].Title < blocks[j].Title }) return blocks, nil } // blockOf builds a Block, or reports that this struct carries no settings. func blockOf(pkg string, ts *ast.TypeSpec, st *ast.StructType) (Block, bool) { b := Block{Package: pkg, Name: ts.Name.Name, Doc: docText(ts.Doc)} for _, f := range st.Fields.List { if f.Tag == nil { continue } tag := reflect.StructTag(strings.Trim(f.Tag.Value, "`")) key, ok := tag.Lookup("koanf") // "-" is a field carried in the struct but never read from a file; // listing it would send someone looking for a setting that does not // exist. if !ok || key == "" || key == "-" { continue } typ, nested := typeOf(pkg, f.Type) b.Fields = append(b.Fields, Field{ Key: key, Type: typ, Doc: docText(f.Doc), Nested: nested, }) } return b, len(b.Fields) > 0 } // typeOf renders a field's type, and names the struct it expands into. func typeOf(pkg string, e ast.Expr) (rendered, nested string) { switch t := e.(type) { case *ast.Ident: if isNamedStruct(t.Name) { return t.Name, pkg + "." + t.Name } return t.Name, "" case *ast.SelectorExpr: // A type from another package, such as tlsconf.Config. Standard // library types are values, not blocks: a time.Duration is a // setting, written "30s". name := t.Sel.Name if x, ok := t.X.(*ast.Ident); ok { if x.Name == "time" { return x.Name + "." + name, "" } return x.Name + "." + name, x.Name + "." + name } return name, "" case *ast.ArrayType: inner, _ := typeOf(pkg, t.Elt) return "[]" + inner, "" case *ast.MapType: k, _ := typeOf(pkg, t.Key) v, _ := typeOf(pkg, t.Value) return "map[" + k + "]" + v, "" case *ast.StarExpr: inner, n := typeOf(pkg, t.X) return "*" + inner, n default: return "value", "" } } // isNamedStruct guesses whether an identifier names a configuration block. // // By capitalisation, because the alternative is resolving types across // packages for a document. A wrong guess costs a "see also" that points at // nothing, which the renderer drops. func isNamedStruct(name string) bool { if name == "" { return false } switch name { case "string", "bool", "int", "int64", "float64", "any", "byte", "rune": return false } return name[0] >= 'A' && name[0] <= 'Z' } // docText flattens a comment group into one paragraph per blank line. func docText(g *ast.CommentGroup) string { if g == nil { return "" } var lines []string for _, c := range g.List { t := strings.TrimPrefix(c.Text, "//") lines = append(lines, strings.TrimPrefix(t, " ")) } return strings.TrimSpace(strings.Join(lines, "\n")) }