Skip to content

Commit 1a45042

Browse files
lzapccoVeilletmzane
authored
feat: implement -msg-style (#76)
Co-authored-by: ccoVeille <[email protected]> Co-authored-by: Nik <[email protected]>
1 parent a72e66b commit 1a45042

File tree

7 files changed

+149
-10
lines changed

7 files changed

+149
-10
lines changed

README.md

+21-2
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ With `sloglint` you can enforce various rules for `log/slog` based on your prefe
1919
* Enforce using either key-value pairs only or attributes only (optional)
2020
* Enforce not using global loggers (optional)
2121
* Enforce using methods that accept a context (optional)
22-
* Enforce using static log messages (optional)
22+
* Enforce using static messages (optional)
23+
* Enforce message style (optional)
2324
* Enforce using constants instead of raw keys (optional)
24-
* Enforce a single key naming convention (optional)
25+
* Enforce key naming convention (optional)
2526
* Enforce not using specific keys (optional)
2627
* Enforce putting arguments on separate lines (optional)
2728

@@ -110,6 +111,24 @@ The report can be fixed by moving dynamic values to arguments:
110111
slog.Info("a user has logged in", "user_id", 42)
111112
```
112113

114+
### Message style
115+
116+
The `msg-style` option causes `sloglint` to check log messages for a particular style.
117+
118+
Possible values are `lowercased` (report messages that begin with an uppercase letter)...
119+
120+
```go
121+
slog.Info("Msg") // sloglint: message should be lowercased
122+
```
123+
124+
...and `capitalized` (report messages that begin with a lowercase letter):
125+
126+
```go
127+
slog.Info("msg") // sloglint: message should be capitalized
128+
```
129+
130+
Special cases such as acronyms (e.g. `HTTP`, `U.S.`) are ignored.
131+
113132
### No raw keys
114133

115134
To prevent typos, you may want to forbid the use of raw keys altogether.

sloglint.go

+56-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"slices"
1212
"strconv"
1313
"strings"
14+
"unicode"
1415

1516
"github.com/ettle/strcase"
1617
"golang.org/x/tools/go/analysis"
@@ -26,9 +27,10 @@ type Options struct {
2627
AttrOnly bool // Enforce using attributes only (overrides NoMixedArgs, incompatible with KVOnly).
2728
NoGlobal string // Enforce not using global loggers ("all" or "default").
2829
ContextOnly string // Enforce using methods that accept a context ("all" or "scope").
29-
StaticMsg bool // Enforce using static log messages.
30+
StaticMsg bool // Enforce using static messages.
31+
MsgStyle string // Enforce message style ("lowercased" or "capitalized").
3032
NoRawKeys bool // Enforce using constants instead of raw keys.
31-
KeyNamingCase string // Enforce a single key naming convention ("snake", "kebab", "camel", or "pascal").
33+
KeyNamingCase string // Enforce key naming convention ("snake", "kebab", "camel", or "pascal").
3234
ForbiddenKeys []string // Enforce not using specific keys.
3335
ArgsOnSepLines bool // Enforce putting arguments on separate lines.
3436
}
@@ -61,6 +63,12 @@ func New(opts *Options) *analysis.Analyzer {
6163
return nil, fmt.Errorf("sloglint: Options.ContextOnly=%s: %w", opts.ContextOnly, errInvalidValue)
6264
}
6365

66+
switch opts.MsgStyle {
67+
case "", styleLowercased, styleCapitalized:
68+
default:
69+
return nil, fmt.Errorf("sloglint: Options.MsgStyle=%s: %w", opts.MsgStyle, errInvalidValue)
70+
}
71+
6472
switch opts.KeyNamingCase {
6573
case "", snakeCase, kebabCase, camelCase, pascalCase:
6674
default:
@@ -101,9 +109,10 @@ func flags(opts *Options) flag.FlagSet {
101109
boolVar(&opts.AttrOnly, "attr-only", "enforce using attributes only (overrides -no-mixed-args, incompatible with -kv-only)")
102110
strVar(&opts.NoGlobal, "no-global", "enforce not using global loggers (all|default)")
103111
strVar(&opts.ContextOnly, "context-only", "enforce using methods that accept a context (all|scope)")
104-
boolVar(&opts.StaticMsg, "static-msg", "enforce using static log messages")
112+
boolVar(&opts.StaticMsg, "static-msg", "enforce using static messages")
113+
strVar(&opts.MsgStyle, "msg-style", "enforce message style (lowercased|capitalized)")
105114
boolVar(&opts.NoRawKeys, "no-raw-keys", "enforce using constants instead of raw keys")
106-
strVar(&opts.KeyNamingCase, "key-naming-case", "enforce a single key naming convention (snake|kebab|camel|pascal)")
115+
strVar(&opts.KeyNamingCase, "key-naming-case", "enforce key naming convention (snake|kebab|camel|pascal)")
107116
boolVar(&opts.ArgsOnSepLines, "args-on-sep-lines", "enforce putting arguments on separate lines")
108117

109118
fset.Func("forbidden-keys", "enforce not using specific keys (comma-separated)", func(s string) error {
@@ -155,6 +164,13 @@ var attrFuncs = map[string]struct{}{
155164
"log/slog.Any": {},
156165
}
157166

167+
// message styles.
168+
const (
169+
styleLowercased = "lowercased"
170+
styleCapitalized = "capitalized"
171+
)
172+
173+
// key naming conventions.
158174
const (
159175
snakeCase = "snake"
160176
kebabCase = "kebab"
@@ -228,6 +244,15 @@ func visit(pass *analysis.Pass, opts *Options, node ast.Node, stack []ast.Node)
228244
pass.Reportf(call.Pos(), "message should be a string literal or a constant")
229245
}
230246

247+
if opts.MsgStyle != "" && msgPos >= 0 {
248+
if msg, ok := call.Args[msgPos].(*ast.BasicLit); ok && msg.Kind == token.STRING {
249+
msg.Value = msg.Value[1 : len(msg.Value)-1] // trim quotes/backticks.
250+
if ok := isValidMsgStyle(msg.Value, opts.MsgStyle); !ok {
251+
pass.Reportf(call.Pos(), "message should be %s", opts.MsgStyle)
252+
}
253+
}
254+
}
255+
231256
// NOTE: we assume that the arguments have already been validated by govet.
232257
args := call.Args[funcInfo.argsPos:]
233258
if len(args) == 0 {
@@ -356,6 +381,33 @@ func isStaticMsg(msg ast.Expr) bool {
356381
}
357382
}
358383

384+
func isValidMsgStyle(msg, style string) bool {
385+
runes := []rune(msg)
386+
if len(runes) < 2 {
387+
return true
388+
}
389+
390+
first, second := runes[0], runes[1]
391+
392+
switch style {
393+
case styleLowercased:
394+
if unicode.IsLower(first) {
395+
return true
396+
}
397+
if unicode.IsPunct(second) {
398+
return true // e.g. "U.S.A."
399+
}
400+
return unicode.IsUpper(second) // e.g. "HTTP"
401+
case styleCapitalized:
402+
if unicode.IsUpper(first) {
403+
return true
404+
}
405+
return unicode.IsUpper(second) // e.g. "iPhone"
406+
default:
407+
panic("unreachable")
408+
}
409+
}
410+
359411
func forEachKey(info *types.Info, keys, attrs []ast.Expr, fn func(key ast.Expr)) {
360412
for _, key := range keys {
361413
fn(key)

sloglint_internal_test.go

+4
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ func TestOptions(t *testing.T) {
2222
opts: Options{ContextOnly: "-"},
2323
err: errInvalidValue,
2424
},
25+
"MsgStyle: invalid value": {
26+
opts: Options{MsgStyle: "-"},
27+
err: errInvalidValue,
28+
},
2529
"KeyNamingCase: invalid value": {
2630
opts: Options{KeyNamingCase: "-"},
2731
err: errInvalidValue,

sloglint_test.go

+10
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,14 @@ func TestAnalyzer(t *testing.T) {
6969
analyzer := sloglint.New(&sloglint.Options{ForbiddenKeys: []string{"foo_bar"}})
7070
analysistest.Run(t, testdata, analyzer, "forbidden_keys")
7171
})
72+
73+
t.Run("message style (lowercased)", func(t *testing.T) {
74+
analyzer := sloglint.New(&sloglint.Options{MsgStyle: "lowercased"})
75+
analysistest.Run(t, testdata, analyzer, "msg_style_lowercased")
76+
})
77+
78+
t.Run("message style (capitalized)", func(t *testing.T) {
79+
analyzer := sloglint.New(&sloglint.Options{MsgStyle: "capitalized"})
80+
analysistest.Run(t, testdata, analyzer, "msg_style_capitalized")
81+
})
7282
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package msg_style_capitalized
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
)
7+
8+
func tests() {
9+
ctx := context.Background()
10+
11+
slog.Info("")
12+
slog.Info("Msg")
13+
slog.InfoContext(ctx, "Msg")
14+
slog.Log(ctx, slog.LevelInfo, "Msg")
15+
slog.With("key", "value").Info("Msg")
16+
17+
slog.Info("msg") // want `message should be capitalized`
18+
slog.InfoContext(ctx, "msg") // want `message should be capitalized`
19+
slog.Log(ctx, slog.LevelInfo, "msg") // want `message should be capitalized`
20+
slog.With("key", "value").Info("msg") // want `message should be capitalized`
21+
22+
// special cases:
23+
slog.Info("U.S. dollar")
24+
slog.Info("HTTP request")
25+
slog.Info("iPhone 18")
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package msg_style_lowercased
2+
3+
import (
4+
"context"
5+
"log/slog"
6+
)
7+
8+
func tests() {
9+
ctx := context.Background()
10+
11+
slog.Info("")
12+
slog.Info("msg")
13+
slog.InfoContext(ctx, "msg")
14+
slog.Log(ctx, slog.LevelInfo, "msg")
15+
slog.With("key", "value").Info("msg")
16+
17+
slog.Info("Msg") // want `message should be lowercased`
18+
slog.InfoContext(ctx, "Msg") // want `message should be lowercased`
19+
slog.Log(ctx, slog.LevelInfo, "Msg") // want `message should be lowercased`
20+
slog.With("key", "value").Info("Msg") // want `message should be lowercased`
21+
22+
// special cases:
23+
slog.Info("U.S. dollar")
24+
slog.Info("HTTP request")
25+
slog.Info("iPhone 18")
26+
}

testdata/src/static_msg/static_msg.go

+6-4
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,20 @@ func tests() {
1616
slog.Info("msg")
1717
slog.InfoContext(ctx, "msg")
1818
slog.Log(ctx, slog.LevelInfo, "msg")
19+
slog.With("key", "value").Info("msg")
1920

2021
slog.Info(constMsg)
2122
slog.InfoContext(ctx, constMsg)
2223
slog.Log(ctx, slog.LevelInfo, constMsg)
23-
slog.With("key", "value").Info("msg")
24+
slog.With("key", "value").Info(constMsg)
2425

2526
slog.Info(fmt.Sprintf("msg")) // want `message should be a string literal or a constant`
2627
slog.InfoContext(ctx, fmt.Sprintf("msg")) // want `message should be a string literal or a constant`
2728
slog.Log(ctx, slog.LevelInfo, fmt.Sprintf("msg")) // want `message should be a string literal or a constant`
2829
slog.With("key", "value").Info(fmt.Sprintf("msg")) // want `message should be a string literal or a constant`
2930

30-
slog.Info(varMsg) // want `message should be a string literal or a constant`
31-
slog.InfoContext(ctx, varMsg) // want `message should be a string literal or a constant`
32-
slog.Log(ctx, slog.LevelInfo, varMsg) // want `message should be a string literal or a constant`
31+
slog.Info(varMsg) // want `message should be a string literal or a constant`
32+
slog.InfoContext(ctx, varMsg) // want `message should be a string literal or a constant`
33+
slog.Log(ctx, slog.LevelInfo, varMsg) // want `message should be a string literal or a constant`
34+
slog.With("key", "value").Info(varMsg) // want `message should be a string literal or a constant`
3335
}

0 commit comments

Comments
 (0)