Skip to content
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

cmd/gen-docs/main.go: auto generate docs for CRD #169

Merged
merged 1 commit into from
May 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export GO111MODULE=on
.PHONY: push container clean container-name container-latest push-latest fmt lint test unit vendor header generate client deepcopy informer lister openapi manifest manfest-latest manifest-annotate manifest manfest-latest manifest-annotate release
.PHONY: push container clean container-name container-latest push-latest fmt lint test unit vendor header generate client deepcopy informer lister openapi manifest manfest-latest manifest-annotate manifest manfest-latest manifest-annotate release gen-docs

OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
Expand Down Expand Up @@ -33,6 +33,7 @@ GO_FILES ?= $$(find . -name '*.go' -not -path './vendor/*')
GO_PKGS ?= $$(go list ./... | grep -v "$(PKG)/vendor")

CLIENT_GEN_BINARY := bin/client-gen
DOCS_GEN_BINARY := bin/docs-gen
DEEPCOPY_GEN_BINARY := bin/deepcopy-gen
INFORMER_GEN_BINARY := bin/informer-gen
LISTER_GEN_BINARY := bin/lister-gen
Expand Down Expand Up @@ -139,6 +140,10 @@ pkg/k8s/apis/kilo/v1alpha1/openapi_generated.go: pkg/k8s/apis/kilo/v1alpha1/type
--go-header-file=.header
go fmt $@

gen-docs: generate docs/api.md
docs/api.md: pkg/k8s/apis/kilo/v1alpha1/types.go $(DOCS_GEN_BINARY)
$(DOCS_GEN_BINARY) $< > $@

$(BINS): $(SRC) go.mod
@mkdir -p bin/$(word 2,$(subst /, ,$@))/$(word 3,$(subst /, ,$@))
@echo "building: $@"
Expand Down Expand Up @@ -226,7 +231,7 @@ website/docs/README.md: README.md
find $(@D) -type f -name '*.md' | xargs -I{} sed -i 's/\.\/\(.\+\.svg\)/\/img\/\1/g' {}
sed -i 's/graphs\//\/img\/graphs\//g' $@

website/build/index.html: website/docs/README.md
website/build/index.html: website/docs/README.md docs/api.md
yarn --cwd website install
yarn --cwd website build

Expand Down Expand Up @@ -330,6 +335,9 @@ $(LISTER_GEN_BINARY):
$(OPENAPI_GEN_BINARY):
go build -mod=vendor -o $@ k8s.io/kube-openapi/cmd/openapi-gen

$(DOCS_GEN_BINARY): cmd/gen-docs/main.go
go build -mod=vendor -o $@ ./cmd/gen-docs

$(GOLINT_BINARY):
go build -mod=vendor -o $@ golang.org/x/lint/golint

Expand Down
297 changes: 297 additions & 0 deletions cmd/gen-docs/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
// 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.

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",
"metav1.LabelSelector": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#labelselector-v1-meta",
"v1.ResourceRequirements": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#resourcerequirements-v1-core",
"v1.LocalObjectReference": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#localobjectreference-v1-core",
"v1.SecretKeySelector": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#secretkeyselector-v1-core",
"v1.PersistentVolumeClaim": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#persistentvolumeclaim-v1-core",
"v1.EmptyDirVolumeSource": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#emptydirvolumesource-v1-core",
"apiextensionsv1.JSON": "https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#json-v1-apiextensions-k8s-io",
}

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 for 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:])
}
Loading