diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9257e4..d56a22e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - - name: Set up Go 1.21 + - name: Set up Go 1.24 uses: actions/setup-go@v1 with: - go-version: 1.21 + go-version: 1.24 - name: Check out code into the Go module directory uses: actions/checkout@v2 @@ -32,4 +32,4 @@ jobs: run: go install ./... - name: Self-check - run: go-header $(git ls-files | grep -E '.*\.go$') + run: go-header ./... diff --git a/.go-header.yml b/.go-header.yml index 3aa6d06..ff3337e 100644 --- a/.go-header.yml +++ b/.go-header.yml @@ -1,7 +1,6 @@ -values: - regexp: - copyright-holder: Copyright \(c\) {{mod-year-range}} Denis Tingaikin -template: | +vars: + copyright-holder: Copyright \(c\) {{mod-year-range}} Denis Tingaikin +template: |- {{copyright-holder}} SPDX-License-Identifier: Apache-2.0 @@ -16,4 +15,4 @@ template: | 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. \ No newline at end of file + limitations under the License. diff --git a/README.md b/README.md index fa385db..f9e3b6e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,23 @@ # go-header [![ci](https://github.com/denis-tingaikin/go-header/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/denis-tingaikin/go-header/actions/workflows/ci.yml) -Go source code linter providing checks for license headers. +Go source code linter providing checks for license headers. + +## Features + +| Feature | Status | Details | +|-----------------------------|--------|------------------------------------------| +| ✅ **Copyright Headers** | ✔️ | Supports all standard formats | +| ✅ **Parallel Processing** | ✔️ | Processes files concurrently | +| ✅ **Comment Support** | ✔️ | `//`, `/* */`, `/* * */`| +| ✅ **Go/Analysis** | ✔️ | Native Go tooling integration | +| ✅ **Regex Customization** | ✔️ | User-defined pattern matching | +| ✅ **Automatic Year Checks** | ✔️ | Validates & updates copyright years | +| ✅ **Auto-Fix Files** | ✔️ | In-place header corrections | +| ⏳ **Go/Template Support** | ❌ | *In development* | +| ⏳ **Multi-License Support** | ❌ | *Planned* | + + ## Installation @@ -10,11 +26,32 @@ For installation you can simply use `go install`. ```bash go install github.com/denis-tingaikin/go-header/cmd/go-header@latest ``` - ## Configuration To configuring `.go-header.yml` linter you simply need to fill the next fields: + +Inline template: +```yaml +--- +template: # expects header template string. +vars: # expects valid key value paris where key is string, value is regexp. + key1: value1 # const value just checks equality. Note `key1` should be used in template string as {{ .key1 }} or {{ .KEY1 }}. + key2: value2(.*) # regexp value just checks regex match. The value should be a valid regexp pattern. Note `key2` should be used in template string as {{ .key2 }} or {{ .KEY2 }}. +``` +Filebased template: +```yaml +--- +template-path: # expects header template path string. +vars: # expects `const` or `regexp` node with values where values is a map string to string. + key1: value1 # const value just checks equality. Note `key1` should be used in template string as {{ key1 }} or {{ KEY1 }}. + key2: value2(.*) # regexp value just checks regex match. The value should be a valid regexp pattern. Note `key2` should be used in template string as {{ key2 }} or {{ KEY2 }}. +``` + +## Configuration (DEPRECATED) + +To configuring `.go-header.yml` linter you simply need to fill the next fields: + ```yaml --- template: # expects header template string. @@ -48,7 +85,7 @@ values: `go-header` linter expects file paths on input. If you want to run `go-header` only on diff files, then you can use this command: ```bash -go-header $(git diff --name-only | grep -E '.*\.go') +go-header ./... ``` ## Setup example @@ -59,11 +96,11 @@ Create configuration file `.go-header.yml` in the root of project. ```yaml --- -values: - const: - MY COMPANY: mycompany.com +vars: + DOMAIN: sales|product + MY_COMPANY: {{ .DOMAIN }}.mycompany.com template: | - {{ MY COMPANY }} + {{ .MY_COMPANY }} SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -80,4 +117,4 @@ template: | ``` ### Step 2 -You are ready! Execute `go-header ${PATH_TO_FILES}` from the root of the project. +Run `go-header ./...` diff --git a/analysis.go b/analysis.go new file mode 100644 index 0000000..799c89a --- /dev/null +++ b/analysis.go @@ -0,0 +1,161 @@ +// Copyright (c) 2025 Denis Tingaikin +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +package goheader + +import ( + "go/ast" + "go/token" + "runtime" + "strings" + "sync" + + "golang.org/x/tools/go/analysis" +) + +// NewAnalyzer creates new analyzer based on template and goheader values +func NewAnalyzer(templ string, vars map[string]Value) *analysis.Analyzer { + var goheader = New(WithTemplate(templ), WithValues(vars)) + return &analysis.Analyzer{ + Doc: "the_only_doc", + URL: "https://github.com/denis-tingaikin/go-header", + Name: "goheader", + Run: func(p *analysis.Pass) (any, error) { + var wg sync.WaitGroup + + var jobCh = make(chan *ast.File, len(p.Files)) + + for _, file := range p.Files { + jobCh <- file + } + close(jobCh) + + for range runtime.NumCPU() { + wg.Add(1) + go func() { + defer wg.Done() + + for file := range jobCh { + + filename := p.Fset.Position(file.Pos()).Filename + if !strings.HasSuffix(filename, ".go") { + continue + } + + issue := goheader.Analyze(&Target{ + File: file, + Path: filename, + }) + + if issue == nil { + continue + } + f := p.Fset.File(file.Pos()) + + commentLine := 1 + var offset int + + // Inspired by https://github.com/denis-tingaikin/go-header/blob/4c75a6a2332f025705325d6c71fff4616aedf48f/analyzer.go#L85-L92 + if len(file.Comments) > 0 && file.Comments[0].Pos() < file.Package { + if !strings.HasPrefix(file.Comments[0].List[0].Text, "/*") { + // When the comment is "//" there is a one character offset. + offset = 1 + } + commentLine = p.Fset.PositionFor(file.Comments[0].Pos(), true).Line + } + + // Skip issues related to build directives. + // https://github.com/denis-tingaikin/go-header/issues/18 + if issue.Location().Position-offset < 0 { + continue + } + + diag := analysis.Diagnostic{ + Pos: f.LineStart(issue.Location().Line+1) + token.Pos(issue.Location().Position-offset), // The position of the first divergence. + Message: issue.Message(), + } + + if fix := issue.Fix(); fix != nil { + current := len(fix.Actual) + for _, s := range fix.Actual { + current += len(s) + } + + start := f.LineStart(commentLine) + + end := start + token.Pos(current) + + header := strings.Join(fix.Expected, "\n") + "\n" + + // Adds an extra line between the package and the header. + if end == file.Package { + header += "\n" + } + + diag.SuggestedFixes = []analysis.SuggestedFix{{ + TextEdits: []analysis.TextEdit{{ + Pos: start, + End: end, + NewText: []byte(header), + }}, + }} + } + + p.Report(diag) + } + }() + } + + wg.Wait() + return nil, nil + }, + } +} + +// NewAnalyzerFromConfig creates a new analysis.Analyzer from goheader config file +func NewAnalyzerFromConfig(config *string) *analysis.Analyzer { + var goheaderOncer sync.Once + var goheader *analysis.Analyzer + + return &analysis.Analyzer{ + Doc: "the_only_doc", + URL: "https://github.com/denis-tingaikin/go-header", + Name: "goheader", + Run: func(p *analysis.Pass) (any, error) { + var err error + goheaderOncer.Do(func() { + var cfg Config + if err = cfg.Parse(*config); err != nil { + return + } + templ, err := cfg.GetTemplate() + if err != nil { + return + } + vars, err := cfg.GetValues() + if err != nil { + return + } + goheader = NewAnalyzer(templ, vars) + }) + + if err != nil { + return nil, err + } + return goheader.Run(p) + }, + } +} diff --git a/analyzer.go b/analyzer.go index c6b361f..360c52a 100644 --- a/analyzer.go +++ b/analyzer.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -23,6 +23,7 @@ import ( "os/exec" "strings" "time" + "unicode" ) type Target struct { @@ -52,29 +53,36 @@ type Analyzer struct { template string } -func (a *Analyzer) processPerTargetValues(target *Target) error { - a.values["mod-year"] = a.values["year"] - a.values["mod-year-range"] = a.values["year-range"] +func (a *Analyzer) getPerTargetValues(target *Target) (map[string]Value, error) { + var res = make(map[string]Value) + + for k, v := range a.values { + res[k] = v + } + + res["mod-year"] = a.values["year"] + res["mod-year-range"] = a.values["year-range"] if t, err := target.ModTime(); err == nil { - a.values["mod-year"] = &ConstValue{RawValue: fmt.Sprint(t.Year())} - a.values["mod-year-range"] = &RegexpValue{RawValue: `((20\d\d\-{{mod-year}})|({{mod-year}}))`} + res["mod-year"] = &ConstValue{RawValue: fmt.Sprint(t.Year())} + res["mod-year-range"] = &RegexpValue{RawValue: `((20\d\d\-{{mod-year}})|({{mod-year}}))`} } - for _, v := range a.values { - if err := v.Calculate(a.values); err != nil { - return err + for _, v := range res { + if err := v.Calculate(res); err != nil { + return nil, err } } - return nil + + return res, nil } func (a *Analyzer) Analyze(target *Target) (i Issue) { if a.template == "" { return NewIssue("Missed template for check") } - - if err := a.processPerTargetValues(target); err != nil { - return &issue{msg: err.Error()} + vals, err := a.getPerTargetValues(target) + if err != nil { + return NewIssue(err.Error()) } file := target.File @@ -82,9 +90,21 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { var offset = Location{ Position: 1, } + + if isNewLineRequired(file.Comments) { + return NewIssueWithLocation( + "Missing a newline after the header. Consider adding a newline separator right after the copyright header.", + Location{ + Line: countLines(file.Comments[0].Text()), + }, + ) + } + if len(file.Comments) > 0 && file.Comments[0].Pos() < file.Package { if strings.HasPrefix(file.Comments[0].List[0].Text, "/*") { header = (&ast.CommentGroup{List: []*ast.Comment{file.Comments[0].List[0]}}).Text() + + header = handleStarBlock(header) } else { header = file.Comments[0].Text() offset.Position += 3 @@ -94,13 +114,14 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { if i == nil { return } - fix, ok := a.generateFix(i, file, header) + fix, ok := a.generateFix(i, file, header, vals) if !ok { return } i = NewIssueWithFix(i.Message(), i.Location(), fix) }() header = strings.TrimSpace(header) + if header == "" { return NewIssue("Missed header for check") } @@ -111,10 +132,10 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { templateCh := t.Peek() if templateCh == '{' { name := a.readField(t) - if a.values[name] == nil { + if vals[name] == nil { return NewIssue(fmt.Sprintf("Template has unknown value: %v", name)) } - if i := a.values[name].Read(s); i != nil { + if i := vals[name].Read(s); i != nil { return i } continue @@ -147,6 +168,12 @@ func (a *Analyzer) readField(reader *Reader) string { _ = reader.Next() _ = reader.Next() + _ = reader.ReadWhile(unicode.IsSpace) + + if reader.Peek() == '.' { + _ = reader.Next() + } + r := reader.ReadWhile(func(r rune) bool { return r != '}' }) @@ -165,17 +192,17 @@ func New(options ...Option) *Analyzer { return a } -func (a *Analyzer) generateFix(i Issue, file *ast.File, header string) (Fix, bool) { +func (a *Analyzer) generateFix(i Issue, file *ast.File, header string, vals map[string]Value) (Fix, bool) { var expect string t := NewReader(a.template) for !t.Done() { ch := t.Peek() if ch == '{' { - f := a.values[a.readField(t)] + f := vals[a.readField(t)] if f == nil { return Fix{}, false } - if f.Calculate(a.values) != nil { + if f.Calculate(vals) != nil { return Fix{}, false } expect += f.Get() @@ -254,3 +281,60 @@ func (a *Analyzer) generateFix(i Issue, file *ast.File, header string) (Fix, boo fix.Actual = append(fix.Actual, strings.Split(actual, "\n")...) return fix, true } + +func isNewLineRequired(group []*ast.CommentGroup) bool { + if len(group[0].List) > 1 { + for _, item := range group[0].List { + if strings.HasPrefix(item.Text, "/*") { + return true + } + } + } + + if len(group) < 2 { + return false + } + return group[0].End() >= group[1].Pos() +} + +func countLines(text string) int { + if text == "" { + return 0 + } + + lines := 1 + for i := 0; i < len(text); i++ { + if text[i] == '\n' { + lines++ + } else if text[i] == '\r' { + lines++ + if i+1 < len(text) && text[i+1] == '\n' { + i++ + } + } + } + return lines +} + +func handleStarBlock(header string) string { + return trimEachLine(header, func(s string) string { + var trimmed = strings.TrimSpace(s) + if !strings.HasPrefix(trimmed, "*") { + return s + } + if v, ok := strings.CutPrefix(trimmed, "* "); ok { + return v + } else { + var res, _ = strings.CutPrefix(trimmed, "*") + return res + } + }) +} + +func trimEachLine(input string, trimFunc func(string) string) string { + lines := strings.Split(input, "\n") + for i, line := range lines { + lines[i] = trimFunc(line) + } + return strings.Join(lines, "\n") +} diff --git a/analyzer_test.go b/analyzer_test.go index 6a0f25f..0ba87f2 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -22,6 +22,7 @@ import ( "go/parser" "go/token" "os" + "path" "path/filepath" "testing" "time" @@ -31,7 +32,7 @@ import ( "github.com/stretchr/testify/require" ) -func header(header string) *goheader.Target { +func header(t *testing.T, header string) *goheader.Target { return &goheader.Target{ File: &ast.File{ Comments: []*ast.CommentGroup{ @@ -45,7 +46,7 @@ func header(header string) *goheader.Target { }, Package: token.Pos(len(header)), }, - Path: os.TempDir(), + Path: t.TempDir(), } } @@ -84,7 +85,7 @@ func TestAnalyzer_Analyze(t *testing.T) { desc: "header comment", filename: "headercomment/headercomment.go", config: "headercomment/headercomment.yml", - assert: assert.Nil, + assert: assert.NotNil, }, { desc: "readme", @@ -92,11 +93,17 @@ func TestAnalyzer_Analyze(t *testing.T) { config: "readme/readme.yml", assert: assert.Nil, }, + { + desc: "star-block like header", + filename: "starcomment/starcomment.go", + config: "starcomment/starcomment.yml", + assert: assert.Nil, + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - cfg := &goheader.Configuration{} + cfg := &goheader.Config{} err := cfg.Parse(filepath.Join("testdata", test.config)) require.NoError(t, err) @@ -222,7 +229,7 @@ func TestAnalyzer_Analyze_fix(t *testing.T) { for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { - cfg := &goheader.Configuration{} + cfg := &goheader.Config{} err := cfg.Parse(filepath.Join("testdata", test.config)) require.NoError(t, err) @@ -252,7 +259,7 @@ func TestAnalyzer_Analyze_fix(t *testing.T) { } func TestAnalyzer_YearRangeValue_ShouldWorkWithComplexVariables(t *testing.T) { - var conf goheader.Configuration + var conf goheader.Config var vals, err = conf.GetValues() require.NoError(t, err) @@ -261,5 +268,282 @@ func TestAnalyzer_YearRangeValue_ShouldWorkWithComplexVariables(t *testing.T) { } var a = goheader.New(goheader.WithTemplate("A {{ my-val }}"), goheader.WithValues(vals)) - require.Nil(t, a.Analyze(header(fmt.Sprintf("A 2000-%v B", time.Now().Year())))) + require.Nil(t, a.Analyze(header(t, fmt.Sprintf("A 2000-%v B", time.Now().Year())))) +} + +func TestAnalyzer_UnicodeHeaders(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("😊早安😊"), + ) + issue := a.Analyze(header(t, `😊早安😊`)) + require.Nil(t, issue) +} + +func TestAnalyzer_Analyze1(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("A {{ YEAR }}\nB"), + goheader.WithValues(map[string]goheader.Value{ + "YEAR": &goheader.ConstValue{ + RawValue: "2020", + }, + })) + issue := a.Analyze(header(t, `A 2020 +B`)) + require.Nil(t, issue) +} + +func TestAnalyzer_Analyze2(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("{{COPYRIGHT HOLDER}}TEXT"), + goheader.WithValues(map[string]goheader.Value{ + "COPYRIGHT HOLDER": &goheader.RegexpValue{ + RawValue: "(A {{ YEAR }}\n(.*)\n)+", + }, + "YEAR": &goheader.ConstValue{ + RawValue: "2020", + }, + })) + issue := a.Analyze(header(t, `A 2020 +B +A 2020 +B +TEXT +`)) + require.Nil(t, issue) +} + +func TestAnalyzer_Analyze3(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("{{COPYRIGHT HOLDER}}TEXT"), + goheader.WithValues(map[string]goheader.Value{ + "COPYRIGHT HOLDER": &goheader.RegexpValue{ + RawValue: "(A {{ .YEAR }}\n(.*)\n)+", + }, + "YEAR": &goheader.ConstValue{ + RawValue: "2020", + }, + })) + issue := a.Analyze(header(t, `A 2020 +B +A 2021 +B +TEXT +`)) + require.NotNil(t, issue) +} + +func TestAnalyzer_Analyze4(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("{{ A }}"), + goheader.WithValues(map[string]goheader.Value{ + "A": &goheader.RegexpValue{ + RawValue: "[{{ B }}{{ C }}]{{D}}", + }, + "B": &goheader.ConstValue{ + RawValue: "a-", + }, + "C": &goheader.RegexpValue{ + RawValue: "z", + }, + "D": &goheader.ConstValue{ + RawValue: "{{E}}", + }, + "E": &goheader.ConstValue{ + RawValue: "{7}", + }, + })) + issue := a.Analyze(header(t, `abcdefg`)) + require.Nil(t, issue) +} + +func TestAnalyzer_Analyze5(t *testing.T) { + a := goheader.New(goheader.WithTemplate("abc")) + p := path.Join(os.TempDir(), t.Name()+".go") + defer func() { + _ = os.Remove(p) + }() + err := os.WriteFile(p, []byte("/*abc*/\n\n//comment\npackage abc"), os.ModePerm) + require.Nil(t, err) + s := token.NewFileSet() + f, err := parser.ParseFile(s, p, nil, parser.ParseComments) + require.Nil(t, err) + require.Nil(t, a.Analyze(&goheader.Target{File: f, Path: p})) +} + +func TestAnalyzer_Analyze6(t *testing.T) { + a := goheader.New(goheader.WithTemplate("abc")) + p := path.Join(t.TempDir(), t.Name()+".go") + defer func() { + _ = os.Remove(p) + }() + + err := os.WriteFile(p, []byte("//abc\n\n//comment\npackage abc"), os.ModePerm) + require.Nil(t, err) + s := token.NewFileSet() + f, err := parser.ParseFile(s, p, nil, parser.ParseComments) + require.Nil(t, err) + require.Nil(t, a.Analyze(&goheader.Target{File: f, Path: p})) +} + +func TestREADME(t *testing.T) { + a := goheader.New( + goheader.WithTemplate(`{{ MY COMPANY }} +SPDX-License-Identifier: Apache-2.0 + +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.`), + goheader.WithValues(map[string]goheader.Value{ + "MY COMPANY": &goheader.ConstValue{ + RawValue: "mycompany.com", + }, + })) + issue := a.Analyze(header(t, `mycompany.com +SPDX-License-Identifier: Apache-2.0 + +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.`)) + require.Nil(t, issue) +} + +func TestFix(t *testing.T) { + const pkg = ` + +// Package foo +package foo + +func Foo() { println("Foo") } +` + + analyze := func(header string) goheader.Issue { + a := goheader.New( + goheader.WithTemplate(`{{ MY COMPANY }} +SPDX-License-Identifier: Foo`), + goheader.WithValues(map[string]goheader.Value{ + "MY COMPANY": &goheader.ConstValue{ + RawValue: "mycompany.com", + }, + })) + + p := filepath.Join(t.TempDir(), t.Name()+".go") + err := os.WriteFile(p, []byte(header+pkg), os.ModePerm) + require.Nil(t, err) + fset := token.NewFileSet() + + file, err := parser.ParseFile(fset, p, nil, parser.ParseComments) + require.NoError(t, err) + + issue := a.Analyze(&goheader.Target{ + File: file, + Path: p, + }) + require.NotNil(t, issue) + require.NotNil(t, issue.Fix()) + return issue + } + + t.Run("Line comment", func(t *testing.T) { + issue := analyze(`// mycompany.net +// SPDX-License-Identifier: Foo`) + + require.Equal(t, []string{ + "// mycompany.net", + "// SPDX-License-Identifier: Foo", + }, issue.Fix().Actual) + require.Equal(t, []string{ + "// mycompany.com", + "// SPDX-License-Identifier: Foo", + }, issue.Fix().Expected) + }) + + t.Run("Block comment 1", func(t *testing.T) { + issue := analyze(`/* mycompany.net +SPDX-License-Identifier: Foo */`) + + require.Equal(t, []string{ + "/* mycompany.net", + "SPDX-License-Identifier: Foo */", + }, issue.Fix().Actual) + require.Equal(t, []string{ + "/* mycompany.com", + "SPDX-License-Identifier: Foo */", + }, issue.Fix().Expected) + }) + + t.Run("Block comment 2", func(t *testing.T) { + issue := analyze(`/* +mycompany.net +SPDX-License-Identifier: Foo */`) + + require.Equal(t, []string{ + "/*", + "mycompany.net", + "SPDX-License-Identifier: Foo */", + }, issue.Fix().Actual) + require.Equal(t, []string{ + "/*", + "mycompany.com", + "SPDX-License-Identifier: Foo */", + }, issue.Fix().Expected) + }) + + t.Run("Block comment 3", func(t *testing.T) { + issue := analyze(`/* mycompany.net +SPDX-License-Identifier: Foo +*/`) + + require.Equal(t, []string{ + "/* mycompany.net", + "SPDX-License-Identifier: Foo", + "*/", + }, issue.Fix().Actual) + require.Equal(t, []string{ + "/* mycompany.com", + "SPDX-License-Identifier: Foo", + "*/", + }, issue.Fix().Expected) + }) + + t.Run("Block comment 4", func(t *testing.T) { + issue := analyze(`/* + +mycompany.net +SPDX-License-Identifier: Foo + +*/`) + + require.Equal(t, []string{ + "/*", + "", + "mycompany.net", + "SPDX-License-Identifier: Foo", + "", + "*/", + }, issue.Fix().Actual) + require.Equal(t, []string{ + "/*", + "", + "mycompany.com", + "SPDX-License-Identifier: Foo", + "", + "*/", + }, issue.Fix().Expected) + }) } diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 58764ae..4f45fa0 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2022 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -17,23 +17,20 @@ package main import ( + "flag" "fmt" - "go/parser" - "go/token" "os" "log" goheader "github.com/denis-tingaikin/go-header" "github.com/denis-tingaikin/go-header/version" + "golang.org/x/tools/go/analysis/singlechecker" ) -const configPath = ".go-header.yml" +var defaultConfigPath = ".go-header.yml" -type issue struct { - goheader.Issue - filePath string -} +var flagSet flag.FlagSet func main() { paths := os.Args[1:] @@ -46,41 +43,11 @@ func main() { return } } - c := &goheader.Configuration{} - if err := c.Parse(configPath); err != nil { - log.Fatal(err.Error()) - } - v, err := c.GetValues() - if err != nil { - log.Fatalf("Can not get values: %v", err.Error()) - } - t, err := c.GetTemplate() - if err != nil { - log.Fatalf("Can not get template: %v", err.Error()) - } - a := goheader.New(goheader.WithValues(v), goheader.WithTemplate(t)) - s := token.NewFileSet() - var issues []*issue - for _, p := range paths { - f, err := parser.ParseFile(s, p, nil, parser.ParseComments) - if err != nil { - log.Fatalf("File %v can not be parsed due compilation errors %v", p, err.Error()) - } - i := a.Analyze(&goheader.Target{ - Path: p, - File: f, - }) - if i != nil { - issues = append(issues, &issue{ - Issue: i, - filePath: p, - }) - } - } - if len(issues) > 0 { - for _, issue := range issues { - fmt.Printf("%v:%v\n%v\n", issue.filePath, issue.Location(), issue.Message()) - } - os.Exit(-1) - } + + var configPath string + flagSet.StringVar(&configPath, "config", defaultConfigPath, "Path to config file") + var analyser = goheader.NewAnalyzerFromConfig(&configPath) + analyser.Flags = flagSet + + singlechecker.Main(analyser) } diff --git a/config.go b/config.go index 8e0b7b2..cc516be 100644 --- a/config.go +++ b/config.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -26,17 +26,20 @@ import ( "gopkg.in/yaml.v3" ) -// Configuration represents go-header linter setup parameters -type Configuration struct { +// Config represents go-header linter setup parameters +type Config struct { // Values is map of values. Supports two types 'const` and `regexp`. Values can be used recursively. + // DEPRECATED: Use Vars instead. Values map[string]map[string]string `yaml:"values"` // Template is template for checking. Uses values. Template string `yaml:"template"` // TemplatePath path to the template file. Useful if need to load the template from a specific file. TemplatePath string `yaml:"template-path"` + // Vars is map of values. Values can be used recursively. + Vars map[string]string `yaml:"vars"` } -func (c *Configuration) builtInValues() map[string]Value { +func (c *Config) builtInValues() map[string]Value { var result = make(map[string]Value) year := fmt.Sprint(time.Now().Year()) result["year-range"] = &RegexpValue{ @@ -48,7 +51,7 @@ func (c *Configuration) builtInValues() map[string]Value { return result } -func (c *Configuration) GetValues() (map[string]Value, error) { +func (c *Config) GetValues() (map[string]Value, error) { var result = c.builtInValues() createConst := func(raw string) Value { return &ConstValue{RawValue: raw} @@ -62,20 +65,13 @@ func (c *Configuration) GetValues() (map[string]Value, error) { result[key] = create(v) } } - for k, v := range c.Values { - switch k { - case "const": - appendValues(v, createConst) - case "regexp": - appendValues(v, createRegexp) - default: - return nil, fmt.Errorf("unknown value type %v", k) - } - } + appendValues(c.Values["const"], createConst) + appendValues(c.Values["regexp"], createRegexp) + appendValues(c.Vars, createRegexp) return result, nil } -func (c *Configuration) GetTemplate() (string, error) { +func (c *Config) GetTemplate() (string, error) { if c.Template != "" { return c.Template, nil } @@ -90,7 +86,7 @@ func (c *Configuration) GetTemplate() (string, error) { } } -func (c *Configuration) Parse(p string) error { +func (c *Config) Parse(p string) error { b, err := os.ReadFile(p) if err != nil { return err diff --git a/go.mod b/go.mod index 27aa697..8000bc3 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,16 @@ module github.com/denis-tingaikin/go-header -go 1.21 +go 1.24 require ( github.com/stretchr/testify v1.7.0 + golang.org/x/tools v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sync v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index e43413c..4bf9502 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/reader.go b/reader.go index 9c9e88a..4ef9320 100644 --- a/reader.go +++ b/reader.go @@ -1,5 +1,5 @@ /* -Copyright (c) 2020-2022 Denis Tingaikin +Copyright (c) 2020-2025 Denis Tingaikin SPDX-License-Identifier: Apache-2.0 @@ -15,14 +15,15 @@ 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. */ + package goheader func NewReader(text string) *Reader { - return &Reader{source: text} + return &Reader{source: []rune(text)} } type Reader struct { - source string + source []rune position int location Location offset Location @@ -44,7 +45,7 @@ func (r *Reader) Peek() rune { if r.Done() { return rune(0) } - return rune(r.source[r.position]) + return r.source[r.position] } func (r *Reader) Done() bool { @@ -55,15 +56,15 @@ func (r *Reader) Next() rune { if r.Done() { return rune(0) } - reuslt := r.Peek() - if reuslt == '\n' { + result := r.Peek() + if result == '\n' { r.location.Line++ r.location.Position = 0 } else { r.location.Position++ } r.position++ - return reuslt + return result } func (r *Reader) Finish() string { @@ -71,7 +72,7 @@ func (r *Reader) Finish() string { return "" } defer r.till() - return r.source[r.position:] + return string(r.source[r.position:]) } func (r *Reader) SetPosition(pos int) { @@ -90,7 +91,7 @@ func (r *Reader) ReadWhile(match func(rune) bool) string { for !r.Done() && match(r.Peek()) { r.Next() } - return r.source[start:r.position] + return string(r.source[start:r.position]) } func (r *Reader) till() { @@ -99,12 +100,9 @@ func (r *Reader) till() { } func (r *Reader) calculateLocation() Location { - min := len(r.source) - if min > r.position { - min = r.position - } + minVal := min(len(r.source), r.position) x, y := 0, 0 - for i := 0; i < min; i++ { + for i := 0; i < minVal; i++ { if r.source[i] == '\n' { y++ x = 0 diff --git a/testdata/constvalue/constvalue.yml b/testdata/constvalue/constvalue.yml index 533edf0..d48f0d0 100644 --- a/testdata/constvalue/constvalue.yml +++ b/testdata/constvalue/constvalue.yml @@ -1,8 +1,7 @@ template: |- - A {{ YEAR }} + A {{ .YEAR }} B -values: - const: - 'YEAR': '2020' +vars: + 'YEAR': '2020' diff --git a/testdata/fix/fix.yml b/testdata/fix/fix.yml index 264e7b4..ffe7c6f 100644 --- a/testdata/fix/fix.yml +++ b/testdata/fix/fix.yml @@ -1,8 +1,7 @@ template: |- - {{ MY COMPANY }} + {{ .MY_COMPANY }} SPDX-License-Identifier: Foo -values: - const: - 'MY COMPANY': 'mycompany.com' +vars: + 'MY_COMPANY': 'mycompany.com' diff --git a/testdata/nestedvalues/nestedvalues.yml b/testdata/nestedvalues/nestedvalues.yml index bed8105..8e0b940 100644 --- a/testdata/nestedvalues/nestedvalues.yml +++ b/testdata/nestedvalues/nestedvalues.yml @@ -1,10 +1,8 @@ template: '{{ A }}' -values: - regexp: - 'A': '[{{ B }}{{ C }}]{{D}}' - const: - 'B': 'a-' - 'C': 'z' - 'D': '{{E}}' - 'E': '{7}' +vars: + 'A': '[{{ B }}{{ C }}]{{D}}' + 'B': 'a-' + 'C': 'z' + 'D': '{{E}}' + 'E': '{7}' diff --git a/testdata/starcomment/starcomment.go b/testdata/starcomment/starcomment.go new file mode 100644 index 0000000..c544232 --- /dev/null +++ b/testdata/starcomment/starcomment.go @@ -0,0 +1,6 @@ +/* + * A 2020 + * B + */ + +package constvalue diff --git a/testdata/starcomment/starcomment.yml b/testdata/starcomment/starcomment.yml new file mode 100644 index 0000000..d48f0d0 --- /dev/null +++ b/testdata/starcomment/starcomment.yml @@ -0,0 +1,7 @@ +template: |- + A {{ .YEAR }} + B + +vars: + 'YEAR': '2020' + diff --git a/version/version.go b/version/version.go index c1c5eb2..6c48fc7 100644 --- a/version/version.go +++ b/version/version.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -17,5 +17,5 @@ package version func Value() string { - return "v0.5.0" + return "v1.0.0" }