Skip to content

Commit b48d220

Browse files
StevenACoffmancsilvers
authored andcommitted
Allow custom linters to auto-fix
This allows custom linters hook into the `--fix` functionality. Custom linters specify the fixes using the Go analysis structures, which allow for arbitrary char offsets for fixes; they get converted into golangci structures, which are line-based. If the conversion is not possible, the fix is dropped on the floor. Signed-off-by: Steve Coffman <[email protected]>
1 parent 52d26a3 commit b48d220

File tree

2 files changed

+292
-13
lines changed

2 files changed

+292
-13
lines changed

pkg/golinters/goanalysis/linter.go

+56-13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"flag"
66
"fmt"
7+
"go/token"
78
"runtime"
89
"sort"
910
"strings"
@@ -219,23 +220,65 @@ func buildIssues(diags []Diagnostic, linterNameBuilder func(diag *Diagnostic) st
219220
var issues []result.Issue
220221
for i := range diags {
221222
diag := &diags[i]
222-
linterName := linterNameBuilder(diag)
223-
var text string
224-
if diag.Analyzer.Name == linterName {
225-
text = diag.Message
226-
} else {
227-
text = fmt.Sprintf("%s: %s", diag.Analyzer.Name, diag.Message)
228-
}
229-
issues = append(issues, result.Issue{
230-
FromLinter: linterName,
231-
Text: text,
232-
Pos: diag.Position,
233-
Pkg: diag.Pkg,
234-
})
223+
issues = append(issues, buildSingleIssue(diag, linterNameBuilder(diag)))
235224
}
236225
return issues
237226
}
238227

