Skip to content

Commit 6dd366a

Browse files
committed
Add //lint:ignore <category> directive to Cadence linter
1 parent a13e39b commit 6dd366a

2 files changed

Lines changed: 258 additions & 23 deletions

File tree

internal/cadence/lint.go

Lines changed: 156 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,19 @@ import (
4242
type lintFlagsCollection struct {
4343
WarningsAsErrors bool `default:"false" flag:"warnings-as-errors" info:"Treat warnings as errors"`
4444
BaseDir string `default:"" flag:"base-dir" info:"Directory to search for .cdc files (defaults to current directory)"`
45+
ShowIgnored bool `default:"false" flag:"show-ignored" info:"Show diagnostics suppressed by //lint:ignore <category> directives"`
4546
}
4647

4748
type fileResult struct {
48-
FilePath string
49-
Diagnostics []analysis.Diagnostic
49+
FilePath string
50+
Diagnostics []analysis.Diagnostic
51+
IgnoredDiagnostics []analysis.Diagnostic
5052
}
5153

5254
type lintResult struct {
53-
Results []fileResult
54-
exitCode int
55+
Results []fileResult
56+
exitCode int
57+
showIgnored bool
5558
}
5659

5760
var _ command.ResultWithExitCode = &lintResult{}
@@ -110,6 +113,7 @@ func lint(
110113
return nil, err
111114
}
112115

116+
result.showIgnored = lintFlags.ShowIgnored
113117
return result, nil
114118
}
115119

@@ -139,24 +143,32 @@ func lintFiles(
139143
},
140144
}
141145
exitCode = 1
146+
sortDiagnostics(diagnostics)
147+
results = append(results, fileResult{
148+
FilePath: location,
149+
Diagnostics: diagnostics,
150+
})
151+
continue
142152
}
143153

144-
// Sort for consistent output
145-
sortDiagnostics(diagnostics)
154+
active, ignored := filterIgnoredDiagnostics(diagnostics, state, location)
155+
sortDiagnostics(active)
156+
sortDiagnostics(ignored)
146157
results = append(results, fileResult{
147-
FilePath: location,
148-
Diagnostics: diagnostics,
158+
FilePath: location,
159+
Diagnostics: active,
160+
IgnoredDiagnostics: ignored,
149161
})
150162

