Files
llmgw/internal/docgen/docgen.go
T
Ilker ManapandClaude Opus 5 100f10ac17
CI / test (push) Successful in 1m41s
Generate the configuration reference, and fail when it goes stale
There was no reference. Four narrative documents explain how to install and
operate llmgw, and the only way to learn that `lease_ttl` exists was to read
the one that happened to mention it — or the struct.

Written by hand it would have been wrong within a fortnight. Six settings
arrived in the last two weeks alone: store, servers, lease_ttl, the two
retention windows, min_disk_gb. A reference nobody maintains is worse than
none, because it is the same list with the setting you came to look up quietly
missing from it.

So it is derived from the structs, with the field comments as the descriptions
— which means the only way for it to be wrong is for the code to be wrong, and
it makes the comments worth writing twice over. 1163 lines, 24 blocks, 87 keys.

Two tests keep that true after everyone has forgotten this exists. One
regenerates and compares, so adding a setting without regenerating is a
failure rather than an omission — checked by adding one and watching it fail.
The other walks the tree for koanf tags and fails on a configuration file
that is in neither the source list nor the short list of structs an operator
never writes, because a list is a thing to forget.

The generator finds the repository from its own path rather than the working
directory, so `go generate ./...` produces the same document from anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 07:43:30 +02:00

159 lines
4.3 KiB
Go

// 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, 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 {
Name string
Doc string
Fields []Field
}
// 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(ts, st)
if !ok || seen[b.Name] {
return true
}
seen[b.Name] = true
blocks = append(blocks, b)
return true
})
}
sort.Slice(blocks, func(i, j int) bool { return blocks[i].Name < blocks[j].Name })
return blocks, nil
}
// blockOf builds a Block, or reports that this struct carries no settings.
func blockOf(ts *ast.TypeSpec, st *ast.StructType) (Block, bool) {
b := Block{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(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(e ast.Expr) (rendered, nested string) {
switch t := e.(type) {
case *ast.Ident:
if isNamedStruct(t.Name) {
return t.Name, t.Name
}
return t.Name, ""
case *ast.SelectorExpr:
// A type from another package, such as tlsconf.Config.
name := t.Sel.Name
if pkg, ok := t.X.(*ast.Ident); ok {
return pkg.Name + "." + name, name
}
return name, name
case *ast.ArrayType:
inner, _ := typeOf(t.Elt)
return "[]" + inner, ""
case *ast.MapType:
k, _ := typeOf(t.Key)
v, _ := typeOf(t.Value)
return "map[" + k + "]" + v, ""
case *ast.StarExpr:
inner, n := typeOf(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"))
}