Skip to content

Commit f4014dd

Browse files
authored
feat: implement -context-only=scope (#33)
1 parent c397610 commit f4014dd

File tree

7 files changed

+190
-117
lines changed

7 files changed

+190
-117
lines changed

README.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,10 @@ For them to work properly, you need to pass a context to all logger calls.
8989
The `context-only` option causes `sloglint` to report the use of methods without a context:
9090

9191
```go
92-
slog.Info("a user has logged in") // sloglint: methods without a context should not be used
92+
slog.Info("a user has logged in") // sloglint: InfoContext should be used instead
9393
```
9494

95-
This report can be fixed by using the equivalent method with the `Context` suffix:
96-
97-
```go
98-
slog.InfoContext(ctx, "a user has logged in")
99-
```
95+
Possible values are `all` (report all contextless calls) and `scope` (report only if a context exists in the scope of the outermost function).
10096

10197
### Static messages
10298

sloglint.go

Lines changed: 120 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type Options struct {
2424
KVOnly bool // Enforce using key-value pairs only (overrides NoMixedArgs, incompatible with AttrOnly).
2525
AttrOnly bool // Enforce using attributes only (overrides NoMixedArgs, incompatible with KVOnly).
2626
NoGlobal string // Enforce not using global loggers ("all" or "default").
27-
ContextOnly bool // Enforce using methods that accept a context.
27+
ContextOnly string // Enforce using methods that accept a context ("all" or "scope").
2828
StaticMsg bool // Enforce using static log messages.
2929
NoRawKeys bool // Enforce using constants instead of raw keys.
3030
KeyNamingCase string // Enforce a single key naming convention ("snake", "kebab", "camel", or "pascal").
@@ -36,6 +36,7 @@ func New(opts *Options) *analysis.Analyzer {
3636
if opts == nil {
3737
opts = &Options{NoMixedArgs: true}
3838
}
39+
3940
return &analysis.Analyzer{
4041
Name: "sloglint",
4142
Doc: "ensure consistent code style when using log/slog",
@@ -52,6 +53,12 @@ func New(opts *Options) *analysis.Analyzer {
5253
return nil, fmt.Errorf("sloglint: Options.NoGlobal=%s: %w", opts.NoGlobal, errInvalidValue)
5354
}
5455

56+
switch opts.ContextOnly {
57+
case "", "all", "scope":
58+
default:
59+
return nil, fmt.Errorf("sloglint: Options.ContextOnly=%s: %w", opts.ContextOnly, errInvalidValue)
60+
}
61+
5562
switch opts.KeyNamingCase {
5663
case "", snakeCase, kebabCase, camelCase, pascalCase:
5764
default:
@@ -91,7 +98,7 @@ func flags(opts *Options) flag.FlagSet {
9198
boolVar(&opts.KVOnly, "kv-only", "enforce using key-value pairs only (overrides -no-mixed-args, incompatible with -attr-only)")
9299
boolVar(&opts.AttrOnly, "attr-only", "enforce using attributes only (overrides -no-mixed-args, incompatible with -kv-only)")
93100
strVar(&opts.NoGlobal, "no-global", "enforce not using global loggers (all|default)")
94-
boolVar(&opts.ContextOnly, "context-only", "enforce using methods that accept a context")
101+
strVar(&opts.ContextOnly, "context-only", "enforce using methods that accept a context (all|scope)")
95102
boolVar(&opts.StaticMsg, "static-msg", "enforce using static log messages")
96103
boolVar(&opts.NoRawKeys, "no-raw-keys", "enforce using constants instead of raw keys")
97104
strVar(&opts.KeyNamingCase, "key-naming-case", "enforce a single key naming convention (snake|kebab|camel|pascal)")
@@ -142,96 +149,116 @@ const (
142149
)
143150

144151
func run(pass *analysis.Pass, opts *Options) {
145-
visit := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
152+
visitor := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
146153
filter := []ast.Node{(*ast.CallExpr)(nil)}
147154

148-
visit.Preorder(filter, func(node ast.Node) {
149-
call := node.(*ast.CallExpr)
155+
// WithStack is ~2x slower than Preorder, use it only when stack is needed.
156+
if opts.ContextOnly == "scope" {
157+
visitor.WithStack(filter, func(node ast.Node, _ bool, stack []ast.Node) bool {
158+
visit(pass, opts, node, stack)
159+
return false
160+
})
161+
return
162+
}
150163

151-
fn := typeutil.StaticCallee(pass.TypesInfo, call)
152-
if fn == nil {
153-
return
154-
}
164+
visitor.Preorder(filter, func(node ast.Node) {
165+
visit(pass, opts, node, nil)
166+
})
167+
}
155168

156-
name := fn.FullName()
157-
argsPos, ok := slogFuncs[name]
158-
if !ok {
159-
return
160-
}
169+
// NOTE: stack is nil if Preorder is used.
170+
func visit(pass *analysis.Pass, opts *Options, node ast.Node, stack []ast.Node) {
171+
call := node.(*ast.CallExpr)
161172

162-
switch opts.NoGlobal {
163-
case "all":
164-
if strings.HasPrefix(name, "log/slog.") || globalLoggerUsed(pass.TypesInfo, call.Fun) {
165-
pass.Reportf(call.Pos(), "global logger should not be used")
166-
}
167-
case "default":
168-
if strings.HasPrefix(name, "log/slog.") {
169-
pass.Reportf(call.Pos(), "default logger should not be used")
170-
}
171-
}
173+
fn := typeutil.StaticCallee(pass.TypesInfo, call)
174+
if fn == nil {
175+
return
176+
}
172177

173-
if opts.ContextOnly {
174-
typ := pass.TypesInfo.TypeOf(call.Args[0])
175-
if typ != nil && typ.String() != "context.Context" {
176-
pass.Reportf(call.Pos(), "methods without a context should not be used")
177-
}
178-
}
178+
name := fn.FullName()
179+
argsPos, ok := slogFuncs[name]
180+
if !ok {
181+
return
182+
}
179183

180-
if opts.StaticMsg && !staticMsg(call.Args[argsPos-1]) {
181-
pass.Reportf(call.Pos(), "message should be a string literal or a constant")
184+
switch opts.NoGlobal {
185+
case "all":
186+
if strings.HasPrefix(name, "log/slog.") || globalLoggerUsed(pass.TypesInfo, call.Fun) {
187+
pass.Reportf(call.Pos(), "global logger should not be used")
182188
}
189+
case "default":
190+
if strings.HasPrefix(name, "log/slog.") {
191+
pass.Reportf(call.Pos(), "default logger should not be used")
192+
}
193+
}
183194

184-
// NOTE: we assume that the arguments have already been validated by govet.
185-
args := call.Args[argsPos:]
186-
if len(args) == 0 {
187-
return
195+
switch opts.ContextOnly {
196+
case "all":
197+
typ := pass.TypesInfo.TypeOf(call.Args[0])
198+
if typ != nil && typ.String() != "context.Context" {
199+
pass.Reportf(call.Pos(), "%sContext should be used instead", fn.Name())
200+
}
201+
case "scope":
202+
typ := pass.TypesInfo.TypeOf(call.Args[0])
203+
if typ != nil && typ.String() != "context.Context" && hasContextInScope(pass.TypesInfo, stack) {
204+
pass.Reportf(call.Pos(), "%sContext should be used instead", fn.Name())
188205
}
206+
}
189207

190-
var keys []ast.Expr
191-
var attrs []ast.Expr
208+
if opts.StaticMsg && !staticMsg(call.Args[argsPos-1]) {
209+
pass.Reportf(call.Pos(), "message should be a string literal or a constant")
210+
}
192211

193-
for i := 0; i < len(args); i++ {
194-
typ := pass.TypesInfo.TypeOf(args[i])
195-
if typ == nil {
196-
continue
197-
}
198-
switch typ.String() {
199-
case "string":
200-
keys = append(keys, args[i])
201-
i++ // skip the value.
202-
case "log/slog.Attr":
203-
attrs = append(attrs, args[i])
204-
}
205-
}
212+
// NOTE: we assume that the arguments have already been validated by govet.
213+
args := call.Args[argsPos:]
214+
if len(args) == 0 {
215+
return
216+
}
206217

207-
switch {
208-
case opts.KVOnly && len(attrs) > 0:
209-
pass.Reportf(call.Pos(), "attributes should not be used")
210-
case opts.AttrOnly && len(attrs) < len(args):
211-
pass.Reportf(call.Pos(), "key-value pairs should not be used")
212-
case opts.NoMixedArgs && 0 < len(attrs) && len(attrs) < len(args):
213-
pass.Reportf(call.Pos(), "key-value pairs and attributes should not be mixed")
214-
}
218+
var keys []ast.Expr
219+
var attrs []ast.Expr
215220

216-
if opts.NoRawKeys && rawKeysUsed(pass.TypesInfo, keys, attrs) {
217-
pass.Reportf(call.Pos(), "raw keys should not be used")
221+
for i := 0; i < len(args); i++ {
222+
typ := pass.TypesInfo.TypeOf(args[i])
223+
if typ == nil {
224+
continue
218225
}
219-
220-
if opts.ArgsOnSepLines && argsOnSameLine(pass.Fset, call, keys, attrs) {
221-
pass.Reportf(call.Pos(), "arguments should be put on separate lines")
226+
switch typ.String() {
227+
case "string":
228+
keys = append(keys, args[i])
229+
i++ // skip the value.
230+
case "log/slog.Attr":
231+
attrs = append(attrs, args[i])
222232
}
233+
}
223234

224-
switch {
225-
case opts.KeyNamingCase == snakeCase && badKeyNames(pass.TypesInfo, strcase.ToSnake, keys, attrs):
226-
pass.Reportf(call.Pos(), "keys should be written in snake_case")
227-
case opts.KeyNamingCase == kebabCase && badKeyNames(pass.TypesInfo, strcase.ToKebab, keys, attrs):
228-
pass.Reportf(call.Pos(), "keys should be written in kebab-case")
229-
case opts.KeyNamingCase == camelCase && badKeyNames(pass.TypesInfo, strcase.ToCamel, keys, attrs):
230-
pass.Reportf(call.Pos(), "keys should be written in camelCase")
231-
case opts.KeyNamingCase == pascalCase && badKeyNames(pass.TypesInfo, strcase.ToPascal, keys, attrs):
232-
pass.Reportf(call.Pos(), "keys should be written in PascalCase")
233-
}
234-
})
235+
switch {
236+
case opts.KVOnly && len(attrs) > 0:
237+
pass.Reportf(call.Pos(), "attributes should not be used")
238+
case opts.AttrOnly && len(attrs) < len(args):
239+
pass.Reportf(call.Pos(), "key-value pairs should not be used")
240+
case opts.NoMixedArgs && 0 < len(attrs) && len(attrs) < len(args):
241+
pass.Reportf(call.Pos(), "key-value pairs and attributes should not be mixed")
242+
}
243+
244+
if opts.NoRawKeys && rawKeysUsed(pass.TypesInfo, keys, attrs) {
245+
pass.Reportf(call.Pos(), "raw keys should not be used")
246+
}
247+
248+
if opts.ArgsOnSepLines && argsOnSameLine(pass.Fset, call, keys, attrs) {
249+
pass.Reportf(call.Pos(), "arguments should be put on separate lines")
250+
}
251+
252+
switch {
253+
case opts.KeyNamingCase == snakeCase && badKeyNames(pass.TypesInfo, strcase.ToSnake, keys, attrs):
254+
pass.Reportf(call.Pos(), "keys should be written in snake_case")
255+
case opts.KeyNamingCase == kebabCase && badKeyNames(pass.TypesInfo, strcase.ToKebab, keys, attrs):
256+
pass.Reportf(call.Pos(), "keys should be written in kebab-case")
257+
case opts.KeyNamingCase == camelCase && badKeyNames(pass.TypesInfo, strcase.ToCamel, keys, attrs):
258+
pass.Reportf(call.Pos(), "keys should be written in camelCase")
259+
case opts.KeyNamingCase == pascalCase && badKeyNames(pass.TypesInfo, strcase.ToPascal, keys, attrs):
260+
pass.Reportf(call.Pos(), "keys should be written in PascalCase")
261+
}
235262
}
236263

237264
func globalLoggerUsed(info *types.Info, expr ast.Expr) bool {
@@ -247,6 +274,24 @@ func globalLoggerUsed(info *types.Info, expr ast.Expr) bool {
247274
return obj.Parent() == obj.Pkg().Scope()
248275
}
249276

277+
func hasContextInScope(info *types.Info, stack []ast.Node) bool {
278+
for i := len(stack) - 1; i >= 0; i-- {
279+
decl, ok := stack[i].(*ast.FuncDecl)
280+
if !ok {
281+
continue
282+
}
283+
params := decl.Type.Params
284+
if len(params.List) == 0 || len(params.List[0].Names) == 0 {
285+
continue
286+
}
287+
typ := info.TypeOf(params.List[0].Names[0])
288+
if typ != nil && typ.String() == "context.Context" {
289+
return true
290+
}
291+
}
292+
return false
293+
}
294+
250295
func staticMsg(expr ast.Expr) bool {
251296
switch msg := expr.(type) {
252297
case *ast.BasicLit: // e.g. slog.Info("msg")

sloglint_internal_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ func TestOptions(t *testing.T) {
1818
opts: Options{NoGlobal: "-"},
1919
err: errInvalidValue,
2020
},
21+
"ContextOnly: invalid value": {
22+
opts: Options{ContextOnly: "-"},
23+
err: errInvalidValue,
24+
},
2125
"KeyNamingCase: invalid value": {
2226
opts: Options{KeyNamingCase: "-"},
2327
err: errInvalidValue,

sloglint_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,14 @@ func TestAnalyzer(t *testing.T) {
3535
analysistest.Run(t, testdata, analyzer, "no_global_default")
3636
})
3737

38-
t.Run("context only", func(t *testing.T) {
39-
analyzer := sloglint.New(&sloglint.Options{ContextOnly: true})
40-
analysistest.Run(t, testdata, analyzer, "context_only")
38+
t.Run("context only (all)", func(t *testing.T) {
39+
analyzer := sloglint.New(&sloglint.Options{ContextOnly: "all"})
40+
analysistest.Run(t, testdata, analyzer, "context_only_all")
41+
})
42+
43+
t.Run("context only (scope)", func(t *testing.T) {
44+
analyzer := sloglint.New(&sloglint.Options{ContextOnly: "scope"})
45+
analysistest.Run(t, testdata, analyzer, "context_only_scope")
4146
})
4247

4348
t.Run("static message", func(t *testing.T) {

testdata/src/context_only/context_only.go

Lines changed: 0 additions & 33 deletions
This file was deleted.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package context_only_all
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
)
7+
8+
func tests(ctx context.Context) {
9+
slog.Log(ctx, slog.LevelInfo, "msg")
10+
slog.DebugContext(ctx, "msg")
11+
slog.InfoContext(ctx, "msg")
12+
slog.WarnContext(ctx, "msg")
13+
slog.ErrorContext(ctx, "msg")
14+
15+
slog.Debug("msg") // want `DebugContext should be used instead`
16+
slog.Info("msg") // want `InfoContext should be used instead`
17+
slog.Warn("msg") // want `WarnContext should be used instead`
18+
slog.Error("msg") // want `ErrorContext should be used instead`
19+
20+
logger := slog.New(nil)
21+
logger.Log(ctx, slog.LevelInfo, "msg")
22+
logger.DebugContext(ctx, "msg")
23+
logger.InfoContext(ctx, "msg")
24+
logger.WarnContext(ctx, "msg")
25+
logger.ErrorContext(ctx, "msg")
26+
27+
logger.Debug("msg") // want `DebugContext should be used instead`
28+
logger.Info("msg") // want `InfoContext should be used instead`
29+
logger.Warn("msg") // want `WarnContext should be used instead`
30+
logger.Error("msg") // want `ErrorContext should be used instead`
31+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package context_only_scope
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
)
7+
8+
func tests(ctx context.Context) {
9+
slog.Info("msg") // want `InfoContext should be used instead`
10+
slog.InfoContext(ctx, "msg")
11+
12+
if true {
13+
slog.Info("msg") // want `InfoContext should be used instead`
14+
slog.InfoContext(ctx, "msg")
15+
}
16+
17+
_ = func() {
18+
slog.Info("msg") // want `InfoContext should be used instead`
19+
slog.InfoContext(ctx, "msg")
20+
}
21+
}
22+
23+
func noctx() {
24+
slog.Info("msg")
25+
}

0 commit comments

Comments
 (0)