Files
mgmt/docs/generate.go
James Shubin a600e11100 cli, docs: Add a docs command for doc generation
This took a lot longer than it looks to get right. It's not perfect, but
it now reliably generates documentation which we can put into gohugo.
2024-11-22 14:20:16 -05:00

789 lines
20 KiB
Go

// Mgmt
// Copyright (C) 2013-2024+ James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package docs
import (
"context"
"encoding/json"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
cliUtil "github.com/purpleidea/mgmt/cli/util"
docsUtil "github.com/purpleidea/mgmt/docs/util"
"github.com/purpleidea/mgmt/engine"
engineUtil "github.com/purpleidea/mgmt/engine/util"
"github.com/purpleidea/mgmt/lang/funcs"
"github.com/purpleidea/mgmt/lang/interfaces"
"github.com/purpleidea/mgmt/util"
)
const (
// JSONSuffix is the output extension for the generated documentation.
JSONSuffix = ".json"
)
// Generate is the main entrypoint for this command. It generates everything.
type Generate struct {
*cliUtil.DocsGenerateArgs // embedded config
Config // embedded Config
// Program is the name of this program, usually set at compile time.
Program string
// Version is the version of this program, usually set at compile time.
Version string
// Debug represents if we're running in debug mode or not.
Debug bool
// Logf is a logger which should be used.
Logf func(format string, v ...interface{})
}
// Main runs everything for this setup item.
func (obj *Generate) Main(ctx context.Context) error {
if err := obj.Validate(); err != nil {
return err
}
if err := obj.Run(ctx); err != nil {
return err
}
return nil
}
// Validate verifies that the structure has acceptable data stored within.
func (obj *Generate) Validate() error {
if obj == nil {
return fmt.Errorf("data is nil")
}
if obj.Program == "" {
return fmt.Errorf("program is empty")
}
if obj.Version == "" {
return fmt.Errorf("version is empty")
}
return nil
}
// Run performs the desired actions to generate the documentation.
func (obj *Generate) Run(ctx context.Context) error {
outputFile := obj.DocsGenerateArgs.Output
if outputFile == "" || !strings.HasSuffix(outputFile, JSONSuffix) {
return fmt.Errorf("must specify output")
}
// support relative paths too!
if !strings.HasPrefix(outputFile, "/") {
wd, err := os.Getwd()
if err != nil {
return err
}
outputFile = filepath.Join(wd, outputFile)
}
if obj.Debug {
obj.Logf("output: %s", outputFile)
}
// Ensure the directory exists.
//d := filepath.Dir(outputFile)
//if err := os.MkdirAll(d, 0750); err != nil {
// return fmt.Errorf("could not make output dir at: %s", d)
//}
resources, err := obj.genResources()
if err != nil {
return err
}
functions, err := obj.genFunctions()
if err != nil {
return err
}
data := &Output{
Version: safeVersion(obj.Version),
Resources: resources,
Functions: functions,
}
b, err := json.Marshal(data)
if err != nil {
return err
}
b = append(b, '\n') // needs a trailing newline
if err := os.WriteFile(outputFile, b, 0600); err != nil {
return err
}
obj.Logf("wrote: %s", outputFile)
return nil
}
func (obj *Generate) getResourceInfo(kind, filename, structName string) (*ResourceInfo, error) {
rootDir := obj.DocsGenerateArgs.RootDir
if rootDir == "" {
wd, err := os.Getwd()
if err != nil {
return nil, err
}
rootDir = wd + "/" // add a trailing slash
}
if !strings.HasPrefix(rootDir, "/") || !strings.HasSuffix(rootDir, "/") {
return nil, fmt.Errorf("bad root dir: %s", rootDir)
}
// filename might be "noop.go" for example
p := filepath.Join(rootDir, engine.ResourcesRelDir, filename)
fset := token.NewFileSet()
// f is a: https://golang.org/pkg/go/ast/#File
f, err := parser.ParseFile(fset, p, nil, parser.ParseComments)
if err != nil {
return nil, err
}
// mcl field name to golang field name
mapping, err := engineUtil.LangFieldNameToStructFieldName(kind)
if err != nil {
return nil, err
}
// golang field name to mcl field name
nameMap, err := util.MapSwap(mapping)
if err != nil {
return nil, err
}
// mcl field name to mcl type
typMap, err := engineUtil.LangFieldNameToStructType(kind)
if err != nil {
return nil, err
}
ri := &ResourceInfo{}
// Populate the fields, even if they don't have a comment.
ri.Name = structName // golang name
ri.Kind = kind // duplicate data
ri.File = filename
ri.Fields = make(map[string]*ResourceFieldInfo)
for mclFieldName, fieldName := range mapping {
typ, exists := typMap[mclFieldName]
if !exists {
continue
}
ri.Fields[mclFieldName] = &ResourceFieldInfo{
Name: fieldName,
Type: typ.String(),
Desc: "", // empty for now
}
}
var previousComment *ast.CommentGroup
// Walk through the AST...
ast.Inspect(f, func(node ast.Node) bool {
// Comments above the struct appear as a node right _before_ we
// find the struct, so if we see one, save it for later...
if cg, ok := node.(*ast.CommentGroup); ok {
previousComment = cg
return true
}
typeSpec, ok := node.(*ast.TypeSpec)
if !ok {
return true
}
name := typeSpec.Name.Name // name is now known!
// If the struct isn't what we're expecting, then move on...
if name != structName {
return true
}
// Check if the TypeSpec is a named struct type that we want...
st, ok := typeSpec.Type.(*ast.StructType)
if !ok {
return true
}
// At this point, we have the struct we want...
var comment *ast.CommentGroup
if typeSpec.Doc != nil {
// I don't know how to even get here...
comment = typeSpec.Doc // found!
} else if previousComment != nil {
comment = previousComment // found!
previousComment = nil
}
ri.Desc = commentCleaner(comment)
// Iterate over the fields of the struct
for _, field := range st.Fields.List {
// Check if the field has a comment associated with it
if field.Doc == nil {
continue
}
if len(field.Names) < 1 { // XXX: why does this happen?
continue
}
fieldName := field.Names[0].Name
if fieldName == "" { // Can this happen?
continue
}
if isPrivate(fieldName) {
continue
}
mclFieldName, exists := nameMap[fieldName]
if !exists {
continue
}
ri.Fields[mclFieldName].Desc = commentCleaner(field.Doc)
}
return true
})
return ri, nil
}
func (obj *Generate) genResources() (map[string]*ResourceInfo, error) {
resources := make(map[string]*ResourceInfo)
if obj.DocsGenerateArgs.NoResources {
return resources, nil
}
r := engine.RegisteredResourcesNames()
sort.Strings(r)
for _, kind := range r {
metadata, err := docsUtil.LookupResource(kind)
if err != nil {
return nil, err
}
if strings.HasPrefix(kind, "_") {
// TODO: Should we display these somehow?
// built-in resource
continue
}
ri, err := obj.getResourceInfo(kind, metadata.Filename, metadata.Typename)
if err != nil {
return nil, err
}
if ri.Name == "" {
return nil, fmt.Errorf("empty resource name: %s", kind)
}
if ri.File == "" {
return nil, fmt.Errorf("empty resource file: %s", kind)
}
if ri.Desc == "" {
obj.Logf("empty resource desc: %s", kind)
}
fields := []string{}
for field := range ri.Fields {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if ri.Fields[field].Desc == "" {
obj.Logf("empty resource (%s) field desc: %s", kind, field)
}
}
resources[kind] = ri
}
return resources, nil
}
func (obj *Generate) getFunctionInfo(pkg, name string, metadata *docsUtil.Metadata) (*FunctionInfo, error) {
rootDir := obj.DocsGenerateArgs.RootDir
if rootDir == "" {
wd, err := os.Getwd()
if err != nil {
return nil, err
}
rootDir = wd + "/" // add a trailing slash
}
if !strings.HasPrefix(rootDir, "/") || !strings.HasSuffix(rootDir, "/") {
return nil, fmt.Errorf("bad root dir: %s", rootDir)
}
if metadata.Filename == "" {
return nil, fmt.Errorf("empty filename for: %s.%s", pkg, name)
}
// filename might be "pow.go" for example and contain a rel dir
p := filepath.Join(rootDir, funcs.FunctionsRelDir, metadata.Filename)
fset := token.NewFileSet()
// f is a: https://golang.org/pkg/go/ast/#File
f, err := parser.ParseFile(fset, p, nil, parser.ParseComments)
if err != nil {
return nil, err
}
fi := &FunctionInfo{}
fi.Name = metadata.Typename
fi.File = metadata.Filename
var previousComment *ast.CommentGroup
found := false
rawFunc := func(node ast.Node) (*ast.CommentGroup, string) {
fd, ok := node.(*ast.FuncDecl)
if !ok {
return nil, ""
}
return fd.Doc, fd.Name.Name // name is now known!
}
rawStruct := func(node ast.Node) (*ast.CommentGroup, string) {
typeSpec, ok := node.(*ast.TypeSpec)
if !ok {
return nil, ""
}
// Check if the TypeSpec is a named struct type that we want...
if _, ok := typeSpec.Type.(*ast.StructType); !ok {
return nil, ""
}
return typeSpec.Doc, typeSpec.Name.Name // name is now known!
}
// Walk through the AST...
ast.Inspect(f, func(node ast.Node) bool {
// Comments above the struct appear as a node right _before_ we
// find the struct, so if we see one, save it for later...
if cg, ok := node.(*ast.CommentGroup); ok {
previousComment = cg
return true
}
doc, name := rawFunc(node) // First see if it's a raw func.
if name == "" {
doc, name = rawStruct(node) // Otherwise it's a struct.
}
// If the func isn't what we're expecting, then move on...
if name != metadata.Typename {
return true
}
var comment *ast.CommentGroup
if doc != nil {
// I don't know how to even get here...
comment = doc // found!
} else if previousComment != nil {
comment = previousComment // found!
previousComment = nil
}
fi.Desc = commentCleaner(comment)
found = true
return true
})
if !found {
//return nil, nil
}
return fi, nil
}
func (obj *Generate) genFunctions() (map[string]*FunctionInfo, error) {
functions := make(map[string]*FunctionInfo)
if obj.DocsGenerateArgs.NoFunctions {
return functions, nil
}
m := funcs.Map() // map[string]func() interfaces.Func
names := []string{}
for name := range m {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
a := names[i]
b := names[j]
// TODO: do a sorted-by-package order.
return a < b
})
for _, name := range names {
//v := m[name]
//fn := v()
fn := m[name]()
// eg: golang/strings.has_suffix
sp := strings.Split(name, ".")
if len(sp) == 0 {
return nil, fmt.Errorf("unexpected empty function")
}
if len(sp) > 2 {
return nil, fmt.Errorf("unexpected function name: %s", name)
}
n := sp[0]
p := sp[0]
if len(sp) == 1 { // built-in
p = "" // no package!
}
if len(sp) == 2 { // normal import
n = sp[1]
}
if strings.HasPrefix(n, "_") {
// TODO: Should we display these somehow?
// built-in function
continue
}
var sig *string
//iface := ""
if x := fn.Info().Sig; x != nil {
s := x.String()
sig = &s
//iface = "simple"
}
metadata := &docsUtil.Metadata{}
// XXX: maybe we need a better way to get this?
mdFunc, ok := fn.(interfaces.MetadataFunc)
if !ok {
// Function doesn't tell us what the data is, let's try
// to get it automatically...
metadata.Typename = funcs.GetFunctionName(fn) // works!
metadata.Filename = "" // XXX: How can we get this?
// XXX: We only need this back-channel metadata store
// because we don't know how to get the filename without
// manually writing code in each function. Alternatively
// we could add a New() method to each struct and then
// we could modify the struct instead of having it be
// behind a copy which is needed to get new copies!
var err error
metadata, err = docsUtil.LookupFunction(name)
if err != nil {
return nil, err
}
} else if mdFunc == nil {
// programming error
return nil, fmt.Errorf("unexpected empty metadata for function: %s", name)
} else {
metadata = mdFunc.GetMetadata()
}
if metadata == nil {
return nil, fmt.Errorf("unexpected nil metadata for function: %s", name)
}
// This may be an empty func name if the function did not know
// how to get it. (This is normal for automatic regular funcs.)
if metadata.Typename == "" {
metadata.Typename = funcs.GetFunctionName(fn) // works!
}
fi, err := obj.getFunctionInfo(p, n, metadata)
if err != nil {
return nil, err
}
// We may not get any fields added if we can't find anything...
fi.Name = metadata.Typename
fi.Package = p
fi.Func = n
fi.File = metadata.Filename
//fi.Desc = desc
fi.Signature = sig
if fi.Func == "" {
return nil, fmt.Errorf("empty function name: %s", name)
}
if fi.File == "" {
return nil, fmt.Errorf("empty function file: %s", name)
}
if fi.Desc == "" {
obj.Logf("empty function desc: %s", name)
}
if fi.Signature == nil {
obj.Logf("empty function sig: %s", name)
}
functions[name] = fi
}
return functions, nil
}
// Output is the type of the final data that will be for the json output.
type Output struct {
// Version is the sha1 or ref name of this specific version. This is
// used if we want to generate documentation with links matching the
// correct version. If unspecified then this assumes git master.
Version string `json:"version"`
// Resources contains the collection of every available resource!
// FIXME: should this be a list instead?
Resources map[string]*ResourceInfo `json:"resources"`
// Functions contains the collection of every available function!
// FIXME: should this be a list instead?
Functions map[string]*FunctionInfo `json:"functions"`
}
// ResourceInfo stores some information about each resource.
type ResourceInfo struct {
// Name is the golang name of this resource.
Name string `json:"name"`
// Kind is the kind of this resource.
Kind string `json:"kind"`
// File is the file name where this resource exists.
File string `json:"file"`
// Desc explains what this resource does.
Desc string `json:"description"`
// Fields is a collection of each resource field and corresponding info.
Fields map[string]*ResourceFieldInfo `json:"fields"`
}
// ResourceFieldInfo stores some information about each field in each resource.
type ResourceFieldInfo struct {
// Name is what this field is called in golang format.
Name string `json:"name"`
// Type is the mcl type for this field.
Type string `json:"type"`
// Desc explains what this field does.
Desc string `json:"description"`
}
// FunctionInfo stores some information about each function.
type FunctionInfo struct {
// Name is the golang name of this function. This may be an actual
// function if used by the simple API, or the name of a struct.
Name string `json:"name"`
// Package is the import name to use to get to this function.
Package string `json:"package"`
// Func is the name of the function in that package.
Func string `json:"func"`
// File is the file name where this function exists.
File string `json:"file"`
// Desc explains what this function does.
Desc string `json:"description"`
// Signature is the type signature of this function. If empty then the
// signature is not known statically and it may be polymorphic.
Signature *string `json:"signature,omitempty"`
}
// commentCleaner takes a comment group and returns it as a clean string. It
// removes the spurious newlines and programmer-focused comments. If there are
// blank lines, it replaces them with a single newline. The idea is that the
// webpage formatter would replace the newline with a <br /> or similar. This
// code is a modified alternative of the ast.CommentGroup.Text() function.
func commentCleaner(g *ast.CommentGroup) string {
if g == nil {
return ""
}
comments := make([]string, len(g.List))
for i, c := range g.List {
comments[i] = c.Text
}
lines := make([]string, 0, 10) // most comments are less than 10 lines
for _, c := range comments {
// Remove comment markers.
// The parser has given us exactly the comment text.
switch c[1] {
case '/':
//-style comment (no newline at the end)
c = c[2:]
if len(c) == 0 {
// empty line
break
}
if isDevComment(c[1:]) { // get rid of one space
continue
}
if c[0] == ' ' {
// strip first space - required for Example tests
c = c[1:]
break
}
//if isDirective(c) {
// // Ignore //go:noinline, //line, and so on.
// continue
//}
case '*':
/*-style comment */
c = c[2 : len(c)-2]
}
// Split on newlines.
cl := strings.Split(c, "\n")
// Walk lines, stripping trailing white space and adding to list.
for _, l := range cl {
lines = append(lines, stripTrailingWhitespace(l))
}
}
// Remove leading blank lines; convert runs of interior blank lines to a
// single blank line.
n := 0
for _, line := range lines {
if line != "" || n > 0 && lines[n-1] != "" {
lines[n] = line
n++
}
}
lines = lines[0:n]
// Concatenate all of these together. Blank lines should be a newline.
s := ""
for i, line := range lines {
if line == "" {
continue
}
s += line
if i < len(lines)-1 { // Is there another line?
if lines[i+1] == "" {
s += "\n" // Will eventually be a line break.
} else {
s += " "
}
}
}
return s
}
// TODO: should we use unicode.IsSpace instead?
func isWhitespace(ch byte) bool { return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' }
// TODO: should we replace with a strings package stdlib function?
func stripTrailingWhitespace(s string) string {
i := len(s)
for i > 0 && isWhitespace(s[i-1]) {
i--
}
return s[0:i]
}
// isPrivate specifies if a field name is "private" or not.
func isPrivate(fieldName string) bool {
if fieldName == "" {
panic("invalid field name")
}
x := fieldName[0:1]
if strings.ToLower(x) == x {
return true // it was already private
}
return false
}
// isDevComment tells us that the comment is for developers only!
func isDevComment(comment string) bool {
if strings.HasPrefix(comment, "TODO:") {
return true
}
if strings.HasPrefix(comment, "FIXME:") {
return true
}
if strings.HasPrefix(comment, "XXX:") {
return true
}
return false
}
// safeVersion parses the main version string and returns a short hash for us.
// For example, we might get a string of 0.0.26-176-gabcdef012-dirty as input,
// and we'd want to return abcdef012.
func safeVersion(version string) string {
const dirty = "-dirty"
s := version
if strings.HasSuffix(s, dirty) { // helpful dirty remover
s = s[0 : len(s)-len(dirty)]
}
ix := strings.LastIndex(s, "-")
if ix == -1 { // assume we have a standalone version (future proofing?)
return s
}
s = s[ix+1:]
// From the `git describe` man page: The "g" prefix stands for "git" and
// is used to allow describing the version of a software depending on
// the SCM the software is managed with. This is useful in an
// environment where people may use different SCMs.
const g = "g"
if strings.HasPrefix(s, g) {
s = s[len(g):]
}
return s
}