@@ -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.
158174const (
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+
359411func forEachKey (info * types.Info , keys , attrs []ast.Expr , fn func (key ast.Expr )) {
360412 for _ , key := range keys {
361413 fn (key )
0 commit comments