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 }