kilo/cmd/docs-gen/main.go

293 lines
8.3 KiB
Go

// Copyright 2021 the Kilo authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// This file was adapted from https://github.com/prometheus-operator/prometheus-operator/blob/master/cmd/po-docgen/api.go.
package main
import (
"bytes"
"fmt"
"go/ast"
"go/doc"
"go/parser"
"go/token"
"os"
"reflect"
"strings"
)
const (
firstParagraph = `# API
This document is a reference of the API types introduced by Kilo.
> **Note**: this document is generated from code comments. When contributing a change to this document, please do so by changing the code comments.`
)
var (
links = map[string]string{
"metav1.ObjectMeta": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#objectmeta-v1-meta",
"metav1.ListMeta": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#listmeta-v1-meta",
}
selfLinks = map[string]string{}
typesDoc = map[string]KubeTypes{}
)
func toSectionLink(name string) string {
name = strings.ToLower(name)
name = strings.Replace(name, " ", "-", -1)
return name
}
func printTOC(types []KubeTypes) {
fmt.Printf("\n## Table of Contents\n")
for _, t := range types {
strukt := t[0]
if len(t) > 1 {
fmt.Printf("* [%s](#%s)\n", strukt.Name, toSectionLink(strukt.Name))
}
}
}
func printAPIDocs(paths []string) {
fmt.Println(firstParagraph)
types := parseDocumentationFrom(paths)
for _, t := range types {
strukt := t[0]
selfLinks[strukt.Name] = "#" + strings.ToLower(strukt.Name)
typesDoc[toLink(strukt.Name)] = t[1:]
}
// we need to parse once more to now add the self links and the inlined fields
types = parseDocumentationFrom(paths)
printTOC(types)
for _, t := range types {
strukt := t[0]
if len(t) > 1 {
fmt.Printf("\n## %s\n\n%s\n\n", strukt.Name, strukt.Doc)
fmt.Println("| Field | Description | Scheme | Required |")
fmt.Println("| ----- | ----------- | ------ | -------- |")
fields := t[1:]
for _, f := range fields {
fmt.Println("|", f.Name, "|", f.Doc, "|", f.Type, "|", f.Mandatory, "|")
}
fmt.Println("")
fmt.Println("[Back to TOC](#table-of-contents)")
}
}
}
// Pair of strings. We need the name of fields and the doc.
type Pair struct {
Name, Doc, Type string
Mandatory bool
}
// KubeTypes is an array to represent all available types in a parsed file. [0] is for the type itself
type KubeTypes []Pair
// parseDocumentationFrom gets all types' documentation and returns them as an
// array. Each type is again represented as an array (we have to use arrays as we
// need to be sure of the order of the fields). This function returns fields and
// struct definitions that have no documentation as {name, ""}.
func parseDocumentationFrom(srcs []string) []KubeTypes {
var docForTypes []KubeTypes
for _, src := range srcs {
pkg := astFrom(src)
for _, kubType := range pkg.Types {
if structType, ok := kubType.Decl.Specs[0].(*ast.TypeSpec).Type.(*ast.StructType); ok {
var ks KubeTypes
ks = append(ks, Pair{kubType.Name, fmtRawDoc(kubType.Doc), "", false})
for _, field := range structType.Fields.List {
// Skip fields that are not tagged.
if field.Tag == nil {
os.Stderr.WriteString(fmt.Sprintf("Tag is nil, skipping field: %v of type %v\n", field, field.Type))
continue
}
// Treat inlined fields separately as we don't want the original types to appear in the doc.
if isInlined(field) {
// Skip external types, as we don't want their content to be part of the API documentation.
if isInternalType(field.Type) {
ks = append(ks, typesDoc[fieldType(field.Type)]...)
}
continue
}
typeString := fieldType(field.Type)
fieldMandatory := fieldRequired(field)
if n := fieldName(field); n != "-" {
fieldDoc := fmtRawDoc(field.Doc.Text())
ks = append(ks, Pair{n, fieldDoc, typeString, fieldMandatory})
}
}
docForTypes = append(docForTypes, ks)
}
}
}
return docForTypes
}
func astFrom(filePath string) *doc.Package {
fset := token.NewFileSet()
m := make(map[string]*ast.File)
f, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
fmt.Println(err)
return nil
}
m[filePath] = f
apkg, _ := ast.NewPackage(fset, m, nil, nil)
return doc.New(apkg, "", 0)
}
func fmtRawDoc(rawDoc string) string {
var buffer bytes.Buffer
delPrevChar := func() {
if buffer.Len() > 0 {
buffer.Truncate(buffer.Len() - 1) // Delete the last " " or "\n"
}
}
// Ignore all lines after ---
rawDoc = strings.Split(rawDoc, "---")[0]
for _, line := range strings.Split(rawDoc, "\n") {
line = strings.TrimRight(line, " ")
leading := strings.TrimLeft(line, " ")
switch {
case len(line) == 0: // Keep paragraphs
delPrevChar()
buffer.WriteString("\n\n")
case strings.HasPrefix(leading, "TODO"): // Ignore one line TODOs
case strings.HasPrefix(leading, "+"): // Ignore instructions to go2idl
default:
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
delPrevChar()
line = "\n" + line + "\n" // Replace it with newline. This is useful when we have a line with: "Example:\n\tJSON-someting..."
} else {
line += " "
}
buffer.WriteString(line)
}
}
postDoc := strings.TrimRight(buffer.String(), "\n")
postDoc = strings.Replace(postDoc, "\\\"", "\"", -1) // replace user's \" to "
postDoc = strings.Replace(postDoc, "\"", "\\\"", -1) // Escape "
postDoc = strings.Replace(postDoc, "\n", "\\n", -1)
postDoc = strings.Replace(postDoc, "\t", "\\t", -1)
postDoc = strings.Replace(postDoc, "|", "\\|", -1)
return postDoc
}
func toLink(typeName string) string {
selfLink, hasSelfLink := selfLinks[typeName]
if hasSelfLink {
return wrapInLink(typeName, selfLink)
}
link, hasLink := links[typeName]
if hasLink {
return wrapInLink(typeName, link)
}
return typeName
}
func wrapInLink(text, link string) string {
return fmt.Sprintf("[%s](%s)", text, link)
}
func isInlined(field *ast.Field) bool {
jsonTag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1]).Get("json") // Delete first and last quotation
return strings.Contains(jsonTag, "inline")
}
func isInternalType(typ ast.Expr) bool {
switch typ := typ.(type) {
case *ast.SelectorExpr:
pkg := typ.X.(*ast.Ident)
return strings.HasPrefix(pkg.Name, "monitoring")
case *ast.StarExpr:
return isInternalType(typ.X)
case *ast.ArrayType:
return isInternalType(typ.Elt)
case *ast.MapType:
return isInternalType(typ.Key) && isInternalType(typ.Value)
default:
return true
}
}
// fieldName returns the name of the field as it should appear in JSON format
// "-" indicates that this field is not part of the JSON representation
func fieldName(field *ast.Field) string {
jsonTag := reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1]).Get("json") // Delete first and last quotation
jsonTag = strings.Split(jsonTag, ",")[0] // This can return "-"
if jsonTag == "" {
if field.Names != nil {
return field.Names[0].Name
}
return field.Type.(*ast.Ident).Name
}
return jsonTag
}
// fieldRequired returns whether a field is a required field.
func fieldRequired(field *ast.Field) bool {
jsonTag := ""
if field.Tag != nil {
jsonTag = reflect.StructTag(field.Tag.Value[1 : len(field.Tag.Value)-1]).Get("json") // Delete first and last quotation
return !strings.Contains(jsonTag, "omitempty")
}
return false
}
func fieldType(typ ast.Expr) string {
switch typ := typ.(type) {
case *ast.Ident:
return toLink(typ.Name)
case *ast.StarExpr:
return "*" + toLink(fieldType(typ.X))
case *ast.SelectorExpr:
pkg := typ.X.(*ast.Ident)
t := typ.Sel
return toLink(pkg.Name + "." + t.Name)
case *ast.ArrayType:
return "[]" + toLink(fieldType(typ.Elt))
case *ast.MapType:
return "map[" + toLink(fieldType(typ.Key)) + "]" + toLink(fieldType(typ.Value))
default:
return ""
}
}
func main() {
printAPIDocs(os.Args[1:])
}