Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 260 additions & 13 deletions pkg/linters/sprintfint/sprintfint.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ import (
"github.com/github/gh-aw/pkg/linters/internal/nolint"
)

const (
strconvPkg = "strconv"
fmtPkg = "fmt"
)

// Analyzer is the sprintfint analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "sprintfint",
Doc: "reports fmt.Sprintf(\"%d\", x) calls where x is a single int value; use strconv.Itoa(x) instead (suggested fixes may require goimports to add/remove imports)",
Doc: `reports fmt.Sprintf("%d", x) calls where x is a single int value; use strconv.Itoa(x) instead`,
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/sprintfint",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: run,
Expand All @@ -32,6 +37,11 @@ func run(pass *analysis.Pass) (any, error) {
}
noLintLinesByFile := nolint.BuildLineIndex(pass, "sprintfint")

// seenImportFiles tracks files that have already received an import edit in
// this pass, preventing duplicate overlapping edits when a single file
// contains multiple flagged calls.
seenImportFiles := make(map[token.Pos]bool)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seenImportFiles makes import edits non-self-contained — applying any fix other than the first one in a multi-violation file produces non-compiling code.

💡 Details and suggested fix

Import edits are attached only to whichever SuggestedFix is emitted first during the analysis walk. Subsequent violations in the same file get a fix with only the call-site rewrite and no import changes.

If a user (or an IDE) applies any fix other than that first one — perfectly reasonable behaviour — the result has:

  • strconv.Itoa(...) but no import "strconv" → compile error
  • Still has import "fmt" with no remaining uses → compile error

This violates the expectation that every SuggestedFix is independently applicable.

Suggested fix: remove seenImportFiles and include the full import edits unconditionally in every fix. The golang.org/x/tools/go/analysis apply machinery is expected to handle overlapping / duplicate TextEdits across fixes. If deduplication is not guaranteed, model the import change as a separate per-file diagnostic (emitted once) rather than piggy-backing it on the first violation's fix.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seenImportFiles is keyed on token.Pos rather than *ast.File — fragile and misleading.

💡 Details

token.Pos is an integer offset. The code relies on the undocumented behaviour that each file's start offset is unique, but the type carries no semantic meaning that this is a file identity. A reviewer (or future refactor) has no way to know the intent without reading the comment.

The idiomatic type is map[*ast.File]bool:

seenImportFiles := make(map[*ast.File]bool)
// ...
if seenImportFiles[file] { return nil }
seenImportFiles[file] = true

This is self-documenting, pointer-equality is correct by definition for AST file nodes within a single pass, and avoids any concern about Pos uniqueness.


nodeFilter := []ast.Node{
(*ast.CallExpr)(nil),
}
Expand Down Expand Up @@ -82,28 +92,265 @@ func run(pass *analysis.Pass) (any, error) {
Pos: call.Pos(),
End: call.End(),
Message: `use strconv.Itoa(x) instead of fmt.Sprintf("%d", x)`,
SuggestedFixes: buildItoaFix(pass, call, arg),
SuggestedFixes: buildItoaFix(pass, call, arg, seenImportFiles),
})
})

return nil, nil
}

