github.com/banzaicloud/operator-tools@v0.28.10/pkg/docgen/docgen.go (about)

     1  // Copyright © 2020 Banzai Cloud
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //    http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package docgen
    16  
    17  import (
    18  	"bytes"
    19  	"fmt"
    20  	"go/ast"
    21  	"go/parser"
    22  	"go/printer"
    23  	"go/token"
    24  	"os"
    25  	filepath2 "path/filepath"
    26  	"regexp"
    27  	"strings"
    28  
    29  	"emperror.dev/errors"
    30  	"github.com/go-logr/logr"
    31  )
    32  
    33  type DocItem struct {
    34  	Name                         string
    35  	SourcePath                   string
    36  	DestPath                     string
    37  	Category                     string
    38  	DefaultValueFromTagExtractor func(string) string
    39  }
    40  
    41  type DocItems []DocItem
    42  
    43  type Doc struct {
    44  	Item        DocItem
    45  	DisplayName string
    46  	Content     string
    47  	Version     string
    48  	Url         string
    49  	Desc        string
    50  	Status      string
    51  
    52  	RootNode *ast.File
    53  	Logger   logr.Logger
    54  }
    55  
    56  func (d *Doc) Append(line string) {
    57  	if d != nil {
    58  		d.Content = d.Content + line + "\n"
    59  	}
    60  }
    61  
    62  func NewDoc(item DocItem, log logr.Logger) *Doc {
    63  	return &Doc{
    64  		Item:   item,
    65  		Logger: log,
    66  	}
    67  }
    68  
    69  func GetDocumentParser(source DocItem, log logr.Logger) *Doc {
    70  	fileSet := token.NewFileSet()
    71  	node, err := parser.ParseFile(fileSet, source.SourcePath, nil, parser.ParseComments)
    72  	if err != nil {
    73  		log.Error(err, "Error!")
    74  	}
    75  	newDoc := &Doc{
    76  		Item:     source,
    77  		RootNode: node,
    78  		Logger:   log,
    79  	}
    80  	return newDoc
    81  }
    82  
    83  func (d *Doc) Generate() error {
    84  	if d == nil {
    85  		return nil
    86  	}
    87  	if d.RootNode != nil {
    88  		ast.Inspect(d.RootNode, d.visitNode)
    89  		d.Logger.V(2).Info("DocumentRoot not present skipping parse")
    90  	}
    91  	err := os.MkdirAll(d.Item.DestPath, os.ModePerm)
    92  	if err != nil {
    93  		return errors.WrapIf(err, "failed to create destination directory")
    94  	}
    95  	filepath := filepath2.Join(d.Item.DestPath, d.Item.Name+".md")
    96  	f, err := os.Create(filepath)
    97  	if err != nil {
    98  		return errors.WrapIf(err, "failed to create destination file")
    99  	}
   100  
   101  	_, err = f.WriteString(d.Content)
   102  	if err != nil {
   103  		return errors.WrapIf(errors.WrapIf(f.Close(), "failed to close file"), "failed to write content")
   104  	}
   105  
   106  	return errors.WrapIf(f.Close(), "failed to close file")
   107  }
   108  
   109  func (d *Doc) visitNode(n ast.Node) bool {
   110  	generic, ok := n.(*ast.GenDecl)
   111  	if ok {
   112  		typeName, ok := generic.Specs[0].(*ast.TypeSpec)
   113  		if ok {
   114  			_, ok := typeName.Type.(*ast.InterfaceType)
   115  			if ok && strings.HasPrefix(typeName.Name.Name, "_hugo") {
   116  				d.Append("---")
   117  				d.Append(fmt.Sprintf("title: %s", GetPrefixedValue(getTypeDocs(generic, true), `\+name:\"(.*)\"`)))
   118  				d.Append(fmt.Sprintf("weight: %s", GetPrefixedValue(getTypeDocs(generic, true), `\+weight:\"(.*)\"`)))
   119  				d.Append("generated_file: true")
   120  				d.Append("---\n")
   121  			}
   122  			if ok && strings.HasPrefix(typeName.Name.Name, "_doc") {
   123  				d.Append(fmt.Sprintf("# %s", getTypeName(generic, d.Item.Name)))
   124  				d.Append("## Overview")
   125  				d.Append(getTypeDocs(generic, false))
   126  				d.Append("## Configuration")
   127  			}
   128  			if ok && strings.HasPrefix(typeName.Name.Name, "_meta") {
   129  				d.DisplayName = GetPrefixedValue(getTypeDocs(generic, true), `\+name:\"(.*)\"`)
   130  				d.Url = GetPrefixedValue(getTypeDocs(generic, true), `\+url:\"(.*)\"`)
   131  				d.Version = GetPrefixedValue(getTypeDocs(generic, true), `\+version:\"(.*)\"`)
   132  				d.Desc = GetPrefixedValue(getTypeDocs(generic, true), `\+description:\"(.*)\"`)
   133  				d.Status = GetPrefixedValue(getTypeDocs(generic, true), `\+status:\"(.*)\"`)
   134  			}
   135  			if d.DisplayName == "" {
   136  				d.DisplayName = typeName.Name.Name
   137  			}
   138  			if ok && strings.HasPrefix(typeName.Name.Name, "_exp") {
   139  				d.Append(getTypeDocs(generic, false))
   140  				d.Append("---")
   141  			}
   142  			structure, ok := typeName.Type.(*ast.StructType)
   143  			if ok && typeName.Name.IsExported() {
   144  				d.Append(fmt.Sprintf("## %s", getTypeName(generic, typeName.Name.Name)))
   145  				d.Append("") // Adds a line-break for markdown formatting
   146  				if getTypeDocs(generic, true) != "" {
   147  					d.Append(fmt.Sprintf("%s", getTypeDocs(generic, true)))
   148  				}
   149  				for i, item := range structure.Fields.List {
   150  					name, com, def, required, err := d.getValuesFromItem(item)
   151  					if err != nil {
   152  						panic(errors.WrapIff(err, "failed to get values for field #%d for type %s", i, typeName.Name.Name))
   153  					}
   154  
   155  					required_string := ""
   156  					if required == "No" {
   157  						required_string = ", optional"
   158  					} else if required == "Yes" {
   159  						required_string = ", required"
   160  					}
   161  
   162  					anchor := strings.ToLower(getTypeName(generic, typeName.Name.Name) + "-" + name)
   163  					d.Append(fmt.Sprintf("### %s (%s%s) {#%s}", name, d.normaliseType(item.Type), required_string, anchor))
   164  					d.Append("")
   165  					if com != "" {
   166  						d.Append(fmt.Sprintf("%s", com))
   167  						d.Append("")
   168  					}
   169  					d.Append(fmt.Sprintf("Default: %s", def))
   170  					d.Append("") // Adds a line-break for markdown formatting
   171  				}
   172  				d.Append("") // Adds a line-break for markdown formatting
   173  			}
   174  		}
   175  	}
   176  
   177  	return true
   178  }
   179  
   180  func (d *Doc) normaliseType(fieldType ast.Expr) string {
   181  	fset := token.NewFileSet()
   182  	var typeNameBuf bytes.Buffer
   183  	err := printer.Fprint(&typeNameBuf, fset, fieldType)
   184  	if err != nil {
   185  		d.Logger.Error(err, "error getting type")
   186  	}
   187  	return typeNameBuf.String()
   188  }
   189  
   190  func GetPrefixedValue(origin, expression string) string {
   191  	r := regexp.MustCompile(expression)
   192  	result := r.FindStringSubmatch(origin)
   193  	if len(result) > 1 {
   194  		return result[1]
   195  	}
   196  	return ""
   197  }
   198  
   199  func getTypeName(generic *ast.GenDecl, defaultName string) string {
   200  	structName := generic.Doc.Text()
   201  	result := GetPrefixedValue(structName, `\+docName:\"(.*)\"`)
   202  	if result != "" {
   203  		return result
   204  	}
   205  	return defaultName
   206  }
   207  
   208  func getTypeDocs(generic *ast.GenDecl, trimSpace bool) string {
   209  	comment := ""
   210  	if generic.Doc != nil {
   211  		for _, line := range generic.Doc.List {
   212  			newLine := strings.TrimPrefix(line.Text, "//")
   213  			if trimSpace {
   214  				newLine = strings.TrimSpace(newLine)
   215  			}
   216  			if !strings.HasPrefix(strings.TrimSpace(newLine), "+kubebuilder") &&
   217  				!strings.HasPrefix(strings.TrimSpace(newLine), "nolint") &&
   218  				!strings.HasPrefix(strings.TrimSpace(newLine), "+docName") {
   219  				comment += newLine + "\n"
   220  			}
   221  		}
   222  	}
   223  	return comment
   224  }
   225  
   226  func getLink(def string) string {
   227  	result := GetPrefixedValue(def, `\+docLink:\"(.*)\"`)
   228  	if result != "" {
   229  		url := strings.Split(result, ",")
   230  		def = strings.Replace(def, fmt.Sprintf("+docLink:\"%s\"", result), fmt.Sprintf("[%s](%s)", url[0], url[1]), 1)
   231  	}
   232  	return def
   233  }
   234  
   235  func formatRequired(r bool) string {
   236  	if r {
   237  		return "Yes"
   238  	}
   239  	return "No"
   240  }
   241  
   242  func (d *Doc) getValuesFromItem(item *ast.Field) (name, comment, def, required string, err error) {
   243  	commentWithDefault := ""
   244  	if item.Doc != nil {
   245  		// Process comments of objects that become ### level headings
   246  		isCodeBlock := false
   247  		for _, line := range item.Doc.List {
   248  			newLine := strings.TrimPrefix(line.Text, "//")
   249  
   250  			if strings.HasPrefix(newLine, " {{< highlight") {
   251  				commentWithDefault += "\n"
   252  				isCodeBlock = true
   253  			}
   254  
   255  			// Do not trim spaces when processing a code block to keep indentation, trim only one character
   256  			if !(isCodeBlock) {
   257  				newLine = strings.TrimSpace(newLine)
   258  			} else {
   259  				newLine = strings.TrimPrefix(newLine, " ")
   260  			}
   261  
   262  			if !strings.HasPrefix(newLine, "+kubebuilder") {
   263  				// Keep newlines in code blocks, but join body text
   264  				if isCodeBlock {
   265  					commentWithDefault += newLine + "\n"
   266  				} else {
   267  					commentWithDefault += newLine + " "
   268  				}
   269  				// Detect the end of code blocks
   270  				if isCodeBlock && strings.HasPrefix(newLine, " {{< /highlight") {
   271  					isCodeBlock = false
   272  				}
   273  			}
   274  		}
   275  	}
   276  	if item.Tag == nil {
   277  		return "", "", "", "", errors.Errorf("field has no tag defined: %+v", item)
   278  	}
   279  	tag := item.Tag.Value
   280  	tagResult := ""
   281  	if d.Item.DefaultValueFromTagExtractor != nil {
   282  		tagResult = d.Item.DefaultValueFromTagExtractor(tag)
   283  	}
   284  	nameResult := GetPrefixedValue(tag, `json:\"([^,\"]*).*\"`)
   285  	required = formatRequired(!strings.Contains(GetPrefixedValue(tag, `json:\"(.*)\"`), "omitempty"))
   286  	if tagResult != "" {
   287  		return nameResult, getLink(commentWithDefault), tagResult, required, nil
   288  	}
   289  	result := GetPrefixedValue(commentWithDefault, `\(default:(.*)\)`)
   290  	if result != "" {
   291  		ignore := fmt.Sprintf("(default:%s)", result)
   292  		comment = strings.Replace(commentWithDefault, ignore, "", 1)
   293  		return nameResult, comment, getLink(result), required, nil
   294  	}
   295  
   296  	return nameResult, getLink(commentWithDefault), "-", required, nil
   297  }