What if I told you that you can create linters for Go in this declarative way?
func alwaysTrue(m dsl.Matcher) {
m.Match(`strings.Count($_, $_) >= 0`).Report(`always evaluates to true`)
m.Match(`bytes.Count($_, $_) >= 0`).Report(`always evaluates to true`)
}
func replaceAll() {
m.Match(`strings.Replace($s, $d, $w, $n)`).
Where(m["n"].Value.Int() <= 0).
Suggest(`strings.ReplaceAll($s, $d, $w)`)
}
A year ago I already talked about the ruleguard utility . Today I would like to share what new has appeared during this time.
Main innovations:
- Support for setting rulesets via Go bundles
- Programmable filters (compiled to bytecode)
- Added debug filter mode
- There is a good teaching material: ruleguard by example
- The project has real users and external rulesets
- Online sandbox allowing you to try ruleguard right in your browser
Small introduction
ruleguard
Is a platform for running dynamic diagnostics. Something like an interpreter for scripts specializing in static analysis.
You describe your set of rules on DSL (or use ready-made sets) and run them through the utility ruleguard
.
These rules are interpreted at runtime, so there is no need to rebuild the analyzer every time you add new diagnostics. This is especially important if we are considering integration with golangci-lint . It would be very awkward to recompile golangci-lint
using your own ruleset if you want to.
, , . .
- , .
Since I sometimes use project-specific terminology, here are a few transcripts.
RU | RU | Value |
---|---|---|
Rule | The rule | AST template combined with filters and associated actions (most often creating an alert). |
Rules group | Rule group | . "", , . |
Rule set | . | |
Rule bundle | () | , Go , . |
Module | Go; โ , . |
, , .
:
: , .
, , .
, Damian Gryski. โ .
: , . . , .
:
-
go get
- Go :
, ruleguard , โ Go ( autocomplete ).
, , :
package gorules
import (
"github.com/quasilyte/go-ruleguard/dsl"
damianrules "github.com/dgryski/semgrep-go"
)
func init() {
// , .
dsl.ImportRules("", damianrules.Bundle)
}
func emptyStringTest(m dsl.Matcher) {
m.Match(`len($s) == 0`).
Where(m["s"].Type.Is("string")).
Report(`maybe use $s == "" instead?`)
m.Match(`len($s) != 0`).
Where(m["s"].Type.Is("string")).
Report(`maybe use $s != "" instead?`)
}
, -disable
.
: DSL
dsl.Matcher
, ruleguard
.
, , . Filter()
, Go - . .
package gorules
import (
"github.com/quasilyte/go-ruleguard/dsl"
"github.com/quasilyte/go-ruleguard/dsl/types"
)
// implementsStringer .
// , T *T `fmt.Stringer`.
func implementsStringer(ctx *dsl.VarFilterContext) bool {
stringer := ctx.GetInterface(`fmt.Stringer`)
return types.Implements(ctx.Type, stringer) ||
types.Implements(types.NewPointer(ctx.Type), stringer)
}
func sprintStringer(m dsl.Matcher) {
// m["x"].Type.Implements(`fmt.Stringer`),
// : $x
// fmt.Stringer *T, T .
// : .
m.Match(`fmt.Sprint($x)`).
Where(m["x"].Filter(implementsStringer) && m["x"].Addressable).
Report(`can use $x.String() directly`)
}
:
package main
import "fmt"
func main() {
fooPtr := &Foo{}
foo := Foo{}
println(fmt.Sprint(foo))
println(fmt.Sprint(fooPtr))
println(fmt.Sprint(0)) // fmt.Stringer
println(fmt.Sprint(&foo)) // addressable
}
type Foo struct{}
func (*Foo) String() string { return "Foo" }
:
$ ruleguard -rules rules.go main.go main.go:9:10: can use foo.String() directly main.go:10:10: can use fooPtr.String() directly
-debug-filter
, :
- , , yaegi.
:
Where()
, , .
debug-group
, .
, :
func offBy1(m dsl.Matcher) {
m.Match(`$s[len($s)]`).
Where(m["s"].Type.Is(`[]$elem`) && m["s"].Pure).
Report(`index expr always panics; maybe you wanted $s[len($s)-1]?`)
}
:
func lastByte(s string) byte {
return s[len(s)]
}
func f() byte {
return randString()[len(randString())]
}
โฆ .
$ ruleguard -rules rules.go -debug-group offBy1 test.go
test.go:6: [rules.go:6] rejected by m["s"].Type.Is(`[]$elem`)
$s string: s
test.go:10: [rules.go:6] rejected by m["s"].Pure
$s []byte: randBytes()
Where()
, . Go AST ( $s
), .
[]$elem
, โ . - ( pure
).
, , string
:
- Where(m["s"].Type.Is(`[]$elem`) && m["s"].Pure).
+ Where((m["s"].Type.Is(`[]$elem`) || m["s"].Type.Is(`string`)) && m["s"].Pure).
:
test.go:6:9: offBy1: index expr always panics; maybe you wanted s[len(s)-1]?
: DSL
, , .
Go by Example. , . , .
ruleguard?
! ruleguard , Go .
, golangci-lint .
, golangci-lint
, ruleguard
{linux/amd64, linux/arm64, darwin/amd64, windows/amd64}.
. : github.com/quasilyte/go-ruleguard/rules
github.com/dgryski/semgrep-go
. .
, github.com/quasilyte/go-ruleguard/rules
, :
-
ruleguard
( ) -
go get -v github.com/quasilyte/go-ruleguard/dsl
-
go get -v github.com/quasilyte/go-ruleguard/rules
-
rules.go
, -
ruleguard
-rules rules.go
$ ruleguard -rules rules.go ./...
:
- Go
-
Bundle
, .
Go , . , Go .
package gorules
import "github.com/quasilyte/go-ruleguard/dsl"
// Bundle .
var Bundle = dsl.Bundle{}
func boolComparison(m dsl.Matcher) {
m.Match(`$x == true`,
`$x != true`,
`$x == false`,
`$x != false`).
Report(`omit bool literal in expression`)
}
testdata
, Go , .
:
// file rules_test.go
package gorules_test
import (
"testing"
"github.com/quasilyte/go-ruleguard/analyzer"
"golang.org/x/tools/go/analysis/analysistest"
)
func TestRules(t *testing.T) {
// , "rules.go"
// , : "style.go,perf.go".
if err := analyzer.Analyzer.Flags.Set("rules", "rules.go"); err != nil {
t.Fatalf("set rules flag: %v", err)
}
analysistest.Run(t, analysistest.TestData(), analyzer.Analyzer, "./...")
}
:
mybundle/ go.mod -- , "go mod init" rules.go -- ( ) rules_test.go -- testdata/ -- , target1.go target2.go ...
Test files will contain magical comments:
// file testdata/target1.go
package test
func f(cond bool) {
if cond == true { // want `omit bool literal in expression`
}
}
After that want
comes a regular expression that should match the issued warning. I recommend using it \Q
at the beginning so that you don't have to screen anything.
The test is run normally go test
from the bundle directory.