// buildItoaFix returns a SuggestedFix rewriting
// fmt.Sprintf("%d", x) → strconv.Itoa(x).
func buildItoaFix(pass *analysis.Pass, call *ast.CallExpr, arg ast.Expr) []analysis.SuggestedFix {
// buildItoaFix returns a SuggestedFix rewriting fmt.Sprintf("%d", x) →
// strconv.Itoa(x). It also emits import TextEdits to add "strconv" and, when
// the flagged call is the file's only "fmt" reference, to remove the now-
// unused "fmt" import so the resulting code compiles without a goimports pass.
func buildItoaFix(pass *analysis.Pass, call *ast.CallExpr, arg ast.Expr, seenImportFiles map[token.Pos]bool) []analysis.SuggestedFix {
argText := astutil.NodeText(pass.Fset, arg)
if argText == "" {
return nil
}

edits := []analysis.TextEdit{{
Pos: call.Pos(),
End: call.End(),
NewText: []byte("strconv.Itoa(" + argText + ")"),
}}

// Find the file that contains this call so we can inspect its imports.
var file *ast.File
for _, f := range pass.Files {
if f.Pos() <= call.Pos() && call.Pos() <= f.End() {
file = f
break
}
}
if file != nil {
edits = append(edits, buildImportEdits(pass, file, seenImportFiles)...)
}

return []analysis.SuggestedFix{{
Message: "Replace fmt.Sprintf with strconv.Itoa",
TextEdits: []analysis.TextEdit{
{
Pos: call.Pos(),
End: call.End(),
NewText: []byte("strconv.Itoa(" + argText + ")"),
},
},
Message: "Replace fmt.Sprintf with strconv.Itoa",
TextEdits: edits,
}}
}

// buildImportEdits returns TextEdits that add "strconv" to file and, when the
// flagged call is the file's only "fmt" reference, also remove the now-unused
// "fmt" import. seenImportFiles prevents duplicate overlapping edits in files
// with multiple violations.
func buildImportEdits(pass *analysis.Pass, file *ast.File, seenImportFiles map[token.Pos]bool) []analysis.TextEdit {
if seenImportFiles[file.Pos()] {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The seenImportFiles guard prevents import edits for the second violation in a multi-flagged file, but there's no test fixture that exercises this path. Without a test, a regression (e.g. emitting duplicate overlapping edits) would not be caught.

💡 Suggested test fixture

Add multiviolation.go / multiviolation.go.golden to the testdata with two fmt.Sprintf("%d", n) calls in a single-import file. The golden output should import strconv exactly once.

This directly exercises the seenImportFiles[file.Pos()] short-circuit on the second call.

@copilot please address this.

return nil
Comment on lines +141 to +142
}

strconvImported := false
fmtImported := false
for _, imp := range file.Imports {
switch imp.Path.Value {
case `"` + strconvPkg + `"`:
strconvImported = true
Comment on lines +147 to +150
case `"` + fmtPkg + `"`:
fmtImported = true
}
}

// If the flagged call is the only "fmt" reference in the file the "fmt"
// import will become unused after the fix and must be removed.
orphanFmt := fmtImported && countPkgUsesInFile(pass, file, fmtPkg) == 1
Comment on lines +156 to +158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

** threshold is wrong for files with multiple violations** — a file with two fmt.Sprintf calls and no other fmt uses has countPkgUsesInFile == 2, so orphanFmt is never true, the fmt import is never removed, and the post-fix code fails to compile.

💡 Details and suggested fix

countPkgUsesInFile returns the current count of fmt selector references, including all the calls that are about to be rewritten. A file like:

import "fmt"
func a(n int) string { return fmt.Sprintf("%d", n) }
func b(n int) string { return fmt.Sprintf("%d", n) }

...has count == 2, so orphanFmt is false for both diagnostics and the fmt import is never scheduled for removal. After applying both fixes the file still contains import "fmt" with zero uses — a compile error.

Root cause: the threshold == 1 only works when there is exactly one violation. For the general case, fmt should be removed when countPkgUsesInFile == numberOfViolationsInFile (i.e. all remaining fmt uses will be replaced).

One approach: count violations per file before the inspector walk, then compare:

// pre-pass: count fmt.Sprintf violations per file
violationsPerFile := countViolationsPerFile(pass, ...)

// then in buildImportEdits:
orphanFmt := fmtImported && countPkgUsesInFile(pass, file, fmtPkg) == violationsPerFile[file]

The existing test only exercises the single-violation path (singleuse.go); a fixture with two flagged calls and no other fmt references would have caught this.


needStrconv := !strconvImported
needRemoveFmt := orphanFmt

if !needStrconv && !needRemoveFmt {
return nil
}
seenImportFiles[file.Pos()] = true

switch {
case needStrconv && needRemoveFmt:
return addStrconvRemoveFmtEdits(pass.Fset, file)
case needStrconv:
if edit, ok := addImportEdit(pass, file, strconvPkg); ok {
return []analysis.TextEdit{edit}
}
case needRemoveFmt:
if edit, ok := removeImportEdit(pass.Fset, file, fmtPkg); ok {
return []analysis.TextEdit{edit}
}
}
return nil
}

// countPkgUsesInFile returns the number of times the package at pkgPath is
// referenced as a selector base within file (e.g. each "fmt.X" call counts
// as one use of the "fmt" package).
func countPkgUsesInFile(pass *analysis.Pass, file *ast.File, pkgPath string) int {
fileStart, fileEnd := file.Pos(), file.End()
count := 0
for ident, obj := range pass.TypesInfo.Uses {
pkgName, ok := obj.(*types.PkgName)
if !ok || pkgName.Imported() == nil || pkgName.Imported().Path() != pkgPath {
continue
}
if p := ident.Pos(); p >= fileStart && p <= fileEnd {
count++
}
}
return count
}

// addStrconvRemoveFmtEdits returns the TextEdits that simultaneously add
// "strconv" and remove "fmt" from file's import section. Three structural
// cases are handled:
// - single ungrouped import "fmt" → replaced with import "strconv"
// - grouped import block with only "fmt" → replaced with import "strconv"
// - grouped block with "fmt" + others → insert "strconv", delete "fmt" line
func addStrconvRemoveFmtEdits(fset *token.FileSet, file *ast.File) []analysis.TextEdit {
var fmtSpec *ast.ImportSpec
var fmtDecl *ast.GenDecl

for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT {
continue
}
for _, spec := range genDecl.Specs {
imp, ok := spec.(*ast.ImportSpec)
if ok && imp.Path.Value == `"`+fmtPkg+`"` {
fmtSpec = imp
fmtDecl = genDecl
break
}
}
if fmtDecl != nil {
break
}
}
if fmtDecl == nil {
return nil
}

// Single ungrouped import or grouped block with only "fmt":
// replace the entire declaration with import "strconv".
if !fmtDecl.Lparen.IsValid() || len(fmtDecl.Specs) == 1 {
return []analysis.TextEdit{{
Pos: fmtDecl.Pos(),
End: fmtDecl.End(),
NewText: []byte(`import "` + strconvPkg + `"`),
}}
}

// Grouped block with "fmt" alongside other packages: insert "strconv"
// before the closing paren and delete the entire "fmt" spec line.
lineStart, lineEnd := importSpecLineRange(fset, fmtSpec)
return []analysis.TextEdit{
{
Pos: fmtDecl.Rparen,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two TextEdits for the multi-spec grouped case are returned in reverse position order: the insert at Rparen (the higher byte offset) is the first element, and the delete of the fmt spec line (lower offset) is the second. The analysistest framework sorts edits before applying them so tests pass, but tools that apply fixes without sorting (e.g. gopls apply) may reject or misapply non-monotonically-ordered edits.

Swap the two elements so edits are emitted in ascending position order:

lineStart, lineEnd := importSpecLineRange(fset, fmtSpec)
return []analysis.TextEdit{
    {Pos: lineStart, End: lineEnd, NewText: nil},
    {Pos: fmtDecl.Rparen, End: fmtDecl.Rparen, NewText: []byte("\t\"" + strconvPkg + "\"\n")},
}

@copilot please address this.

End: fmtDecl.Rparen,
NewText: []byte("\t\"" + strconvPkg + "\"\n"),
},
{
Pos: lineStart,
End: lineEnd,
NewText: nil,
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] The two TextEdits in the grouped multi-spec branch are returned with the Rparen insert (higher Pos) placed before the fmtSpec line deletion (lower Pos). While analysistest sorts edits internally, returning them in descending position order is unconventional and risks subtle issues with tools that apply edits sequentially.

💡 Suggested fix — return edits in ascending Pos order
return []analysis.TextEdit{
    {
        Pos:     lineStart,           // lower position — delete "fmt" line first
        End:     lineEnd,
        NewText: nil,
    },
    {
        Pos:     fmtDecl.Rparen,      // higher position — append "strconv"
        End:     fmtDecl.Rparen,
        NewText: []byte("\t\"" + strconvPkg + "\"\n"),
    },
}

The go/analysis spec does not mandate sorted edits from analysers, but matching the ascending-position convention is safer for all consumers.

@copilot please address this.

}

// addImportEdit returns a TextEdit that inserts an import for pkg into file,
// choosing the least-invasive insertion point: append to an existing grouped
// block, convert a single non-grouped import to a grouped block, or insert a
// standalone declaration after the package name.
func addImportEdit(pass *analysis.Pass, file *ast.File, pkg string) (analysis.TextEdit, bool) {
// Append to an existing grouped import block.
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT || !genDecl.Lparen.IsValid() {
continue
}
return analysis.TextEdit{
Pos: genDecl.Rparen,
End: genDecl.Rparen,
NewText: []byte("\t\"" + pkg + "\"\n"),
}, true
}

// Convert a single non-grouped import into a grouped block.
if len(file.Imports) == 1 {
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT || genDecl.Lparen.IsValid() {
continue
}
specText := astutil.NodeText(pass.Fset, genDecl.Specs[0])
if specText == "" {
continue
}
return analysis.TextEdit{
Pos: genDecl.Pos(),
End: genDecl.End(),
NewText: []byte("import (\n\t" + specText + "\n\t\"" + pkg + "\"\n)"),
}, true
}
}

