blob: 1146d7be457f891d50f21e052490e11b958171df [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package directive defines an Analyzer that checks known Go toolchain directives.
package directive
import (
"go/ast"
"go/parser"
"go/token"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/internal/analysisutil"
)
const Doc = `check Go toolchain directives such as //go:debug
This analyzer checks for problems with known Go toolchain directives
in all Go source files in a package directory, even those excluded by
//go:build constraints, and all non-Go source files too.
For //go:debug (see https://go.dev/doc/godebug), the analyzer checks
that the directives are placed only in Go source files, only above the
package comment, and only in package main or *_test.go files.
Support for other known directives may be added in the future.
This analyzer does not check //go:build, which is handled by the
buildtag analyzer.
`
var Analyzer = &analysis.Analyzer{
Name: "directive",
Doc: Doc,
URL: "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/directive",
Run: runDirective,
}
func runDirective(pass *analysis.Pass) (interface{}, error) {
for _, f := range pass.Files {
checkGoFile(pass, f)
}
for _, name := range pass.OtherFiles {
if err := checkOtherFile(pass, name); err != nil {
return nil, err
}
}
for _, name := range pass.IgnoredFiles {
if strings.HasSuffix(name, ".go") {
f, err := parser.ParseFile(pass.Fset, name, nil, parser.ParseComments)
if err != nil {
// Not valid Go source code - not our job to diagnose, so ignore.
continue
}
checkGoFile(pass, f)
} else {
if err := checkOtherFile(pass, name); err != nil {
return nil, err
}
}
}
return nil, nil
}
func checkGoFile(pass *analysis.Pass, f *ast.File) {
check := newChecker(pass, pass.Fset.File(f.Package).Name(), f)
for _, group := range f.Comments {
// A +build comment is ignored after or adjoining the package declaration.
if group.End()+1 >= f.Package {
check.inHeader = false
}
// A //go:build comment is ignored after the package declaration
// (but adjoining it is OK, in contrast to +build comments).
if group.Pos() >= f.Package {
check.inHeader = false
}
// Check each line of a //-comment.
for _, c := range group.List {
check.comment(c.Slash, c.Text)
}
}
}
func checkOtherFile(pass *analysis.Pass, filename string) error {
// We cannot use the Go parser, since is not a Go source file.
// Read the raw bytes instead.
content, tf, err := analysisutil.ReadFile(pass.Fset, filename)
if err != nil {
return err
}
check := newChecker(pass, filename, nil)
check.nonGoFile(token.Pos(tf.Base()), string(content))
return nil
}
type checker struct {
pass *analysis.Pass
filename string
file *ast.File // nil for non-Go file
inHeader bool // in file header (before package declaration)
inStar bool // currently in a /* */ comment
}
func newChecker(pass *analysis.Pass, filename string, file *ast.File) *checker {
return &checker{
pass: pass,
filename: filename,
file: file,
inHeader: true,
}
}
func (check *checker) nonGoFile(pos token.Pos, fullText string) {
// Process each line.
text := fullText
inStar := false
for text != "" {
offset := len(fullText) - len(text)
var line string
line, text, _ = stringsCut(text, "\n")
if !inStar && strings.HasPrefix(line, "//") {
check.comment(pos+token.Pos(offset), line)
continue
}
// Skip over, cut out any /* */ comments,
// to avoid being confused by a commented-out // comment.
for {
line = strings.TrimSpace(line)
if inStar {
var ok bool
_, line, ok = stringsCut(line, "*/")
if !ok {
break
}
inStar = false
continue
}
line, inStar = stringsCutPrefix(line, "/*")
if !inStar {
break
}
}
if line != "" {
// Found non-comment non-blank line.
// Ends space for valid //go:build comments,
// but also ends the fraction of the file we can
// reliably parse. From this point on we might
// incorrectly flag "comments" inside multiline
// string constants or anything else (this might
// not even be a Go program). So stop.
break
}
}
}
func (check *checker) comment(pos token.Pos, line string) {
if !strings.HasPrefix(line, "//go:") {
return
}
// testing hack: stop at // ERROR
if i := strings.Index(line, " // ERROR "); i >= 0 {
line = line[:i]
}
verb := line
if i := strings.IndexFunc(verb, unicode.IsSpace); i >= 0 {
verb = verb[:i]
if line[i] != ' ' && line[i] != '\t' && line[i] != '\n' {
r, _ := utf8.DecodeRuneInString(line[i:])
check.pass.Reportf(pos, "invalid space %#q in %s directive", r, verb)
}
}
switch verb {
default:
// TODO: Use the go language version for the file.
// If that version is not newer than us, then we can
// report unknown directives.
case "//go:build":
// Ignore. The buildtag analyzer reports misplaced comments.
case "//go:debug":
if check.file == nil {
check.pass.Reportf(pos, "//go:debug directive only valid in Go source files")
} else if check.file.Name.Name != "main" && !strings.HasSuffix(check.filename, "_test.go") {
check.pass.Reportf(pos, "//go:debug directive only valid in package main or test")
} else if !check.inHeader {
check.pass.Reportf(pos, "//go:debug directive only valid before package declaration")
}
}
}
// Go 1.18 strings.Cut.
func stringsCut(s, sep string) (before, after string, found bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i+len(sep):], true
}
return s, "", false
}
// Go 1.20 strings.CutPrefix.
func stringsCutPrefix(s, prefix string) (after string, found bool) {
if !strings.HasPrefix(s, prefix) {
return s, false
}
return s[len(prefix):], true
}