1// Copyright 2023 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Package directive defines an Analyzer that checks known Go toolchain directives.
6package directive
7
8import (
9	"go/ast"
10	"go/parser"
11	"go/token"
12	"strings"
13	"unicode"
14	"unicode/utf8"
15
16	"golang.org/x/tools/go/analysis"
17	"golang.org/x/tools/go/analysis/passes/internal/analysisutil"
18)
19
20const Doc = `check Go toolchain directives such as //go:debug
21
22This analyzer checks for problems with known Go toolchain directives
23in all Go source files in a package directory, even those excluded by
24//go:build constraints, and all non-Go source files too.
25
26For //go:debug (see https://go.dev/doc/godebug), the analyzer checks
27that the directives are placed only in Go source files, only above the
28package comment, and only in package main or *_test.go files.
29
30Support for other known directives may be added in the future.
31
32This analyzer does not check //go:build, which is handled by the
33buildtag analyzer.
34`
35
36var Analyzer = &analysis.Analyzer{
37	Name: "directive",
38	Doc:  Doc,
39	URL:  "https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/directive",
40	Run:  runDirective,
41}
42
43func runDirective(pass *analysis.Pass) (interface{}, error) {
44	for _, f := range pass.Files {
45		checkGoFile(pass, f)
46	}
47	for _, name := range pass.OtherFiles {
48		if err := checkOtherFile(pass, name); err != nil {
49			return nil, err
50		}
51	}
52	for _, name := range pass.IgnoredFiles {
53		if strings.HasSuffix(name, ".go") {
54			f, err := parser.ParseFile(pass.Fset, name, nil, parser.ParseComments)
55			if err != nil {
56				// Not valid Go source code - not our job to diagnose, so ignore.
57				continue
58			}
59			checkGoFile(pass, f)
60		} else {
61			if err := checkOtherFile(pass, name); err != nil {
62				return nil, err
63			}
64		}
65	}
66	return nil, nil
67}
68
69func checkGoFile(pass *analysis.Pass, f *ast.File) {
70	check := newChecker(pass, pass.Fset.File(f.Package).Name(), f)
71
72	for _, group := range f.Comments {
73		// A //go:build or a //go:debug comment is ignored after the package declaration
74		// (but adjoining it is OK, in contrast to +build comments).
75		if group.Pos() >= f.Package {
76			check.inHeader = false
77		}
78
79		// Check each line of a //-comment.
80		for _, c := range group.List {
81			check.comment(c.Slash, c.Text)
82		}
83	}
84}
85
86func checkOtherFile(pass *analysis.Pass, filename string) error {
87	// We cannot use the Go parser, since is not a Go source file.
88	// Read the raw bytes instead.
89	content, tf, err := analysisutil.ReadFile(pass, filename)
90	if err != nil {
91		return err
92	}
93
94	check := newChecker(pass, filename, nil)
95	check.nonGoFile(token.Pos(tf.Base()), string(content))
96	return nil
97}
98
99type checker struct {
100	pass     *analysis.Pass
101	filename string
102	file     *ast.File // nil for non-Go file
103	inHeader bool      // in file header (before or adjoining package declaration)
104}
105
106func newChecker(pass *analysis.Pass, filename string, file *ast.File) *checker {
107	return &checker{
108		pass:     pass,
109		filename: filename,
110		file:     file,
111		inHeader: true,
112	}
113}
114
115func (check *checker) nonGoFile(pos token.Pos, fullText string) {
116	// Process each line.
117	text := fullText
118	inStar := false
119	for text != "" {
120		offset := len(fullText) - len(text)
121		var line string
122		line, text, _ = strings.Cut(text, "\n")
123
124		if !inStar && strings.HasPrefix(line, "//") {
125			check.comment(pos+token.Pos(offset), line)
126			continue
127		}
128
129		// Skip over, cut out any /* */ comments,
130		// to avoid being confused by a commented-out // comment.
131		for {
132			line = strings.TrimSpace(line)
133			if inStar {
134				var ok bool
135				_, line, ok = strings.Cut(line, "*/")
136				if !ok {
137					break
138				}
139				inStar = false
140				continue
141			}
142			line, inStar = stringsCutPrefix(line, "/*")
143			if !inStar {
144				break
145			}
146		}
147		if line != "" {
148			// Found non-comment non-blank line.
149			// Ends space for valid //go:build comments,
150			// but also ends the fraction of the file we can
151			// reliably parse. From this point on we might
152			// incorrectly flag "comments" inside multiline
153			// string constants or anything else (this might
154			// not even be a Go program). So stop.
155			break
156		}
157	}
158}
159
160func (check *checker) comment(pos token.Pos, line string) {
161	if !strings.HasPrefix(line, "//go:") {
162		return
163	}
164	// testing hack: stop at // ERROR
165	if i := strings.Index(line, " // ERROR "); i >= 0 {
166		line = line[:i]
167	}
168
169	verb := line
170	if i := strings.IndexFunc(verb, unicode.IsSpace); i >= 0 {
171		verb = verb[:i]
172		if line[i] != ' ' && line[i] != '\t' && line[i] != '\n' {
173			r, _ := utf8.DecodeRuneInString(line[i:])
174			check.pass.Reportf(pos, "invalid space %#q in %s directive", r, verb)
175		}
176	}
177
178	switch verb {
179	default:
180		// TODO: Use the go language version for the file.
181		// If that version is not newer than us, then we can
182		// report unknown directives.
183
184	case "//go:build":
185		// Ignore. The buildtag analyzer reports misplaced comments.
186
187	case "//go:debug":
188		if check.file == nil {
189			check.pass.Reportf(pos, "//go:debug directive only valid in Go source files")
190		} else if check.file.Name.Name != "main" && !strings.HasSuffix(check.filename, "_test.go") {
191			check.pass.Reportf(pos, "//go:debug directive only valid in package main or test")
192		} else if !check.inHeader {
193			check.pass.Reportf(pos, "//go:debug directive only valid before package declaration")
194		}
195	}
196}
197
198// Go 1.20 strings.CutPrefix.
199func stringsCutPrefix(s, prefix string) (after string, found bool) {
200	if !strings.HasPrefix(s, prefix) {
201		return s, false
202	}
203	return s[len(prefix):], true
204}
205