internal/godoc: smarter import path resolution
simpleImporter is a little too simple to handle imports
of v2+ packages. This reuses the same logic that go/doc
internally uses for its unexported importByName field.
This also deduplicates a few copies of the same function.
Fixes golang/go#60404
Change-Id: I59665b51c8081d7719c65ebc7c5198a26a6a6964
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/728261
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Ethan Lee <ethanalee@google.com>
kokoro-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/godoc/dochtml/dochtml_test.go b/internal/godoc/dochtml/dochtml_test.go
index 12f8536..f57cce3 100644
--- a/internal/godoc/dochtml/dochtml_test.go
+++ b/internal/godoc/dochtml/dochtml_test.go
@@ -26,6 +26,7 @@
"github.com/google/safehtml/template"
"golang.org/x/net/html"
"golang.org/x/pkgsite/internal/godoc/dochtml/internal/render"
+ "golang.org/x/pkgsite/internal/godoc/importer"
"golang.org/x/pkgsite/internal/testing/testhelper"
)
@@ -106,6 +107,101 @@
testhelper.CompareWithGolden(t, got, name+".golden", update)
}
+func TestImportLink(t *testing.T) {
+ LoadTemplates(templateFS)
+
+ cases := []struct {
+ name string
+ path string
+ }{{
+ name: "std",
+ path: "math/rand",
+ }, {
+ name: "std-v2",
+ path: "math/rand/v2",
+ }, {
+ name: "regular",
+ path: "example.com/rand",
+ }, {
+ name: "regular-v2",
+ path: "example.com/rand/v2",
+ }, {
+ name: "go-prefix",
+ path: "example.com/go-rand",
+ }, {
+ name: "go-prefix-v2",
+ path: "example.com/go-rand/v2",
+ }}
+
+ // Create a simple package with the "rand" dependency imported from the given path.
+ createPackage := func(importPath, depPath string) (*token.FileSet, *doc.Package) {
+ const codeTpl = `
+package foo
+
+import %q
+
+func F(rng *rand.Rand) {}
+`
+ code := fmt.Sprintf(codeTpl, depPath)
+
+ fset := token.NewFileSet()
+ astFile, _ := parser.ParseFile(fset, "main.go", code, parser.ParseComments)
+ files := []*ast.File{astFile}
+
+ filesMap := map[string]*ast.File{
+ "main.go": astFile,
+ }
+
+ //lint:ignore SA1019 We had a preexisting dependency on ast.Object.
+ ast.NewPackage(fset, filesMap, importer.SimpleImporter, nil)
+
+ astPackage, err := doc.NewFromFiles(fset, files, importPath, doc.AllDecls)
+ if err != nil {
+ panic(err)
+ }
+
+ return fset, astPackage
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := t.Context()
+
+ // render out the package
+ fset, d := createPackage("example.com/module/pkg", tc.path)
+ parts, err := Render(ctx, fset, d, testRenderOptions)
+ if err != nil {
+ t.Fatal(err)
+ }
+ htmlDoc, err := html.Parse(strings.NewReader(parts.Body.String()))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Walk the rendered output looking for "rand" in the function signature,
+ // and get the location it's linked to.
+ var declarations *html.Node
+ var got string
+ walk(htmlDoc, func(n *html.Node) {
+ if strings.Contains(attr(n, "class"), "Documentation-declaration") {
+ declarations = n
+ }
+ if n.Data == "rand" {
+ got = attr(n.Parent, "href")
+ }
+ })
+
+ want := "/" + tc.path
+ if got != want {
+ var buf bytes.Buffer
+ html.Render(&buf, declarations)
+ t.Errorf("dep link = %q, want = %q\ndeclarations:\n%s",
+ got, want, buf.String())
+ }
+ })
+ }
+}
+
func TestExampleRender(t *testing.T) {
LoadTemplates(templateFS)
ctx := context.Background()
@@ -483,7 +579,7 @@
}
//lint:ignore SA1019 We had a preexisting dependency on ast.Object.
- ast.NewPackage(fset, filesMap, simpleImporter, nil)
+ ast.NewPackage(fset, filesMap, importer.SimpleImporter, nil)
astPackage, err := doc.NewFromFiles(fset, files, path, doc.AllDecls)
if err != nil {
@@ -492,20 +588,3 @@
return fset, astPackage
}
-
-// simpleImporter returns a (dummy) package object named by the last path
-// component of the provided package path (as is the convention for packages).
-// This is sufficient to resolve package identifiers without doing an actual
-// import. It never returns an error.
-//
-//lint:ignore SA1019 We had a preexisting dependency on ast.Object.
-func simpleImporter(imports map[string]*ast.Object, path string) (*ast.Object, error) {
- pkg := imports[path]
- if pkg == nil {
- // note that strings.LastIndex returns -1 if there is no "/"
- pkg = ast.NewObj(ast.Pkg, path[strings.LastIndex(path, "/")+1:])
- pkg.Data = ast.NewScope(nil) // required by ast.NewPackage for dot-import
- imports[path] = pkg
- }
- return pkg, nil
-}
diff --git a/internal/godoc/dochtml/internal/render/render_test.go b/internal/godoc/dochtml/internal/render/render_test.go
index 483423d..fa775d6 100644
--- a/internal/godoc/dochtml/internal/render/render_test.go
+++ b/internal/godoc/dochtml/internal/render/render_test.go
@@ -11,7 +11,8 @@
"go/token"
"os"
"path/filepath"
- "strings"
+
+ "golang.org/x/pkgsite/internal/godoc/importer"
)
//lint:file-ignore SA1019 We only need the syntax tree.
@@ -21,18 +22,6 @@
var pkgTime, fsetTime = mustLoadPackage("time")
func mustLoadPackage(path string) (*doc.Package, *token.FileSet) {
- // simpleImporter is used by ast.NewPackage.
- simpleImporter := func(imports map[string]*ast.Object, pkgPath string) (*ast.Object, error) {
- pkg := imports[pkgPath]
- if pkg == nil {
- pkgName := pkgPath[strings.LastIndex(pkgPath, "/")+1:]
- pkg = ast.NewObj(ast.Pkg, pkgName)
- pkg.Data = ast.NewScope(nil) // required for or dot-imports
- imports[pkgPath] = pkg
- }
- return pkg, nil
- }
-
srcName := filepath.Base(path) + ".go"
code, err := os.ReadFile(filepath.Join("testdata", srcName))
if err != nil {
@@ -43,6 +32,6 @@
pkgFiles := make(map[string]*ast.File)
astFile, _ := parser.ParseFile(fset, srcName, code, parser.ParseComments)
pkgFiles[srcName] = astFile
- astPkg, _ := ast.NewPackage(fset, pkgFiles, simpleImporter, nil)
+ astPkg, _ := ast.NewPackage(fset, pkgFiles, importer.SimpleImporter, nil)
return doc.New(astPkg, path, 0), fset
}
diff --git a/internal/godoc/importer/importer.go b/internal/godoc/importer/importer.go
new file mode 100644
index 0000000..97c7544
--- /dev/null
+++ b/internal/godoc/importer/importer.go
@@ -0,0 +1,57 @@
+// Copyright 2025 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 importer
+
+import (
+ "go/ast"
+ "path"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+// SimpleImporter returns a (dummy) package object named by the last path element,
+// stripping off any major version suffix or go- prefix.
+// This is sufficient to resolve most package identifiers without doing an actual
+// import. It never returns an error.
+//
+//lint:ignore SA1019 We had a preexisting dependency on ast.Object.
+func SimpleImporter(imports map[string]*ast.Object, path string) (*ast.Object, error) {
+ pkg := imports[path]
+ if pkg == nil {
+ name := importPathToAssumedName(path)
+ pkg = ast.NewObj(ast.Pkg, name)
+ pkg.Data = ast.NewScope(nil) // required by ast.NewPackage for dot-import
+ imports[path] = pkg
+ }
+ return pkg, nil
+}
+
+// importPathToAssumedName is a copy of golang.org/x/tools/internal/imports.ImportPathToAssumedName
+func importPathToAssumedName(importPath string) string {
+ base := path.Base(importPath)
+ if strings.HasPrefix(base, "v") {
+ if _, err := strconv.Atoi(base[1:]); err == nil {
+ dir := path.Dir(importPath)
+ if dir != "." {
+ base = path.Base(dir)
+ }
+ }
+ }
+ base = strings.TrimPrefix(base, "go-")
+ if i := strings.IndexFunc(base, notIdentifier); i >= 0 {
+ base = base[:i]
+ }
+ return base
+}
+
+// notIdentifier reports whether ch is an invalid identifier character.
+func notIdentifier(ch rune) bool {
+ return !('a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' ||
+ '0' <= ch && ch <= '9' ||
+ ch == '_' ||
+ ch >= utf8.RuneSelf && (unicode.IsLetter(ch) || unicode.IsDigit(ch)))
+}
diff --git a/internal/godoc/importer/importer_test.go b/internal/godoc/importer/importer_test.go
new file mode 100644
index 0000000..04a7d23
--- /dev/null
+++ b/internal/godoc/importer/importer_test.go
@@ -0,0 +1,47 @@
+// Copyright 2025 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 importer
+
+import (
+ "go/ast"
+ "testing"
+)
+
+func TestSimpleImporter(t *testing.T) {
+ cases := []struct {
+ name string
+ path string
+ }{{
+ name: "std",
+ path: "math/rand",
+ }, {
+ name: "std-v2",
+ path: "math/rand/v2",
+ }, {
+ name: "regular",
+ path: "example.com/rand",
+ }, {
+ name: "regular-v2",
+ path: "example.com/rand/v2",
+ }, {
+ name: "go-prefix",
+ path: "example.com/go-rand",
+ }, {
+ name: "go-prefix-v2",
+ path: "example.com/go-rand/v2",
+ }}
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ //lint:ignore SA1019 We had a preexisting dependency on ast.Object.
+ obj, err := SimpleImporter(make(map[string]*ast.Object), tc.path)
+ if err != nil {
+ t.Fatalf("SimpleImporter(%q) returned error: %v", tc.path, err)
+ }
+ if obj.Name != "rand" {
+ t.Errorf("SimpleImporter(%q).Name = %s, want = rand", tc.path, obj.Name)
+ }
+ })
+ }
+}
diff --git a/internal/godoc/render.go b/internal/godoc/render.go
index 4bd8cde..9d21fbb 100644
--- a/internal/godoc/render.go
+++ b/internal/godoc/render.go
@@ -19,6 +19,7 @@
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/godoc/dochtml"
+ "golang.org/x/pkgsite/internal/godoc/importer"
"golang.org/x/pkgsite/internal/source"
"golang.org/x/pkgsite/internal/stdlib"
)
@@ -125,7 +126,7 @@
// Call ast.NewPackage for side-effects to populate objects. In Go 1.25+
// doc.NewFromFiles will not cause the objects to be populated.
//lint:ignore SA1019 We had a preexisting dependency on ast.Object.
- ast.NewPackage(p.Fset, nonTestFiles, simpleImporter, nil)
+ ast.NewPackage(p.Fset, nonTestFiles, importer.SimpleImporter, nil)
d, err := doc.NewFromFiles(p.Fset, allGoFiles, importPath, m)
if err != nil {
return nil, fmt.Errorf("doc.NewFromFiles: %v", err)
@@ -150,23 +151,6 @@
return d, nil
}
-// simpleImporter returns a (dummy) package object named by the last path
-// component of the provided package path (as is the convention for packages).
-// This is sufficient to resolve package identifiers without doing an actual
-// import. It never returns an error.
-//
-//lint:ignore SA1019 We had a preexisting dependency on ast.Object.
-func simpleImporter(imports map[string]*ast.Object, path string) (*ast.Object, error) {
- pkg := imports[path]
- if pkg == nil {
- // note that strings.LastIndex returns -1 if there is no "/"
- pkg = ast.NewObj(ast.Pkg, path[strings.LastIndex(path, "/")+1:])
- pkg.Data = ast.NewScope(nil) // required by ast.NewPackage for dot-import
- imports[path] = pkg
- }
- return pkg, nil
-}
-
// renderOptions returns a RenderOptions for p.
func (p *Package) renderOptions(innerPath string, sourceInfo *source.Info, modInfo *ModuleInfo,
nameToVersion map[string]string, bc internal.BuildContext) dochtml.RenderOptions {