// No existing import block; insert a standalone import after the package name.
return analysis.TextEdit{
Pos: file.Name.End(),
End: file.Name.End(),
NewText: []byte("\n\nimport \"" + pkg + "\""),
}, true
}

// removeImportEdit returns a TextEdit that removes the import of pkg from
// file's import section. For an ungrouped or sole-spec grouped declaration the
// entire decl is removed; for a multi-spec grouped block only the spec line is
// deleted using line-boundary positions from fset to handle any indentation.
func removeImportEdit(fset *token.FileSet, file *ast.File, pkg string) (analysis.TextEdit, bool) {
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT {
continue
}
for _, spec := range genDecl.Specs {
imp, ok := spec.(*ast.ImportSpec)
if !ok || imp.Path.Value != `"`+pkg+`"` {
continue
}
// Ungrouped or single-spec grouped: remove the entire declaration.
if !genDecl.Lparen.IsValid() || len(genDecl.Specs) == 1 {
return analysis.TextEdit{
Pos: genDecl.Pos(),
End: genDecl.End(),
NewText: nil,
}, true
}
// Multi-spec grouped: remove just this spec's line.
lineStart, lineEnd := importSpecLineRange(fset, imp)
return analysis.TextEdit{
Pos: lineStart,
End: lineEnd,
NewText: nil,
}, true
}
}
return analysis.TextEdit{}, false
}

