Skip to content

Commit 9008c92

Browse files
committed
Fix unused import detection in Cadence linter
1 parent 8a0ec60 commit 9008c92

2 files changed

Lines changed: 171 additions & 6 deletions

File tree

internal/cadence/lint_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,65 @@ func Test_Lint(t *testing.T) {
8989
)
9090
})
9191

92+
t.Run("detects unused import", func(t *testing.T) {
93+
t.Parallel()
94+
95+
state := setupMockState(t)
96+
97+
results, err := lintFiles(state, false, "UnusedImport.cdc")
98+
require.NoError(t, err)
99+
100+
require.Len(t, results.Results, 1)
101+
require.Len(t, results.Results[0].Diagnostics, 1)
102+
103+
diagnostic := results.Results[0].Diagnostics[0]
104+
require.Equal(t, "unused-import-hint", diagnostic.Category)
105+
require.Equal(t, "unused import", diagnostic.Message)
106+
require.Equal(t, 0, results.exitCode)
107+
})
108+
109+
t.Run("detects unused contract-name import", func(t *testing.T) {
110+
t.Parallel()
111+
112+
state := setupMockState(t)
113+
114+
results, err := lintFiles(state, false, "UnusedContractNameImport.cdc")
115+
require.NoError(t, err)
116+
117+
require.Len(t, results.Results, 1)
118+
require.Len(t, results.Results[0].Diagnostics, 1)
119+
120+
diagnostic := results.Results[0].Diagnostics[0]
121+
require.Equal(t, "unused-import-hint", diagnostic.Category)
122+
require.Equal(t, 0, results.exitCode)
123+
})
124+
125+
t.Run("does not flag import used only in resource conformance", func(t *testing.T) {
126+
t.Parallel()
127+
128+
state := setupMockState(t)
129+
130+
results, err := lintFiles(state, false, "ConformanceUser.cdc")
131+
require.NoError(t, err)
132+
133+
require.Len(t, results.Results, 1)
134+
require.Empty(t, results.Results[0].Diagnostics)
135+
require.Equal(t, 0, results.exitCode)
136+
})
137+
138+
t.Run("does not flag import used only in entitlement access specifier", func(t *testing.T) {
139+
t.Parallel()
140+
141+
state := setupMockState(t)
142+
143+
results, err := lintFiles(state, false, "EntitlementUser.cdc")
144+
require.NoError(t, err)
145+
146+
require.Len(t, results.Results, 1)
147+
require.Empty(t, results.Results[0].Diagnostics)
148+
require.Equal(t, 0, results.exitCode)
149+
})
150+
92151
t.Run("lints multiple files", func(t *testing.T) {
93152
t.Parallel()
94153

@@ -503,6 +562,20 @@ func setupMockState(t *testing.T) *flowkit.State {
503562
_ = afero.WriteFile(mockFs, "foo/WithImports.cdc", []byte(`
504563
import "../NoError.cdc"
505564
access(all) contract WithImports {
565+
init() {
566+
let _ = NoError.getType()
567+
}
568+
}
569+
`), 0644)
570+
_ = afero.WriteFile(mockFs, "UnusedImport.cdc", []byte(`
571+
import "NoError.cdc"
572+
access(all) contract UnusedImport {
573+
init() {}
574+
}
575+
`), 0644)
576+
_ = afero.WriteFile(mockFs, "UnusedContractNameImport.cdc", []byte(`
577+
import "NoError"
578+
access(all) contract UnusedContractNameImport {
506579
init() {}
507580
}
508581
`), 0644)
@@ -636,6 +709,10 @@ func setupMockState(t *testing.T) *flowkit.State {
636709
Name: "ContractWithNestedImports",
637710
Location: "ContractWithNestedImports.cdc",
638711
})
712+
state.Contracts().AddOrUpdate(config.Contract{
713+
Name: "Scheduler",
714+
Location: "Scheduler.cdc",
715+
})
639716

640717
return state
641718
}

internal/cadence/linter.go

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ import (
4242
)
4343

4444
type linter struct {
45-
checkers map[string]*sema.Checker
46-
state *flowkit.State
47-
checkerStandardConfig *sema.Config
48-
checkerScriptConfig *sema.Config
45+
checkers map[string]*sema.Checker
46+
exportedIdentifiers map[string][]ast.Identifier
47+
state *flowkit.State
48+
checkerStandardConfig *sema.Config
49+
checkerScriptConfig *sema.Config
50+
currentLocation common.Location
4951
}
5052

5153
type positionedError interface {
@@ -64,8 +66,9 @@ var analyzers = maps.Values(cdclint.Analyzers)
6466

6567
func newLinter(state *flowkit.State) *linter {
6668
l := &linter{
67-
checkers: make(map[string]*sema.Checker),
68-
state: state,
69+
checkers: make(map[string]*sema.Checker),
70+
exportedIdentifiers: make(map[string][]ast.Identifier),
71+
state: state,
6972
}
7073

7174
// Create checker configs for both standard and script
@@ -102,6 +105,8 @@ func (l *linter) lintCode(
102105
diagnostics = make([]analysis.Diagnostic, 0)
103106
codeStr := string(code)
104107

108+
l.currentLocation = location
109+
105110
// Parse program & convert any parsing errors to diagnostics
106111
program, parseProgramErr := parser.ParseProgram(nil, code, parser.Config{})
107112
if parseProgramErr != nil {
@@ -332,6 +337,7 @@ func (l *linter) newCheckerConfig(standardLibrary *util.StandardLibrary) *sema.C
332337
PositionInfoEnabled: true, // Must be enabled for linters
333338
ExtendedElaborationEnabled: true, // Must be enabled for linters
334339
ImportHandler: l.handleImport,
340+
LocationHandler: l.resolveLocation,
335341
SuggestionsEnabled: true, // Must be enabled to offer semantic suggestions
336342
}
337343
}
@@ -437,7 +443,13 @@ func (l *linter) handleImport(
437443
}
438444

439445
l.checkers[filepath] = importedChecker
446+
// Pre-populate so resolveLocation doesn't re-parse during sub-checking
447+
l.exportedIdentifiers[filepath] = exportedIdentifiersFromProgram(importedProgram)
448+
449+
prevLocation := l.currentLocation
450+
l.currentLocation = fileLocation
440451
err = importedChecker.Check()
452+
l.currentLocation = prevLocation
441453
if err != nil {
442454
return nil, err
443455
}
@@ -474,6 +486,82 @@ func (l *linter) resolveImportFilepath(
474486
}
475487
}
476488

489+
// resolveLocation is the LocationHandler for the sema.Checker config.
490+
// For implicit imports (no explicit identifiers), it resolves the exported names
491+
// from the imported file so the unused-import analyzer can track usage.
492+
func (l *linter) resolveLocation(
493+
identifiers []ast.Identifier,
494+
location common.Location,
495+
) ([]sema.ResolvedLocation, error) {
496+
defaultResolution := []sema.ResolvedLocation{{
497+
Location: location,
498+
Identifiers: identifiers,
499+
}}
500+
501+
// Explicit imports already carry their identifiers — nothing to add
502+
if len(identifiers) > 0 {
503+
return defaultResolution, nil
504+
}
505+
506+
// Only handle string locations (path-based or contract-name imports)
507+
if _, ok := location.(common.StringLocation); !ok {
508+
return defaultResolution, nil
509+
}
510+
511+
// Normalize relative path imports against the current file's location,
512+
// then resolve to a file path (handles both .cdc paths and contract names)
513+
resolvedLoc := location
514+
if l.currentLocation != nil && util.IsPathLocation(location) {
515+
resolvedLoc = util.NormalizePathLocation(l.currentLocation, location)
516+
}
517+
518+
filePath, err := l.resolveImportFilepath(resolvedLoc, l.currentLocation)
519+
if err != nil {
520+
return defaultResolution, nil
521+
}
522+
523+
exportedIdents := l.getExportedIdentifiers(filePath)
524+
if len(exportedIdents) == 0 {
525+
return defaultResolution, nil
526+
}
527+
528+
return []sema.ResolvedLocation{{
529+
Location: location,
530+
Identifiers: exportedIdents,
531+
}}, nil
532+
}
533+
534+
func (l *linter) getExportedIdentifiers(filePath string) []ast.Identifier {
535+
if cached, ok := l.exportedIdentifiers[filePath]; ok {
536+
return cached
537+
}
538+
539+
code, err := l.state.ReadFile(filePath)
540+
if err != nil {
541+
return nil
542+
}
543+
544+
program, err := parser.ParseProgram(nil, code, parser.Config{})
545+
if err != nil || program == nil {
546+
return nil
547+
}
548+
549+
identifiers := exportedIdentifiersFromProgram(program)
550+
l.exportedIdentifiers[filePath] = identifiers
551+
return identifiers
552+
}
553+
554+
func exportedIdentifiersFromProgram(program *ast.Program) []ast.Identifier {
555+
var identifiers []ast.Identifier
556+
for _, decl := range program.CompositeDeclarations() {
557+
identifiers = append(identifiers, decl.Identifier)
558+
}
559+
for _, decl := range program.InterfaceDeclarations() {
560+
identifiers = append(identifiers, decl.Identifier)
561+
}
562+
return identifiers
563+
}
564+
477565
// helpers
478566

479567
func getDiagnosticsFromParentError(err cdcerrors.ParentError, location common.Location, code string) ([]analysis.Diagnostic, error) {

0 commit comments

Comments
 (0)