1
0
Fork 0
mirror of https://github.com/imjasonh/ctxhttp-analyzer synced 2026-07-06 22:12:38 +00:00
ctxhttp-analyzer/analyzer.go
Jason Hall 742f21c993 Initial implementation of ctxhttp-analyzer
Go analyzer that detects net/http calls without context and rewrites
them to use http.NewRequestWithContext. Handles http.Get/Post/Head/PostForm,
client methods, and injects context at function boundaries (main, tests,
HTTP handlers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:51:39 -04:00

561 lines
15 KiB
Go

package main
import (
"bytes"
"fmt"
"go/ast"
"go/printer"
"go/token"
"go/types"
"strings"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "ctxhttp",
Doc: "detects HTTP calls that should use context and suggests fixes",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
// Track which functions have already had context injected,
// so we don't duplicate the injection for multiple HTTP calls
// in the same function.
injected := map[*ast.FuncDecl]bool{}
// Track whether we've already added the "context" import for this file.
importAdded := false
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
kind := detectHTTPCall(pass, call)
if kind == "" {
return true
}
enc := enclosingFunc(file, call)
ctxExpr := contextExpr(enc, pass)
// Should we inject a ctx := ... statement?
inject := !injected[enc] && needsContextInjection(enc, pass)
// Should we add the "context" import?
addImport := !importAdded && !hasContextImport(file)
var fixes []analysis.SuggestedFix
switch kind {
case "http.NewRequest":
fixes = fixNewRequest(pass, file, call, ctxExpr, enc, inject, addImport)
case "http.Get", "http.Head":
fixes = fixSimpleHTTPFunc(pass, file, call, kind, ctxExpr, enc, inject, addImport, "http.DefaultClient")
case "http.Post":
fixes = fixHTTPPost(pass, file, call, ctxExpr, enc, inject, addImport, "http.DefaultClient")
case "http.PostForm":
fixes = fixHTTPPostForm(pass, file, call, ctxExpr, enc, inject, addImport, "http.DefaultClient")
case "client.Get", "client.Head":
receiver := nodeText(pass, call.Fun.(*ast.SelectorExpr).X)
fixes = fixSimpleHTTPFunc(pass, file, call, kind, ctxExpr, enc, inject, addImport, receiver)
case "client.Post":
receiver := nodeText(pass, call.Fun.(*ast.SelectorExpr).X)
fixes = fixHTTPPost(pass, file, call, ctxExpr, enc, inject, addImport, receiver)
case "client.PostForm":
receiver := nodeText(pass, call.Fun.(*ast.SelectorExpr).X)
fixes = fixHTTPPostForm(pass, file, call, ctxExpr, enc, inject, addImport, receiver)
}
if inject {
injected[enc] = true
}
if addImport {
importAdded = true
}
pass.Report(analysis.Diagnostic{
Pos: call.Pos(),
End: call.End(),
Message: kind + " should use context",
SuggestedFixes: fixes,
})
return true
})
}
return nil, nil
}
// detectHTTPCall returns the kind of HTTP call, or "" if not relevant.
func detectHTTPCall(pass *analysis.Pass, call *ast.CallExpr) string {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return ""
}
obj := pass.TypesInfo.ObjectOf(sel.Sel)
if obj == nil {
return ""
}
fn, ok := obj.(*types.Func)
if !ok {
return ""
}
switch fn.FullName() {
case "net/http.NewRequest":
return "http.NewRequest"
case "net/http.Get":
return "http.Get"
case "net/http.Head":
return "http.Head"
case "net/http.Post":
return "http.Post"
case "net/http.PostForm":
return "http.PostForm"
case "(*net/http.Client).Get":
return "client.Get"
case "(*net/http.Client).Head":
return "client.Head"
case "(*net/http.Client).Post":
return "client.Post"
case "(*net/http.Client).PostForm":
return "client.PostForm"
}
return ""
}
// enclosingFunc finds the FuncDecl containing a node.
func enclosingFunc(file *ast.File, target ast.Node) *ast.FuncDecl {
var result *ast.FuncDecl
ast.Inspect(file, func(n ast.Node) bool {
fd, ok := n.(*ast.FuncDecl)
if !ok {
return true
}
if fd.Body != nil && fd.Pos() <= target.Pos() && target.End() <= fd.End() {
result = fd
}
return true
})
return result
}
// enclosingStmt finds the statement in a block that directly contains the call.
// Returns the BlockStmt and the index of the statement within it.
func enclosingStmt(file *ast.File, call *ast.CallExpr) (*ast.BlockStmt, int) {
var resultBlock *ast.BlockStmt
var resultIdx int
ast.Inspect(file, func(n ast.Node) bool {
block, ok := n.(*ast.BlockStmt)
if !ok {
return true
}
for i, stmt := range block.List {
if stmt.Pos() <= call.Pos() && call.End() <= stmt.End() {
resultBlock = block
resultIdx = i
}
}
return true
})
return resultBlock, resultIdx
}
// contextExpr determines what expression provides context in the enclosing function.
func contextExpr(fd *ast.FuncDecl, pass *analysis.Pass) string {
if fd == nil {
return "context.TODO()"
}
if name := existingContextParam(fd, pass); name != "" {
return name
}
return "ctx"
}
// existingContextParam returns the name of an existing context.Context parameter, or "".
func existingContextParam(fd *ast.FuncDecl, pass *analysis.Pass) string {
if fd.Type.Params == nil {
return ""
}
for _, field := range fd.Type.Params.List {
typ := pass.TypesInfo.TypeOf(field.Type)
if typ != nil && typ.String() == "context.Context" {
if len(field.Names) > 0 {
return field.Names[0].Name
}
return "ctx"
}
}
return ""
}
// needsContextInjection returns true if the enclosing function needs a ctx := ... statement.
func needsContextInjection(fd *ast.FuncDecl, pass *analysis.Pass) bool {
if fd == nil {
return false
}
return existingContextParam(fd, pass) == ""
}
// contextInjectionText returns the statement to inject for context creation.
func contextInjectionText(fd *ast.FuncDecl, pass *analysis.Pass) string {
if fd == nil {
return ""
}
name := fd.Name.Name
switch {
case name == "main" || name == "init":
return "ctx := context.Background()\n\t"
case strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Benchmark"):
tName := testParamName(fd)
return "ctx := " + tName + ".Context()\n\t"
case isHTTPHandler(fd, pass):
reqName := httpRequestParamName(fd, pass)
return "ctx := " + reqName + ".Context()\n\t"
default:
return "ctx := context.TODO()\n\t"
}
}
// testParamName returns the name of the *testing.T or *testing.B parameter.
func testParamName(fd *ast.FuncDecl) string {
if fd.Type.Params == nil {
return "t"
}
for _, field := range fd.Type.Params.List {
if len(field.Names) > 0 {
return field.Names[0].Name
}
}
return "t"
}
// isHTTPHandler checks if a function has the (http.ResponseWriter, *http.Request) signature.
func isHTTPHandler(fd *ast.FuncDecl, pass *analysis.Pass) bool {
if fd.Type.Params == nil {
return false
}
params := flattenParams(fd.Type.Params.List)
if len(params) < 2 {
return false
}
p0Type := pass.TypesInfo.TypeOf(params[len(params)-2].Type)
p1Type := pass.TypesInfo.TypeOf(params[len(params)-1].Type)
if p0Type == nil || p1Type == nil {
return false
}
return p0Type.String() == "net/http.ResponseWriter" &&
p1Type.String() == "*net/http.Request"
}
// httpRequestParamName returns the name of the *http.Request parameter.
func httpRequestParamName(fd *ast.FuncDecl, pass *analysis.Pass) string {
for _, field := range flattenParams(fd.Type.Params.List) {
typ := pass.TypesInfo.TypeOf(field.Type)
if typ != nil && typ.String() == "*net/http.Request" {
if len(field.Names) > 0 {
return field.Names[0].Name
}
}
}
return "r"
}
// flattenParams expands grouped params (a, b int) into individual fields.
func flattenParams(fields []*ast.Field) []*ast.Field {
var result []*ast.Field
for _, f := range fields {
if len(f.Names) <= 1 {
result = append(result, f)
} else {
for _, name := range f.Names {
result = append(result, &ast.Field{
Names: []*ast.Ident{name},
Type: f.Type,
})
}
}
}
return result
}
// fixNewRequest generates a SuggestedFix for http.NewRequest → http.NewRequestWithContext.
func fixNewRequest(pass *analysis.Pass, file *ast.File, call *ast.CallExpr, ctxExpr string, enc *ast.FuncDecl, inject bool, addImport bool) []analysis.SuggestedFix {
sel := call.Fun.(*ast.SelectorExpr)
var edits []analysis.TextEdit
// Replace "NewRequest" with "NewRequestWithContext".
edits = append(edits, analysis.TextEdit{
Pos: sel.Sel.Pos(),
End: sel.Sel.End(),
NewText: []byte("NewRequestWithContext"),
})
// Insert ctx as first argument.
if len(call.Args) > 0 {
edits = append(edits, analysis.TextEdit{
Pos: call.Args[0].Pos(),
End: call.Args[0].Pos(),
NewText: []byte(ctxExpr + ", "),
})
}
if inject {
edits = append(edits, contextInjectionEdit(enc, pass))
}
if addImport {
edits = append(edits, contextImportEdit(file)...)
}
return []analysis.SuggestedFix{{
Message: "use http.NewRequestWithContext",
TextEdits: edits,
}}
}
// fixSimpleHTTPFunc rewrites http.Get(url) or http.Head(url) (and client equivalents).
//
// Before:
//
// resp, err := http.Get(url)
//
// After:
//
// req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
// if err != nil {
// return ..., err // or handle based on context
// }
// resp, err := http.DefaultClient.Do(req)
func fixSimpleHTTPFunc(pass *analysis.Pass, file *ast.File, call *ast.CallExpr, kind string, ctxExpr string, enc *ast.FuncDecl, inject bool, addImport bool, client string) []analysis.SuggestedFix {
method := "GET"
if strings.HasSuffix(kind, "Head") {
method = "HEAD"
}
urlArg := nodeText(pass, call.Args[0])
// Find what the result is assigned to.
block, idx := enclosingStmt(file, call)
if block == nil {
return nil
}
stmt := block.List[idx]
var lhs string
assign, isAssign := stmt.(*ast.AssignStmt)
if isAssign && len(assign.Lhs) == 2 {
lhs = nodeText(pass, assign.Lhs[0])
}
if lhs == "" {
lhs = "_"
}
tok := ":="
if isAssign {
tok = assign.Tok.String()
}
replacement := fmt.Sprintf(
"req, err %s http.NewRequestWithContext(%s, %q, %s, nil)\n\tif err != nil {\n\t\treturn err\n\t}\n\t%s, err %s %s.Do(req)",
tok, ctxExpr, method, urlArg,
lhs, tok, client,
)
var edits []analysis.TextEdit
edits = append(edits, analysis.TextEdit{
Pos: stmt.Pos(),
End: stmt.End(),
NewText: []byte(replacement),
})
if inject {
edits = append(edits, contextInjectionEdit(enc, pass))
}
if addImport {
edits = append(edits, contextImportEdit(file)...)
}
return []analysis.SuggestedFix{{
Message: fmt.Sprintf("use http.NewRequestWithContext with %s.Do", client),
TextEdits: edits,
}}
}
// fixHTTPPost rewrites http.Post(url, contentType, body) and client.Post().
func fixHTTPPost(pass *analysis.Pass, file *ast.File, call *ast.CallExpr, ctxExpr string, enc *ast.FuncDecl, inject bool, addImport bool, client string) []analysis.SuggestedFix {
urlArg := nodeText(pass, call.Args[0])
ctypeArg := nodeText(pass, call.Args[1])
bodyArg := nodeText(pass, call.Args[2])
block, idx := enclosingStmt(file, call)
if block == nil {
return nil
}
stmt := block.List[idx]
var lhs string
assign, isAssign := stmt.(*ast.AssignStmt)
if isAssign && len(assign.Lhs) == 2 {
lhs = nodeText(pass, assign.Lhs[0])
}
if lhs == "" {
lhs = "_"
}
tok := ":="
if isAssign {
tok = assign.Tok.String()
}
replacement := fmt.Sprintf(
"req, err %s http.NewRequestWithContext(%s, \"POST\", %s, %s)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", %s)\n\t%s, err %s %s.Do(req)",
tok, ctxExpr, urlArg, bodyArg,
ctypeArg,
lhs, tok, client,
)
var edits []analysis.TextEdit
edits = append(edits, analysis.TextEdit{
Pos: stmt.Pos(),
End: stmt.End(),
NewText: []byte(replacement),
})
if inject {
edits = append(edits, contextInjectionEdit(enc, pass))
}
if addImport {
edits = append(edits, contextImportEdit(file)...)
}
return []analysis.SuggestedFix{{
Message: fmt.Sprintf("use http.NewRequestWithContext with %s.Do", client),
TextEdits: edits,
}}
}
// fixHTTPPostForm rewrites http.PostForm(url, data) and client.PostForm().
func fixHTTPPostForm(pass *analysis.Pass, file *ast.File, call *ast.CallExpr, ctxExpr string, enc *ast.FuncDecl, inject bool, addImport bool, client string) []analysis.SuggestedFix {
urlArg := nodeText(pass, call.Args[0])
dataArg := nodeText(pass, call.Args[1])
block, idx := enclosingStmt(file, call)
if block == nil {
return nil
}
stmt := block.List[idx]
var lhs string
assign, isAssign := stmt.(*ast.AssignStmt)
if isAssign && len(assign.Lhs) == 2 {
lhs = nodeText(pass, assign.Lhs[0])
}
if lhs == "" {
lhs = "_"
}
tok := ":="
if isAssign {
tok = assign.Tok.String()
}
replacement := fmt.Sprintf(
"req, err %s http.NewRequestWithContext(%s, \"POST\", %s, strings.NewReader(%s.Encode()))\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/x-www-form-urlencoded\")\n\t%s, err %s %s.Do(req)",
tok, ctxExpr, urlArg, dataArg,
lhs, tok, client,
)
var edits []analysis.TextEdit
edits = append(edits, analysis.TextEdit{
Pos: stmt.Pos(),
End: stmt.End(),
NewText: []byte(replacement),
})
if inject {
edits = append(edits, contextInjectionEdit(enc, pass))
}
if addImport {
edits = append(edits, contextImportEdit(file)...)
}
return []analysis.SuggestedFix{{
Message: fmt.Sprintf("use http.NewRequestWithContext with %s.Do", client),
TextEdits: edits,
}}
}
// nodeText returns the source text of an AST node.
func nodeText(pass *analysis.Pass, node ast.Node) string {
var buf bytes.Buffer
if err := printer.Fprint(&buf, pass.Fset, node); err != nil {
return ""
}
return buf.String()
}
// contextInjectionEdit creates a TextEdit that inserts a context statement at the
// beginning of a function body.
func contextInjectionEdit(fd *ast.FuncDecl, pass *analysis.Pass) analysis.TextEdit {
text := contextInjectionText(fd, pass)
insertPos := fd.Body.Lbrace + 1
return analysis.TextEdit{
Pos: insertPos,
End: insertPos,
NewText: []byte("\n\t" + text),
}
}
// hasContextImport checks if the file already imports "context".
func hasContextImport(file *ast.File) bool {
for _, imp := range file.Imports {
if imp.Path.Value == `"context"` {
return true
}
}
return false
}
// contextImportEdit creates TextEdits to add "context" to the import block.
func contextImportEdit(file *ast.File) []analysis.TextEdit {
// Find the import declaration.
for _, decl := range file.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.IMPORT {
continue
}
if gd.Lparen == 0 {
// Single import without parens: import "net/http"
// Insert before it.
return []analysis.TextEdit{{
Pos: gd.Pos(),
End: gd.Pos(),
NewText: []byte("import \"context\"\n"),
}}
}
// Grouped import: insert "context" right after the opening paren.
return []analysis.TextEdit{{
Pos: gd.Lparen + 1,
End: gd.Lparen + 1,
NewText: []byte("\n\t\"context\""),
}}
}
// No import block at all — add one after the package declaration.
return []analysis.TextEdit{{
Pos: file.Name.End(),
End: file.Name.End(),
NewText: []byte("\n\nimport \"context\"\n"),
}}
}