github.com/phsym/gomarkdoc@v0.5.4/cmd/gomarkdoc/command.go (about)

     1  package main
     2  
     3  import (
     4  	"bytes"
     5  	"container/list"
     6  	"errors"
     7  	"flag"
     8  	"fmt"
     9  	"go/build"
    10  	"hash/fnv"
    11  	"html/template"
    12  	"io"
    13  	"io/ioutil"
    14  	"os"
    15  	"path/filepath"
    16  	"runtime/debug"
    17  	"strings"
    18  
    19  	"github.com/spf13/cobra"
    20  	"github.com/spf13/viper"
    21  
    22  	"github.com/phsym/gomarkdoc"
    23  	"github.com/phsym/gomarkdoc/format"
    24  	"github.com/phsym/gomarkdoc/lang"
    25  	"github.com/phsym/gomarkdoc/logger"
    26  )
    27  
    28  // PackageSpec defines the data available to the --output option's template.
    29  // Information is recomputed for each package generated.
    30  type PackageSpec struct {
    31  	// Dir holds the local path where the package is located. If the package is
    32  	// a remote package, this will always be ".".
    33  	Dir string
    34  
    35  	// ImportPath holds a representation of the package that should be unique
    36  	// for most purposes. If a package is on the filesystem, this is equivalent
    37  	// to the value of Dir. For remote packages, this holds the string used to
    38  	// import that package in code (e.g. "encoding/json").
    39  	ImportPath string
    40  	isWildcard bool
    41  	isLocal    bool
    42  	outputFile string
    43  	pkg        *lang.Package
    44  }
    45  
    46  type commandOptions struct {
    47  	repository            lang.Repo
    48  	output                string
    49  	header                string
    50  	headerFile            string
    51  	footer                string
    52  	footerFile            string
    53  	format                string
    54  	tags                  []string
    55  	templateOverrides     map[string]string
    56  	templateFileOverrides map[string]string
    57  	verbosity             int
    58  	includeUnexported     bool
    59  	check                 bool
    60  	embed                 bool
    61  	version               bool
    62  }
    63  
    64  // Flags populated by goreleaser
    65  var version = ""
    66  
    67  const configFilePrefix = ".gomarkdoc"
    68  
    69  func buildCommand() *cobra.Command {
    70  	var opts commandOptions
    71  	var configFile string
    72  
    73  	// cobra.OnInitialize(func() { buildConfig(configFile) })
    74  
    75  	var command = &cobra.Command{
    76  		Use:   "gomarkdoc [package ...]",
    77  		Short: "generate markdown documentation for golang code",
    78  		RunE: func(cmd *cobra.Command, args []string) error {
    79  			if opts.version {
    80  				printVersion()
    81  				return nil
    82  			}
    83  
    84  			buildConfig(configFile)
    85  
    86  			// Load configuration from viper
    87  			opts.includeUnexported = viper.GetBool("includeUnexported")
    88  			opts.output = viper.GetString("output")
    89  			opts.check = viper.GetBool("check")
    90  			opts.embed = viper.GetBool("embed")
    91  			opts.format = viper.GetString("format")
    92  			opts.templateOverrides = viper.GetStringMapString("template")
    93  			opts.templateFileOverrides = viper.GetStringMapString("templateFile")
    94  			opts.header = viper.GetString("header")
    95  			opts.headerFile = viper.GetString("headerFile")
    96  			opts.footer = viper.GetString("footer")
    97  			opts.footerFile = viper.GetString("footerFile")
    98  			opts.tags = viper.GetStringSlice("tags")
    99  			opts.repository.Remote = viper.GetString("repository.url")
   100  			opts.repository.DefaultBranch = viper.GetString("repository.defaultBranch")
   101  			opts.repository.PathFromRoot = viper.GetString("repository.path")
   102  
   103  			if opts.check && opts.output == "" {
   104  				return errors.New("gomarkdoc: check mode cannot be run without an output set")
   105  			}
   106  
   107  			if len(args) == 0 {
   108  				// Default to current directory
   109  				args = []string{"."}
   110  			}
   111  
   112  			return runCommand(args, opts)
   113  		},
   114  	}
   115  
   116  	command.Flags().StringVar(
   117  		&configFile,
   118  		"config",
   119  		"",
   120  		fmt.Sprintf("File from which to load configuration (default: %s.yml)", configFilePrefix),
   121  	)
   122  	command.Flags().BoolVarP(
   123  		&opts.includeUnexported,
   124  		"include-unexported",
   125  		"u",
   126  		false,
   127  		"Output documentation for unexported symbols, methods and fields in addition to exported ones.",
   128  	)
   129  	command.Flags().StringVarP(
   130  		&opts.output,
   131  		"output",
   132  		"o",
   133  		"",
   134  		"File or pattern specifying where to write documentation output. Defaults to printing to stdout.",
   135  	)
   136  	command.Flags().BoolVarP(
   137  		&opts.check,
   138  		"check",
   139  		"c",
   140  		false,
   141  		"Check the output to see if it matches the generated documentation. --output must be specified to use this.",
   142  	)
   143  	command.Flags().BoolVarP(
   144  		&opts.embed,
   145  		"embed",
   146  		"e",
   147  		false,
   148  		"Embed documentation into existing markdown files if available, otherwise append to file.",
   149  	)
   150  	command.Flags().StringVarP(
   151  		&opts.format,
   152  		"format",
   153  		"f",
   154  		"github",
   155  		"Format to use for writing output data. Valid options: github (default), azure-devops, plain",
   156  	)
   157  	command.Flags().StringToStringVarP(
   158  		&opts.templateOverrides,
   159  		"template",
   160  		"t",
   161  		map[string]string{},
   162  		"Custom template string to use for the provided template name instead of the default template.",
   163  	)
   164  	command.Flags().StringToStringVar(
   165  		&opts.templateFileOverrides,
   166  		"template-file",
   167  		map[string]string{},
   168  		"Custom template file to use for the provided template name instead of the default template.",
   169  	)
   170  	command.Flags().StringVar(
   171  		&opts.header,
   172  		"header",
   173  		"",
   174  		"Additional content to inject at the beginning of each output file.",
   175  	)
   176  	command.Flags().StringVar(
   177  		&opts.headerFile,
   178  		"header-file",
   179  		"",
   180  		"File containing additional content to inject at the beginning of each output file.",
   181  	)
   182  	command.Flags().StringVar(
   183  		&opts.footer,
   184  		"footer",
   185  		"",
   186  		"Additional content to inject at the end of each output file.",
   187  	)
   188  	command.Flags().StringVar(
   189  		&opts.footerFile,
   190  		"footer-file",
   191  		"",
   192  		"File containing additional content to inject at the end of each output file.",
   193  	)
   194  	command.Flags().StringSliceVar(
   195  		&opts.tags,
   196  		"tags",
   197  		defaultTags(),
   198  		"Set of build tags to apply when choosing which files to include for documentation generation.",
   199  	)
   200  	command.Flags().CountVarP(
   201  		&opts.verbosity,
   202  		"verbose",
   203  		"v",
   204  		"Log additional output from the execution of the command. Can be chained for additional verbosity.",
   205  	)
   206  	command.Flags().StringVar(
   207  		&opts.repository.Remote,
   208  		"repository.url",
   209  		"",
   210  		"Manual override for the git repository URL used in place of automatic detection.",
   211  	)
   212  	command.Flags().StringVar(
   213  		&opts.repository.DefaultBranch,
   214  		"repository.default-branch",
   215  		"",
   216  		"Manual override for the git repository URL used in place of automatic detection.",
   217  	)
   218  	command.Flags().StringVar(
   219  		&opts.repository.PathFromRoot,
   220  		"repository.path",
   221  		"",
   222  		"Manual override for the path from the root of the git repository used in place of automatic detection.",
   223  	)
   224  	command.Flags().BoolVar(
   225  		&opts.version,
   226  		"version",
   227  		false,
   228  		"Print the version.",
   229  	)
   230  
   231  	// We ignore the errors here because they only happen if the specified flag doesn't exist
   232  	_ = viper.BindPFlag("includeUnexported", command.Flags().Lookup("include-unexported"))
   233  	_ = viper.BindPFlag("output", command.Flags().Lookup("output"))
   234  	_ = viper.BindPFlag("check", command.Flags().Lookup("check"))
   235  	_ = viper.BindPFlag("embed", command.Flags().Lookup("embed"))
   236  	_ = viper.BindPFlag("format", command.Flags().Lookup("format"))
   237  	_ = viper.BindPFlag("template", command.Flags().Lookup("template"))
   238  	_ = viper.BindPFlag("templateFile", command.Flags().Lookup("template-file"))
   239  	_ = viper.BindPFlag("header", command.Flags().Lookup("header"))
   240  	_ = viper.BindPFlag("headerFile", command.Flags().Lookup("header-file"))
   241  	_ = viper.BindPFlag("footer", command.Flags().Lookup("footer"))
   242  	_ = viper.BindPFlag("footerFile", command.Flags().Lookup("footer-file"))
   243  	_ = viper.BindPFlag("tags", command.Flags().Lookup("tags"))
   244  	_ = viper.BindPFlag("repository.url", command.Flags().Lookup("repository.url"))
   245  	_ = viper.BindPFlag("repository.defaultBranch", command.Flags().Lookup("repository.default-branch"))
   246  	_ = viper.BindPFlag("repository.path", command.Flags().Lookup("repository.path"))
   247  
   248  	return command
   249  }
   250  
   251  func defaultTags() []string {
   252  	f, ok := os.LookupEnv("GOFLAGS")
   253  	if !ok {
   254  		return nil
   255  	}
   256  
   257  	fs := flag.NewFlagSet("goflags", flag.ContinueOnError)
   258  	tags := fs.String("tags", "", "")
   259  
   260  	if err := fs.Parse(strings.Fields(f)); err != nil {
   261  		return nil
   262  	}
   263  
   264  	if tags == nil {
   265  		return nil
   266  	}
   267  
   268  	return strings.Split(*tags, ",")
   269  }
   270  
   271  func buildConfig(configFile string) {
   272  	if configFile != "" {
   273  		viper.SetConfigFile(configFile)
   274  	} else {
   275  		viper.AddConfigPath(".")
   276  		viper.SetConfigName(configFilePrefix)
   277  	}
   278  
   279  	viper.AutomaticEnv()
   280  
   281  	if err := viper.ReadInConfig(); err != nil {
   282  		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
   283  			// TODO: better handling
   284  			fmt.Println(err)
   285  		}
   286  	}
   287  }
   288  
   289  func runCommand(paths []string, opts commandOptions) error {
   290  	outputTmpl, err := template.New("output").Parse(opts.output)
   291  	if err != nil {
   292  		return fmt.Errorf("gomarkdoc: invalid output template: %w", err)
   293  	}
   294  
   295  	specs := getSpecs(paths...)
   296  
   297  	if err := resolveOutput(specs, outputTmpl); err != nil {
   298  		return err
   299  	}
   300  
   301  	if err := loadPackages(specs, opts); err != nil {
   302  		return err
   303  	}
   304  
   305  	return writeOutput(specs, opts)
   306  }
   307  
   308  func resolveOutput(specs []*PackageSpec, outputTmpl *template.Template) error {
   309  	for _, spec := range specs {
   310  		var outputFile strings.Builder
   311  		if err := outputTmpl.Execute(&outputFile, spec); err != nil {
   312  			return err
   313  		}
   314  
   315  		outputStr := outputFile.String()
   316  		if outputStr == "" {
   317  			// Preserve empty values
   318  			spec.outputFile = ""
   319  		} else {
   320  			// Clean up other values
   321  			spec.outputFile = filepath.Clean(outputFile.String())
   322  		}
   323  	}
   324  
   325  	return nil
   326  }
   327  
   328  func resolveOverrides(opts commandOptions) ([]gomarkdoc.RendererOption, error) {
   329  	var overrides []gomarkdoc.RendererOption
   330  
   331  	// Content overrides take precedence over file overrides
   332  	for name, s := range opts.templateOverrides {
   333  		overrides = append(overrides, gomarkdoc.WithTemplateOverride(name, s))
   334  	}
   335  
   336  	for name, f := range opts.templateFileOverrides {
   337  		// File overrides get applied only if there isn't already a content
   338  		// override.
   339  		if _, ok := opts.templateOverrides[name]; ok {
   340  			continue
   341  		}
   342  
   343  		b, err := ioutil.ReadFile(f)
   344  		if err != nil {
   345  			return nil, fmt.Errorf("gomarkdoc: couldn't resolve template for %s: %w", name, err)
   346  		}
   347  
   348  		overrides = append(overrides, gomarkdoc.WithTemplateOverride(name, string(b)))
   349  	}
   350  
   351  	var f format.Format
   352  	switch opts.format {
   353  	case "github":
   354  		f = &format.GitHubFlavoredMarkdown{}
   355  	case "azure-devops":
   356  		f = &format.AzureDevOpsMarkdown{}
   357  	case "plain":
   358  		f = &format.PlainMarkdown{}
   359  	default:
   360  		return nil, fmt.Errorf("gomarkdoc: invalid format: %s", opts.format)
   361  	}
   362  
   363  	overrides = append(overrides, gomarkdoc.WithFormat(f))
   364  
   365  	return overrides, nil
   366  }
   367  
   368  func resolveHeader(opts commandOptions) (string, error) {
   369  	if opts.header != "" {
   370  		return opts.header, nil
   371  	}
   372  
   373  	if opts.headerFile != "" {
   374  		b, err := ioutil.ReadFile(opts.headerFile)
   375  		if err != nil {
   376  			return "", fmt.Errorf("gomarkdoc: couldn't resolve header file: %w", err)
   377  		}
   378  
   379  		return string(b), nil
   380  	}
   381  
   382  	return "", nil
   383  }
   384  
   385  func resolveFooter(opts commandOptions) (string, error) {
   386  	if opts.footer != "" {
   387  		return opts.footer, nil
   388  	}
   389  
   390  	if opts.footerFile != "" {
   391  		b, err := ioutil.ReadFile(opts.footerFile)
   392  		if err != nil {
   393  			return "", fmt.Errorf("gomarkdoc: couldn't resolve footer file: %w", err)
   394  		}
   395  
   396  		return string(b), nil
   397  	}
   398  
   399  	return "", nil
   400  }
   401  
   402  func loadPackages(specs []*PackageSpec, opts commandOptions) error {
   403  	for _, spec := range specs {
   404  		log := logger.New(getLogLevel(opts.verbosity), logger.WithField("dir", spec.Dir))
   405  
   406  		buildPkg, err := getBuildPackage(spec.ImportPath, opts.tags)
   407  		if err != nil {
   408  			log.Debugf("unable to load package in directory: %s", err)
   409  			// We don't care if a wildcard path produces nothing
   410  			if spec.isWildcard {
   411  				continue
   412  			}
   413  
   414  			return err
   415  		}
   416  
   417  		var pkgOpts []lang.PackageOption
   418  		pkgOpts = append(pkgOpts, lang.PackageWithRepositoryOverrides(&opts.repository))
   419  
   420  		if opts.includeUnexported {
   421  			pkgOpts = append(pkgOpts, lang.PackageWithUnexportedIncluded())
   422  		}
   423  
   424  		pkg, err := lang.NewPackageFromBuild(log, buildPkg, pkgOpts...)
   425  		if err != nil {
   426  			return err
   427  		}
   428  
   429  		spec.pkg = pkg
   430  	}
   431  
   432  	return nil
   433  }
   434  
   435  func getBuildPackage(path string, tags []string) (*build.Package, error) {
   436  	ctx := build.Default
   437  	ctx.BuildTags = tags
   438  
   439  	if isLocalPath(path) {
   440  		pkg, err := ctx.ImportDir(path, build.ImportComment)
   441  		if err != nil {
   442  			return nil, fmt.Errorf("gomarkdoc: invalid package in directory: %s", path)
   443  		}
   444  
   445  		return pkg, nil
   446  	}
   447  
   448  	wd, err := os.Getwd()
   449  	if err != nil {
   450  		return nil, err
   451  	}
   452  
   453  	pkg, err := ctx.Import(path, wd, build.ImportComment)
   454  	if err != nil {
   455  		return nil, fmt.Errorf("gomarkdoc: invalid package at import path: %s", path)
   456  	}
   457  
   458  	return pkg, nil
   459  }
   460  
   461  func getSpecs(paths ...string) []*PackageSpec {
   462  	var expanded []*PackageSpec
   463  	for _, path := range paths {
   464  		// Ensure that the path we're working with is normalized for the OS
   465  		// we're using (i.e. "\" for windows, "/" for everything else)
   466  		path = filepath.FromSlash(path)
   467  
   468  		// Not a recursive path
   469  		if !strings.HasSuffix(path, fmt.Sprintf("%s...", string(os.PathSeparator))) {
   470  			isLocal := isLocalPath(path)
   471  			var dir string
   472  			if isLocal {
   473  				dir = path
   474  			} else {
   475  				dir = "."
   476  			}
   477  			expanded = append(expanded, &PackageSpec{
   478  				Dir:        dir,
   479  				ImportPath: path,
   480  				isWildcard: false,
   481  				isLocal:    isLocal,
   482  			})
   483  			continue
   484  		}
   485  
   486  		// Remove the recursive marker so we can work with the path
   487  		trimmedPath := path[0 : len(path)-3]
   488  
   489  		// Not a file path. Add the original path back to the list so as to not
   490  		// mislead someone into thinking we're processing the recursive path
   491  		if !isLocalPath(trimmedPath) {
   492  			expanded = append(expanded, &PackageSpec{
   493  				Dir:        ".",
   494  				ImportPath: path,
   495  				isWildcard: false,
   496  				isLocal:    false,
   497  			})
   498  			continue
   499  		}
   500  
   501  		expanded = append(expanded, &PackageSpec{
   502  			Dir:        trimmedPath,
   503  			ImportPath: trimmedPath,
   504  			isWildcard: true,
   505  			isLocal:    true,
   506  		})
   507  
   508  		queue := list.New()
   509  		queue.PushBack(trimmedPath)
   510  		for e := queue.Front(); e != nil; e = e.Next() {
   511  			prev := e.Prev()
   512  			if prev != nil {
   513  				queue.Remove(prev)
   514  			}
   515  
   516  			p := e.Value.(string)
   517  
   518  			files, err := ioutil.ReadDir(p)
   519  			if err != nil {
   520  				// If we couldn't read the folder, there are no directories that
   521  				// we're going to find beneath it
   522  				continue
   523  			}
   524  
   525  			for _, f := range files {
   526  				if isIgnoredDir(f.Name()) {
   527  					continue
   528  				}
   529  
   530  				if f.IsDir() {
   531  					subPath := filepath.Join(p, f.Name())
   532  
   533  					// Some local paths have their prefixes stripped by Join().
   534  					// If the path is no longer a local path, add the current
   535  					// working directory.
   536  					if !isLocalPath(subPath) {
   537  						subPath = fmt.Sprintf("%s%s", cwdPathPrefix, subPath)
   538  					}
   539  
   540  					expanded = append(expanded, &PackageSpec{
   541  						Dir:        subPath,
   542  						ImportPath: subPath,
   543  						isWildcard: true,
   544  						isLocal:    true,
   545  					})
   546  					queue.PushBack(subPath)
   547  				}
   548  			}
   549  		}
   550  	}
   551  
   552  	return expanded
   553  }
   554  
   555  var ignoredDirs = []string{".git"}
   556  
   557  // isIgnoredDir identifies if the dir is one we want to intentionally ignore.
   558  func isIgnoredDir(dirname string) bool {
   559  	for _, ignored := range ignoredDirs {
   560  		if ignored == dirname {
   561  			return true
   562  		}
   563  	}
   564  
   565  	return false
   566  }
   567  
   568  const (
   569  	cwdPathPrefix    = "." + string(os.PathSeparator)
   570  	parentPathPrefix = ".." + string(os.PathSeparator)
   571  )
   572  
   573  func isLocalPath(path string) bool {
   574  	return strings.HasPrefix(path, cwdPathPrefix) || strings.HasPrefix(path, parentPathPrefix) || filepath.IsAbs(path)
   575  }
   576  
   577  func compare(r1, r2 io.Reader) (bool, error) {
   578  	r1Hash := fnv.New128()
   579  	if _, err := io.Copy(r1Hash, r1); err != nil {
   580  		return false, fmt.Errorf("gomarkdoc: failed when checking documentation: %w", err)
   581  	}
   582  
   583  	r2Hash := fnv.New128()
   584  	if _, err := io.Copy(r2Hash, r2); err != nil {
   585  		return false, fmt.Errorf("gomarkdoc: failed when checking documentation: %w", err)
   586  	}
   587  
   588  	return bytes.Equal(r1Hash.Sum(nil), r2Hash.Sum(nil)), nil
   589  }
   590  
   591  func getLogLevel(verbosity int) logger.Level {
   592  	switch verbosity {
   593  	case 0:
   594  		return logger.WarnLevel
   595  	case 1:
   596  		return logger.InfoLevel
   597  	case 2:
   598  		return logger.DebugLevel
   599  	default:
   600  		return logger.DebugLevel
   601  	}
   602  }
   603  
   604  func printVersion() {
   605  	if version != "" {
   606  		fmt.Println(version)
   607  		return
   608  	}
   609  
   610  	if info, ok := debug.ReadBuildInfo(); ok {
   611  		fmt.Println(info.Main.Version)
   612  	} else {
   613  		fmt.Println("<unknown>")
   614  	}
   615  }