From 9e3e45e761fc47f4139ef6f2d2a282bbdce793b2 Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Sun, 13 Apr 2025 17:16:52 +0300 Subject: [PATCH 01/16] fix issues: 1. https://github.com/denis-tingaikin/go-header/issues/35 2. https://github.com/denis-tingaikin/go-header/issues/43 Signed-off-by: Denis Tingaikin --- analyzer.go | 49 +++++++- analyzer_test.go | 286 ++++++++++++++++++++++++++++++++++++++++++++++- reader.go | 26 ++--- 3 files changed, 341 insertions(+), 20 deletions(-) diff --git a/analyzer.go b/analyzer.go index c6b361f..85f0374 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 // @@ -74,7 +74,7 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { } if err := a.processPerTargetValues(target); err != nil { - return &issue{msg: err.Error()} + return NewIssue(err.Error()) } file := target.File @@ -82,6 +82,17 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { var offset = Location{ Position: 1, } + + if isNewLinewRequired(file.Comments) { + return NewIssueWithLocation( + "Missing a newline after the header. Consider adding a newline separator right after the copyright header.", + Location{ + Position: int(file.Comments[0].End()), + 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() @@ -254,3 +265,37 @@ 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 isNewLinewRequired(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[0].Pos() >= 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 +} diff --git a/analyzer_test.go b/analyzer_test.go index 6a0f25f..a96b7c7 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(), } } @@ -261,5 +262,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/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 From 724119f1a8ccb5034fca35422c04285fd5e8308a Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Sun, 13 Apr 2025 17:50:28 +0300 Subject: [PATCH 02/16] fix ci Signed-off-by: Denis Tingaikin --- analyzer.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/analyzer.go b/analyzer.go index 85f0374..6c808c4 100644 --- a/analyzer.go +++ b/analyzer.go @@ -87,8 +87,7 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { return NewIssueWithLocation( "Missing a newline after the header. Consider adding a newline separator right after the copyright header.", Location{ - Position: int(file.Comments[0].End()), - Line: countLines(file.Comments[0].Text()), + Line: countLines(file.Comments[0].Text()), }, ) } @@ -278,7 +277,7 @@ func isNewLinewRequired(group []*ast.CommentGroup) bool { if len(group) < 2 { return false } - return group[0].End()+group[0].Pos() >= group[1].Pos() + return group[0].End() >= group[1].Pos() } func countLines(text string) int { From 123ab1fa8df4cc105354b27a3a323a49dd19432f Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 18:14:51 +0300 Subject: [PATCH 03/16] rebase and add 'vars' Signed-off-by: Denis Tingaikin --- .github/workflows/ci.yml | 2 +- README.md | 15 +++++++++++++- analyzer_test.go | 8 ++++---- cmd/go-header/main.go | 2 +- config.go | 28 +++++++++++--------------- testdata/constvalue/constvalue.yml | 7 +++---- testdata/fix/fix.yml | 7 +++---- testdata/nestedvalues/nestedvalues.yml | 14 ++++++------- 8 files changed, 44 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9257e4..c180e96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,4 @@ jobs: run: go install ./... - name: Self-check - run: go-header $(git ls-files | grep -E '.*\.go$') + run: go-header $(git ls-files | grep -E '.*\.go$' | grep - E '.*testdata.*') diff --git a/README.md b/README.md index fa385db..38b53be 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,24 @@ 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: + +```yaml +--- +template: # expects header template string. +template-path: # expects path to file with license header 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. diff --git a/analyzer_test.go b/analyzer_test.go index a96b7c7..7d91339 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -85,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", @@ -97,7 +97,7 @@ func TestAnalyzer_Analyze(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) @@ -223,7 +223,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) @@ -253,7 +253,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) diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 58764ae..0494545 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -46,7 +46,7 @@ func main() { return } } - c := &goheader.Configuration{} + c := &goheader.Config{} if err := c.Parse(configPath); err != nil { log.Fatal(err.Error()) } diff --git a/config.go b/config.go index 8e0b7b2..c8a62c3 100644 --- a/config.go +++ b/config.go @@ -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/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}' From ebb64bf213a0e9f7ed109b691999ae532e50b291 Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 18:31:21 +0300 Subject: [PATCH 04/16] fix tests and ci Signed-off-by: Denis Tingaikin --- .github/workflows/ci.yml | 2 +- README.md | 7 +++---- analyzer.go | 11 +++++++++-- analyzer_test.go | 2 +- go.mod | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c180e96..d03790b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,4 @@ jobs: run: go install ./... - name: Self-check - run: go-header $(git ls-files | grep -E '.*\.go$' | grep - E '.*testdata.*') + run: go-header $(git ls-files | grep -E '.*\.go$' | grep -v 'testdata') diff --git a/README.md b/README.md index 38b53be..595f7bf 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,10 @@ Create configuration file `.go-header.yml` in the root of project. ```yaml --- -values: - const: - MY COMPANY: mycompany.com +vars: + MY_COMPANY: mycompany.com template: | - {{ MY COMPANY }} + {{ .MY_COMPANY }} SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/analyzer.go b/analyzer.go index 6c808c4..4f65e86 100644 --- a/analyzer.go +++ b/analyzer.go @@ -23,6 +23,7 @@ import ( "os/exec" "strings" "time" + "unicode" ) type Target struct { @@ -83,7 +84,7 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { Position: 1, } - if isNewLinewRequired(file.Comments) { + if isNewLineRequired(file.Comments) { return NewIssueWithLocation( "Missing a newline after the header. Consider adding a newline separator right after the copyright header.", Location{ @@ -157,6 +158,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 != '}' }) @@ -265,7 +272,7 @@ func (a *Analyzer) generateFix(i Issue, file *ast.File, header string) (Fix, boo return fix, true } -func isNewLinewRequired(group []*ast.CommentGroup) bool { +func isNewLineRequired(group []*ast.CommentGroup) bool { if len(group[0].List) > 1 { for _, item := range group[0].List { if strings.HasPrefix(item.Text, "/*") { diff --git a/analyzer_test.go b/analyzer_test.go index 7d91339..2de26fc 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -311,7 +311,7 @@ func TestAnalyzer_Analyze3(t *testing.T) { goheader.WithTemplate("{{COPYRIGHT HOLDER}}TEXT"), goheader.WithValues(map[string]goheader.Value{ "COPYRIGHT HOLDER": &goheader.RegexpValue{ - RawValue: "(A {{ YEAR }}\n(.*)\n)+", + RawValue: "(A {{ .YEAR }}\n(.*)\n)+", }, "YEAR": &goheader.ConstValue{ RawValue: "2020", diff --git a/go.mod b/go.mod index 27aa697..ad7d1d3 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/denis-tingaikin/go-header -go 1.21 +go 1.24 require ( github.com/stretchr/testify v1.7.0 From adc22d3936e194e8e7edb7a4861da41803297c6b Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 18:33:36 +0300 Subject: [PATCH 05/16] fix self check Signed-off-by: Denis Tingaikin --- cmd/go-header/main.go | 2 +- config.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 0494545..375eae5 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 // diff --git a/config.go b/config.go index c8a62c3..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 // From 0c76dad89fd56703697fc633d36680d62730616a Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 19:18:26 +0300 Subject: [PATCH 06/16] add star-block like headers support Signed-off-by: Denis Tingaikin --- analyzer.go | 26 ++++++++++++++++++++++++++ analyzer_test.go | 6 ++++++ testdata/starcomment/starcomment.go | 6 ++++++ testdata/starcomment/starcomment.yml | 7 +++++++ 4 files changed, 45 insertions(+) create mode 100644 testdata/starcomment/starcomment.go create mode 100644 testdata/starcomment/starcomment.yml diff --git a/analyzer.go b/analyzer.go index 4f65e86..2dc3a83 100644 --- a/analyzer.go +++ b/analyzer.go @@ -96,6 +96,8 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { 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 @@ -112,6 +114,7 @@ func (a *Analyzer) Analyze(target *Target) (i Issue) { i = NewIssueWithFix(i.Message(), i.Location(), fix) }() header = strings.TrimSpace(header) + if header == "" { return NewIssue("Missed header for check") } @@ -305,3 +308,26 @@ func countLines(text string) int { } 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 2de26fc..0ba87f2 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -93,6 +93,12 @@ 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 { 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' + From 8186b8e9c9ed5a294e728e6b6736185020b0b15b Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 20:43:25 +0300 Subject: [PATCH 07/16] add analysis.Analyzer Signed-off-by: Denis Tingaikin --- .github/workflows/ci.yml | 6 +-- README.md | 5 +- analysis.go | 105 +++++++++++++++++++++++++++++++++++++++ analyzer.go | 41 ++++++++------- cmd/go-header/main.go | 47 ++++-------------- go.mod | 3 ++ go.sum | 8 +++ version/version.go | 4 +- 8 files changed, 157 insertions(+), 62 deletions(-) create mode 100644 analysis.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d03790b..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$' | grep -v 'testdata') + run: go-header ./... diff --git a/README.md b/README.md index 595f7bf..2340d44 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ Create configuration file `.go-header.yml` in the root of project. ```yaml --- vars: - MY_COMPANY: mycompany.com + DOMAIN: sales|product + MY_COMPANY: {{ .DOMAIN }}.mycompany.com template: | {{ .MY_COMPANY }} SPDX-License-Identifier: Apache-2.0 @@ -92,4 +93,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..b84ecaf --- /dev/null +++ b/analysis.go @@ -0,0 +1,105 @@ +// 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/token" + "strings" + + "golang.org/x/tools/go/analysis" +) + +func NewAnalyzer(template string, vals map[string]Value) *analysis.Analyzer { + var goheader = New(WithTemplate(template), WithValues(vals)) + + return &analysis.Analyzer{ + Doc: "the_only_doc", + URL: "https://github.com/denis-tingaikin/go-header", + Name: "goheader", + Run: func(p *analysis.Pass) (any, error) { + for _, file := range p.Files { + 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) + } + + return nil, nil + }, + } +} diff --git a/analyzer.go b/analyzer.go index 2dc3a83..360c52a 100644 --- a/analyzer.go +++ b/analyzer.go @@ -53,28 +53,35 @@ 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 { + vals, err := a.getPerTargetValues(target) + if err != nil { return NewIssue(err.Error()) } @@ -107,7 +114,7 @@ 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 } @@ -125,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 @@ -185,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() diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 375eae5..ecf7337 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -18,22 +18,16 @@ package main import ( "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" - -type issue struct { - goheader.Issue - filePath string -} +const defaultConfigPath = ".go-header.yml" func main() { paths := os.Args[1:] @@ -47,40 +41,17 @@ func main() { } } c := &goheader.Config{} - if err := c.Parse(configPath); err != nil { + if err := c.Parse(defaultConfigPath); err != nil { log.Fatal(err.Error()) } - v, err := c.GetValues() + tmpl, err := c.GetTemplate() if err != nil { - log.Fatalf("Can not get values: %v", err.Error()) + log.Fatal(err.Error()) } - t, err := c.GetTemplate() + vals, err := c.GetValues() 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) + log.Fatal(err.Error()) } + + singlechecker.Main(goheader.NewAnalyzer(tmpl, vals)) } diff --git a/go.mod b/go.mod index ad7d1d3..8000bc3 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,13 @@ 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/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" } From 7a4c9abbf4d1b21085d3745d3721854bda756908 Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 21:33:15 +0300 Subject: [PATCH 08/16] add parallel execution support and plans Signed-off-by: Denis Tingaikin --- README.md | 20 +++++- analysis.go | 163 ++++++++++++++++++++++++++---------------- cmd/go-header/main.go | 24 +++---- 3 files changed, 131 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 2340d44..e8d60b1 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 @@ -61,7 +77,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 diff --git a/analysis.go b/analysis.go index b84ecaf..6cefd44 100644 --- a/analysis.go +++ b/analysis.go @@ -17,88 +17,131 @@ package goheader import ( + "go/ast" "go/token" + "runtime" "strings" + "sync" "golang.org/x/tools/go/analysis" ) -func NewAnalyzer(template string, vals map[string]Value) *analysis.Analyzer { - var goheader = New(WithTemplate(template), WithValues(vals)) +func NewAnalyzer(config *string) *analysis.Analyzer { + var goheaderOncer sync.Once + var goheader *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) { - for _, file := range p.Files { - filename := p.Fset.Position(file.Pos()).Filename - if !strings.HasSuffix(filename, ".go") { - continue + var err error + goheaderOncer.Do(func() { + var cfg Config + if err = cfg.Parse(*config); err != nil { + return } - - 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 + templ, err := cfg.GetTemplate() + if err != nil { + return } - - 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(), + vars, err := cfg.GetValues() + if err != nil { + return } + goheader = New(WithTemplate(templ), WithValues(vars)) + }) - if fix := issue.Fix(); fix != nil { - current := len(fix.Actual) - for _, s := range fix.Actual { - current += len(s) - } - - start := f.LineStart(commentLine) + if err != nil { + return nil, err + } - end := start + token.Pos(current) + var wg sync.WaitGroup - header := strings.Join(fix.Expected, "\n") + "\n" + var jobCh = make(chan *ast.File, len(p.Files)) - // Adds an extra line between the package and the header. - if end == file.Package { - header += "\n" + 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) } - - diag.SuggestedFixes = []analysis.SuggestedFix{{ - TextEdits: []analysis.TextEdit{{ - Pos: start, - End: end, - NewText: []byte(header), - }}, - }} - } - - p.Report(diag) + }() } + wg.Wait() return nil, nil }, } diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index ecf7337..940bb0f 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -17,6 +17,7 @@ package main import ( + "flag" "fmt" "os" @@ -27,7 +28,9 @@ import ( "golang.org/x/tools/go/analysis/singlechecker" ) -const defaultConfigPath = ".go-header.yml" +var defaultConfigPath = ".go-header.yml" + +var flagSet flag.FlagSet func main() { paths := os.Args[1:] @@ -40,18 +43,11 @@ func main() { return } } - c := &goheader.Config{} - if err := c.Parse(defaultConfigPath); err != nil { - log.Fatal(err.Error()) - } - tmpl, err := c.GetTemplate() - if err != nil { - log.Fatal(err.Error()) - } - vals, err := c.GetValues() - if err != nil { - log.Fatal(err.Error()) - } - singlechecker.Main(goheader.NewAnalyzer(tmpl, vals)) + var configPath string + flagSet.StringVar(&configPath, "config", defaultConfigPath, "Path to config file") + var analyser = goheader.NewAnalyzer(&configPath) + analyser.Flags = flagSet + + singlechecker.Main(analyser) } From 167bd465f2645260c490512b54e6294ce86e9104 Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 21:37:57 +0300 Subject: [PATCH 09/16] simplify readme Signed-off-by: Denis Tingaikin --- .go-header.yml | 9 ++++----- README.md | 10 +++++++++- 2 files changed, 13 insertions(+), 6 deletions(-) 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 e8d60b1..f9e3b6e 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,18 @@ go install github.com/denis-tingaikin/go-header/cmd/go-header@latest To configuring `.go-header.yml` linter you simply need to fill the next fields: +Inline template: ```yaml --- template: # expects header template string. -template-path: # expects path to file with license header 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 }}. From c7b27507b2fd353f4513e0ffe4e29eb631a4e8cc Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Thu, 8 May 2025 21:56:26 +0300 Subject: [PATCH 10/16] add fixes to be compatible with go tools Signed-off-by: Denis Tingaikin --- analysis.go | 63 ++++++++++++++++++++++++++----------------- cmd/go-header/main.go | 2 +- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/analysis.go b/analysis.go index 6cefd44..799c89a 100644 --- a/analysis.go +++ b/analysis.go @@ -26,36 +26,14 @@ import ( "golang.org/x/tools/go/analysis" ) -func NewAnalyzer(config *string) *analysis.Analyzer { - var goheaderOncer sync.Once - var goheader *Analyzer - +// 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 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 = New(WithTemplate(templ), WithValues(vars)) - }) - - if err != nil { - return nil, err - } - var wg sync.WaitGroup var jobCh = make(chan *ast.File, len(p.Files)) @@ -146,3 +124,38 @@ func NewAnalyzer(config *string) *analysis.Analyzer { }, } } + +// 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/cmd/go-header/main.go b/cmd/go-header/main.go index 940bb0f..4f45fa0 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -46,7 +46,7 @@ func main() { var configPath string flagSet.StringVar(&configPath, "config", defaultConfigPath, "Path to config file") - var analyser = goheader.NewAnalyzer(&configPath) + var analyser = goheader.NewAnalyzerFromConfig(&configPath) analyser.Flags = flagSet singlechecker.Main(analyser) From 764886d301b6dc66de6caac1189238a0690cac93 Mon Sep 17 00:00:00 2001 From: Denis Tingaikin Date: Fri, 9 May 2025 03:45:54 +0300 Subject: [PATCH 11/16] simplify goheader Signed-off-by: Denis Tingaikin --- .go-header.yml | 4 +- README.md | 38 +- analysis.go | 92 ++--- analyzer.go | 352 +++++++----------- analyzer_test.go | 322 +++++----------- cmd/go-header/main.go | 20 +- config.go | 9 +- goheader | Bin 0 -> 10912802 bytes issue.go | 67 ---- location.go | 35 -- option.go | 4 +- reader.go | 114 ------ testdata/fix/blockcomment5.go | 9 + testdata/golangci-linter/sample.go | 5 + testdata/golangci-linter/sample.yml | 4 + testdata/nestedvalues/nestedvalues.yml | 6 +- testdata/readme/readme.yml | 4 +- testdata/regexpvalue/regexpvalue.yml | 6 +- .../regexpvalue_issue/regexpvalue_issue.yml | 6 +- value.go | 45 +-- version/version.go | 21 -- 21 files changed, 341 insertions(+), 822 deletions(-) create mode 100755 goheader delete mode 100644 issue.go delete mode 100644 location.go delete mode 100644 reader.go create mode 100644 testdata/fix/blockcomment5.go create mode 100644 testdata/golangci-linter/sample.go create mode 100644 testdata/golangci-linter/sample.yml delete mode 100644 version/version.go diff --git a/.go-header.yml b/.go-header.yml index ff3337e..b447724 100644 --- a/.go-header.yml +++ b/.go-header.yml @@ -1,7 +1,7 @@ vars: - copyright-holder: Copyright \(c\) {{mod-year-range}} Denis Tingaikin + copyright_holder: "{{ .MOD_YEAR_RANGE}} Denis Tingaikin" template: |- - {{copyright-holder}} + Copyright (c) {{ .copyright_holder }} SPDX-License-Identifier: Apache-2.0 diff --git a/README.md b/README.md index f9e3b6e..9691962 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,40 @@ For installation you can simply use `go install`. ```bash go install github.com/denis-tingaikin/go-header/cmd/go-header@latest ``` +## Usage + +```bash + -V print version and exit + -all + no effect (deprecated) + -c int + display offending line with this many lines of context (default -1) + -config string + path to config file (default ".go-header.yml") + -cpuprofile string + write CPU profile to this file + -debug string + debug flags, any subset of "fpstv" + -diff + with -fix, don't update the files, but print a unified diff + -fix + apply all suggested fixes + -flags + print analyzer flags in JSON + -json + emit JSON output + -memprofile string + write memory profile to this file + -source + no effect (deprecated) + -tags string + no effect (deprecated) + -test + indicates whether test files should be analyzed, too (default true) + -trace string + write trace log to this file + -v no effect (deprecated) +``` ## Configuration To configuring `.go-header.yml` linter you simply need to fill the next fields: @@ -75,8 +109,8 @@ values: ## Bult-in values -- **MOD-YEAR** - Returns the year when the file was modified. -- **MOD-YEAR-RANGE** - Returns a year-range where the range starts from the year when the file was modified. +- **MOD_YEAR** - Returns the year when the file was modified. +- **MOD_YEAR-RANGE** - Returns a year-range where the range starts from the year when the file was modified. - **YEAR** - Expects current year. Example header value: `2020`. Example of template using: `{{YEAR}}` or `{{year}}`. - **YEAR-RANGE** - Expects any valid year interval or current year. Example header value: `2020` or `2000-2020`. Example of template using: `{{year-range}}` or `{{YEAR-RANGE}}`. diff --git a/analysis.go b/analysis.go index 799c89a..524d650 100644 --- a/analysis.go +++ b/analysis.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -18,7 +18,6 @@ package goheader import ( "go/ast" - "go/token" "runtime" "strings" "sync" @@ -27,13 +26,34 @@ import ( ) // 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)) +func NewAnalyzer(c *Config) *analysis.Analyzer { + var initOncer sync.Once + var initErr error + var goheader *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) { + initOncer.Do(func() { + var templ string + var vals map[string]Value + + templ, initErr = c.GetTemplate() + if initErr != nil { + return + } + + vals, initErr = c.GetValues() + if initErr != nil { + return + } + goheader = New(WithTemplate(templ), WithValues(vals)) + }) + if initErr != nil { + return nil, initErr + } + var wg sync.WaitGroup var jobCh = make(chan *ast.File, len(p.Files)) @@ -55,61 +75,27 @@ func NewAnalyzer(templ string, vars map[string]Value) *analysis.Analyzer { continue } - issue := goheader.Analyze(&Target{ + res := 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 { + if res.Err == nil { 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(), + Pos: 0, + Message: filename + ":" + res.Err.Error(), } - 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" - } + if res.Fix != "" { diag.SuggestedFixes = []analysis.SuggestedFix{{ TextEdits: []analysis.TextEdit{{ - Pos: start, - End: end, - NewText: []byte(header), + Pos: file.FileStart, + End: file.Package - 2, + NewText: []byte(res.Fix), }}, }} } @@ -125,8 +111,8 @@ func NewAnalyzer(templ string, vars map[string]Value) *analysis.Analyzer { } } -// NewAnalyzerFromConfig creates a new analysis.Analyzer from goheader config file -func NewAnalyzerFromConfig(config *string) *analysis.Analyzer { +// NewAnalyzerFromConfigPath creates a new analysis.Analyzer from goheader config file +func NewAnalyzerFromConfigPath(config *string) *analysis.Analyzer { var goheaderOncer sync.Once var goheader *analysis.Analyzer @@ -141,15 +127,7 @@ func NewAnalyzerFromConfig(config *string) *analysis.Analyzer { 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) + goheader = NewAnalyzer(&cfg) }) if err != nil { diff --git a/analyzer.go b/analyzer.go index 360c52a..9acab53 100644 --- a/analyzer.go +++ b/analyzer.go @@ -17,13 +17,24 @@ package goheader import ( + "bytes" + "errors" "fmt" "go/ast" "os" "os/exec" + "regexp" "strings" + "text/template" "time" - "unicode" +) + +type CommentStyleType int + +const ( + DoubleSlash CommentStyleType = iota + MultiLine + MultiLineStar ) type Target struct { @@ -53,282 +64,201 @@ type Analyzer struct { template string } -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 { - res["mod-year"] = &ConstValue{RawValue: fmt.Sprint(t.Year())} - res["mod-year-range"] = &RegexpValue{RawValue: `((20\d\d\-{{mod-year}})|({{mod-year}}))`} +func New(opts ...Option) *Analyzer { + var a Analyzer + for _, opt := range opts { + opt.apply(&a) } - - for _, v := range res { - if err := v.Calculate(res); err != nil { - return nil, err - } - } - - return res, nil + return &a } -func (a *Analyzer) Analyze(target *Target) (i Issue) { - if a.template == "" { - return NewIssue("Missed template for check") - } - vals, err := a.getPerTargetValues(target) - if err != nil { - return NewIssue(err.Error()) - } +type Result struct { + Err error + Fix string +} - file := target.File - var header string - var offset = Location{ - Position: 1, - } +func (a *Analyzer) Analyze(t *Target) *Result { + file := t.File + header := "" - 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()), - }, - ) - } + var style CommentStyleType 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() + style = MultiLine + + if handledHeader, ok := handleStarBlock(header); ok { + header = handledHeader + style = MultiLineStar + } - header = handleStarBlock(header) } else { + style = DoubleSlash header = file.Comments[0].Text() - offset.Position += 3 } } - defer func() { - if i == nil { - return - } - 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") - } - s := NewReader(header) - s.SetOffset(offset) - t := NewReader(a.template) - for !s.Done() && !t.Done() { - templateCh := t.Peek() - if templateCh == '{' { - name := a.readField(t) - if vals[name] == nil { - return NewIssue(fmt.Sprintf("Template has unknown value: %v", name)) - } - if i := vals[name].Read(s); i != nil { - return i - } - continue - } - sourceCh := s.Peek() - if sourceCh != templateCh { - l := s.Location() - notNextLine := func(r rune) bool { - return r != '\n' - } - actual := s.ReadWhile(notNextLine) - expected := t.ReadWhile(notNextLine) - return NewIssueWithLocation(fmt.Sprintf("Actual: %v\nExpected:%v", actual, expected), l) - } - s.Next() - t.Next() - } - if !s.Done() { - l := s.Location() - return NewIssueWithLocation(fmt.Sprintf("Unexpected string: %v", s.Finish()), l) - } - if !t.Done() { - l := s.Location() - return NewIssueWithLocation(fmt.Sprintf("Missed string: %v", t.Finish()), l) + vars, err := a.getPerTargetValues(t) + if err != nil { + return &Result{Err: err} } - return nil -} -func (a *Analyzer) readField(reader *Reader) string { - _ = reader.Next() - _ = reader.Next() + templateRaw := quoteMeta(a.template) + + template, err := template.New("header").Parse(templateRaw) + if err != nil { + return &Result{Err: err} + } - _ = reader.ReadWhile(unicode.IsSpace) + res := new(bytes.Buffer) - if reader.Peek() == '.' { - _ = reader.Next() + if err := template.Execute(res, vars); err != nil { + return &Result{Err: err} } - r := reader.ReadWhile(func(r rune) bool { - return r != '}' - }) + headerTemplate := res.String() - _ = reader.Next() - _ = reader.Next() + r, err := regexp.Compile(headerTemplate) - return strings.ToLower(strings.TrimSpace(r)) -} + if err != nil { + return &Result{Err: err} + } -func New(options ...Option) *Analyzer { - a := &Analyzer{values: make(map[string]Value)} - for _, o := range options { - o.apply(a) + if !r.MatchString(header) { + // log.Println(header) + // log.Println("template " + headerTemplate) + return &Result{Err: errors.New("template doens't match"), Fix: a.generateFix(style, vars)} } - return a + + return &Result{} } -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 := vals[a.readField(t)] - if f == nil { - return Fix{}, false - } - if f.Calculate(vals) != nil { - return Fix{}, false - } - expect += f.Get() - continue - } +func (a *Analyzer) generateFix(style CommentStyleType, vals map[string]Value) string { + // TODO: add values for quick fixes in config + vals["YEAR_RANGE"] = vals["YEAR"] + vals["MOD_YEAR_RANGE"] = vals["YEAR"] - expect += string(ch) - t.Next() + for _, v := range vals { + _ = v.Calculate(vals) } - fix := Fix{Expected: strings.Split(expect, "\n")} - if !(len(file.Comments) > 0 && file.Comments[0].Pos() < file.Package) { - for i := range fix.Expected { - fix.Expected[i] = "// " + fix.Expected[i] - } - return fix, true + fixTemplate, err := template.New("fix").Parse(a.template) + if err != nil { + return "" } - - actual := file.Comments[0].List[0].Text - if !strings.HasPrefix(actual, "/*") { - for i := range fix.Expected { - fix.Expected[i] = "// " + fix.Expected[i] - } - for _, c := range file.Comments[0].List { - fix.Actual = append(fix.Actual, c.Text) - } - i = NewIssueWithFix(i.Message(), i.Location(), fix) - return fix, true + fixOut := new(bytes.Buffer) + _ = fixTemplate.Execute(fixOut, vals) + res := fixOut.String() + resSplit := strings.Split(res, "\n") + if style == MultiLine { + resSplit[0] = "/* " + resSplit[0] } - gets := func(i int, end bool) string { - if i < 0 { - return header - } - if end { - return header[i+1:] + for i := range resSplit { + switch style { + case DoubleSlash: + resSplit[i] = "// " + resSplit[i] + case MultiLineStar: + resSplit[i] = " * " + resSplit[i] + case MultiLine: + continue } - return header[:i] } - start := strings.Index(actual, gets(strings.IndexByte(header, '\n'), false)) - if start < 0 { - return Fix{}, false // Should be impossible - } - nl := strings.LastIndexByte(actual[:start], '\n') - if nl >= 0 { - fix.Actual = strings.Split(actual[:nl], "\n") - fix.Expected = append(fix.Actual, fix.Expected...) - actual = actual[nl+1:] - start -= nl + 1 + + switch style { + case MultiLine: + resSplit[len(resSplit)-1] = resSplit[len(resSplit)-1] + " */" + case MultiLineStar: + resSplit = append([]string{"/*"}, resSplit...) + resSplit = append(resSplit, " */") } - prefix := actual[:start] - if nl < 0 { - fix.Expected[0] = prefix + fix.Expected[0] - } else { - n := len(fix.Actual) - for i := range fix.Expected[n:] { - fix.Expected[n+i] = prefix + fix.Expected[n+i] - } + return strings.Join(resSplit, "\n") +} + +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 } - last := gets(strings.LastIndexByte(header, '\n'), true) - end := strings.Index(actual, last) - if end < 0 { - return Fix{}, false // Should be impossible + res["MOD_YEAR"] = a.values["YEAR"] + res["MOD_YEAR_RANGE"] = a.values["YEAR_RANGE"] + if t, err := target.ModTime(); err == nil { + res["MOD_YEAR"] = &ConstValue{RawValue: fmt.Sprint(t.Year())} + res["MOD_YEAR_RANGE"] = &RegexpValue{RawValue: `((20\d\d\-{{MOD_YEAR}})|({{MOD_YEAR}}))`} } - trailing := actual[end+len(last):] - if i := strings.IndexRune(trailing, '\n'); i < 0 { - fix.Expected[len(fix.Expected)-1] += trailing - } else { - fix.Expected[len(fix.Expected)-1] += trailing[:i] - fix.Expected = append(fix.Expected, strings.Split(trailing[i+1:], "\n")...) + for _, v := range res { + if err := v.Calculate(res); err != nil { + return nil, err + } } - fix.Actual = append(fix.Actual, strings.Split(actual, "\n")...) - return fix, true + return res, nil } -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 +// TODO: Fix vibe conding +func quoteMeta(text string) string { + var result strings.Builder + i := 0 + n := len(text) + + for i < n { + // Check for template placeholder start + if i+3 < n && text[i] == '{' && text[i+1] == '{' { + // Find the end of the placeholder + end := i + 2 + for end < n && !(text[end] == '}' && end+1 < n && text[end+1] == '}') { + end++ + } + if end+1 < n { + // Append the entire placeholder without escaping + result.WriteString(text[i : end+2]) + i = end + 2 + continue } } - } - if len(group) < 2 { - return false + // Escape regular expression metacharacters for non-template parts + c := text[i] + if strings.ContainsAny(string(c), `\.+*?()|[]{}^$`) { + result.WriteByte('\\') + } + result.WriteByte(c) + i++ } - return group[0].End() >= group[1].Pos() -} -func countLines(text string) int { - if text == "" { - return 0 - } + return result.String() +} - 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++ - } - } +func isNewLineRequired(group *ast.CommentGroup) bool { + if len(group.List) < 2 { + return false } - return lines + end := group.List[0].End() + pos := group.List[1].Pos() + return end+1 >= pos && group.List[0].Text[len(group.List[0].Text)-1] != '\n' } -func handleStarBlock(header string) string { +func handleStarBlock(header string) (string, bool) { + var handled = false 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 { + handled = true return v } else { var res, _ = strings.CutPrefix(trimmed, "*") return res } - }) + }), handled } func trimEachLine(input string, trimFunc func(string) string) string { diff --git a/analyzer_test.go b/analyzer_test.go index 0ba87f2..e4bc9ad 100644 --- a/analyzer_test.go +++ b/analyzer_test.go @@ -75,6 +75,12 @@ func TestAnalyzer_Analyze(t *testing.T) { config: "regexpvalue_issue/regexpvalue_issue.yml", assert: assert.NotNil, }, + { + desc: "golangci-linter sample", + filename: "golangci-linter/sample.go", + config: "golangci-linter/sample.yml", + assert: assert.NotNil, + }, { desc: "nested values", filename: "nestedvalues/nestedvalues.go", @@ -82,10 +88,10 @@ func TestAnalyzer_Analyze(t *testing.T) { assert: assert.Nil, }, { - desc: "header comment", + desc: "headercomment", filename: "headercomment/headercomment.go", config: "headercomment/headercomment.yml", - assert: assert.NotNil, + assert: assert.Nil, }, { desc: "readme", @@ -126,7 +132,7 @@ func TestAnalyzer_Analyze(t *testing.T) { issue := a.Analyze(&goheader.Target{Path: filename, File: file}) - test.assert(t, issue) + test.assert(t, issue.Err) }) } } @@ -136,95 +142,65 @@ func TestAnalyzer_Analyze_fix(t *testing.T) { desc string filename string config string - expected goheader.Fix + expected goheader.Result }{ { desc: "Line comment", filename: "fix/linecomment.go", config: "fix/fix.yml", - expected: goheader.Fix{ - Actual: []string{ - "// mycompany.net", - "// SPDX-License-Identifier: Foo", - }, - Expected: []string{ - "// mycompany.com", - "// SPDX-License-Identifier: Foo", - }, - }, - }, - { - desc: "Block comment 1", - filename: "fix/blockcomment1.go", - config: "fix/fix.yml", - expected: goheader.Fix{ - Actual: []string{ - "/* mycompany.net", - "SPDX-License-Identifier: Foo */", - }, - Expected: []string{ - "/* mycompany.com", - "SPDX-License-Identifier: Foo */", - }, - }, - }, - { - desc: "Block comment 2", - filename: "fix/blockcomment2.go", - config: "fix/fix.yml", - expected: goheader.Fix{ - Actual: []string{ - "/*", - "mycompany.net", - "SPDX-License-Identifier: Foo */", - }, - Expected: []string{ - "/*", - "mycompany.com", - "SPDX-License-Identifier: Foo */", - }, - }, - }, - { - desc: "Block comment 3", - filename: "fix/blockcomment3.go", - config: "fix/fix.yml", - expected: goheader.Fix{ - Actual: []string{ - "/* mycompany.net", - "SPDX-License-Identifier: Foo", - "*/", - }, - Expected: []string{ - "/* mycompany.com", - "SPDX-License-Identifier: Foo", - "*/", - }, - }, - }, - { - desc: "Block comment 4", - filename: "fix/blockcomment4.go", - config: "fix/fix.yml", - expected: goheader.Fix{ - Actual: []string{ - "/*", - "", - "mycompany.net", - "SPDX-License-Identifier: Foo", - "", - "*/", - }, - Expected: []string{ - "/*", - "", - "mycompany.com", - "SPDX-License-Identifier: Foo", - "", - "*/", - }, + expected: goheader.Result{ + Fix: `// mycompany.com +// SPDX-License-Identifier: Foo`, }, }, + // { + // desc: "Block comment 1", + // filename: "fix/blockcomment1.go", + // config: "fix/fix.yml", + // expected: goheader.Result{ + // Fix: `/* mycompany.com + // SPDX-License-Identifier: Foo */`, + // }, + // }, + // { + // desc: "Block comment 2", + // filename: "fix/blockcomment2.go", + // config: "fix/fix.yml", + + // expected: goheader.Result{ + // Fix: `/* mycompany.com + // SPDX-License-Identifier: Foo */`, + // }, + // }, + // { + // desc: "Block comment 3", + // filename: "fix/blockcomment3.go", + // config: "fix/fix.yml", + // expected: goheader.Result{ + // Fix: `/* mycompany.com + // SPDX-License-Identifier: Foo */`, + // }, + // }, + // { + // desc: "Block comment 4", + // filename: "fix/blockcomment4.go", + // config: "fix/fix.yml", + // expected: goheader.Result{ + // Fix: `/* mycompany.com + // SPDX-License-Identifier: Foo */`, + // }, + // }, + // { + // desc: "Star block comment", + // filename: "fix/blockcomment5.go", + // config: "fix/fix.yml", + // expected: goheader.Result{ + // Fix: `/* + // * mycompany.com + // * SPDX-License-Identifier: Foo + // */`, + // }, + // }, } for _, test := range testCases { @@ -250,10 +226,9 @@ func TestAnalyzer_Analyze_fix(t *testing.T) { file, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.ParseComments) require.NoError(t, err) - issue := a.Analyze(&goheader.Target{Path: filename, File: file}) + actual := a.Analyze(&goheader.Target{Path: filename, File: file}) - assert.Equal(t, test.expected.Actual, issue.Fix().Actual) - assert.Equal(t, test.expected.Expected, issue.Fix().Expected) + assert.Equal(t, test.expected.Fix, actual.Fix) }) } } @@ -263,12 +238,12 @@ func TestAnalyzer_YearRangeValue_ShouldWorkWithComplexVariables(t *testing.T) { var vals, err = conf.GetValues() require.NoError(t, err) - vals["my-val"] = &goheader.RegexpValue{ - RawValue: "{{year-range }} B", + vals["MY_VAL"] = &goheader.RegexpValue{ + RawValue: "{{ .YEAR_RANGE }} B", } - var a = goheader.New(goheader.WithTemplate("A {{ my-val }}"), goheader.WithValues(vals)) - require.Nil(t, a.Analyze(header(t, fmt.Sprintf("A 2000-%v B", time.Now().Year())))) + var a = goheader.New(goheader.WithTemplate("A {{ .MY_VAL }}"), goheader.WithValues(vals)) + require.Nil(t, a.Analyze(header(t, fmt.Sprintf("A 2000-%v B", time.Now().Year()))).Err) } func TestAnalyzer_UnicodeHeaders(t *testing.T) { @@ -276,12 +251,12 @@ func TestAnalyzer_UnicodeHeaders(t *testing.T) { goheader.WithTemplate("😊早安😊"), ) issue := a.Analyze(header(t, `😊早安😊`)) - require.Nil(t, issue) + require.Nil(t, issue.Err) } func TestAnalyzer_Analyze1(t *testing.T) { a := goheader.New( - goheader.WithTemplate("A {{ YEAR }}\nB"), + goheader.WithTemplate("A {{ .YEAR }}\nB"), goheader.WithValues(map[string]goheader.Value{ "YEAR": &goheader.ConstValue{ RawValue: "2020", @@ -289,15 +264,15 @@ func TestAnalyzer_Analyze1(t *testing.T) { })) issue := a.Analyze(header(t, `A 2020 B`)) - require.Nil(t, issue) + require.Nil(t, issue.Err) } func TestAnalyzer_Analyze2(t *testing.T) { a := goheader.New( - goheader.WithTemplate("{{COPYRIGHT HOLDER}}TEXT"), + goheader.WithTemplate("{{ .COPYRIGHT_HOLDER }}TEXT"), goheader.WithValues(map[string]goheader.Value{ - "COPYRIGHT HOLDER": &goheader.RegexpValue{ - RawValue: "(A {{ YEAR }}\n(.*)\n)+", + "COPYRIGHT_HOLDER": &goheader.RegexpValue{ + RawValue: "(A {{ .YEAR }}\n(.*)\n)+", }, "YEAR": &goheader.ConstValue{ RawValue: "2020", @@ -309,14 +284,14 @@ A 2020 B TEXT `)) - require.Nil(t, issue) + require.Nil(t, issue.Err) } func TestAnalyzer_Analyze3(t *testing.T) { a := goheader.New( - goheader.WithTemplate("{{COPYRIGHT HOLDER}}TEXT"), + goheader.WithTemplate("{{.COPYRIGHT_HOLDER}}TEXT"), goheader.WithValues(map[string]goheader.Value{ - "COPYRIGHT HOLDER": &goheader.RegexpValue{ + "COPYRIGHT_HOLDER": &goheader.RegexpValue{ RawValue: "(A {{ .YEAR }}\n(.*)\n)+", }, "YEAR": &goheader.ConstValue{ @@ -329,15 +304,15 @@ A 2021 B TEXT `)) - require.NotNil(t, issue) + require.NotNil(t, issue.Err) } func TestAnalyzer_Analyze4(t *testing.T) { a := goheader.New( - goheader.WithTemplate("{{ A }}"), + goheader.WithTemplate("{{ .A }}"), goheader.WithValues(map[string]goheader.Value{ "A": &goheader.RegexpValue{ - RawValue: "[{{ B }}{{ C }}]{{D}}", + RawValue: "[{{ .B }}{{ .C }}]{{.D}}", }, "B": &goheader.ConstValue{ RawValue: "a-", @@ -346,14 +321,14 @@ func TestAnalyzer_Analyze4(t *testing.T) { RawValue: "z", }, "D": &goheader.ConstValue{ - RawValue: "{{E}}", + RawValue: "{{.E}}", }, "E": &goheader.ConstValue{ RawValue: "{7}", }, })) issue := a.Analyze(header(t, `abcdefg`)) - require.Nil(t, issue) + require.Nil(t, issue.Err) } func TestAnalyzer_Analyze5(t *testing.T) { @@ -367,7 +342,7 @@ func TestAnalyzer_Analyze5(t *testing.T) { 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})) + require.Nil(t, a.Analyze(&goheader.Target{File: f, Path: p}).Err) } func TestAnalyzer_Analyze6(t *testing.T) { @@ -382,12 +357,12 @@ func TestAnalyzer_Analyze6(t *testing.T) { 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})) + require.Nil(t, a.Analyze(&goheader.Target{File: f, Path: p}).Err) } func TestREADME(t *testing.T) { a := goheader.New( - goheader.WithTemplate(`{{ MY COMPANY }} + goheader.WithTemplate(`{{ .MY_COMPANY }} SPDX-License-Identifier: Apache-2.0 Licensed under the Apache License, Version 2.0 (the "License"); @@ -402,7 +377,7 @@ 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{ + "MY_COMPANY": &goheader.ConstValue{ RawValue: "mycompany.com", }, })) @@ -420,130 +395,5 @@ 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) - }) + require.Nil(t, issue.Err) } diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 4f45fa0..cc69dda 100644 --- a/cmd/go-header/main.go +++ b/cmd/go-header/main.go @@ -18,13 +18,8 @@ package main import ( "flag" - "fmt" - "os" - - "log" goheader "github.com/denis-tingaikin/go-header" - "github.com/denis-tingaikin/go-header/version" "golang.org/x/tools/go/analysis/singlechecker" ) @@ -33,20 +28,9 @@ var defaultConfigPath = ".go-header.yml" var flagSet flag.FlagSet func main() { - paths := os.Args[1:] - if len(paths) == 0 { - log.Fatal("Paths has not passed") - } - if len(paths) == 1 { - if paths[0] == "version" { - fmt.Println(version.Value()) - return - } - } - var configPath string - flagSet.StringVar(&configPath, "config", defaultConfigPath, "Path to config file") - var analyser = goheader.NewAnalyzerFromConfig(&configPath) + flagSet.StringVar(&configPath, "config", defaultConfigPath, "path to config file") + var analyser = goheader.NewAnalyzerFromConfigPath(&configPath) analyser.Flags = flagSet singlechecker.Main(analyser) diff --git a/config.go b/config.go index cc516be..69992b1 100644 --- a/config.go +++ b/config.go @@ -42,10 +42,10 @@ type Config struct { func (c *Config) builtInValues() map[string]Value { var result = make(map[string]Value) year := fmt.Sprint(time.Now().Year()) - result["year-range"] = &RegexpValue{ - RawValue: `((20\d\d\-{{YEAR}})|({{YEAR}}))`, + result["YEAR_RANGE"] = &RegexpValue{ + RawValue: `((20\d\d\-{{.YEAR}})|({{.YEAR}}))`, } - result["year"] = &ConstValue{ + result["YEAR"] = &ConstValue{ RawValue: year, } return result @@ -61,8 +61,7 @@ func (c *Config) GetValues() (map[string]Value, error) { } appendValues := func(m map[string]string, create func(string) Value) { for k, v := range m { - key := strings.ToLower(k) - result[key] = create(v) + result[k] = create(v) } } appendValues(c.Values["const"], createConst) diff --git a/goheader b/goheader new file mode 100755 index 0000000000000000000000000000000000000000..de9bc1ecdc9bc1eaa161d2facb71ed6da0db0968 GIT binary patch literal 10912802 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