228+
func buildSingleIssue(diag *Diagnostic, linterName string) result.Issue {
229+
text := generateIssueText(diag, linterName)
230+
issue := result.Issue{
231+
FromLinter: linterName,
232+
Text: text,
233+
Pos: diag.Position,
234+
Pkg: diag.Pkg,
235+
}
236+
237+
if len(diag.SuggestedFixes) > 0 {
238+
// Don't really have a better way of picking a best fix right now
239+
chosenFix := diag.SuggestedFixes[0]
240+
241+
// It could be confusing to return more than one issue per single diagnostic,
242+
// but if we return a subset it might be a partial application of a fix. Don't
243+
// apply a fix unless there is only one for now
244+
if len(chosenFix.TextEdits) == 1 {
245+
edit := chosenFix.TextEdits[0]
246+
247+
pos := diag.Pkg.Fset.Position(edit.Pos)
248+
end := diag.Pkg.Fset.Position(edit.End)
249+
250+
newLines := strings.Split(string(edit.NewText), "\n")
251+
252+
// This only works if we're only replacing whole lines with brand new lines
253+
if onlyReplacesWholeLines(pos, end, newLines) {
254+
255+
// both original and new content ends with newline, omit to avoid partial line replacement
256+
newLines = newLines[:len(newLines)-1]
257+
258+
issue.Replacement = &result.Replacement{NewLines: newLines}
259+
issue.LineRange = &result.Range{From: pos.Line, To: end.Line - 1}
260+
261+
return issue
262+
}
263+
}
264+
}
265+
266+
return issue
267+
}
268+
269+
func onlyReplacesWholeLines(oPos token.Position, oEnd token.Position, newLines []string) bool {
270+
return oPos.Column == 1 && oEnd.Column == 1 &&
271+
oPos.Line < oEnd.Line && // must be replacing at least one line
272+
newLines[len(newLines)-1] == "" // edit.NewText ended with '\n'
273+
}
274+
275+
func generateIssueText(diag *Diagnostic, linterName string) string {
276+
if diag.Analyzer.Name == linterName {
277+
return diag.Message
278+
}
279+
return fmt.Sprintf("%s: %s", diag.Analyzer.Name, diag.Message)
280+
}
281+
239282
func (lnt *Linter) preRun(lintCtx *linter.Context) error {
240283
if err := analysis.Validate(lnt.analyzers); err != nil {
241284
return errors.Wrap(err, "failed to validate analyzers")

pkg/golinters/goanalysis/linter_test.go

+236
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package goanalysis
22

33
import (
44
"fmt"
5+
"go/token"
6+
"reflect"
57
"testing"
68

9+
"github.com/golangci/golangci-lint/pkg/result"
10+
"golang.org/x/tools/go/analysis"
11+
712
"github.com/stretchr/testify/assert"
813
"golang.org/x/tools/go/packages"
914
)
@@ -46,3 +51,234 @@ func TestParseError(t *testing.T) {
4651
assert.Equal(t, "msg", i.Text)
4752
}
4853
}
54+
55+
func Test_buildIssues(t *testing.T) {
56+
type args struct {
57+
diags []Diagnostic
58+
linterNameBuilder func(diag *Diagnostic) string
59+
}
60+
tests := []struct {
61+
name string
62+
args args
63+
want []result.Issue
64+
}{
65+
{
66+
name: "No Diagnostics",
67+
args: args{
68+
diags: []Diagnostic{},
69+
linterNameBuilder: func(*Diagnostic) string {
70+
return "some-linter"
71+
},
72+
},
73+
want: []result.Issue(nil),
74+
},
75+
{
76+
name: "Linter Name is Analyzer Name",
77+
args: args{
78+
diags: []Diagnostic{
79+
{
80+
Diagnostic: analysis.Diagnostic{
81+
Message: "failure message",
82+
},
83+
Analyzer: &analysis.Analyzer{
84+
Name: "some-linter",
85+
},
86+
Position: token.Position{},
87+
Pkg: nil,
88+
},
89+
},
90+
linterNameBuilder: func(*Diagnostic) string {
91+
return "some-linter"
92+
},
93+
},
94+
want: []result.Issue{
95+
{
96+
FromLinter: "some-linter",
97+
Text: "failure message",
98+
},
99+
},
100+
},
101+
}
102+
for _, tt := range tests {
103+
t.Run(tt.name, func(t *testing.T) {
104+
if got := buildIssues(tt.args.diags, tt.args.linterNameBuilder); !reflect.DeepEqual(got, tt.want) {
105+
t.Errorf("buildIssues() = %v, want %v", got, tt.want)
106+
}
107+
})
108+
}
109+
}
110+
111+
func Test_buildSingleIssue(t *testing.T) {
112+
type args struct {
113+
diag *Diagnostic
114+
linterName string
115+
}
116+
fakePkg := packages.Package{
117+
Fset: makeFakeFileSet(),
118+
}
119+
tests := []struct {
120+
name string
121+
args args
122+
wantIssue result.Issue
123+
}{
124+
{
125+
name: "Linter Name is Analyzer Name",
126+
args: args{
127+
diag: &Diagnostic{
128+
Diagnostic: analysis.Diagnostic{
129+
Message: "failure message",
130+
},
131+
Analyzer: &analysis.Analyzer{
132+
Name: "some-linter",
133+
},
134+
Position: token.Position{},
135+
Pkg: nil,
136+
},
137+
138+
linterName: "some-linter",
139+
},
140+
wantIssue: result.Issue{
141+
FromLinter: "some-linter",
142+
Text: "failure message",
143+
},
144+
},
145+
{
146+
name: "Linter Name is NOT Analyzer Name",
147+
args: args{
148+
diag: &Diagnostic{
149+
Diagnostic: analysis.Diagnostic{
150+
Message: "failure message",
151+
},
152+
Analyzer: &analysis.Analyzer{
153+
Name: "some-analyzer",
154+
},
155+
Position: token.Position{},
156+
Pkg: nil,
157+
},
158+
linterName: "some-linter",
159+
},
160+
wantIssue: result.Issue{
161+
FromLinter: "some-linter",
162+
Text: "some-analyzer: failure message",
163+
},
164+
},
165+
{
166+
name: "Shows issue when suggested edits exist but has no TextEdits",
167+
args: args{
168+
diag: &Diagnostic{
169+
Diagnostic: analysis.Diagnostic{
170+
Message: "failure message",
171+
SuggestedFixes: []analysis.SuggestedFix{
172+
{
173+
Message: "fix something",
174+
TextEdits: []analysis.TextEdit{},
175+
},
176+
},
177+
},
178+
Analyzer: &analysis.Analyzer{
179+
Name: "some-analyzer",
180+
},
181+
Position: token.Position{},
182+
Pkg: nil,
183+
},
184+
linterName: "some-linter",
185+
},
186+
wantIssue: result.Issue{
187+
FromLinter: "some-linter",
188+
Text: "some-analyzer: failure message",
189+
},
190+
},
191+
{
192+
name: "Replace Whole Line",
193+
args: args{
194+
diag: &Diagnostic{
195+
Diagnostic: analysis.Diagnostic{
196+
Message: "failure message",
197+
SuggestedFixes: []analysis.SuggestedFix{
198+
{
199+
Message: "fix something",
200+
TextEdits: []analysis.TextEdit{
201+
{
202+
Pos: 101,
203+
End: 201,
204+
NewText: []byte("// Some comment to fix\n"),
205+
},
206+
},
207+
},
208+
},
209+
},
210+
Analyzer: &analysis.Analyzer{
211+
Name: "some-analyzer",
212+
},
213+
Position: token.Position{},
214+
Pkg: &fakePkg,
215+
},
216+
linterName: "some-linter",
217+
},
218+
wantIssue: result.Issue{
219+
FromLinter: "some-linter",
220+
Text: "some-analyzer: failure message",
221+
LineRange: &result.Range{
222+
From: 2,
223+
To: 2,
224+
},
225+
Replacement: &result.Replacement{
226+
NeedOnlyDelete: false,
227+
NewLines: []string{
228+
"// Some comment to fix",
229+
},
230+
},
231+
Pkg: &fakePkg,
232+
},
233+
},
234+
{
235+
name: "Excludes Replacement if TextEdit doesn't modify only whole lines",
236+
args: args{
237+
diag: &Diagnostic{
238+
Diagnostic: analysis.Diagnostic{
239+
Message: "failure message",
240+
SuggestedFixes: []analysis.SuggestedFix{
241+
{
242+
Message: "fix something",
243+
TextEdits: []analysis.TextEdit{
244+
{
245+
Pos: 101,
246+
End: 151,
247+
NewText: []byte("// Some comment to fix\n"),
248+
},
249+
},
250+
},
251+
},
252+
},
253+
Analyzer: &analysis.Analyzer{
254+
Name: "some-analyzer",
255+
},
256+
Position: token.Position{},
257+
Pkg: &fakePkg,
258+
},
259+
linterName: "some-linter",
260+
},
261+
wantIssue: result.Issue{
262+
FromLinter: "some-linter",
263+
Text: "some-analyzer: failure message",
264+
Pkg: &fakePkg,
265+
},
266+
},
267+
}
268+
for _, tt := range tests {
269+
t.Run(tt.name, func(t *testing.T) {
270+
if gotIssues := buildSingleIssue(tt.args.diag, tt.args.linterName); !reflect.DeepEqual(gotIssues, tt.wantIssue) {
271+
t.Errorf("buildSingleIssue() = %v, want %v", gotIssues, tt.wantIssue)
272+
}
273+
})
274+
}
275+
}
276+
277+
func makeFakeFileSet() *token.FileSet {
278+
fSet := token.NewFileSet()
279+
file := fSet.AddFile("fake.go", 1, 1000)
280+
for i := 100; i < 1000; i += 100 {
281+
file.AddLine(i)
282+
}
283+
return fSet
284+
}

0 commit comments

Comments
 (0)