// importSpecLineRange returns the [start, end) byte range that covers the
// entire source line of spec — including any leading whitespace and the
// trailing newline. It uses the token.File's line table so it works correctly
// regardless of indentation style.
func importSpecLineRange(fset *token.FileSet, spec *ast.ImportSpec) (token.Pos, token.Pos) {
tokFile := fset.File(spec.Pos())
if tokFile == nil {
// Unreachable in practice; fall back to simple single-char arithmetic.
return spec.Pos() - 1, spec.End() + 1
}
line := tokFile.Line(spec.Pos())
lineStart := tokFile.LineStart(line)
if line < tokFile.LineCount() {
return lineStart, tokFile.LineStart(line + 1)
}
// Last line has no following newline — extend past the spec token end.
return lineStart, spec.End() + 1
}
12 changes: 12 additions & 0 deletions pkg/linters/sprintfint/testdata/src/sprintfint/singleuse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package sprintfint contains a single-use test fixture for the sprintfint
// analyzer: the file imports only "fmt" and uses fmt.Sprintf("%d", n) exactly
// once, exercising the strconv-add and fmt-removal paths in the suggested fix.
package sprintfint

import "fmt"

// singleUseFmt is the only "fmt" reference in this file, so the fix must add
// "strconv" and remove the now-unused "fmt" import.
func singleUseFmt(n int) string {
return fmt.Sprintf("%d", n) // want `use strconv\.Itoa\(x\) instead of fmt\.Sprintf\("%d", x\)`
}
12 changes: 12 additions & 0 deletions pkg/linters/sprintfint/testdata/src/sprintfint/singleuse.go.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package sprintfint contains a single-use test fixture for the sprintfint
// analyzer: the file imports only "fmt" and uses fmt.Sprintf("%d", n) exactly
// once, exercising the strconv-add and fmt-removal paths in the suggested fix.
package sprintfint

import "strconv"

// singleUseFmt is the only "fmt" reference in this file, so the fix must add
// "strconv" and remove the now-unused "fmt" import.
func singleUseFmt(n int) string {
return strconv.Itoa(n) // want `use strconv\.Itoa\(x\) instead of fmt\.Sprintf\("%d", x\)`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The golden file still contains the // want annotation. Golden files represent the expected output after the fix — the // want comment is an analysistest directive for the unfixed source and has no meaning in the golden file; it just adds noise.

💡 Suggested fix

Remove the // want ... comment from singleuse.go.golden line 11:

// Before (line 11 in golden):
return strconv.Itoa(n) // want `use strconv\.Itoa\(x\) instead of fmt\.Sprintf\("%d", x\)`

// After:
return strconv.Itoa(n)

Compare with how sprintfint.go.golden handles this — it keeps the // want in the golden file too, so this is a pre-existing pattern, but it is still confusing. If the project convention is to keep them, this comment can be ignored.

@copilot please address this.

}
Loading