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