go.charczuk.com@v0.0.0-20240327042549-bc490516bd1a/cmd/copyright/main.go (about)

     1  /*
     2  
     3  Copyright (c) 2023 - Present. Will Charczuk. All rights reserved.
     4  Use of this source code is governed by a MIT license that can be found in the LICENSE file at the root of the repository.
     5  
     6  */
     7  
     8  package main
     9  
    10  import (
    11  	"bufio"
    12  	"errors"
    13  	"fmt"
    14  	"os"
    15  	"strings"
    16  	"time"
    17  
    18  	"github.com/urfave/cli/v2"
    19  	"go.charczuk.com/sdk/copyright"
    20  )
    21  
    22  func main() {
    23  	app := &cli.App{
    24  		Name: "copyright",
    25  		UsageText: `code copyright management cli
    26  
    27  > copyright [inject|verify|remove] [ROOT(s)...]
    28  
    29  Verify, inject or remove copyright notices from files in a given tree roots.
    30  
    31  By default without other flags provided, this tool verifies that copyright notices are present.
    32  
    33  To verify headers:
    34  
    35  > copyright
    36  - OR -
    37  > copyright verify
    38  - OR -
    39  > copyright verify ./critical
    40  - OR -
    41  > copyright verify ./critical/foo.py
    42  
    43  To inject headers:
    44  
    45  > copyright inject
    46  - OR -
    47  > copyright inject ./critical/foo.py
    48  
    49  - NOTE: you can run "--inject" multiple times; it will only add the header if it is not present.
    50  - NOTE: Headers are treated exactly; do not edit the headers once they've been injected 
    51  			  otherwise the next 'inject' pass will duplicate the headers.
    52  
    53  To remove headers:
    54  
    55  > copyright remove
    56  
    57  If you have an old version of the header in your files, and you want to migrate to an updated version:
    58  
    59  1. Save the existing header to a file, "notice.txt", including any newlines between the notice and code
    60  2. Remove existing notices:
    61  	> copyright remove --ext=py=notice.txt
    62  3. Then inject the new notice:
    63  	> copyright inject --use-include-defaults=false --include="*.py"
    64  4. You should now have the new notice in your files, and "copyright inject" will honor it`,
    65  
    66  		Flags: persistentFlags,
    67  		Commands: []*cli.Command{
    68  			verify,
    69  			inject,
    70  			remove,
    71  		},
    72  	}
    73  
    74  	if err := app.Run(os.Args); err != nil {
    75  		if !errors.Is(err, copyright.ErrFailure) {
    76  			fmt.Fprintf(os.Stderr, "%+v\n", err)
    77  		}
    78  		os.Exit(1)
    79  	}
    80  }
    81  
    82  var persistentFlags = []cli.Flag{
    83  	&cli.BoolFlag{Name: "quiet", Usage: "If all output should be suppressed"},
    84  	&cli.BoolFlag{Name: "verbose", Usage: "If verbose output should be shown"},
    85  	&cli.BoolFlag{Name: "debug", Usage: "If debug output should be shown"},
    86  
    87  	&cli.StringFlag{Name: "notice-path", Usage: "The {{ .Notice }} template value read from a given path"},
    88  	&cli.StringFlag{Name: "notice", Value: copyright.DefaultNoticeTemplate, Usage: "The notice template value as a literal string"},
    89  
    90  	&cli.StringFlag{Name: "restrictions-path", Usage: "The {{ .Restrictions }} template variable value read from a given path"},
    91  	&cli.StringFlag{Name: "restrictions", Value: "open-source", Usage: "The {{ .Restrictions }} template variable value as a literal (use `internal` for the default internal template, and `open-source` for the default open source template)"},
    92  
    93  	&cli.StringFlag{Name: "holder", Value: copyright.DefaultCopyrightHolder, Usage: "The {{ .CopyrightHolder }} template variable value, or the entity that holds the copyright"},
    94  	&cli.IntFlag{Name: "year", Value: time.Now().UTC().Year(), Usage: "The {{ .Year }} template variable value"},
    95  	&cli.StringFlag{Name: "license", Value: copyright.DefaultOpenSourceLicense, Usage: "The {{ .License }} template variable value, or the license type"},
    96  
    97  	&cli.StringSliceFlag{Name: "ext", Usage: "Injection template paths for extensions; should be in the form -ext=js=js_template.txt, you can provide be multiple"},
    98  
    99  	&cli.BoolFlag{Name: "use-exclude-defaults", Value: true, Usage: "If we should consider the default exclude glob list"},
   100  	&cli.StringSliceFlag{Name: "exclude", Usage: "Path globs to exclude from processing"},
   101  	&cli.StringFlag{Name: "excludes-path", Usage: "Path globs to exclude from processing read from a given path"},
   102  	&cli.BoolFlag{Name: "use-include-defaults", Value: true, Usage: "If we should consider the default include glob list"},
   103  	&cli.StringSliceFlag{Name: "include", Usage: "Path globs to include in processing"},
   104  	&cli.StringFlag{Name: "includes-path", Usage: "Path globs to include in processing read a given path"},
   105  }
   106  
   107  var verify = &cli.Command{
   108  	Name:  "verify",
   109  	Usage: "Verify that files contain proper copyright headers",
   110  	Flags: append(persistentFlags,
   111  		&cli.BoolFlag{Name: "show-diff", Usage: "If we should show the difference between the template and the header found in files"},
   112  		&cli.BoolFlag{Name: "exit-first", Usage: "If we should exit after the first failure"},
   113  	),
   114  	Action: wrapCommand(func(ctx *cli.Context, engine *copyright.Copyright, roots ...string) (err error) {
   115  		engine.ShowDiff = ctx.Bool("show-diff")
   116  		engine.ExitFirst = ctx.Bool("exit-first")
   117  		for _, root := range roots {
   118  			if err = engine.Verify(ctx.Context, root); err != nil {
   119  				return
   120  			}
   121  		}
   122  		return
   123  	}),
   124  }
   125  
   126  var inject = &cli.Command{
   127  	Name:  "inject",
   128  	Usage: "Inject copyright headers into files",
   129  	Flags: append(persistentFlags,
   130  		&cli.BoolFlag{Name: "dry-run", Usage: "If we should print the actions taken but not modify files"},
   131  	),
   132  	Action: wrapCommand(func(ctx *cli.Context, engine *copyright.Copyright, roots ...string) (err error) {
   133  		engine.DryRun = ctx.Bool("dry-run")
   134  		for _, root := range roots {
   135  			if err = engine.Inject(ctx.Context, root); err != nil {
   136  				return
   137  			}
   138  		}
   139  		return
   140  	}),
   141  }
   142  var remove = &cli.Command{
   143  	Name:  "remove",
   144  	Usage: "Remove copyright headers from files",
   145  	Flags: append(persistentFlags,
   146  		&cli.BoolFlag{Name: "dry-run", Value: false, Usage: "If we should print the actions taken but not modify files"},
   147  	),
   148  	Action: wrapCommand(func(ctx *cli.Context, engine *copyright.Copyright, roots ...string) (err error) {
   149  		engine.DryRun = ctx.Bool("dry-run")
   150  		for _, root := range roots {
   151  			if err = engine.Remove(ctx.Context, root); err != nil {
   152  				return
   153  			}
   154  		}
   155  		return
   156  	}),
   157  }
   158  
   159  func wrapCommand(commandAction func(*cli.Context, *copyright.Copyright, ...string) error) func(*cli.Context) error {
   160  	return func(ctx *cli.Context) error {
   161  		flagNoticeTemplate := strings.TrimSpace(readPathOrLiteral(ctx.String("notice-path"), ctx.String("notice")))
   162  		if flagNoticeTemplate == "" {
   163  			return fmt.Errorf("--notice provided is an empty string; cannot continue")
   164  		}
   165  
   166  		var roots []string
   167  		if args := ctx.Args().Slice(); len(args) > 0 {
   168  			roots = args[:]
   169  		} else {
   170  			roots = []string{"."}
   171  		}
   172  
   173  		flagExcludes := ctx.StringSlice("exclude")
   174  		if ctx.Bool("use-exclude-defaults") {
   175  			flagExcludes = append(flagExcludes, flagStrings(copyright.DefaultExcludes)...)
   176  		}
   177  		if excludesPath := ctx.String("excludes-path"); excludesPath != "" {
   178  			excludes, err := readGlobFile(excludesPath)
   179  			if err != nil {
   180  				return err
   181  			}
   182  			flagExcludes = append(flagExcludes, excludes...)
   183  		}
   184  
   185  		flagIncludes := ctx.StringSlice("include")
   186  		if ctx.Bool("use-include-defaults") {
   187  			flagIncludes = append(flagIncludes, flagStrings(copyright.DefaultIncludes)...)
   188  		}
   189  		if includesPath := ctx.String("includes-path"); includesPath != "" {
   190  			includes, err := readGlobFile(includesPath)
   191  			if err != nil {
   192  				return err
   193  			}
   194  			flagIncludes = append(flagIncludes, includes...)
   195  		}
   196  
   197  		var restrictions string
   198  		var err error
   199  		if restrictionsPath := ctx.String("restrictions-path"); restrictionsPath != "" {
   200  			restrictions, err = tryReadFile(restrictionsPath)
   201  			if err != nil {
   202  				return fmt.Errorf("cannot read restrictions by path; %w", err)
   203  			}
   204  		} else {
   205  			restrictions = ctx.String("restrictions")
   206  			switch restrictions {
   207  			case "open-source":
   208  				restrictions = copyright.DefaultRestrictionsTemplate
   209  			case "internal":
   210  				restrictions = copyright.DefaultRestrictionsTemplateInternal
   211  			default:
   212  			}
   213  		}
   214  
   215  		extensionInjectionTemplates := copyright.DefaultExtensionInjectionTemplates
   216  		for _, extValue := range ctx.StringSlice("ext") {
   217  			ext, noticeTemplate, err := parseExtensionNoticeBodyTemplate(extValue)
   218  			if err != nil {
   219  				return err
   220  			}
   221  			extensionInjectionTemplates[ext] = noticeTemplate
   222  		}
   223  
   224  		engine := &copyright.Copyright{
   225  			Config: copyright.Config{
   226  				NoticeTemplate:              flagNoticeTemplate,
   227  				CopyrightHolder:             ctx.String("holder"),
   228  				RestrictionsTemplate:        restrictions,
   229  				Year:                        ctx.Int("year"),
   230  				License:                     ctx.String("license"),
   231  				ExtensionInjectionTemplates: extensionInjectionTemplates,
   232  				Excludes:                    flagExcludes,
   233  				Includes:                    flagIncludes,
   234  				Quiet:                       ctx.Bool("quiet"),
   235  				Verbose:                     ctx.Bool("verbose"),
   236  				Debug:                       ctx.Bool("debug"),
   237  			},
   238  		}
   239  		return commandAction(ctx, engine, roots...)
   240  	}
   241  }
   242  
   243  type flagStrings []string
   244  
   245  func (fs flagStrings) String() string {
   246  	return strings.Join(fs, ", ")
   247  }
   248  
   249  func (fs *flagStrings) Set(flagValue string) error {
   250  	if flagValue == "" {
   251  		return fmt.Errorf("invalid flag value; is empty")
   252  	}
   253  	*fs = append(*fs, flagValue)
   254  	return nil
   255  }
   256  
   257  func tryReadFile(path string) (string, error) {
   258  	contents, err := os.ReadFile(path)
   259  	return string(contents), err
   260  }
   261  
   262  func readPathOrLiteral(path, literal string) string {
   263  	if path != "" {
   264  		contents, _ := os.ReadFile(path)
   265  		return string(contents)
   266  	}
   267  	return literal
   268  }
   269  
   270  func readGlobFile(path string) ([]string, error) {
   271  	f, err := os.Open(path)
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	defer f.Close()
   276  	var output []string
   277  	scanner := bufio.NewScanner(f)
   278  	for scanner.Scan() {
   279  		output = append(output, strings.TrimSpace(scanner.Text()))
   280  	}
   281  	return output, nil
   282  }
   283  
   284  func parseExtensionNoticeBodyTemplate(extensionNoticeBodyTemplate string) (extension copyright.Extension, noticeBodyTemplate string, err error) {
   285  	parts := strings.SplitN(extensionNoticeBodyTemplate, "=", 2)
   286  	if len(parts) < 2 {
   287  		err = fmt.Errorf("invalid `-ext` value; %s", extensionNoticeBodyTemplate)
   288  		return
   289  	}
   290  	extension = copyright.Extension(parts[0])
   291  	if !strings.HasPrefix(string(extension), ".") {
   292  		extension = "." + extension
   293  	}
   294  	noticeBodyTemplate = readPathOrLiteral(parts[1], parts[1])
   295  	return
   296  }