151-
// Set the exitCode to 1 if any of the diagnostics are error-level,
163+
// Set the exitCode to 1 if any of the active diagnostics are error-level,
152164
// or warning-level when warningsAsErrors is set.
153-
for _, diagnostic := range diagnostics {
154-
severity := getDiagnosticSeverity(diagnostic)
155-
if severity == errorSeverity {
165+
for _, diagnostic := range active {
166+
sev := getDiagnosticSeverity(diagnostic)
167+
if sev == errorSeverity {
156168
exitCode = 1
157169
break
158170
}
159-
if severity == warningSeverity && warningsAsErrors {
171+
if sev == warningSeverity && warningsAsErrors {
160172
exitCode = 1
161173
break
162174
}
@@ -169,6 +181,71 @@ func lintFiles(
169181
}, nil
170182
}
171183

184+
// parseLintIgnoreDirectives returns a map of 1-indexed line number to the set of
185+
// categories ignored on that line via //lint:ignore <category> comments.
186+
func parseLintIgnoreDirectives(code string) map[int]map[string]bool {
187+
directives := make(map[int]map[string]bool)
188+
for i, line := range strings.Split(code, "\n") {
189+
lineNum := i + 1
190+
_, after, found := strings.Cut(line, "//lint:ignore ")
191+
if !found {
192+
continue
193+
}
194+
category := strings.TrimSpace(after)
195+
if sp := strings.IndexByte(category, ' '); sp >= 0 {
196+
category = category[:sp]
197+
}
198+
if category == "" {
199+
continue
200+
}
201+
if directives[lineNum] == nil {
202+
directives[lineNum] = make(map[string]bool)
203+
}
204+
directives[lineNum][category] = true
205+
}
206+
return directives
207+
}
208+
209+
func isDiagnosticIgnored(d analysis.Diagnostic, directives map[int]map[string]bool) bool {
210+
line := d.Range.StartPos.Line
211+
for _, l := range []int{line, line - 1} {
212+
if cats, ok := directives[l]; ok && cats[d.Category] {
213+
return true
214+
}
215+
}
216+
return false
217+
}
218+
219+
// filterIgnoredDiagnostics reads the source for location and splits diagnostics
220+
// into active and ignored based on //lint:ignore directives. If the source cannot
221+
// be read, all diagnostics are returned as active.
222+
func filterIgnoredDiagnostics(
223+
diagnostics []analysis.Diagnostic,
224+
state *flowkit.State,
225+
location string,
226+
) (active, ignored []analysis.Diagnostic) {
227+
code, err := state.ReadFile(location)
228+
if err != nil {
229+
if diagnostics == nil {
230+
return []analysis.Diagnostic{}, nil
231+
}
232+
return diagnostics, nil
233+
}
234+
235+
directives := parseLintIgnoreDirectives(string(code))
236+
for _, d := range diagnostics {
237+
if isDiagnosticIgnored(d, directives) {
238+
ignored = append(ignored, d)
239+
} else {
240+
active = append(active, d)
241+
}
242+
}
243+
if active == nil {
244+
active = []analysis.Diagnostic{}
245+
}
246+
return active, ignored
247+
}
248+
172249
func getDiagnosticSeverity(
173250
diagnostic analysis.Diagnostic,
174251
) severity {
@@ -212,6 +289,18 @@ func renderDiagnostic(diagnostic analysis.Diagnostic) string {
212289
)
213290
}
214291

292+
func renderIgnoredDiagnostic(diagnostic analysis.Diagnostic) string {
293+
startPos := diagnostic.Range.StartPos
294+
locationText := fmt.Sprintf("%s:%d:%d:", diagnostic.Location.String(), startPos.Line, startPos.Column)
295+
categoryText := fmt.Sprintf("%s:", diagnostic.Category)
296+
297+
return fmt.Sprintf("%s %s %s (ignored)",
298+
aurora.Gray(12, locationText).String(),
299+
aurora.Gray(12, categoryText).String(),
300+
aurora.Gray(12, diagnostic.Message).String(),
301+
)
302+
}
303+
215304
func (r *lintResult) countProblems() (int, int) {
216305
numErrors := 0
217306
numWarnings := 0
@@ -227,36 +316,65 @@ func (r *lintResult) countProblems() (int, int) {
227316
return numErrors, numWarnings
228317
}
229318

319+
func (r *lintResult) countIgnored() int {
320+
n := 0
321+
for _, result := range r.Results {
322+
n += len(result.IgnoredDiagnostics)
323+
}
324+
return n
325+
}
326+
230327
func (r *lintResult) String() string {
231328
var sb strings.Builder
232329

233330
for _, result := range r.Results {
234331
for _, diagnostic := range result.Diagnostics {
235-
sb.WriteString(fmt.Sprintf("%s\n\n", renderDiagnostic(diagnostic)))
332+
fmt.Fprintf(&sb, "%s\n\n", renderDiagnostic(diagnostic))
333+
}
334+
if r.showIgnored {
335+
for _, diagnostic := range result.IgnoredDiagnostics {
336+
fmt.Fprintf(&sb, "%s\n\n", renderIgnoredDiagnostic(diagnostic))
337+
}
236338
}
237339
}
238340

239-
var color aurora.Color
240341
numErrors, numWarnings := r.countProblems()
342+
numIgnored := r.countIgnored()
343+
total := numErrors + numWarnings + numIgnored
344+
345+
var color aurora.Color
241346
if numErrors > 0 {
242347
color = aurora.RedFg
243348
} else if numWarnings > 0 {
244349
color = aurora.YellowFg
245350
}
246351

247-
total := numErrors + numWarnings
248-
if total > 0 {
352+
if total == 0 {
353+
sb.WriteString(aurora.Green("Lint passed").String())
354+
return sb.String()
355+
}
356+
357+
if numIgnored > 0 {
249358
sb.WriteString(aurora.Colorize(fmt.Sprintf(
250-
"%d %s (%d %s, %d %s)",
359+
"%d %s (%d %s, %d %s, %d ignored)",
251360
total,
252361
util.Pluralize("problem", total),
253362
numErrors,
254363
util.Pluralize("error", numErrors),
255364
numWarnings,
256365
util.Pluralize("warning", numWarnings),
366+
numIgnored,
257367
), color).String())
258368
} else {
259-
sb.WriteString(aurora.Green("Lint passed").String())
369+
sb.WriteString(aurora.Colorize(fmt.Sprintf(
370+
"%d %s (%d %s, %d %s)",
371+
total,
372+
util.Pluralize("problem", total),
373+
numErrors,
374+
util.Pluralize("error", numErrors),
375+
numWarnings,
376+
util.Pluralize("warning", numWarnings),
377+
), color).String())
260378
}
261379

262380
return sb.String()
@@ -268,12 +386,27 @@ func (r *lintResult) JSON() any {
268386

269387
func (r *lintResult) Oneliner() string {
270388
numErrors, numWarnings := r.countProblems()
271-
total := numErrors + numWarnings
389+
numIgnored := r.countIgnored()
390+
total := numErrors + numWarnings + numIgnored
272391

273-
if total > 0 {
274-
return fmt.Sprintf("%d %s (%d %s, %d %s)", total, util.Pluralize("problem", total), numErrors, util.Pluralize("error", numErrors), numWarnings, util.Pluralize("warning", numWarnings))
392+
if total == 0 {
393+
return "Lint passed"
275394
}
276-
return "Lint passed"
395+
396+
if numIgnored > 0 {
397+
return fmt.Sprintf("%d %s (%d %s, %d %s, %d ignored)",
398+
total, util.Pluralize("problem", total),
399+
numErrors, util.Pluralize("error", numErrors),
400+
numWarnings, util.Pluralize("warning", numWarnings),
401+
numIgnored,
402+
)
403+
}
404+
405+
return fmt.Sprintf("%d %s (%d %s, %d %s)",
406+
total, util.Pluralize("problem", total),
407+
numErrors, util.Pluralize("error", numErrors),
408+
numWarnings, util.Pluralize("warning", numWarnings),
409+
)
277410
}
278411

279412
func (r *lintResult) ExitCode() int {

internal/cadence/lint_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,78 @@ func Test_Lint(t *testing.T) {
458458
)
459459
})
460460

461+
t.Run("lint:ignore on previous line suppresses matching diagnostic", func(t *testing.T) {
462+
t.Parallel()
463+
464+
state := setupMockState(t)
465+
466+
results, err := lintFiles(state, false, "IgnoreOnPreviousLine.cdc")
467+
require.NoError(t, err)
468+
469+
require.Len(t, results.Results, 1)
470+
require.Empty(t, results.Results[0].Diagnostics)
471+
require.Len(t, results.Results[0].IgnoredDiagnostics, 1)
472+
require.Equal(t, "semantic-error", results.Results[0].IgnoredDiagnostics[0].Category)
473+
require.Equal(t, 0, results.exitCode)
474+
})
475+
476+
t.Run("lint:ignore on same line suppresses matching diagnostic", func(t *testing.T) {
477+
t.Parallel()
478+
479+
state := setupMockState(t)
480+
481+
results, err := lintFiles(state, false, "IgnoreOnSameLine.cdc")
482+
require.NoError(t, err)
483+
484+
require.Len(t, results.Results, 1)
485+
require.Empty(t, results.Results[0].Diagnostics)
486+
require.Len(t, results.Results[0].IgnoredDiagnostics, 1)
487+
require.Equal(t, "semantic-error", results.Results[0].IgnoredDiagnostics[0].Category)
488+
require.Equal(t, 0, results.exitCode)
489+
})
490+
491+
t.Run("lint:ignore for wrong category does not suppress diagnostic", func(t *testing.T) {
492+
t.Parallel()
493+
494+
state := setupMockState(t)
495+
496+
results, err := lintFiles(state, false, "IgnoreWrongCategory.cdc")
497+
require.NoError(t, err)
498+
499+
require.Len(t, results.Results, 1)
500+
require.Len(t, results.Results[0].Diagnostics, 1)
501+
require.Equal(t, "semantic-error", results.Results[0].Diagnostics[0].Category)
502+
require.Empty(t, results.Results[0].IgnoredDiagnostics)
503+
require.Equal(t, 1, results.exitCode)
504+
})
505+
506+
t.Run("lint:ignore suppresses warning and does not affect exit code", func(t *testing.T) {
507+
t.Parallel()
508+
509+
state := setupMockState(t)
510+
511+
results, err := lintFiles(state, true, "IgnoreWarning.cdc")
512+
require.NoError(t, err)
513+
514+
require.Len(t, results.Results, 1)
515+
require.Empty(t, results.Results[0].Diagnostics)
516+
require.Len(t, results.Results[0].IgnoredDiagnostics, 1)
517+
require.Equal(t, "removal-hint", results.Results[0].IgnoredDiagnostics[0].Category)
518+
require.Equal(t, 0, results.exitCode)
519+
})
520+
521+
t.Run("parseLintIgnoreDirectives parses directives correctly", func(t *testing.T) {
522+
t.Parallel()
523+
524+
code := "access(all) contract Foo {\n\t//lint:ignore semantic-error\n\tlet x = 1\n\tlet y = 2 //lint:ignore removal-hint\n}"
525+
directives := parseLintIgnoreDirectives(code)
526+
527+
require.Equal(t, map[string]bool{"semantic-error": true}, directives[2])
528+
require.Equal(t, map[string]bool{"removal-hint": true}, directives[4])
529+
require.Nil(t, directives[1])
530+
require.Nil(t, directives[3])
531+
})
532+
461533
t.Run("allows access(account) when dependencies have Source but no Aliases", func(t *testing.T) {
462534
t.Parallel()
463535

@@ -583,6 +655,36 @@ func setupMockState(t *testing.T) *flowkit.State {
583655
}
584656
`), 0644)
585657

658+
// lint:ignore directive test files
659+
_ = afero.WriteFile(mockFs, "IgnoreOnPreviousLine.cdc", []byte(
660+
"access(all) contract IgnoreOnPreviousLine {\n"+
661+
"\tinit() {\n"+
662+
"\t\t//lint:ignore semantic-error\n"+
663+
"\t\tqqq\n"+
664+
"\t}\n"+
665+
"}"), 0644)
666+
_ = afero.WriteFile(mockFs, "IgnoreOnSameLine.cdc", []byte(
667+
"access(all) contract IgnoreOnSameLine {\n"+
668+
"\tinit() {\n"+
669+
"\t\tqqq //lint:ignore semantic-error\n"+
670+
"\t}\n"+
671+
"}"), 0644)
672+
_ = afero.WriteFile(mockFs, "IgnoreWrongCategory.cdc", []byte(
673+
"access(all) contract IgnoreWrongCategory {\n"+
674+
"\tinit() {\n"+
675+
"\t\t//lint:ignore removal-hint\n"+
676+
"\t\tqqq\n"+
677+
"\t}\n"+
678+
"}"), 0644)
679+
_ = afero.WriteFile(mockFs, "IgnoreWarning.cdc", []byte(
680+
"access(all) contract IgnoreWarning {\n"+
681+
"\tinit() {\n"+
682+
"\t\t//lint:ignore removal-hint\n"+
683+
"\t\tlet x = 1!\n"+
684+
"\t\tlog(x)\n"+
685+
"\t}\n"+
686+
"}"), 0644)
687+
586688
// Regression test files for nested import bug
587689
_ = afero.WriteFile(mockFs, "Helper.cdc", []byte(`
588690
access(all) contract Helper {

0 commit comments

Comments
 (0)