fix(11.1-07): require built, run src= code; case-sensitive go doc; parse command forms

- .go src= targets must be in the default build and reached from a Test or an Example with output
- go doc -c makes the identifier fallback case-sensitive
- commandWord parses env prefixes, flags, go run and bin/ forms
This commit is contained in:
Jakub Zych
2026-10-01 08:37:38 +02:00
parent 73c72af244
commit efc3161c3f
4 changed files with 192 additions and 34 deletions

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"go/ast"
"go/build"
"go/doc"
"go/parser"
"go/token"
@@ -14,6 +15,8 @@ import (
"path/filepath"
"slices"
"strings"
"unicode"
"unicode/utf8"
)
// Ref is a src= reference in a code fence info string: a
@@ -106,11 +109,8 @@ func Extract(root string, ref Ref) (string, error) {
if err := checkRootModule(rootReal, filepath.Dir(real)); err != nil {
return "", err
}
if !isTest {
tests, _ := filepath.Glob(filepath.Join(filepath.Dir(real), "*_test.go"))
if len(tests) == 0 {
return "", snippetError("package has no _test.go files, so go test does not cover it")
}
if err := checkBuiltGo(ref.Path, real, isTest); err != nil {
return "", err
}
}
if ref.Fragment == "" {
@@ -161,6 +161,53 @@ func checkRefPath(p string) error {
return nil
}
// buildPackage loads dir the way go test ./... does: build.Default, so
// the host GOOS, GOARCH, CGO_ENABLED and release tags apply.
func buildPackage(dir string) (*build.Package, error) {
return build.ImportDir(dir, 0)
}
// checkBuiltGo refuses a .go src= target go test ./... does not compile:
// a testdata or underscore directory, a file left out of the default
// build, or a non-test file in a package with no tests.
func checkBuiltGo(refPath, real string, isTest bool) error {
for _, seg := range strings.Split(refPath, "/") {
if seg == "testdata" || strings.HasPrefix(seg, "_") {
return snippetError("file is in a directory go test ./... skips (testdata or a _-prefixed directory)")
}
}
pkg, err := buildPackage(filepath.Dir(real))
if err != nil {
var noGo *build.NoGoError
if errors.As(err, &noGo) {
return snippetError("file is excluded from the default build (build constraint or GOOS/GOARCH file-name suffix)")
}
return snippetError("cannot load package: " + firstLine(err.Error()))
}
base := filepath.Base(real)
listed := func(groups ...[]string) bool {
for _, g := range groups {
if slices.Contains(g, base) {
return true
}
}
return false
}
if isTest {
if !listed(pkg.TestGoFiles, pkg.XTestGoFiles) {
return snippetError("file is excluded from the default build (build constraint or GOOS/GOARCH file-name suffix)")
}
return nil
}
if !listed(pkg.GoFiles, pkg.CgoFiles) {
return snippetError("file is excluded from the default build (build constraint or GOOS/GOARCH file-name suffix)")
}
if len(pkg.TestGoFiles) == 0 && len(pkg.XTestGoFiles) == 0 {
return snippetError("package has no _test.go files, so go test does not cover it")
}
return nil
}
// checkRootModule refuses a Go source that sits in a nested module (a
// directory between it and the root holds a go.mod): root go test does not
// run it.
@@ -246,11 +293,9 @@ func extractIdent(file string, raw []byte, name string, isTest bool) (string, er
if funcKey(d) != name {
continue
}
if isTest {
if err := checkTestIdent(file, name, true); err != nil {
return "", err
}
}
// An Example with output is a root, so it is not also asked
// to be reachable from some other test. Without output, go
// test compiles it and never runs it.
if d.Recv == nil && strings.HasPrefix(name, "Example") && isTest {
if !exampleHasOutput(f, name) {
return "", snippetError(name + " has no // Output: comment, so go test compiles it but never runs it")
@@ -258,6 +303,11 @@ func extractIdent(file string, raw []byte, name string, isTest bool) (string, er
body := string(raw[offset(d.Body.Lbrace)+1 : offset(d.Body.Rbrace)])
return strings.Join(dedent(strings.Split(body, "\n")), "\n"), nil
}
if isTest {
if err := checkTestIdent(file, name, true); err != nil {
return "", err
}
}
start := d.Pos()
if d.Doc != nil {
start = d.Doc.Pos()
@@ -342,25 +392,43 @@ type testGraph struct {
files map[string]*ast.File // by path
}
func isRoot(d *ast.FuncDecl) bool {
return d.Recv == nil && (strings.HasPrefix(d.Name.Name, "Test") || strings.HasPrefix(d.Name.Name, "Example"))
// isTestName is the cmd/go rule for a test function: "Test" alone, or
// "Test" followed by a rune that is not a lowercase letter. Testable
// is not a test. Benchmark and Fuzz are not roots.
func isTestName(name string) bool {
rest, ok := strings.CutPrefix(name, "Test")
if !ok {
return false
}
if rest == "" {
return true
}
r, _ := utf8.DecodeRuneInString(rest)
return !unicode.IsLower(r)
}
func loadTestGraph(dir string) (*testGraph, error) {
paths, err := filepath.Glob(filepath.Join(dir, "*_test.go"))
pkg, err := buildPackage(dir)
if err != nil {
return nil, fmt.Errorf("docsite: %w", err)
var noGo *build.NoGoError
if !errors.As(err, &noGo) {
return nil, snippetError("cannot load package: " + firstLine(err.Error()))
}
pkg = &build.Package{}
}
g := &testGraph{fset: token.NewFileSet(), funcs: map[string][]*ast.FuncDecl{},
reachable: map[string]bool{}, referenced: map[string]bool{}, files: map[string]*ast.File{}}
byName := map[string][]string{} // plain or method name -> keys
var queue []string
for _, p := range paths {
f, err := parser.ParseFile(g.fset, p, nil, 0)
var files []*ast.File
for _, name := range append(append([]string{}, pkg.TestGoFiles...), pkg.XTestGoFiles...) {
p := filepath.Join(dir, name)
f, err := parser.ParseFile(g.fset, p, nil, parser.ParseComments)
if err != nil {
return nil, snippetError("cannot parse " + filepath.Base(p) + ": " + firstLine(err.Error()))
}
g.files[p] = f
files = append(files, f)
for _, decl := range f.Decls {
d, ok := decl.(*ast.FuncDecl)
if !ok {
@@ -369,12 +437,22 @@ func loadTestGraph(dir string) (*testGraph, error) {
key := funcKey(d)
g.funcs[key] = append(g.funcs[key], d)
byName[d.Name.Name] = append(byName[d.Name.Name], key)
if isRoot(d) && !g.reachable[key] {
if d.Recv == nil && isTestName(d.Name.Name) && !g.reachable[key] {
g.reachable[key] = true
queue = append(queue, key)
}
}
}
for _, ex := range doc.Examples(files...) {
if ex.Output == "" && !ex.EmptyOutput {
continue
}
key := "Example" + ex.Name
if !g.reachable[key] {
g.reachable[key] = true
queue = append(queue, key)
}
}
for len(queue) > 0 {
key := queue[0]
queue = queue[1:]
@@ -401,7 +479,7 @@ func loadTestGraph(dir string) (*testGraph, error) {
return g, nil
}
const notRunMessage = "fragment is not inside a Test or Example function, or a function one of them calls, so go test does not run it"
const notRunMessage = "fragment is not inside a Test or Example function (an Example counts only with an // Output: comment), or a function one of them calls, so go test does not run it"
// checkTestIdent requires a _test.go declaration to be run by go test: a
// function reachable from a Test or Example, or a type, var or const that