diff --git a/README.md b/README.md index c74ccbf..1300061 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,15 @@ addlicense requires go 1.16 or later. addlicense [flags] pattern [pattern ...] - -c copyright holder (default "Google LLC") - -check check only mode: verify presence of license headers and exit with non-zero code if missing - -f license file - -ignore file patterns to ignore, for example: -ignore **/*.go -ignore vendor/** - -l license type: apache, bsd, mit, mpl (default "apache") - -s Include SPDX identifier in license header. Set -s=only to only include SPDX identifier. - -v verbose mode: print the name of the files that are modified - -y copyright year(s) (default is the current year) + -c copyright holder (default "Google LLC") + -check check only mode: verify presence of license headers and exit with non-zero code if missing + -comment-style override the comment style for a file extension, for example: -comment-style h://,ts:docblock + -f license file + -ignore file patterns to ignore, for example: -ignore **/*.go -ignore vendor/** + -l license type: apache, bsd, mit, mpl (default "apache") + -s Include SPDX identifier in license header. Set -s=only to only include SPDX identifier. + -v verbose mode: print the name of the files that are modified + -y copyright year(s) (default is the current year) The pattern argument can be provided multiple times, and may also refer to single files. Directories are processed recursively. @@ -36,6 +37,29 @@ all subdirectories: The `-ignore` flag can use any pattern [supported by doublestar](https://github.com/bmatcuk/doublestar#patterns). +The `-comment-style` flag can be used to override the default comment style for one or more supported file extensions. For example: + + addlicense -comment-style h://,ts:docblock . + +Here `.h` files get `//` line comments, and `.ts` files get a `/** */` block. + +The available styles are: + +| Style | Renders as | +| ------------ | ------------------- | +| `//` | `// text` | +| `#` | `# text` | +| `;;` | `;; text` | +| `%` | `% text` | +| `--` | `-- text` | +| `vim` | `" text` | +| `block` | `/*` … ` * text` … ` */` | +| `docblock` | `/**` … ` * text` … ` */` | +| `html` | `` | +| `jinja` | `{# text #}` | +| `ocaml` | `(** text *)` | +| `powershell` | `<# text #>` | + ## Running in a Docker Container The simplest way to get the addlicense docker image is to pull from GitHub diff --git a/main.go b/main.go index 18bb5a4..a1b5a1b 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,7 @@ var ( skipExtensionFlags stringSlice ignorePatterns stringSlice spdx spdxFlag + commentOverrides commentStyleFlag holder = flag.String("c", "Google LLC", "copyright holder") license = flag.String("l", "apache", "license type: apache, bsd, mit, mpl") @@ -70,6 +71,7 @@ func init() { flag.Var(&skipExtensionFlags, "skip", "[deprecated: see -ignore] file extensions to skip, for example: -skip rb -skip go") flag.Var(&ignorePatterns, "ignore", "file patterns to ignore, for example: -ignore **/*.go -ignore vendor/**") flag.Var(&spdx, "s", "Include SPDX identifier in license header. Set -s=only to only include SPDX identifier.") + flag.Var(&commentOverrides, "comment-style", "override the comment style for a file extension, as comma-separated ext:style pairs, for example: -comment-style h://,ts:docblock") } // stringSlice stores the results of a repeated command line flag as a string slice. @@ -107,6 +109,97 @@ func (i *spdxFlag) Set(value string) error { return nil } +// commentStyleFlag accumulates -comment-style overrides as a map of normalized +// file extension to comment style label. +type commentStyleFlag map[string]commentStyle + +func (i *commentStyleFlag) String() string { + if len(*i) == 0 { + return "" + } + v := "" + for ext, style := range *i { + v += fmt.Sprintf("%s:%s,", ext, style) + } + return v[:len(v)-1] +} + +// Set parses one -comment-style value, a comma-separated list of "ext:style" +// pairs, and records each override. It may be called more than once when the +// flag is repeated. +func (i *commentStyleFlag) Set(value string) error { + if *i == nil { + *i = commentStyleFlag{} + } + for _, entry := range strings.Split(value, ",") { + parts := strings.SplitN(entry, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("error: flag 'comment-style' expects \"ext:style[,ext:style]\", got %q", entry) + } + ext := strings.ToLower(parts[0]) + style := parts[1] + + if !strings.HasPrefix(ext, ".") { + // normalize extension to start with a dot, for example "go" becomes ".go" + // this matches the behavior of fileExtension() + ext = "." + ext + } + + (*i)[ext] = commentStyle(style) + } + return nil +} + +// commentStyle identifies a comment formatting style by label. Its string value +// is what users pass to -comment-style. +type commentStyle string + +const ( + styleDoubleSlash commentStyle = "//" + styleSingleHash commentStyle = "#" + styleDoubleSemicolon commentStyle = ";;" + styleSinglePercent commentStyle = "%" + styleDoubleDash commentStyle = "--" + styleVim commentStyle = "vim" // Vimscript: leading " + styleBlock commentStyle = "block" // /* * */ + styleDocBlock commentStyle = "docblock" // /** * */ + styleHTML commentStyle = "html" + styleJinja commentStyle = "jinja" + styleOCaml commentStyle = "ocaml" + stylePowerShell commentStyle = "powershell" +) + +func (cs *commentStyle) affixes() (top string, mid string, bot string) { + switch *cs { + case styleDoubleSlash: + return "", "// ", "" + case styleSingleHash: + return "", "# ", "" + case styleDoubleSemicolon: + return "", ";; ", "" + case styleSinglePercent: + return "", "% ", "" + case styleDoubleDash: + return "", "-- ", "" + case styleVim: + return "", `" `, "" + case styleBlock: + return "/*", " * ", " */" + case styleDocBlock: + return "/**", " * ", " */" + case styleHTML: + return "" + case styleJinja: + return "{#", "", "#}" + case styleOCaml: + return "(**", " ", "*)" + case stylePowerShell: + return "<#", " ", "#>" + default: + return "", "", "" + } +} + func main() { flag.Parse() if flag.NArg() == 0 { @@ -125,6 +218,13 @@ func main() { } } + // verify that all -comment-style overrides are valid + for _, label := range commentOverrides { + if top, mid, bot := label.affixes(); top == "" && mid == "" && bot == "" { + log.Fatalf("-comment-style %q is not valid", label) + } + } + // map legacy license values if t, ok := legacyLicenseTypes[*license]; ok { *license = t @@ -285,25 +385,30 @@ func fileHasLicense(path string) (bool, error) { // licenseHeader populates the provided license template with data, and returns // it with the proper prefix for the file type specified by path. The file does // not need to actually exist, only its name is used to determine the prefix. +// +// The comment style is the one set via -comment-style for the file's extension, +// or the built-in default from defaultStyleLabel. A nil result with a nil error +// means the file type is unrecognized and should be skipped. func licenseHeader(path string, tmpl *template.Template, data licenseData) ([]byte, error) { - var lic []byte - var err error base := strings.ToLower(filepath.Base(path)) + ext := fileExtension(base) + + var style commentStyle // When adding an extension, also add it to TestLicenseHeader in main_test.go - switch fileExtension(base) { + switch ext { case ".c", ".h", ".gv", ".java", ".kt", ".kts", ".scala": - lic, err = executeTemplate(tmpl, data, "/*", " * ", " */") + style = styleBlock case ".css", ".scss", ".sass", ".less", ".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx": - lic, err = executeTemplate(tmpl, data, "/**", " * ", " */") + style = styleDocBlock case ".cc", ".cpp", ".hh", ".hpp", ".cs", @@ -318,7 +423,7 @@ func licenseHeader(path string, tmpl *template.Template, data licenseData) ([]by ".rs", ".swift", ".v", ".sv": - lic, err = executeTemplate(tmpl, data, "", "// ", "") + style = styleDoubleSlash case ".awk", ".buckconfig", "buck", @@ -339,41 +444,51 @@ func licenseHeader(path string, tmpl *template.Template, data licenseData) ([]by ".tf", ".toml", ".yaml", ".yml": - lic, err = executeTemplate(tmpl, data, "", "# ", "") + style = styleSingleHash case ".el", ".lisp", ".scm": - lic, err = executeTemplate(tmpl, data, "", ";; ", "") + style = styleDoubleSemicolon case ".erl": - lic, err = executeTemplate(tmpl, data, "", "% ", "") + style = styleSinglePercent case ".hs", ".lua", ".sql", ".sdl": - lic, err = executeTemplate(tmpl, data, "", "-- ", "") + style = styleDoubleDash case ".html", ".htm", ".vue", ".svelte", ".wxi", ".wxl", ".wxs", ".xml": - lic, err = executeTemplate(tmpl, data, "") + style = styleHTML case ".j2", ".jinja2", ".jinja": - lic, err = executeTemplate(tmpl, data, "{#", "", "#}") + style = styleJinja case ".ml", ".mli", ".mll", ".mly": - lic, err = executeTemplate(tmpl, data, "(**", " ", "*)") + style = styleOCaml case ".ps1", ".psm1": - lic, err = executeTemplate(tmpl, data, "<#", " ", "#>") + style = stylePowerShell case ".vim": - lic, err = executeTemplate(tmpl, data, "", `" `, "") + style = styleVim default: // handle various cmake files if base == "cmakelists.txt" || strings.HasSuffix(base, ".cmake.in") || strings.HasSuffix(base, ".cmake") { - lic, err = executeTemplate(tmpl, data, "", "# ", "") + style = styleSingleHash + } else { + // unknown file type, skip + return nil, nil } } - return lic, err + + if userStyle, ok := commentOverrides[ext]; ok { + style = userStyle + } + + top, mid, bot := style.affixes() + + return executeTemplate(tmpl, data, top, mid, bot) } // fileExtension returns the file extension of name, or the full name if there diff --git a/main_test.go b/main_test.go index 5adb4ed..f10968b 100644 --- a/main_test.go +++ b/main_test.go @@ -19,6 +19,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "strings" "testing" "text/template" @@ -555,3 +556,98 @@ func TestFileMatches(t *testing.T) { } } } + +// Test that -comment-style overrides change the comment style for an extension, +// while non-overridden extensions keep their built-in defaults. +func TestLicenseHeaderOverrides(t *testing.T) { + tpl := template.Must(template.New("").Parse("{{.Holder}}{{.Year}}{{.SPDXID}}")) + data := licenseData{Holder: "H", Year: "Y", SPDXID: "S"} + + commentOverrides = commentStyleFlag{ + ".h": styleDoubleSlash, // default is single-star block + ".ts": styleBlock, // default is docblock + ".go": styleDocBlock, // default is // + } + defer func() { commentOverrides = nil }() + + tests := []struct { + path string + want string + }{ + {"f.h", "// HYS\n\n"}, + {"f.ts", "/*\n * HYS\n */\n\n"}, + {"f.go", "/**\n * HYS\n */\n\n"}, + {"f.c", "/*\n * HYS\n */\n\n"}, // not overridden: default block style + } + for _, tt := range tests { + header, _ := licenseHeader(tt.path, tpl, data) + if got := string(header); got != tt.want { + t.Errorf("licenseHeader(%q) with overrides returned: %q, want: %q", tt.path, got, tt.want) + } + } +} + +// Test parsing of the -comment-style flag value. +func TestCommentStyleFlagSet(t *testing.T) { + tests := []struct { + name string + values []string // each element is one -comment-style flag occurrence + want map[string]commentStyle + wantErr bool + }{ + {"single line style", []string{"h://"}, map[string]commentStyle{".h": "//"}, false}, + {"comma separated", []string{"h://,ts:docblock"}, map[string]commentStyle{".h": "//", ".ts": "docblock"}, false}, + {"repeated flag accumulates", []string{"h://", "py:#"}, map[string]commentStyle{".h": "//", ".py": "#"}, false}, + {"missing colon", []string{"hslashes"}, nil, true}, + {"empty extension", []string{":#"}, nil, true}, + {"empty style", []string{"h:"}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := commentStyleFlag{} + var err error + for _, v := range tt.values { + if err = c.Set(v); err != nil { + break + } + } + if tt.wantErr { + if err == nil { + t.Fatalf("Set(%v) expected error, got nil (result %v)", tt.values, c) + } + return + } + if err != nil { + t.Fatalf("Set(%v) unexpected error: %v", tt.values, err) + } + if !reflect.DeepEqual(map[string]commentStyle(c), tt.want) { + t.Errorf("Set(%v) = %v, want %v", tt.values, map[string]commentStyle(c), tt.want) + } + }) + } +} + +// Test that the -comment-style flag changes the emitted header end-to-end. +func TestCommentStyleOverride(t *testing.T) { + if os.Getenv("RUNME") != "" { + main() + return + } + + tmp := tempDir(t) + t.Logf("tmp dir: %s", tmp) + samplefile := filepath.Join(tmp, "file.c") + const sampleLicensed = "testdata/comment_style_file.c" + + run(t, "cp", "testdata/initial/file.c", samplefile) + cmd := exec.Command(os.Args[0], + "-test.run=TestCommentStyleOverride", + "-l", "apache", "-c", "Google LLC", "-y", "2018", + "-comment-style", "c://", samplefile, + ) + cmd.Env = []string{"RUNME=1"} + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v\n%s", err, out) + } + run(t, "diff", samplefile, sampleLicensed) +} diff --git a/testdata/comment_style_file.c b/testdata/comment_style_file.c new file mode 100644 index 0000000..ce4e60b --- /dev/null +++ b/testdata/comment_style_file.c @@ -0,0 +1,20 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +int main() { + printf("Hello world\n"); + return 0; +}