diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9257e4..ad69ce1 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.23 uses: actions/setup-go@v1 with: - go-version: 1.21 + go-version: 1.23 - 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..b447724 100644 --- a/.go-header.yml +++ b/.go-header.yml @@ -1,8 +1,7 @@ -values: - regexp: - copyright-holder: Copyright \(c\) {{mod-year-range}} Denis Tingaikin -template: | - {{copyright-holder}} +vars: + copyright_holder: "{{ .MOD_YEAR_RANGE}} Denis Tingaikin" +template: |- + Copyright (c) {{ .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..1e783d7 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. +Simple go source code linter providing checks for copyrgiht 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** | ❌ | Does any one need this? 🤔 | + + ## Installation @@ -10,9 +26,41 @@ 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: ```yaml @@ -38,8 +86,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}}`. @@ -48,7 +96,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 +107,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 +128,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..ce59efb --- /dev/null +++ b/analysis.go @@ -0,0 +1,141 @@ +// Copyright (c) 2020-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" + "strings" + "sync" + + "golang.org/x/tools/go/analysis" +) + +// NewAnalyzer creates new analyzer based on template and goheader values +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", + RunDespiteErrors: true, + 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), WithDelims(c.GetDelims())) + + }) + if initErr != nil { + return nil, initErr + } + + var wg sync.WaitGroup + + var jobCh = make(chan *ast.File, len(p.Files)) + + for _, f := range p.Files { + file := f + jobCh <- file + } + close(jobCh) + + for range c.GetParallel() { + 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 + } + + res := goheader.Analyze(filename, file) + + if res.Message == "" { + continue + } + var line = 1 + if ast.IsGenerated(file) { + line = 4 + } + + var start = p.Fset.File(file.Pos()).LineStart(line) + var end = res.End - res.Pos + start + var endLine = p.Fset.File(file.Pos()).Line(end) + 1 + end = p.Fset.File(file.Pos()).LineStart(endLine) + + res.Pos = start + res.End = end + + if len(res.SuggestedFixes) > 0 && len(res.SuggestedFixes[0].TextEdits) > 0 { + res.SuggestedFixes[0].TextEdits[0].Pos = start + res.SuggestedFixes[0].TextEdits[0].End = end + } + + p.Report(res) + } + }() + } + + wg.Wait() + return nil, nil + }, + } +} + +// NewAnalyzerFromConfigPath creates a new analysis.Analyzer from goheader config file +func NewAnalyzerFromConfigPath(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", + RunDespiteErrors: true, + Run: func(p *analysis.Pass) (any, error) { + var err error + goheaderOncer.Do(func() { + var cfg Config + if err = cfg.Parse(*config); err != nil { + return + } + goheader = NewAnalyzer(&cfg) + }) + + if err != nil { + return nil, err + } + return goheader.Run(p) + }, + } +} diff --git a/analyzer.go b/analyzer.go index c6b361f..eb20a6f 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 // @@ -17,30 +17,39 @@ package goheader import ( + "bytes" "fmt" "go/ast" + "go/token" "os" "os/exec" + "regexp" "strings" + "text/template" "time" + + "golang.org/x/tools/go/analysis" ) -type Target struct { - Path string - File *ast.File -} +type CommentStyleType int + +const ( + DoubleSlash CommentStyleType = iota + MultiLine + MultiLineStar +) const iso = "2006-01-02 15:04:05 -0700" -func (t *Target) ModTime() (time.Time, error) { - diff, err := exec.Command("git", "diff", t.Path).CombinedOutput() +func modTime(path string) (time.Time, error) { + diff, err := exec.Command("git", "diff", path).CombinedOutput() if err == nil && len(diff) == 0 { - line, err := exec.Command("git", "log", "-1", "--pretty=format:%cd", "--date=iso", "--", t.Path).CombinedOutput() + line, err := exec.Command("git", "log", "-1", "--pretty=format:%cd", "--date=iso", "--", path).CombinedOutput() if err == nil { return time.Parse(iso, string(line)) } } - info, err := os.Stat(t.Path) + info, err := os.Stat(path) if err != nil { return time.Time{}, err } @@ -48,209 +57,253 @@ func (t *Target) ModTime() (time.Time, error) { } type Analyzer struct { - values map[string]Value - template string + values map[string]Value + template, delimsLeft, delimsRight string } -func (a *Analyzer) processPerTargetValues(target *Target) error { - a.values["mod-year"] = a.values["year"] - a.values["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}}))`} +func New(opts ...Option) *Analyzer { + var a = Analyzer{ + delimsLeft: "{{", + delimsRight: "}}", + } + for _, opt := range opts { + opt.apply(&a) } + return &a +} + +type Result struct { + Err error + Fix string + End, Start token.Pos +} - for _, v := range a.values { - if err := v.Calculate(a.values); err != nil { - return err +func (a *Analyzer) skipCodeGen(file *ast.File) ([]*ast.CommentGroup, []*ast.Comment) { + var comments = file.Comments + var list []*ast.Comment + if len(comments) > 0 { + list = comments[0].List + } + if len(comments) > 0 && strings.Contains(comments[0].Text(), "DO NOT EDIT") { + comments = comments[1:] + list = comments[0].List + if len(list) > 0 && strings.HasSuffix(list[0].Text, "//line:") { + list = list[1:] } } - return nil + + return comments, list } -func (a *Analyzer) Analyze(target *Target) (i Issue) { +func (a *Analyzer) Analyze(path string, file *ast.File) (result analysis.Diagnostic) { + header := "" + if a.template == "" { - return NewIssue("Missed template for check") + return result } - if err := a.processPerTargetValues(target); err != nil { - return &issue{msg: err.Error()} - } + var style CommentStyleType + + var comments, list = a.skipCodeGen(file) + + if len(comments) > 0 && comments[0].Pos() < file.Package { + if strings.HasPrefix(list[0].Text, "/*") { + + result.Pos = list[0].Pos() + result.End = list[0].End() + + header = (&ast.CommentGroup{List: []*ast.Comment{list[0]}}).Text() + style = MultiLine + + if handledHeader, ok := handleStarBlock(header); ok { + header = handledHeader + style = MultiLineStar + } - file := target.File - var header string - var offset = Location{ - Position: 1, - } - 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() } else { - header = file.Comments[0].Text() - offset.Position += 3 + style = DoubleSlash + header = comments[0].Text() + result.Pos = comments[0].Pos() + result.End = comments[0].Pos() } } - defer func() { - if i == nil { - return - } - fix, ok := a.generateFix(i, file, header) - 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 a.values[name] == nil { - return NewIssue(fmt.Sprintf("Template has unknown value: %v", name)) - } - if i := a.values[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() + + vars, err := a.getPerTargetValues(path, file) + if err != nil { + result.Message = err.Error() + return result } - if !s.Done() { - l := s.Location() - return NewIssueWithLocation(fmt.Sprintf("Unexpected string: %v", s.Finish()), l) + + if header == "" { + result.Message = "missed copyright header" + result.SuggestedFixes = append(result.SuggestedFixes, analysis.SuggestedFix{ + TextEdits: []analysis.TextEdit{ + { + NewText: []byte(a.generateFix(style, vars)), + }, + }, + }) + return result } - if !t.Done() { - l := s.Location() - return NewIssueWithLocation(fmt.Sprintf("Missed string: %v", t.Finish()), l) + + templateRaw := a.quoteMeta(a.template) + + template, err := template.New("header").Delims(a.delimsLeft, a.delimsRight).Parse(templateRaw) + if err != nil { + result.Message = err.Error() + return result } - return nil -} -func (a *Analyzer) readField(reader *Reader) string { - _ = reader.Next() - _ = reader.Next() + headerTemplateBuffer := new(bytes.Buffer) - r := reader.ReadWhile(func(r rune) bool { - return r != '}' - }) + if err := template.Execute(headerTemplateBuffer, vars); err != nil { + result.Message = err.Error() + return result + } - _ = reader.Next() - _ = reader.Next() + headerTemplate := headerTemplateBuffer.String() - return strings.ToLower(strings.TrimSpace(r)) -} + r, err := regexp.Compile(headerTemplate) + + if err != nil { + result.Message = err.Error() + return result + } -func New(options ...Option) *Analyzer { - a := &Analyzer{values: make(map[string]Value)} - for _, o := range options { - o.apply(a) + if !r.MatchString(header) { + result.Message = "template doesn't match" + result.SuggestedFixes = append(result.SuggestedFixes, analysis.SuggestedFix{ + TextEdits: []analysis.TextEdit{ + { + NewText: []byte(a.generateFix(style, vars)), + }, + }, + }) + return result } - return a + + return result } -func (a *Analyzer) generateFix(i Issue, file *ast.File, header string) (Fix, bool) { - var expect string - t := NewReader(a.template) - for !t.Done() { - ch := t.Peek() - if ch == '{' { - f := a.values[a.readField(t)] - if f == nil { - return Fix{}, false - } - if f.Calculate(a.values) != 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 "" } + fixOut := new(bytes.Buffer) + _ = fixTemplate.Execute(fixOut, vals) + res := fixOut.String() + resSplit := strings.Split(res, "\n") - 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) + for i := range resSplit { + switch style { + case DoubleSlash: + resSplit[i] = "// " + resSplit[i] + case MultiLineStar: + resSplit[i] = " * " + resSplit[i] + case MultiLine: + continue } - i = NewIssueWithFix(i.Message(), i.Location(), fix) - return fix, true } - gets := func(i int, end bool) string { - if i < 0 { - return header - } - if end { - return header[i+1:] - } - return header[:i] + switch style { + case MultiLineStar: + resSplit = append([]string{"/*"}, resSplit...) + resSplit = append(resSplit, " */") + case MultiLine: + resSplit = append([]string{"/*"}, resSplit...) + resSplit = append(resSplit, "*/") } - start := strings.Index(actual, gets(strings.IndexByte(header, '\n'), false)) - if start < 0 { - return Fix{}, false // Should be impossible + + return strings.Join(resSplit, "\n") + "\n" +} + +func (a *Analyzer) getPerTargetValues(path string, file *ast.File) (map[string]Value, error) { + var res = make(map[string]Value) + + for k, v := range a.values { + res[k] = v } - 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 + + res["MOD_YEAR"] = a.values["YEAR"] + res["MOD_YEAR_RANGE"] = a.values["YEAR_RANGE"] + if t, err := modTime(path); err == nil { + res["MOD_YEAR"] = &ConstValue{RawValue: fmt.Sprint(t.Year())} + res["MOD_YEAR_RANGE"] = &RegexpValue{RawValue: `((20\d\d\-{{.MOD_YEAR}})|({{.MOD_YEAR}}))`} } - 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] + for _, v := range res { + if err := v.Calculate(res); err != nil { + return nil, err } } - last := gets(strings.LastIndexByte(header, '\n'), true) - end := strings.Index(actual, last) - if end < 0 { - return Fix{}, false // Should be impossible - } + return res, nil +} + +// TODO: Do not vibe code +func (a *Analyzer) 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] == a.delimsLeft[0] && text[i+1] == a.delimsLeft[1] { + // Find the end of the placeholder + end := i + 2 + for end < n && !(text[end] == a.delimsRight[0] && end+1 < n && text[end+1] == a.delimsRight[1]) { + end++ + } + if end+1 < n { + // Append the entire placeholder without escaping + result.WriteString(text[i : end+2]) + i = end + 2 + continue + } + } - 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")...) + // Escape regular expression metacharacters for non-template parts + c := text[i] + if strings.ContainsAny(string(c), `\.+*?()|[]{}^$`) { + result.WriteByte('\\') + } + result.WriteByte(c) + i++ } - fix.Actual = append(fix.Actual, strings.Split(actual, "\n")...) - return fix, true + return result.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 { + 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..f93b21e 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,21 +32,19 @@ import ( "github.com/stretchr/testify/require" ) -func header(header string) *goheader.Target { - return &goheader.Target{ - File: &ast.File{ - Comments: []*ast.CommentGroup{ - { - List: []*ast.Comment{ - { - Text: header, - }, +func header(t *testing.T, header string) (path string, file *ast.File) { + return t.TempDir(), &ast.File{ + Comments: []*ast.CommentGroup{ + { + List: []*ast.Comment{ + { + Text: header, }, }, }, - Package: token.Pos(len(header)), }, - Path: os.TempDir(), + + Package: token.Pos(len(header)), } } @@ -60,43 +59,79 @@ func TestAnalyzer_Analyze(t *testing.T) { desc: "const value", filename: "constvalue/constvalue.go", config: "constvalue/constvalue.yml", - assert: assert.Nil, + assert: assert.Empty, + }, + { + desc: "const value 2", + filename: "constvalue2/constvalue.go", + config: "constvalue2/constvalue.yml", + assert: assert.Empty, }, { desc: "regexp value", filename: "regexpvalue/regexpvalue.go", config: "regexpvalue/regexpvalue.yml", - assert: assert.Nil, + assert: assert.Empty, }, { desc: "regexp value with issue", filename: "regexpvalue_issue/regexpvalue_issue.go", config: "regexpvalue_issue/regexpvalue_issue.yml", - assert: assert.NotNil, + assert: assert.NotEmpty, + }, + { + desc: "golangci-linter sample", + filename: "golangci-linter/sample.go", + config: "golangci-linter/sample.yml", + assert: assert.NotEmpty, }, { desc: "nested values", filename: "nestedvalues/nestedvalues.go", config: "nestedvalues/nestedvalues.yml", - assert: assert.Nil, + assert: assert.Empty, + }, + { + desc: "missed header ", + filename: "noheader/noheader.go", + config: "noheader/noheader.yml", + assert: assert.NotEmpty, }, { - desc: "header comment", + desc: "headercomment", filename: "headercomment/headercomment.go", config: "headercomment/headercomment.yml", - assert: assert.Nil, + assert: assert.Empty, }, { desc: "readme", filename: "readme/readme.go", config: "readme/readme.yml", - assert: assert.Nil, + assert: assert.Empty, + }, + { + desc: "cgo", + filename: "cgo/cgo.go", + config: "cgo/cgo.yml", + assert: assert.NotEmpty, + }, + { + desc: "star-block like header", + filename: "starcomment/starcomment.go", + config: "starcomment/starcomment.yml", + assert: assert.Empty, + }, + { + desc: "checks old config compatibility", + filename: "oldconfig/oldconfig.go", + config: "oldconfig/oldconfig.yml", + assert: assert.Empty, }, } 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) @@ -110,6 +145,7 @@ func TestAnalyzer_Analyze(t *testing.T) { a := goheader.New( goheader.WithValues(values), goheader.WithTemplate(tmpl), + goheader.WithDelims(cfg.GetDelims()), ) filename := filepath.Join("testdata", test.filename) @@ -117,9 +153,9 @@ func TestAnalyzer_Analyze(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}) + issue := a.Analyze(filename, file) - test.assert(t, issue) + test.assert(t, issue.Message) }) } } @@ -129,100 +165,84 @@ 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", - }, + expected: goheader.Result{ + Fix: `// 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 */", - }, + expected: goheader.Result{ + Fix: `/* +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 */", - }, + + expected: goheader.Result{ + Fix: `/* +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", - "*/", - }, + expected: goheader.Result{ + Fix: `/* +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: "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 { 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) @@ -243,23 +263,180 @@ 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(filename, file) - assert.Equal(t, test.expected.Actual, issue.Fix().Actual) - assert.Equal(t, test.expected.Expected, issue.Fix().Expected) + actualFix := "" + + if len(actual.SuggestedFixes) > 0 && len(actual.SuggestedFixes[0].TextEdits) > 0 { + actualFix = string(actual.SuggestedFixes[0].TextEdits[0].NewText) + } + + assert.Equal(t, test.expected.Fix, actualFix) }) } } func TestAnalyzer_YearRangeValue_ShouldWorkWithComplexVariables(t *testing.T) { - var conf goheader.Configuration + var conf goheader.Config 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(fmt.Sprintf("A 2000-%v B", time.Now().Year())))) + var a = goheader.New(goheader.WithTemplate("A {{ .MY_VAL }}"), goheader.WithValues(vals)) + require.Empty(t, a.Analyze(header(t, fmt.Sprintf("A 2000-%v B", time.Now().Year()))).Message) +} + +func TestAnalyzer_UnicodeHeaders(t *testing.T) { + a := goheader.New( + goheader.WithTemplate("😊早安😊"), + ) + issue := a.Analyze(header(t, `😊早安😊`)) + require.Empty(t, issue.Message) +} + +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.Empty(t, issue.Message) +} + +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.Empty(t, issue.Message) +} + +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.NotEmpty(t, issue.Message) +} + +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.Empty(t, issue.Message) +} + +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.Empty(t, a.Analyze(p, f).Message) +} + +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.Empty(t, a.Analyze(p, f).Message) +} + +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.Empty(t, issue.Message) } diff --git a/cmd/go-header/main.go b/cmd/go-header/main.go index 58764ae..cc69dda 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,70 +17,21 @@ package main import ( - "fmt" - "go/parser" - "go/token" - "os" - - "log" + "flag" 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:] - if len(paths) == 0 { - log.Fatal("Paths has not passed") - } - if len(paths) == 1 { - if paths[0] == "version" { - fmt.Println(version.Value()) - 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.NewAnalyzerFromConfigPath(&configPath) + analyser.Flags = flagSet + + singlechecker.Main(analyser) } diff --git a/config.go b/config.go index 8e0b7b2..95398d7 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 // @@ -17,38 +17,60 @@ package goheader import ( - "errors" "fmt" "os" + "regexp" + "runtime" "strings" "time" "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"` + // Delims represents a string marker for values. The default is "{{}}". + Delims string `yaml:"delims"` + // Parallel means a number of goroutines to proccess files. Default runtime.NumCPU() + Parallel int `yaml:"parallel"` } -func (c *Configuration) builtInValues() map[string]Value { +func (c *Config) GetDelims() string { + if c.Delims == "" { + return "{{}}" + } + return c.Delims +} + +func (c *Config) GetParallel() int { + if c.Parallel <= 0 { + return runtime.NumCPU() + } + return c.Parallel +} + +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 } -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} @@ -58,29 +80,32 @@ func (c *Configuration) 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) - } - } - 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) + result[strings.ToLower(k)] = create(v) + result[strings.ToUpper(k)] = create(v) } } + 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) { + var tmpl, err = c.getTemplate() + + if err != nil { + return tmpl, err + } + + return migrageOldConfig(tmpl, c.GetDelims()), nil +} + +func (c *Config) getTemplate() (string, error) { if c.Template != "" { return c.Template, nil } if c.TemplatePath == "" { - return "", errors.New("template has not passed") + return "", nil } if b, err := os.ReadFile(c.TemplatePath); err != nil { return "", err @@ -90,10 +115,37 @@ 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 } return yaml.Unmarshal(b, c) } + +func migrageOldConfig(input string, delims string) string { + var left = delims[:len(delims)/2] + var right = delims[len(delims)/2:] + + // Regular expression to find all {{...}} patterns + re := regexp.MustCompile(regexp.QuoteMeta("{{") + `\s*([^}]+)\s*` + regexp.QuoteMeta("}}")) + + // Replace each match with the converted version + result := re.ReplaceAllStringFunc(input, func(match string) string { + // Extract the inner content (between {{ and }}) + inner := match[2 : len(match)-2] + inner = strings.TrimSpace(inner) + + if strings.HasPrefix(inner, ".") { + return fmt.Sprintf("%v %v %v", left, inner, right) + } + + // Replace spaces with underscores + convertedInner := strings.ReplaceAll(inner, " ", "_") + + // Add the dot prefix + return fmt.Sprintf("%v .%v %v", left, convertedInner, right) + }) + + return result +} diff --git a/go.mod b/go.mod index 27aa697..597954d 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,16 @@ module github.com/denis-tingaikin/go-header -go 1.21 +go 1.23.0 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/issue.go b/issue.go deleted file mode 100644 index e922797..0000000 --- a/issue.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2020-2024 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 - -type Issue interface { - Location() Location - Message() string - Fix() *Fix -} - -type issue struct { - msg string - location Location - fix *Fix -} - -type Fix struct { - Actual []string - Expected []string -} - -func (i *issue) Location() Location { - return i.location -} - -func (i *issue) Message() string { - return i.msg -} - -func (i *issue) Fix() *Fix { - return i.fix -} - -func NewIssueWithLocation(msg string, location Location) Issue { - return &issue{ - msg: msg, - location: location, - } -} - -func NewIssueWithFix(msg string, location Location, fix Fix) Issue { - return &issue{ - msg: msg, - location: location, - fix: &fix, - } -} - -func NewIssue(msg string) Issue { - return &issue{ - msg: msg, - } -} diff --git a/location.go b/location.go deleted file mode 100644 index 9f18394..0000000 --- a/location.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2020-2022 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 "fmt" - -type Location struct { - Line int - Position int -} - -func (l Location) String() string { - return fmt.Sprintf("%v:%v", l.Line+1, l.Position) -} - -func (l Location) Add(other Location) Location { - return Location{ - Line: l.Line + other.Line, - Position: l.Position + other.Position, - } -} diff --git a/option.go b/option.go index a9689e8..3a1ee36 100644 --- a/option.go +++ b/option.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2022 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -16,8 +16,6 @@ package goheader -import "strings" - type Option interface { apply(*Analyzer) } @@ -32,11 +30,22 @@ func WithValues(values map[string]Value) Option { return applyAnalyzerOptionFunc(func(a *Analyzer) { a.values = make(map[string]Value) for k, v := range values { - a.values[strings.ToLower(k)] = v + a.values[k] = v } }) } +// WithDelims replaces default delims for parsing. +func WithDelims(delims string) Option { + return applyAnalyzerOptionFunc(func(a *Analyzer) { + var left = delims[:len(delims)/2] + var right = delims[len(delims)/2:] + + a.delimsLeft = left + a.delimsRight = right + }) +} + func WithTemplate(template string) Option { return applyAnalyzerOptionFunc(func(a *Analyzer) { a.template = template diff --git a/reader.go b/reader.go deleted file mode 100644 index 9c9e88a..0000000 --- a/reader.go +++ /dev/null @@ -1,116 +0,0 @@ -/* -Copyright (c) 2020-2022 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 - -func NewReader(text string) *Reader { - return &Reader{source: text} -} - -type Reader struct { - source string - position int - location Location - offset Location -} - -func (r *Reader) SetOffset(offset Location) { - r.offset = offset -} - -func (r *Reader) Position() int { - return r.position -} - -func (r *Reader) Location() Location { - return r.location.Add(r.offset) -} - -func (r *Reader) Peek() rune { - if r.Done() { - return rune(0) - } - return rune(r.source[r.position]) -} - -func (r *Reader) Done() bool { - return r.position >= len(r.source) -} - -func (r *Reader) Next() rune { - if r.Done() { - return rune(0) - } - reuslt := r.Peek() - if reuslt == '\n' { - r.location.Line++ - r.location.Position = 0 - } else { - r.location.Position++ - } - r.position++ - return reuslt -} - -func (r *Reader) Finish() string { - if r.position >= len(r.source) { - return "" - } - defer r.till() - return r.source[r.position:] -} - -func (r *Reader) SetPosition(pos int) { - if pos < 0 { - r.position = 0 - } - r.position = pos - r.location = r.calculateLocation() -} - -func (r *Reader) ReadWhile(match func(rune) bool) string { - if match == nil { - return "" - } - start := r.position - for !r.Done() && match(r.Peek()) { - r.Next() - } - return r.source[start:r.position] -} - -func (r *Reader) till() { - r.position = len(r.source) - r.location = r.calculateLocation() -} - -func (r *Reader) calculateLocation() Location { - min := len(r.source) - if min > r.position { - min = r.position - } - x, y := 0, 0 - for i := 0; i < min; i++ { - if r.source[i] == '\n' { - y++ - x = 0 - } else { - x++ - } - } - return Location{Line: y, Position: x} -} diff --git a/testdata/cgo/cgo.go b/testdata/cgo/cgo.go new file mode 100644 index 0000000..8e2c11d --- /dev/null +++ b/testdata/cgo/cgo.go @@ -0,0 +1,25 @@ +/*oops!*/ // want `template doens't match` + +//golangcitest:args -Egoheader +//golangcitest:config_path testdata/goheader.yml +package testdata + +/* + #include + #include + + void myprint(char* s) { + printf("%d\n", s); + } +*/ +import "C" + +import ( + "unsafe" +) + +func _() { + cs := C.CString("Hello from stdio\n") + C.myprint(cs) + C.free(unsafe.Pointer(cs)) +} diff --git a/testdata/cgo/cgo.yml b/testdata/cgo/cgo.yml new file mode 100644 index 0000000..309809c --- /dev/null +++ b/testdata/cgo/cgo.yml @@ -0,0 +1,4 @@ +template: MY {{.title}}. +values: + const: + title: TITLE 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/constvalue2/constvalue.go b/testdata/constvalue2/constvalue.go new file mode 100644 index 0000000..f4edba9 --- /dev/null +++ b/testdata/constvalue2/constvalue.go @@ -0,0 +1,4 @@ +// A 2020 +// B + +package constvalue2 diff --git a/testdata/constvalue2/constvalue.yml b/testdata/constvalue2/constvalue.yml new file mode 100644 index 0000000..224bd29 --- /dev/null +++ b/testdata/constvalue2/constvalue.yml @@ -0,0 +1,7 @@ +template: |- + A [[ .YEAR ]] + B +delims: "[[]]" +vars: + YEAR: 2020 + diff --git a/testdata/fix/blockcomment5.go b/testdata/fix/blockcomment5.go new file mode 100644 index 0000000..2a39b41 --- /dev/null +++ b/testdata/fix/blockcomment5.go @@ -0,0 +1,9 @@ +/* + * mycompany.net + * SPDX-License-Identifier: Foo + */ + +// Package foo +package foo + +func _() { println("Foo") } 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/golangci-linter/sample.go b/testdata/golangci-linter/sample.go new file mode 100644 index 0000000..805137c --- /dev/null +++ b/testdata/golangci-linter/sample.go @@ -0,0 +1,5 @@ +/*MY TITLQ!*/ // want `Expected:TITLE\., Actual: TITLE!` + +//golangcitest:args -Egoheader +//golangcitest:config_path testdata/goheader.yml +package sample diff --git a/testdata/golangci-linter/sample.yml b/testdata/golangci-linter/sample.yml new file mode 100644 index 0000000..b2f4d17 --- /dev/null +++ b/testdata/golangci-linter/sample.yml @@ -0,0 +1,4 @@ +template: MY {{title}} +values: + const: + title: TITLE. diff --git a/testdata/nestedvalues/nestedvalues.yml b/testdata/nestedvalues/nestedvalues.yml index bed8105..7e1e1e0 100644 --- a/testdata/nestedvalues/nestedvalues.yml +++ b/testdata/nestedvalues/nestedvalues.yml @@ -1,10 +1,8 @@ -template: '{{ A }}' +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/noheader/noheader.go b/testdata/noheader/noheader.go new file mode 100644 index 0000000..c1acf65 --- /dev/null +++ b/testdata/noheader/noheader.go @@ -0,0 +1 @@ +package noheader diff --git a/testdata/noheader/noheader.yml b/testdata/noheader/noheader.yml new file mode 100644 index 0000000..dc06b76 --- /dev/null +++ b/testdata/noheader/noheader.yml @@ -0,0 +1 @@ +template: abc diff --git a/testdata/oldconfig/oldconfig.go b/testdata/oldconfig/oldconfig.go new file mode 100644 index 0000000..f6b7669 --- /dev/null +++ b/testdata/oldconfig/oldconfig.go @@ -0,0 +1,3 @@ +/*mycompany.com*/ + +package oldconfig diff --git a/testdata/oldconfig/oldconfig.yml b/testdata/oldconfig/oldconfig.yml new file mode 100644 index 0000000..f0591d7 --- /dev/null +++ b/testdata/oldconfig/oldconfig.yml @@ -0,0 +1,5 @@ +template: '{{ MY COMPANY }}' + +values: + const: + MY_COMPANY: mycompany.com \ No newline at end of file diff --git a/testdata/readme/readme.yml b/testdata/readme/readme.yml index aca0e60..77ea4f6 100644 --- a/testdata/readme/readme.yml +++ b/testdata/readme/readme.yml @@ -1,8 +1,8 @@ values: const: - MY COMPANY: mycompany.com + 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/testdata/regexpvalue/regexpvalue.yml b/testdata/regexpvalue/regexpvalue.yml index 555bc0d..2716054 100644 --- a/testdata/regexpvalue/regexpvalue.yml +++ b/testdata/regexpvalue/regexpvalue.yml @@ -1,11 +1,11 @@ template: |- - {{COPYRIGHT HOLDER}}TEXT + {{.COPYRIGHT_HOLDER}}TEXT values: const: 'YEAR': '2020' regexp: - 'COPYRIGHT HOLDER': |- - (A {{ YEAR }} + 'COPYRIGHT_HOLDER': |- + (A {{ .YEAR }} (.*) )+ diff --git a/testdata/regexpvalue_issue/regexpvalue_issue.yml b/testdata/regexpvalue_issue/regexpvalue_issue.yml index 555bc0d..8f8d2c5 100644 --- a/testdata/regexpvalue_issue/regexpvalue_issue.yml +++ b/testdata/regexpvalue_issue/regexpvalue_issue.yml @@ -1,11 +1,11 @@ template: |- - {{COPYRIGHT HOLDER}}TEXT + {{.COPYRIGHT_HOLDER}}TEXT values: const: 'YEAR': '2020' regexp: - 'COPYRIGHT HOLDER': |- - (A {{ YEAR }} + '.COPYRIGHT_HOLDER': |- + (A {{ .YEAR }} (.*) )+ 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/value.go b/value.go index 706a84f..0f7cb8a 100644 --- a/value.go +++ b/value.go @@ -1,4 +1,4 @@ -// Copyright (c) 2020-2024 Denis Tingaikin +// Copyright (c) 2020-2025 Denis Tingaikin // // SPDX-License-Identifier: Apache-2.0 // @@ -19,22 +19,16 @@ package goheader import ( "errors" "fmt" - "regexp" "strings" ) -type Calculable interface { +type Value interface { Calculate(map[string]Value) error Get() string Raw() string } -type Value interface { - Calculable - Read(*Reader) Issue -} - -func calculateValue(calculable Calculable, values map[string]Value) (string, error) { +func calculateValue(calculable Value, values map[string]Value) (string, error) { sb := strings.Builder{} r := calculable.Raw() var endIndex int @@ -45,7 +39,8 @@ func calculateValue(calculable Calculable, values map[string]Value) (string, err if endIndex < 0 { return "", errors.New("missed value ending") } - subVal := strings.ToLower(strings.TrimSpace(r[startIndex+2 : endIndex])) + subVal := strings.TrimSpace(r[startIndex+2 : endIndex]) + subVal, _ = strings.CutPrefix(subVal, ".") if val := values[subVal]; val != nil { if err := val.Calculate(values); err != nil { return "", err @@ -89,22 +84,6 @@ func (c *ConstValue) String() string { return c.Get() } -func (c *ConstValue) Read(s *Reader) Issue { - l := s.Location() - p := s.Position() - for _, ch := range c.Get() { - if ch != s.Peek() { - s.SetPosition(p) - f := s.ReadWhile(func(r rune) bool { - return r != '\n' - }) - return NewIssueWithLocation(fmt.Sprintf("Expected:%v, Actual: %v", c.Get(), f), l) - } - s.Next() - } - return nil -} - type RegexpValue struct { RawValue, Value string } @@ -132,19 +111,5 @@ func (r *RegexpValue) String() string { return r.Get() } -func (r *RegexpValue) Read(s *Reader) Issue { - l := s.Location() - p := regexp.MustCompile(r.Get()) - pos := s.Position() - str := s.Finish() - s.SetPosition(pos) - indexes := p.FindAllIndex([]byte(str), -1) - if len(indexes) == 0 { - return NewIssueWithLocation(fmt.Sprintf("Pattern %v doesn't match.", p.String()), l) - } - s.SetPosition(pos + indexes[0][1]) - return nil -} - var _ Value = &ConstValue{} var _ Value = &RegexpValue{} diff --git a/version/version.go b/version/version.go deleted file mode 100644 index c1c5eb2..0000000 --- a/version/version.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) 2020-2024 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 version - -func Value() string { - return "v0.5.0" -}