cmd/go/internal/doc: support @version suffix on first argument

This change allows `go doc` to display documentation for packages
outside the workspace by explicitly providing a version (e.g., `go doc
pkg@version`) or by inferring it from an installed command.

Therefore, all of the following are now valid:

```
go doc txtar@v0.13.0
go doc txtar@v0.13.0 FS
go doc txtar.FS@v0.13.0
go doc golang.org/x/tools/txtar@v0.13.0
go doc golang.org/x/tools/txtar@v0.13.0 FS
go doc golang.org/x/tools/txtar.FS@v0.13.0
```

Fixes #63696.

Change-Id: I22fb68e29c7f62bbe0bb240b82e5b789edc653f2
Reviewed-on: https://go-review.googlesource.com/c/go/+/747380
Reviewed-by: Michael Matloob <matloob@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Michael Matloob <matloob@google.com>
This commit is contained in:
Ian Alexander
2026-02-20 00:24:55 -05:00
parent 76222756d9
commit 5055a18156
4 changed files with 237 additions and 34 deletions

View File

@@ -151,7 +151,7 @@ func runDoc(ctx context.Context, cmd *base.Command, args []string) {
log.SetPrefix("doc: ")
dirsInit()
var flagSet flag.FlagSet
err := do(os.Stdout, &flagSet, args)
err := do(ctx, os.Stdout, &flagSet, args)
if err != nil {
log.Fatal(err)
}
@@ -185,7 +185,7 @@ func usage(flagSet *flag.FlagSet) {
}
// do is the workhorse, broken out of runDoc to make testing easier.
func do(writer io.Writer, flagSet *flag.FlagSet, args []string) (err error) {
func do(ctx context.Context, writer io.Writer, flagSet *flag.FlagSet, args []string) (err error) {
flagSet.Usage = func() { usage(flagSet) }
unexported = false
matchCase = false
@@ -234,7 +234,7 @@ func do(writer io.Writer, flagSet *flag.FlagSet, args []string) (err error) {
// Loop until something is printed.
dirs.Reset()
for i := 0; ; i++ {
buildPackage, userPath, sym, more := parseArgs(flagSet, flagSet.Args())
buildPackage, userPath, sym, more := parseArgs(ctx, flagSet, flagSet.Args())
if i > 0 && !more { // Ignore the "more" bit on the first iteration.
return failMessage(paths, symbol, method)
}
@@ -356,7 +356,7 @@ func failMessage(paths []string, symbol, method string) error {
// and there may be more matches. For example, if the argument
// is rand.Float64, we must scan both crypto/rand and math/rand
// to find the symbol, and the first call will return crypto/rand, true.
func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path, symbol string, more bool) {
func parseArgs(ctx context.Context, flagSet *flag.FlagSet, args []string) (pkg *build.Package, path, symbol string, more bool) {
wd, err := os.Getwd()
if err != nil {
log.Fatal(err)
@@ -366,12 +366,29 @@ func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path,
return importDir(wd), "", "", false
}
arg := args[0]
var version string
if i := strings.Index(arg, "@"); i >= 0 {
arg, version = arg[:i], arg[i+1:]
}
// We have an argument. If it is a directory name beginning with . or ..,
// use the absolute path name. This discriminates "./errors" from "errors"
// if the current directory contains a non-standard errors package.
if isDotSlash(arg) {
arg = filepath.Join(wd, arg)
}
if version != "" && (build.IsLocalImport(filepath.ToSlash(arg)) || filepath.IsAbs(arg)) {
log.Fatal("cannot use @version with local or absolute paths")
}
importPkg := func(p string) (*build.Package, error) {
if version != "" {
return loadVersioned(ctx, p, version)
}
return build.Import(p, wd, build.ImportComment)
}
switch len(args) {
default:
usage(flagSet)
@@ -379,20 +396,29 @@ func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path,
// Done below.
case 2:
// Package must be findable and importable.
pkg, err := build.Import(args[0], wd, build.ImportComment)
pkg, err := importPkg(arg)
if err == nil {
return pkg, args[0], args[1], false
return pkg, arg, args[1], false
}
for {
packagePath, ok := findNextPackage(arg)
dir, importPath, ok := findNextPackage(arg)
if !ok {
break
}
if pkg, err := build.ImportDir(packagePath, build.ImportComment); err == nil {
return pkg, arg, args[1], true
if version != "" {
if pkg, err = loadVersioned(ctx, importPath, version); err == nil {
return pkg, arg, args[1], true
}
} else {
if pkg, err = build.ImportDir(dir, build.ImportComment); err == nil {
return pkg, arg, args[1], true
}
}
}
return nil, args[0], args[1], false
if version != "" {
log.Fatal(err)
}
return nil, arg, args[1], false
}
// Usual case: one argument.
// If it contains slashes, it begins with either a package path
@@ -407,7 +433,7 @@ func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path,
return pkg, arg, "", false
}
} else {
pkg, importErr = build.Import(arg, wd, build.ImportComment)
pkg, importErr = importPkg(arg)
if importErr == nil {
return pkg, arg, "", false
}
@@ -443,7 +469,7 @@ func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path,
symbol = arg[period+1:]
}
// Have we identified a package already?
pkg, err := build.Import(arg[0:period], wd, build.ImportComment)
pkg, err := importPkg(arg[0:period])
if err == nil {
return pkg, arg[0:period], symbol, false
}
@@ -451,16 +477,43 @@ func parseArgs(flagSet *flag.FlagSet, args []string) (pkg *build.Package, path,
// or ivy/value for robpike.io/ivy/value.
pkgName := arg[:period]
for {
path, ok := findNextPackage(pkgName)
dir, importPath, ok := findNextPackage(pkgName)
if !ok {
break
}
if pkg, err = build.ImportDir(path, build.ImportComment); err == nil {
return pkg, arg[0:period], symbol, true
if version != "" {
if pkg, err = loadVersioned(ctx, importPath, version); err == nil {
return pkg, arg[0:period], symbol, true
}
} else {
if pkg, err = build.ImportDir(dir, build.ImportComment); err == nil {
return pkg, arg[0:period], symbol, true
}
}
}
dirs.Reset() // Next iteration of for loop must scan all the directories again.
}
// Try inference from $PATH before giving up.
if slash < 0 && !isDotSlash(arg) && !filepath.IsAbs(arg) {
if pkgPath, v, ok := inferVersion(arg); ok {
if version == "" {
version = v
}
pkg, err := loadVersioned(ctx, pkgPath, version)
if err == nil {
return pkg, pkgPath, "", false
}
}
}
if version != "" {
if importErr != nil {
log.Fatal(importErr)
}
log.Fatalf("no such package %q at version %q", arg, version)
}
// If it has a slash, we've failed.
if slash >= 0 {
// build.Import should always include the path in its error message,
@@ -541,28 +594,29 @@ func isExported(name string) bool {
return unexported || token.IsExported(name)
}
// findNextPackage returns the next full file name path that matches the
// (perhaps partial) package path pkg. The boolean reports if any match was found.
func findNextPackage(pkg string) (string, bool) {
// findNextPackage returns the next full file name path and import path that
// matches the (perhaps partial) package path pkg. The boolean reports if
// any match was found.
func findNextPackage(pkg string) (string, string, bool) {
if filepath.IsAbs(pkg) {
if dirs.offset == 0 {
dirs.offset = -1
return pkg, true
return pkg, "", true
}
return "", false
return "", "", false
}
if pkg == "" || token.IsExported(pkg) { // Upper case symbol cannot be a package name.
return "", false
return "", "", false
}
pkg = path.Clean(pkg)
pkgSuffix := "/" + pkg
for {
d, ok := dirs.Next()
if !ok {
return "", false
return "", "", false
}
if d.importPath == pkg || strings.HasSuffix(d.importPath, pkgSuffix) {
return d.dir, true
return d.dir, d.importPath, true
}
}
}

View File

@@ -910,7 +910,7 @@ func TestDoc(t *testing.T) {
var flagSet flag.FlagSet
var logbuf bytes.Buffer
log.SetOutput(&logbuf)
err := do(&b, &flagSet, test.args)
err := do(t.Context(), &b, &flagSet, test.args)
if err != nil {
t.Fatalf("%s %v: %s\n", test.name, test.args, err)
}
@@ -963,7 +963,7 @@ func TestMultiplePackages(t *testing.T) {
// Make sure crypto/rand does not have the symbol.
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"crypto/rand.float64"})
err := do(t.Context(), &b, &flagSet, []string{"crypto/rand.float64"})
if err == nil {
t.Errorf("expected error from crypto/rand.float64")
} else if !strings.Contains(err.Error(), "no symbol float64") {
@@ -973,7 +973,7 @@ func TestMultiplePackages(t *testing.T) {
// Make sure math/rand does have the symbol.
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"math/rand.float64"})
err := do(t.Context(), &b, &flagSet, []string{"math/rand.float64"})
if err != nil {
t.Errorf("unexpected error %q from math/rand.float64", err)
}
@@ -981,7 +981,7 @@ func TestMultiplePackages(t *testing.T) {
// Try the shorthand.
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"rand.float64"})
err := do(t.Context(), &b, &flagSet, []string{"rand.float64"})
if err != nil {
t.Errorf("unexpected error %q from rand.float64", err)
}
@@ -989,7 +989,7 @@ func TestMultiplePackages(t *testing.T) {
// Now try a missing symbol. We should see both packages in the error.
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"rand.doesnotexit"})
err := do(t.Context(), &b, &flagSet, []string{"rand.doesnotexit"})
if err == nil {
t.Errorf("expected error from rand.doesnotexit")
} else {
@@ -1027,21 +1027,21 @@ func TestTwoArgLookup(t *testing.T) {
var b bytes.Buffer // We don't care about the output.
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"binary", "BigEndian"})
err := do(t.Context(), &b, &flagSet, []string{"binary", "BigEndian"})
if err != nil {
t.Errorf("unexpected error %q from binary BigEndian", err)
}
}
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"rand", "Float64"})
err := do(t.Context(), &b, &flagSet, []string{"rand", "Float64"})
if err != nil {
t.Errorf("unexpected error %q from rand Float64", err)
}
}
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"bytes", "Foo"})
err := do(t.Context(), &b, &flagSet, []string{"bytes", "Foo"})
if err == nil {
t.Errorf("expected error from bytes Foo")
} else if !strings.Contains(err.Error(), "no symbol Foo") {
@@ -1050,7 +1050,7 @@ func TestTwoArgLookup(t *testing.T) {
}
{
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"nosuchpackage", "Foo"})
err := do(t.Context(), &b, &flagSet, []string{"nosuchpackage", "Foo"})
if err == nil {
// actually present in the user's filesystem
} else if !strings.Contains(err.Error(), "no such package") {
@@ -1071,7 +1071,7 @@ func TestDotSlashLookup(t *testing.T) {
var b strings.Builder
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"./template"})
err := do(t.Context(), &b, &flagSet, []string{"./template"})
if err != nil {
t.Errorf("unexpected error %q from ./template", err)
}
@@ -1089,7 +1089,7 @@ func TestNoPackageClauseWhenNoMatch(t *testing.T) {
maybeSkip(t)
var b strings.Builder
var flagSet flag.FlagSet
err := do(&b, &flagSet, []string{"template.ZZZ"})
err := do(t.Context(), &b, &flagSet, []string{"template.ZZZ"})
// Expect an error.
if err == nil {
t.Error("expect an error for template.zzz")

View File

@@ -0,0 +1,54 @@
// Copyright 2024 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 doc
import (
"context"
"debug/buildinfo"
"fmt"
"go/build"
"os/exec"
"cmd/go/internal/load"
"cmd/go/internal/modload"
)
// loadVersioned loads a package at a specific version.
func loadVersioned(ctx context.Context, pkgPath, version string) (*build.Package, error) {
loaderState := modload.NewState()
loaderState.ForceUseModules = true
loaderState.RootMode = modload.NoRoot
modload.Init(loaderState)
var opts load.PackageOpts
args := []string{
fmt.Sprintf("%s@%s", pkgPath, version),
}
pkgs, err := load.PackagesAndErrorsOutsideModule(loaderState, ctx, opts, args)
if err != nil {
return nil, err
}
if len(pkgs) != 1 {
return nil, fmt.Errorf("incorrect number of packages: want 1, got %d", len(pkgs))
}
return pkgs[0].Internal.Build, nil
}
// inferVersion checks if the argument matches a command on $PATH and returns its module path and version.
func inferVersion(arg string) (pkgPath, version string, ok bool) {
path, err := exec.LookPath(arg)
if err != nil {
return "", "", false
}
bi, err := buildinfo.ReadFile(path)
if err != nil {
return "", "", false
}
if bi.Main.Path == "" || bi.Main.Version == "" {
return "", "", false
}
// bi.Path is the package path for the main package.
return bi.Path, bi.Main.Version, true
}

View File

@@ -0,0 +1,95 @@
go mod init m
go get rsc.io/quote@latest
go doc rsc.io/quote
cmp stdout want-latest.txt
go doc rsc.io/quote@latest
cmp stdout want-latest.txt
go doc rsc.io/quote@v1.0.0
cmp stdout want-1.0.0.txt
go doc rsc.io/quote@v1.1.0
cmp stdout want-1.1.0.txt
go doc rsc.io/quote@v1.2.0
cmp stdout want-1.2.0.txt
# Test short format
go doc quote@latest
cmp stdout want-latest.txt
go doc quote@v1.0.0
cmp stdout want-1.0.0.txt
go doc quote@v1.1.0
cmp stdout want-1.1.0.txt
go doc quote@v1.2.0
cmp stdout want-1.2.0.txt
go doc quote
cmp stdout want-latest.txt
go doc quote@v1.0.0 Hello
cmp stdout hello-1.0.0.txt
go doc quote@v1.1.0 Glass
cmp stdout glass-1.1.0.txt
go doc quote@v1.2.0 Go
cmp stdout go-1.2.0.txt
go doc quote@v1.3.0 Opt
cmp stdout opt-1.3.0.txt
-- main.go --
package main
import (
"fmt"
"rsc.io/quote"
)
func main() {
fmt.Println(quote.Hello())
}
-- want-latest.txt --
package quote // import "rsc.io/quote"
Package quote collects pithy sayings.
func Glass() string
func Go() string
func Hello() string
func Opt() string
-- want-1.0.0.txt --
package quote // import "rsc.io/quote"
Package quote collects pithy sayings.
func Hello() string
-- want-1.1.0.txt --
package quote // import "rsc.io/quote"
Package quote collects pithy sayings.
func Glass() string
func Hello() string
-- want-1.2.0.txt --
package quote // import "rsc.io/quote"
Package quote collects pithy sayings.
func Glass() string
func Go() string
func Hello() string
-- hello-1.0.0.txt --
package quote // import "rsc.io/quote"
func Hello() string
Hello returns a greeting.
-- glass-1.1.0.txt --
package quote // import "rsc.io/quote"
func Glass() string
Glass returns a useful phrase for world travelers.
-- go-1.2.0.txt --
package quote // import "rsc.io/quote"
func Go() string
Go returns a Go proverb.
-- opt-1.3.0.txt --
package quote // import "rsc.io/quote"
func Opt() string
Opt returns an optimization truth.