cmd/pkgsite: add multi-module support, invalidation, and search
This change adds a new goPackagesModuleGetter, which uses
x/tools/go/packages to query package information for modules requested
by the pkgsite command.
Additionally, add new extension interfaces that allow getters to add
support for search and content invalidation. The go/packages getter uses
these extensions to implement search (via a simple fuzzy-matching
algorithm copied from x/tools), and invalidation via statting package
files.
Along the way, refactor slightly for testing ergonomics.
Updates golang/go#40371
Updates golang/go#50229
Fixes golang/go#54479
Change-Id: Iea91a4d6327707733cbbc4f74a9d93052f33e295
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/474295
TryBot-Result: kokoro <noreply+kokoro@google.com>
Run-TryBot: Robert Findley <rfindley@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/cmd/pkgsite/main.go b/cmd/pkgsite/main.go
index 29fd67e..127d35e 100644
--- a/cmd/pkgsite/main.go
+++ b/cmd/pkgsite/main.go
@@ -8,14 +8,26 @@
//
// To install, run `go install ./cmd/pkgsite` from the pkgsite repo root.
//
-// With no arguments, pkgsite will serve docs for the module in the current
-// directory, which must have a go.mod file:
+// With no arguments, pkgsite will serve docs for main modules relative to the
+// current directory, i.e. the modules listed by `go list -m`. This is
+// typically the module defined by the nearest go.mod file in a parent
+// directory. However, this may include multiple main modules when using a
+// go.work file to define a [workspace].
//
-// cd ~/repos/cue && pkgsite
+// For example, both of the following the following forms could be used to work
+// on the module defined in repos/cue/go.mod:
//
-// This form will also serve all of the module's required modules at their
-// required versions. You can disable serving the required modules by passing
-// -list=false.
+// The single module form:
+//
+// cd repos/cue && pkgsite
+//
+// The multiple module form:
+//
+// go work init repos/cue repos/other && pkgsite
+//
+// By default, the resulting server will also serve all of the module's
+// dependencies at their required versions. You can disable serving the
+// required modules by passing -list=false.
//
// You can also serve docs from your module cache, directly from the proxy
// (it uses the GOPROXY environment variable), or both:
@@ -32,13 +44,12 @@
// while to appear the first time because the Go repo must be cloned and
// processed. If you clone the repo yourself (https://go.googlesource.com/go),
// you can provide its location with the -gorepo flag to save a little time.
+//
+// [workspace]: https://go.dev/ref/mod#workspaces
package main
import (
- "bytes"
"context"
- "encoding/json"
- "errors"
"flag"
"fmt"
"net/http"
@@ -65,16 +76,30 @@
const defaultAddr = "localhost:8080" // default webserver address
var (
- gopathMode = flag.Bool("gopath_mode", false, "assume that local modules' paths are relative to GOPATH/src")
- httpAddr = flag.String("http", defaultAddr, "HTTP service address to listen for incoming requests on")
- useCache = flag.Bool("cache", false, "fetch from the module cache")
- cacheDir = flag.String("cachedir", "", "module cache directory (defaults to `go env GOMODCACHE`)")
- useProxy = flag.Bool("proxy", false, "fetch from GOPROXY if not found locally")
- goRepoPath = flag.String("gorepo", "", "path to Go repo on local filesystem")
- useListedMods = flag.Bool("list", true, "for each path, serve all modules in build list")
+ httpAddr = flag.String("http", defaultAddr, "HTTP service address to listen for incoming requests on")
+ goRepoPath = flag.String("gorepo", "", "path to Go repo on local filesystem")
+ useProxy = flag.Bool("proxy", false, "fetch from GOPROXY if not found locally")
+ // other flags are bound to serverConfig below
)
+type serverConfig struct {
+ paths []string
+ gopathMode bool
+ useCache bool
+ cacheDir string
+ useListedMods bool
+
+ proxy *proxy.Client // client, or nil; controlled by the -proxy flag
+}
+
func main() {
+ var serverCfg serverConfig
+
+ flag.BoolVar(&serverCfg.gopathMode, "gopath_mode", false, "assume that local modules' paths are relative to GOPATH/src")
+ flag.BoolVar(&serverCfg.useCache, "cache", false, "fetch from the module cache")
+ flag.StringVar(&serverCfg.cacheDir, "cachedir", "", "module cache directory (defaults to `go env GOMODCACHE`)")
+ flag.BoolVar(&serverCfg.useListedMods, "list", true, "for each path, serve all modules in build list")
+
flag.Usage = func() {
out := flag.CommandLine.Output()
fmt.Fprintf(out, "usage: %s [flags] [PATHS ...]\n", os.Args[0])
@@ -83,40 +108,19 @@
flag.PrintDefaults()
}
flag.Parse()
- ctx := context.Background()
+ serverCfg.paths = collectPaths(flag.Args())
- paths := collectPaths(flag.Args())
- if len(paths) == 0 && !*useCache && !*useProxy {
- paths = []string{"."}
- }
-
- var modCacheDir string
- if *useCache || *useListedMods {
- modCacheDir = *cacheDir
- if modCacheDir == "" {
- var err error
- modCacheDir, err = defaultCacheDir()
- if err != nil {
- die("%v", err)
- }
- if modCacheDir == "" {
- die("empty value for GOMODCACHE")
- }
- }
- }
-
- if *useCache || *useProxy {
+ if serverCfg.useCache || *useProxy {
fmt.Fprintf(os.Stderr, "BYPASSING LICENSE CHECKING: MAY DISPLAY NON-REDISTRIBUTABLE INFORMATION\n")
}
- var prox *proxy.Client
if *useProxy {
url := os.Getenv("GOPROXY")
if url == "" {
die("GOPROXY environment variable is not set")
}
var err error
- prox, err = proxy.New(url)
+ serverCfg.proxy, err = proxy.New(url)
if err != nil {
die("connecting to proxy: %s", err)
}
@@ -126,23 +130,12 @@
stdlib.SetGoRepoPath(*goRepoPath)
}
- var cacheMods []internal.Modver
- if *useListedMods && !*useCache {
- var err error
- paths, cacheMods, err = listModsForPaths(paths, modCacheDir)
- if err != nil {
- die("listing mods (consider passing -list=false): %v", err)
- }
+ ctx := context.Background()
+ server, err := buildServer(ctx, serverCfg)
+ if err != nil {
+ die(err.Error())
}
- getters, err := buildGetters(ctx, paths, *gopathMode, modCacheDir, cacheMods, prox)
- if err != nil {
- die("%s", err)
- }
- server, err := newServer(getters, prox)
- if err != nil {
- die("%s", err)
- }
router := http.NewServeMux()
server.Install(router.Handle, nil, nil)
mw := middleware.Timeout(54 * time.Second)
@@ -150,6 +143,59 @@
die("%v", http.ListenAndServe(*httpAddr, mw(router)))
}
+func die(format string, args ...any) {
+ fmt.Fprintf(os.Stderr, format, args...)
+ fmt.Fprintln(os.Stderr)
+ os.Exit(1)
+}
+
+func buildServer(ctx context.Context, serverCfg serverConfig) (*frontend.Server, error) {
+ if len(serverCfg.paths) == 0 && !serverCfg.useCache && serverCfg.proxy == nil {
+ serverCfg.paths = []string{"."}
+ }
+
+ cfg := getterConfig{
+ dirs: serverCfg.paths,
+ proxy: serverCfg.proxy,
+ }
+
+ // By default, the requested paths are interpreted as directories. However,
+ // if -gopath_mode is set, they are interpreted as relative paths to modules
+ // in a GOPATH directory.
+ if serverCfg.gopathMode {
+ var err error
+ cfg.dirs, err = getGOPATHModuleDirs(ctx, serverCfg.paths)
+ if err != nil {
+ return nil, fmt.Errorf("searching GOPATH: %v", err)
+ }
+ }
+
+ cfg.pattern = "./..."
+ if serverCfg.useListedMods {
+ cfg.pattern = "all"
+ }
+
+ if serverCfg.useCache {
+ cfg.modCacheDir = serverCfg.cacheDir
+ if cfg.modCacheDir == "" {
+ var err error
+ cfg.modCacheDir, err = defaultCacheDir()
+ if err != nil {
+ return nil, err
+ }
+ if cfg.modCacheDir == "" {
+ return nil, fmt.Errorf("empty value for GOMODCACHE")
+ }
+ }
+ }
+
+ getters, err := buildGetters(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
+ return newServer(getters, cfg.proxy)
+}
+
func collectPaths(args []string) []string {
var paths []string
for _, arg := range args {
@@ -158,48 +204,93 @@
return paths
}
-func buildGetters(ctx context.Context, paths []string, gopathMode bool, downloadDir string, cacheMods []internal.Modver, prox *proxy.Client) ([]fetch.ModuleGetter, error) {
- getters := buildPathGetters(ctx, paths, gopathMode)
- if downloadDir != "" {
- g, err := fetch.NewFSProxyModuleGetter(downloadDir, cacheMods)
+// getGOPATHModuleDirs returns the absolute paths to directories in GOPATH
+// corresponding to the requested module paths.
+//
+// An error is returned if any operations failed unexpectedly. If individual
+// module paths are not found, an error is logged and the path skipped. An
+// error is returned only if no module paths resolved to a GOPATH directory.
+func getGOPATHModuleDirs(ctx context.Context, modulePaths []string) ([]string, error) {
+ gopath, err := runGo("", "env", "GOPATH")
+ if err != nil {
+ return nil, err
+ }
+ gopaths := filepath.SplitList(string(gopath))
+
+ var dirs []string
+ for _, path := range modulePaths {
+ dir := ""
+ for _, gopath := range gopaths {
+ candidate := filepath.Join(gopath, "src", path)
+ info, err := os.Stat(candidate)
+ if err == nil && info.IsDir() {
+ dir = candidate
+ break
+ }
+ if err != nil && !os.IsNotExist(err) {
+ return nil, err
+ }
+ }
+ if dir == "" {
+ log.Errorf(ctx, "ERROR: no GOPATH directory contains %q", path)
+ } else {
+ dirs = append(dirs, dir)
+ }
+ }
+
+ if len(modulePaths) > 0 && len(dirs) == 0 {
+ return nil, fmt.Errorf("no GOPATH directories contain any of the requested module(s)")
+ }
+ return dirs, nil
+}
+
+// getterConfig defines the set of getters for the server to use.
+// See buildGetters.
+type getterConfig struct {
+ dirs []string // local directories to serve
+ pattern string // go/packages query to load in each directory
+ modCacheDir string // path to module cache, or ""
+ proxy *proxy.Client // proxy client, or nil
+}
+
+// buildGetters constructs module getters based on the given configuration.
+//
+// Getters are returned in the following priority order:
+// 1. local getters for cfg.dirs, in the given order
+// 2. a module cache getter, if cfg.modCacheDir != ""
+// 3. a proxy getter, if cfg.proxy != nil
+func buildGetters(ctx context.Context, cfg getterConfig) ([]fetch.ModuleGetter, error) {
+ var getters []fetch.ModuleGetter
+
+ // Load local getters for each directory.
+ for _, dir := range cfg.dirs {
+ mg, err := fetch.NewGoPackagesModuleGetter(ctx, dir, cfg.pattern)
+ if err != nil {
+ log.Errorf(ctx, "Loading packages from %s: %v", dir, err)
+ } else {
+ getters = append(getters, mg)
+ }
+ }
+ if len(getters) == 0 && len(cfg.dirs) > 0 {
+ return nil, fmt.Errorf("failed to load any module(s) at %v", cfg.dirs)
+ }
+
+ // Add a getter for the local module cache.
+ if cfg.modCacheDir != "" {
+ g, err := fetch.NewModCacheGetter(cfg.modCacheDir)
if err != nil {
return nil, err
}
getters = append(getters, g)
}
- if prox != nil {
- getters = append(getters, fetch.NewProxyModuleGetter(prox, source.NewClient(time.Second)))
+
+ // Add a proxy
+ if cfg.proxy != nil {
+ getters = append(getters, fetch.NewProxyModuleGetter(cfg.proxy, source.NewClient(time.Second)))
}
return getters, nil
}
-func buildPathGetters(ctx context.Context, paths []string, gopathMode bool) []fetch.ModuleGetter {
- var getters []fetch.ModuleGetter
- loaded := len(paths)
- for _, path := range paths {
- var (
- mg fetch.ModuleGetter
- err error
- )
- if gopathMode {
- mg, err = fetchdatasource.NewGOPATHModuleGetter(path)
- } else {
- mg, err = fetch.NewDirectoryModuleGetter("", path)
- }
- if err != nil {
- log.Error(ctx, err)
- loaded--
- } else {
- getters = append(getters, mg)
- }
- }
-
- if loaded == 0 && len(paths) > 0 {
- die("failed to load module(s) at %v", paths)
- }
- return getters
-}
-
func newServer(getters []fetch.ModuleGetter, prox *proxy.Client) (*frontend.Server, error) {
lds := fetchdatasource.Options{
Getters: getters,
@@ -232,32 +323,6 @@
return strings.TrimSpace(string(out)), nil
}
-// listedMod has a subset of the fields written by `go list -m`.
-type listedMod struct {
- internal.Modver
- GoMod string // absolute path to go.mod file; in download cache or replaced
- Indirect bool
-}
-
-var listModules = _listModules
-
-func _listModules(dir string) ([]listedMod, error) {
- out, err := runGo(dir, "list", "-json", "-m", "-mod", "readonly", "all")
- if err != nil {
- return nil, err
- }
- d := json.NewDecoder(bytes.NewReader(out))
- var ms []listedMod
- for d.More() {
- var m listedMod
- if err := d.Decode(&m); err != nil {
- return nil, err
- }
- ms = append(ms, m)
- }
- return ms, nil
-}
-
func runGo(dir string, args ...string) ([]byte, error) {
cmd := exec.Command("go", args...)
cmd.Dir = dir
@@ -267,35 +332,3 @@
}
return out, nil
}
-
-func listModsForPaths(paths []string, cacheDir string) ([]string, []internal.Modver, error) {
- var outPaths []string
- var cacheMods []internal.Modver
- for _, p := range paths {
- lms, err := listModules(p)
- if err != nil {
- return nil, nil, err
- }
- for _, lm := range lms {
- // Ignore indirect modules.
- if lm.Indirect {
- continue
- }
- if lm.GoMod == "" {
- return nil, nil, errors.New("empty GoMod: please file a pkgsite bug at https://go.dev/issues/new")
- }
- if strings.HasPrefix(lm.GoMod, cacheDir) {
- cacheMods = append(cacheMods, lm.Modver)
- } else { // probably the result of a replace directive
- outPaths = append(outPaths, filepath.Dir(lm.GoMod))
- }
- }
- }
- return outPaths, cacheMods, nil
-}
-
-func die(format string, args ...any) {
- fmt.Fprintf(os.Stderr, format, args...)
- fmt.Fprintln(os.Stderr)
- os.Exit(1)
-}
diff --git a/cmd/pkgsite/main_test.go b/cmd/pkgsite/main_test.go
index c78fda7..50b3164 100644
--- a/cmd/pkgsite/main_test.go
+++ b/cmd/pkgsite/main_test.go
@@ -16,10 +16,10 @@
"github.com/google/go-cmp/cmp"
"golang.org/x/net/html"
- "golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/proxy"
"golang.org/x/pkgsite/internal/proxy/proxytest"
"golang.org/x/pkgsite/internal/testing/htmlcheck"
+ "golang.org/x/pkgsite/internal/testing/testhelper"
)
var (
@@ -51,20 +51,19 @@
prox, teardown := proxytest.SetupTestClient(t, testModules)
defer teardown()
- localGetter := "Dir(example.com/testmod, " + abs(localModule) + ")"
+ localGetter := "Dir(" + abs(localModule) + ")"
cacheGetter := "FSProxy(" + abs(cacheDir) + ")"
for _, test := range []struct {
name string
- paths []string
- cmods []internal.Modver
+ dirs []string
cacheDir string
- prox *proxy.Client
+ proxy *proxy.Client
want []string
}{
{
- name: "local only",
- paths: []string{localModule},
- want: []string{localGetter},
+ name: "local only",
+ dirs: []string{localModule},
+ want: []string{localGetter},
},
{
name: "cache",
@@ -72,27 +71,25 @@
want: []string{cacheGetter},
},
{
- name: "proxy",
- prox: prox,
- want: []string{"Proxy"},
+ name: "proxy",
+ proxy: prox,
+ want: []string{"Proxy"},
},
{
name: "all three",
- paths: []string{localModule},
+ dirs: []string{localModule},
cacheDir: cacheDir,
- prox: prox,
+ proxy: prox,
want: []string{localGetter, cacheGetter, "Proxy"},
},
- {
- name: "list",
- paths: []string{localModule},
- cacheDir: cacheDir,
- cmods: []internal.Modver{{Path: "foo", Version: "v1.2.3"}},
- want: []string{localGetter, "FSProxy(" + abs(cacheDir) + ", foo@v1.2.3)"},
- },
} {
t.Run(test.name, func(t *testing.T) {
- getters, err := buildGetters(ctx, test.paths, false, test.cacheDir, test.cmods, test.prox)
+ getters, err := buildGetters(ctx, getterConfig{
+ dirs: test.dirs,
+ pattern: "./...",
+ modCacheDir: test.cacheDir,
+ proxy: test.proxy,
+ })
if err != nil {
t.Fatal(err)
}
@@ -119,34 +116,40 @@
return a
}
- localModule := repoPath("internal/fetch/testdata/has_go_mod")
+ localModule, _ := testhelper.WriteTxtarToTempDir(t, `
+-- go.mod --
+module example.com/testmod
+-- a.go --
+package a
+`)
cacheDir := repoPath("internal/fetch/testdata/modcache")
testModules := proxytest.LoadTestModules(repoPath("internal/proxy/testdata"))
prox, teardown := proxytest.SetupTestClient(t, testModules)
defer teardown()
- getters, err := buildGetters(context.Background(), []string{localModule}, false, cacheDir, nil, prox)
- if err != nil {
- t.Fatal(err)
+ defaultConfig := serverConfig{
+ paths: []string{localModule},
+ gopathMode: false,
+ useListedMods: true,
+ useCache: true,
+ cacheDir: cacheDir,
+ proxy: prox,
}
- server, err := newServer(getters, prox)
- if err != nil {
- t.Fatal(err)
- }
- mux := http.NewServeMux()
- server.Install(mux.Handle, nil, nil)
modcacheChecker := in("",
in(".Documentation", hasText("var V = 1")),
sourceLinks(path.Join(abs(cacheDir), "modcache.com@v1.0.0"), "a.go"))
+ ctx := context.Background()
for _, test := range []struct {
name string
+ cfg serverConfig
url string
want htmlcheck.Checker
}{
{
"local",
+ defaultConfig,
"example.com/testmod",
in("",
in(".Documentation", hasText("There is no documentation for this package.")),
@@ -154,21 +157,50 @@
},
{
"modcache",
+ defaultConfig,
"modcache.com@v1.0.0",
modcacheChecker,
},
{
"modcache latest",
+ defaultConfig,
"modcache.com",
modcacheChecker,
},
{
"proxy",
+ defaultConfig,
"example.com/single/pkg",
hasText("G is new in v1.1.0"),
},
+ {
+ "search",
+ defaultConfig,
+ "search?q=a",
+ in(".SearchResults",
+ hasText("example.com/testmod"),
+ ),
+ },
+ {
+ "search",
+ defaultConfig,
+ "search?q=zzz",
+ in(".SearchResults",
+ hasText("no matches"),
+ ),
+ },
+ // TODO(rfindley): add more tests, including a test for the standard
+ // library once it doesn't go through the stdlib package.
+ // See also golang/go#58923.
} {
t.Run(test.name, func(t *testing.T) {
+ server, err := buildServer(ctx, test.cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mux := http.NewServeMux()
+ server.Install(mux.Handle, nil, nil)
+
w := httptest.NewRecorder()
mux.ServeHTTP(w, httptest.NewRequest("GET", "/"+test.url, nil))
if w.Code != http.StatusOK {
@@ -203,36 +235,3 @@
t.Errorf("got %v, want %v", got, want)
}
}
-
-func TestListModsForPaths(t *testing.T) {
- listModules = func(string) ([]listedMod, error) {
- return []listedMod{
- {
- internal.Modver{Path: "m1", Version: "v1.2.3"},
- "/dir/cache/download/m1/@v/v1.2.3.mod",
- false,
- },
- {
- internal.Modver{Path: "m2", Version: "v1.0.0"},
- "/repos/m2/go.mod",
- false,
- },
- {
- internal.Modver{Path: "indir", Version: "v2.3.4"},
- "",
- true,
- },
- }, nil
- }
- defer func() { listModules = _listModules }()
-
- gotPaths, gotCacheMods, err := listModsForPaths([]string{"m1"}, "/dir")
- if err != nil {
- t.Fatal(err)
- }
- wantPaths := []string{"/repos/m2"}
- wantCacheMods := []internal.Modver{{Path: "m1", Version: "v1.2.3"}}
- if !cmp.Equal(gotPaths, wantPaths) || !cmp.Equal(gotCacheMods, wantCacheMods) {
- t.Errorf("got\n%v, %v\nwant\n%v, %v", gotPaths, gotCacheMods, wantPaths, wantCacheMods)
- }
-}
diff --git a/internal/datasource.go b/internal/datasource.go
index 657e1ea..72b924a 100644
--- a/internal/datasource.go
+++ b/internal/datasource.go
@@ -4,7 +4,73 @@
package internal
-import "context"
+import (
+ "context"
+ "time"
+)
+
+// SearchOptions provide information used by db.Search.
+type SearchOptions struct {
+ // Maximum number of results to return (page size).
+ MaxResults int
+
+ // Offset for DB query.
+ Offset int
+
+ // Maximum number to use for total result count.
+ MaxResultCount int
+
+ // If true, perform a symbol search.
+ SearchSymbols bool
+
+ // SymbolFilter is the word in a search query with a # prefix.
+ SymbolFilter string
+}
+
+// SearchResult represents a single search result from SearchDocuments.
+type SearchResult struct {
+ Name string
+ PackagePath string
+ ModulePath string
+ Version string
+ Synopsis string
+ Licenses []string
+
+ CommitTime time.Time
+
+ // Score is used to sort items in an array of SearchResult.
+ Score float64
+
+ // NumImportedBy is the number of packages that import PackagePath.
+ NumImportedBy uint64
+
+ // SameModule is a list of SearchResults from the same module as this one,
+ // with lower scores.
+ SameModule []*SearchResult
+
+ // OtherMajor is a map from module paths with the same series path but at
+ // different major versions of this module, to major version.
+ // The major version for a non-vN module path (either 0 or 1) is computed
+ // based on the version in search documents.
+ OtherMajor map[string]int
+
+ // NumResults is the total number of packages that were returned for this
+ // search.
+ NumResults uint64
+
+ // Symbol information returned by a search request.
+ // Only populated for symbol search mode.
+ SymbolName string
+ SymbolKind SymbolKind
+ SymbolSynopsis string
+ SymbolGOOS string
+ SymbolGOARCH string
+
+ // Offset is the 0-based number of this row in the DB query results, which
+ // is the value to use in a SQL OFFSET clause to have this row be the first
+ // one returned.
+ Offset int
+}
// DataSource is the interface used by the frontend to interact with module data.
type DataSource interface {
@@ -22,10 +88,14 @@
GetUnitMeta(ctx context.Context, path, requestedModulePath, requestedVersion string) (_ *UnitMeta, err error)
// GetModuleReadme gets the readme for the module.
GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (*Readme, error)
-
// GetLatestInfo gets information about the latest versions of a unit and module.
// See LatestInfo for documentation.
GetLatestInfo(ctx context.Context, unitPath, modulePath string, latestUnitMeta *UnitMeta) (LatestInfo, error)
+
+ // SupportsSearch reports whether this data source supports search.
+ SupportsSearch() bool
+ // Search searches for packages matching the given query.
+ Search(ctx context.Context, q string, opts SearchOptions) (_ []*SearchResult, err error)
}
// LatestInfo holds information about the latest versions and paths.
diff --git a/internal/fetch/getters.go b/internal/fetch/getters.go
index 9258924..33c4eaf 100644
--- a/internal/fetch/getters.go
+++ b/internal/fetch/getters.go
@@ -13,6 +13,9 @@
"encoding/json"
"errors"
"fmt"
+ "go/doc"
+ "go/parser"
+ "go/token"
"io"
"io/fs"
"os"
@@ -26,9 +29,12 @@
"golang.org/x/mod/module"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
+ "golang.org/x/pkgsite/internal/fuzzy"
+ "golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/proxy"
"golang.org/x/pkgsite/internal/source"
"golang.org/x/pkgsite/internal/version"
+ "golang.org/x/tools/go/packages"
)
// ModuleGetter gets module data.
@@ -60,6 +66,21 @@
String() string
}
+// SearchableModuleGetter is an additional interface that may be implemented by
+// ModuleGetters to support search.
+type SearchableModuleGetter interface {
+ // Search searches for packages matching the given query, returning at most
+ // limit results.
+ Search(ctx context.Context, q string, limit int) ([]*internal.SearchResult, error)
+}
+
+// VolatileModuleGetter is an additional interface that may be implemented by
+// ModuleGetters to support invalidating content.
+type VolatileModuleGetter interface {
+ // HasChanged reports whether the referenced module has changed.
+ HasChanged(context.Context, internal.ModuleInfo) (bool, error)
+}
+
type proxyModuleGetter struct {
prox *proxy.Client
src *source.Client
@@ -120,7 +141,6 @@
// NewDirectoryModuleGetter returns a ModuleGetter for reading a module from a directory.
func NewDirectoryModuleGetter(modulePath, dir string) (*directoryModuleGetter, error) {
-
if modulePath == "" {
goModBytes, err := os.ReadFile(filepath.Join(dir, "go.mod"))
if err != nil {
@@ -203,39 +223,313 @@
return fmt.Sprintf("Dir(%s, %s)", g.modulePath, g.dir)
}
-// An fsProxyModuleGetter gets modules from a directory in the filesystem that
-// is organized like the module cache, with a cache/download directory that has
-// paths that correspond to proxy URLs. An example of such a directory is
-// $(go env GOMODCACHE).
-type fsProxyModuleGetter struct {
- dir string
- allowed map[internal.Modver]bool
+// A goPackagesModuleGetter is a ModuleGetter whose source is go/packages.Load
+// from a directory in the local file system.
+type goPackagesModuleGetter struct {
+ dir string // directory from which go/packages was run
+ packages []*packages.Package // all packages
+ modules []*packages.Module // modules references by packagages; sorted by path
}
-// NewFSProxyModuleGetter returns a ModuleGetter that reads modules from a filesystem
+// NewGoPackagesModuleGetter returns a ModuleGetter that loads packages using
+// go/packages.Load(pattern), from the requested directory.
+func NewGoPackagesModuleGetter(ctx context.Context, dir string, pattern string) (*goPackagesModuleGetter, error) {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return nil, err
+ }
+ start := time.Now()
+ cfg := &packages.Config{
+ Context: ctx,
+ Dir: abs,
+ Mode: packages.NeedName |
+ packages.NeedModule |
+ packages.NeedCompiledGoFiles |
+ packages.NeedFiles,
+ }
+ pkgs, err := packages.Load(cfg, pattern)
+ log.Infof(ctx, "go/packages.Load(%q) loaded %d packages from %s in %v", pattern, len(pkgs), dir, time.Since(start))
+ if err != nil {
+ return nil, err
+ }
+
+ // Collect reachable modules. Modules must be sorted for search.
+ moduleSet := make(map[string]*packages.Module)
+ for _, pkg := range pkgs {
+ if pkg.Module != nil {
+ moduleSet[pkg.Module.Path] = pkg.Module
+ }
+ }
+ var modules []*packages.Module
+ for _, m := range moduleSet {
+ modules = append(modules, m)
+ }
+ sort.Slice(modules, func(i, j int) bool {
+ return modules[i].Path < modules[j].Path
+ })
+
+ return &goPackagesModuleGetter{
+ dir: abs,
+ packages: pkgs,
+ modules: modules,
+ }, nil
+}
+
+// findModule searches known modules for a module matching the provided path.
+func (g *goPackagesModuleGetter) findModule(path string) (*packages.Module, error) {
+ i := sort.Search(len(g.modules), func(i int) bool {
+ return g.modules[i].Path >= path
+ })
+ if i >= len(g.modules) || g.modules[i].Path != path {
+ return nil, fmt.Errorf("%w: no module with path %q", derrors.NotFound, path)
+ }
+ return g.modules[i], nil
+}
+
+// Info returns basic information about the module.
+//
+// For invalidation of locally edited modules, the time of the resulting
+// version is set to the latest mtime of a file referenced by any compiled file
+// in the module.
+func (g *goPackagesModuleGetter) Info(ctx context.Context, modulePath, version string) (*proxy.VersionInfo, error) {
+ m, err := g.findModule(modulePath)
+ if err != nil {
+ return nil, err
+ }
+ v := LocalVersion
+ if m.Version != "" {
+ v = m.Version
+ }
+ // Note: if we ever support loading dependencies out of the module cache, we
+ // may have a valid m.Time to use here.
+ var t time.Time
+ mtime, err := g.mtime(ctx, m)
+ if err != nil {
+ return nil, err
+ }
+ if mtime != nil {
+ t = *mtime
+ } else {
+ t = LocalCommitTime
+ }
+ return &proxy.VersionInfo{
+ Version: v,
+ Time: t,
+ }, nil
+}
+
+// mtime returns the latest modification time of a compiled Go file contained
+// in a package in the module.
+//
+// TODO(rfindley): we should probably walk the entire module directory, so that
+// we pick up new or deleted go files, but must be careful about nested
+// modules.
+func (g *goPackagesModuleGetter) mtime(ctx context.Context, m *packages.Module) (*time.Time, error) {
+ var mtime *time.Time
+ for _, pkg := range g.packages {
+ if pkg.Module != nil && pkg.Module.Path == m.Path {
+ for _, f := range pkg.CompiledGoFiles {
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ fi, err := os.Stat(f)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
+ return nil, err
+ }
+ if mtime == nil || fi.ModTime().After(*mtime) {
+ modTime := fi.ModTime()
+ mtime = &modTime
+ }
+ }
+ }
+ }
+
+ // If mtime is recent, it may be unrelable as due to system time resolution
+ // we may yet receive another edit within the same tick.
+ if mtime != nil && time.Since(*mtime) < 2*time.Second {
+ return nil, nil
+ }
+
+ return mtime, nil
+}
+
+// Mod returns the contents of the module's go.mod file.
+// If the file does not exist, it returns a synthesized one.
+func (g *goPackagesModuleGetter) Mod(ctx context.Context, modulePath, version string) ([]byte, error) {
+ m, err := g.findModule(modulePath)
+ if err != nil {
+ return nil, err
+ }
+ if m.Dir == "" {
+ return nil, fmt.Errorf("module %q missing dir", modulePath)
+ }
+ data, err := os.ReadFile(filepath.Join(m.Dir, "go.mod"))
+ if errors.Is(err, os.ErrNotExist) {
+ return []byte(fmt.Sprintf("module %s\n", modulePath)), nil
+ }
+ return data, err
+}
+
+// ContentDir returns an fs.FS for the module's contents.
+func (g *goPackagesModuleGetter) ContentDir(ctx context.Context, modulePath, version string) (fs.FS, error) {
+ m, err := g.findModule(modulePath)
+ if err != nil {
+ return nil, err
+ }
+ if m.Dir == "" {
+ return nil, fmt.Errorf("module %q missing dir", modulePath)
+ }
+ return os.DirFS(m.Dir), nil
+}
+
+// SourceInfo returns a source.Info that will link to the files in the
+// directory. The files will be under /files/directory/modulePath, with no
+// version.
+func (g *goPackagesModuleGetter) SourceInfo(ctx context.Context, modulePath, _ string) (*source.Info, error) {
+ m, err := g.findModule(modulePath)
+ if err != nil {
+ return nil, err
+ }
+ if m.Dir == "" {
+ return nil, fmt.Errorf("module %q missing dir", modulePath)
+ }
+ p := path.Join(filepath.ToSlash(g.dir), modulePath)
+ return source.FilesInfo(p), nil
+}
+
+// Open implements the fs.FS interface, matching the path name to a loaded
+// module.
+func (g *goPackagesModuleGetter) Open(name string) (fs.File, error) {
+ var bestMatch *packages.Module
+ for _, m := range g.modules {
+ if strings.HasPrefix(name+"/", m.Path+"/") {
+ if bestMatch == nil || m.Path > bestMatch.Path {
+ bestMatch = m
+ }
+ }
+ }
+ if bestMatch == nil {
+ return nil, fmt.Errorf("no module matching %s", name)
+ }
+ suffix := strings.TrimPrefix(name, bestMatch.Path)
+ suffix = strings.TrimPrefix(suffix, "/")
+ filename := filepath.Join(bestMatch.Dir, filepath.FromSlash(suffix))
+ return os.Open(filename)
+}
+
+func (g *goPackagesModuleGetter) SourceFS() (string, fs.FS) {
+ return filepath.ToSlash(g.dir), g
+}
+
+// For testing.
+func (g *goPackagesModuleGetter) String() string {
+ return fmt.Sprintf("Dir(%s)", g.dir)
+}
+
+// Search implements a crude search, using fuzzy matching to match loaded
+// packages.
+//
+// It parses file headers to produce a synopsis of results.
+func (g *goPackagesModuleGetter) Search(ctx context.Context, query string, limit int) ([]*internal.SearchResult, error) {
+ matcher := fuzzy.NewSymbolMatcher(query)
+
+ type scoredPackage struct {
+ pkg *packages.Package
+ score float64
+ }
+
+ var pkgs []scoredPackage
+ for _, pkg := range g.packages {
+ i, score := matcher.Match([]string{pkg.PkgPath})
+ if i < 0 {
+ continue
+ }
+ pkgs = append(pkgs, scoredPackage{pkg, score})
+ }
+
+ // Sort and truncate results before parsing, to save on work.
+ sort.Slice(pkgs, func(i, j int) bool {
+ return pkgs[i].score > pkgs[j].score
+ })
+
+ if len(pkgs) > limit {
+ pkgs = pkgs[:limit]
+ }
+
+ var results []*internal.SearchResult
+ for i, pkg := range pkgs {
+ result := &internal.SearchResult{
+ Name: pkg.pkg.Name,
+ PackagePath: pkg.pkg.PkgPath,
+ Score: pkg.score,
+ Offset: i,
+ }
+ if pkg.pkg.Module != nil {
+ result.ModulePath = pkg.pkg.Module.Path
+ result.Version = pkg.pkg.Module.Version
+ }
+ for _, file := range pkg.pkg.CompiledGoFiles {
+ mode := parser.PackageClauseOnly | parser.ParseComments
+ f, err := parser.ParseFile(token.NewFileSet(), file, nil, mode)
+ if err != nil {
+ continue
+ }
+ if f.Doc != nil {
+ result.Synopsis = doc.Synopsis(f.Doc.Text())
+ }
+ }
+ results = append(results, result)
+ }
+ return results, nil
+}
+
+// HasChanged stats the filesystem to see if content has changed for the
+// provided module. It compares the latest mtime of package files to the time
+// recorded in info.CommitTime, which stores the last observed mtime.
+func (g *goPackagesModuleGetter) HasChanged(ctx context.Context, info internal.ModuleInfo) (bool, error) {
+ m, err := g.findModule(info.ModulePath)
+ if err != nil {
+ return false, err
+ }
+ mtime, err := g.mtime(ctx, m)
+ if err != nil {
+ return false, err
+ }
+ return mtime == nil || mtime.After(info.CommitTime), nil
+}
+
+// A modCacheModuleGetter gets modules from a directory in the filesystem that
+// is organized like the module cache, with a cache/download directory that has
+// paths that correspond to proxy URLs. An example of such a directory is $(go
+// env GOMODCACHE).
+//
+// TODO(rfindley): it would be easy and useful to add support for Search to
+// this getter.
+type modCacheModuleGetter struct {
+ dir string
+}
+
+// NewModCacheGetter returns a ModuleGetter that reads modules from a filesystem
// directory organized like the proxy.
// If allowed is non-empty, only module@versions in allowed are served; others
// result in NotFound errors.
-func NewFSProxyModuleGetter(dir string, allowed []internal.Modver) (_ *fsProxyModuleGetter, err error) {
+func NewModCacheGetter(dir string) (_ *modCacheModuleGetter, err error) {
defer derrors.Wrap(&err, "NewFSProxyModuleGetter(%q)", dir)
abs, err := filepath.Abs(dir)
if err != nil {
return nil, err
}
- g := &fsProxyModuleGetter{dir: abs}
- if len(allowed) > 0 {
- g.allowed = map[internal.Modver]bool{}
- for _, a := range allowed {
- g.allowed[a] = true
- }
- }
+ g := &modCacheModuleGetter{dir: abs}
return g, nil
}
// Info returns basic information about the module.
-func (g *fsProxyModuleGetter) Info(ctx context.Context, path, vers string) (_ *proxy.VersionInfo, err error) {
- defer derrors.Wrap(&err, "fsProxyModuleGetter.Info(%q, %q)", path, vers)
+func (g *modCacheModuleGetter) Info(ctx context.Context, path, vers string) (_ *proxy.VersionInfo, err error) {
+ defer derrors.Wrap(&err, "modCacheGetter.Info(%q, %q)", path, vers)
if vers == version.Latest {
vers, err = g.latestVersion(path)
@@ -262,8 +556,8 @@
}
// Mod returns the contents of the module's go.mod file.
-func (g *fsProxyModuleGetter) Mod(ctx context.Context, path, vers string) (_ []byte, err error) {
- defer derrors.Wrap(&err, "fsProxyModuleGetter.Mod(%q, %q)", path, vers)
+func (g *modCacheModuleGetter) Mod(ctx context.Context, path, vers string) (_ []byte, err error) {
+ defer derrors.Wrap(&err, "modCacheModuleGetter.Mod(%q, %q)", path, vers)
if vers == version.Latest {
vers, err = g.latestVersion(path)
@@ -282,8 +576,8 @@
}
// ContentDir returns an fs.FS for the module's contents.
-func (g *fsProxyModuleGetter) ContentDir(ctx context.Context, path, vers string) (_ fs.FS, err error) {
- defer derrors.Wrap(&err, "fsProxyModuleGetter.ContentDir(%q, %q)", path, vers)
+func (g *modCacheModuleGetter) ContentDir(ctx context.Context, path, vers string) (_ fs.FS, err error) {
+ defer derrors.Wrap(&err, "modCacheModuleGetter.ContentDir(%q, %q)", path, vers)
if vers == version.Latest {
vers, err = g.latestVersion(path)
@@ -305,19 +599,19 @@
// SourceInfo returns a source.Info that will create /files links to modules in
// the cache.
-func (g *fsProxyModuleGetter) SourceInfo(ctx context.Context, mpath, version string) (*source.Info, error) {
+func (g *modCacheModuleGetter) SourceInfo(ctx context.Context, mpath, version string) (*source.Info, error) {
return source.FilesInfo(path.Join(g.dir, mpath+"@"+version)), nil
}
// SourceFS returns the absolute path to the cache, and an FS that retrieves
// files from it.
-func (g *fsProxyModuleGetter) SourceFS() (string, fs.FS) {
+func (g *modCacheModuleGetter) SourceFS() (string, fs.FS) {
return filepath.ToSlash(g.dir), os.DirFS(g.dir)
}
// latestVersion gets the latest version that is in the directory.
-func (g *fsProxyModuleGetter) latestVersion(modulePath string) (_ string, err error) {
- defer derrors.Wrap(&err, "fsProxyModuleGetter.latestVersion(%q)", modulePath)
+func (g *modCacheModuleGetter) latestVersion(modulePath string) (_ string, err error) {
+ defer derrors.Wrap(&err, "modCacheModuleGetter.latestVersion(%q)", modulePath)
dir, err := g.moduleDir(modulePath)
if err != nil {
@@ -333,27 +627,13 @@
var versions []string
for _, z := range zips {
vers := strings.TrimSuffix(filepath.Base(z), ".zip")
- if g.allow(modulePath, vers) {
- versions = append(versions, vers)
- }
- }
- if len(versions) == 0 {
- return "", fmt.Errorf("no allowed versions form module %q: %w", modulePath, derrors.NotFound)
+ versions = append(versions, vers)
}
return version.LatestOf(versions), nil
}
-// allow reports whether the module path and version may be served.
-func (g *fsProxyModuleGetter) allow(path, vers string) bool {
- return g.allowed == nil || g.allowed[internal.Modver{Path: path, Version: vers}]
-}
-
-func (g *fsProxyModuleGetter) readFile(path, version, suffix string) (_ []byte, err error) {
- defer derrors.Wrap(&err, "fsProxyModuleGetter.readFile(%q, %q, %q)", path, version, suffix)
-
- if !g.allow(path, version) {
- return nil, fmt.Errorf("%s@%s not allowed: %w", path, version, derrors.NotFound)
- }
+func (g *modCacheModuleGetter) readFile(path, version, suffix string) (_ []byte, err error) {
+ defer derrors.Wrap(&err, "modCacheModuleGetter.readFile(%q, %q, %q)", path, version, suffix)
f, err := g.openFile(path, version, suffix)
if err != nil {
@@ -363,7 +643,7 @@
return io.ReadAll(f)
}
-func (g *fsProxyModuleGetter) openFile(path, version, suffix string) (_ *os.File, err error) {
+func (g *modCacheModuleGetter) openFile(path, version, suffix string) (_ *os.File, err error) {
epath, err := g.escapedPath(path, version, suffix)
if err != nil {
return nil, err
@@ -378,7 +658,7 @@
return f, nil
}
-func (g *fsProxyModuleGetter) escapedPath(modulePath, version, suffix string) (string, error) {
+func (g *modCacheModuleGetter) escapedPath(modulePath, version, suffix string) (string, error) {
dir, err := g.moduleDir(modulePath)
if err != nil {
return "", err
@@ -390,7 +670,7 @@
return filepath.Join(dir, fmt.Sprintf("%s.%s", ev, suffix)), nil
}
-func (g *fsProxyModuleGetter) moduleDir(modulePath string) (string, error) {
+func (g *modCacheModuleGetter) moduleDir(modulePath string) (string, error) {
ep, err := module.EscapePath(modulePath)
if err != nil {
return "", fmt.Errorf("path: %v: %w", err, derrors.InvalidArgument)
@@ -399,14 +679,6 @@
}
// For testing.
-func (g *fsProxyModuleGetter) String() string {
- if g.allowed == nil {
- return fmt.Sprintf("FSProxy(%s)", g.dir)
- }
- var as []string
- for a := range g.allowed {
- as = append(as, a.String())
- }
- sort.Strings(as)
- return fmt.Sprintf("FSProxy(%s, %s)", g.dir, strings.Join(as, ","))
+func (g *modCacheModuleGetter) String() string {
+ return fmt.Sprintf("FSProxy(%s)", g.dir)
}
diff --git a/internal/fetch/getters_test.go b/internal/fetch/getters_test.go
index c29bd9a..77fe56b 100644
--- a/internal/fetch/getters_test.go
+++ b/internal/fetch/getters_test.go
@@ -8,14 +8,16 @@
"context"
"errors"
"io"
+ "io/fs"
+ "os"
"path/filepath"
"testing"
"time"
"github.com/google/go-cmp/cmp"
- "golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/proxy"
+ "golang.org/x/pkgsite/internal/testing/testhelper"
"golang.org/x/pkgsite/internal/version"
)
@@ -34,6 +36,186 @@
}
}
+const multiModule = `
+-- go.work --
+go 1.21
+
+use (
+ ./foo
+ ./bar
+)
+
+-- foo/go.mod --
+module foo.com/foo
+
+go 1.21
+-- foo/foolog/f.go --
+package foolog
+
+const Log = 1
+-- bar/go.mod --
+module bar.com/bar
+
+go 1.20
+-- bar/barlog/b.go --
+package barlog
+
+const Log = 1
+`
+
+func TestGoPackagesModuleGetter(t *testing.T) {
+ modulePaths := map[string]string{ // dir -> module path
+ "foo": "foo.com/foo",
+ "bar": "bar.com/bar",
+ }
+
+ tests := []struct {
+ name string
+ dir string
+ }{
+ {"work dir", "."},
+ {"module dir", "foo"},
+ {"nested package dir", "foo/foolog"},
+ }
+
+ ctx := context.Background()
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ tempDir, files := testhelper.WriteTxtarToTempDir(t, multiModule)
+ dir := filepath.Join(tempDir, test.dir)
+
+ g, err := NewGoPackagesModuleGetter(ctx, dir, "all")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for moduleDir, modulePath := range modulePaths {
+ t.Run("info", func(t *testing.T) {
+ got, err := g.Info(ctx, modulePath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, want := got.Version, LocalVersion; got != want {
+ t.Errorf("Info(%s): got version %s, want %s", modulePath, got, want)
+ }
+ })
+
+ mod := files[moduleDir+"/go.mod"]
+ t.Run("mod", func(t *testing.T) {
+ got, err := g.Mod(ctx, modulePath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(mod, string(got)); diff != "" {
+ t.Errorf("Mod(%q) mismatch [-want +got]:\n%s", modulePath, diff)
+ }
+ })
+
+ t.Run("contentdir", func(t *testing.T) {
+ fsys, err := g.ContentDir(ctx, modulePath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Just check that the go.mod file is there and has the right contents.
+ got, err := fs.ReadFile(fsys, "go.mod")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(mod, string(got)); diff != "" {
+ t.Errorf("fs.ReadFile(ContentDir(%q), %q) mismatch [-want +got]:\n%s", modulePath, "go.mod", diff)
+ }
+ })
+
+ t.Run("search", func(t *testing.T) {
+ tests := []struct {
+ query string
+ want []string
+ }{
+ {"log", []string{"barlog", "foolog"}},
+ {"barlog", []string{"barlog"}},
+ {"xxxxxx", nil},
+ }
+
+ for _, test := range tests {
+ results, err := g.Search(ctx, test.query, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got []string
+ for _, r := range results {
+ got = append(got, r.Name)
+ }
+ if diff := cmp.Diff(test.want, got); diff != "" {
+ t.Errorf("Search(%s) mismatch [-want +got]:\n%s", test.query, diff)
+ }
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestGoPackagesModuleGetter_Invalidation(t *testing.T) {
+ ctx := context.Background()
+
+ tempDir, _ := testhelper.WriteTxtarToTempDir(t, multiModule)
+
+ // Sleep before fetching the initial info, so that the written mtime will be
+ // considered reliable enough for caching by the getter.
+ time.Sleep(3 * time.Second)
+
+ g, err := NewGoPackagesModuleGetter(ctx, tempDir, "all")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const fooPath = "foo.com/foo"
+ foo1, err := g.Info(ctx, fooPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ foo2, err := g.Info(ctx, fooPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cmp.Equal(foo1, foo2) {
+ t.Errorf("Info(%q) returned inconsistent results: %v != %v", fooPath, foo1, foo2)
+ }
+
+ const barPath = "bar.com/bar"
+ bar1, err := g.Info(ctx, barPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ bar2, err := g.Info(ctx, barPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cmp.Equal(bar1, bar2) {
+ t.Errorf("Info(%q) returned inconsistent results: %v != %v", barPath, bar1, bar2)
+ }
+
+ fpath := filepath.Join(tempDir, "foo", "foolog", "f.go")
+ newContent := []byte("package foolog; const Log = 3")
+ if err := os.WriteFile(fpath, newContent, 0600); err != nil {
+ t.Fatal(err)
+ }
+ foo3, err := g.Info(ctx, fooPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cmp.Equal(foo1, foo3) {
+ t.Errorf("Info(%q) results unexpectedly match: %v == %v", fooPath, foo1, foo3)
+ }
+ bar3, err := g.Info(ctx, barPath, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cmp.Equal(bar1, bar2) {
+ t.Errorf("Info(%q) returned inconsistent results: %v != %v", barPath, bar1, bar3)
+ }
+}
+
func TestEscapedPath(t *testing.T) {
for _, test := range []struct {
path, version, suffix string
@@ -48,7 +230,7 @@
"dir/cache/download/github.com/a!bc/@v/v2.3.4.zip",
},
} {
- g, err := NewFSProxyModuleGetter("dir", nil)
+ g, err := NewModCacheGetter("dir")
if err != nil {
t.Fatal(err)
}
@@ -77,7 +259,7 @@
if err != nil {
t.Fatal(err)
}
- g, err := NewFSProxyModuleGetter("testdata/modcache", nil)
+ g, err := NewModCacheGetter("testdata/modcache")
if err != nil {
t.Fatal(err)
}
@@ -143,37 +325,3 @@
}
})
}
-
-func TestFSProxyGetterAllowed(t *testing.T) {
- ctx := context.Background()
- // The cache contains:
- // github.com/jackc/pgio@v1.0.0
- // modcache.com@v1.0.0
- // Allow only the latter.
- allow := []internal.Modver{{Path: "modcache.com", Version: "v1.0.0"}}
- g, err := NewFSProxyModuleGetter("testdata/modcache", allow)
- if err != nil {
- t.Fatal(err)
- }
-
- check := func(path, vers string, wantOK bool) {
- t.Helper()
- _, err := g.Info(ctx, path, vers)
- if (err == nil) != wantOK {
- s := "a"
- if wantOK {
- s = "no"
- }
- t.Errorf("got %v; wanted %s error", err, s)
- } else if err != nil && !errors.Is(err, derrors.NotFound) {
- t.Errorf("got %v, wanted NotFound", err)
- }
- }
-
- // We can get modcache.com.
- check("modcache.com", "v1.0.0", true)
- check("modcache.com", "latest", true)
- // We get NotFound for jackc.
- check("github.com/jackc/pgio", "v1.0.0", false)
- check("github.com/jackc/pgio", "latest", false)
-}
diff --git a/internal/fetchdatasource/fetchdatasource.go b/internal/fetchdatasource/fetchdatasource.go
index 87a69fb..efcd20e 100644
--- a/internal/fetchdatasource/fetchdatasource.go
+++ b/internal/fetchdatasource/fetchdatasource.go
@@ -11,6 +11,7 @@
"context"
"errors"
"fmt"
+ "sort"
"strconv"
"strings"
"time"
@@ -22,6 +23,7 @@
"golang.org/x/pkgsite/internal/fetch"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/proxy"
+ "golang.org/x/pkgsite/internal/stdlib"
"golang.org/x/pkgsite/internal/version"
)
@@ -61,6 +63,7 @@
// cacheEntry holds a fetched module or an error, if the fetch failed.
type cacheEntry struct {
+ g fetch.ModuleGetter
module *internal.Module
err error
}
@@ -68,21 +71,21 @@
const maxCachedModules = 100
// cacheGet returns information from the cache if it is present, and (nil, nil) otherwise.
-func (ds *FetchDataSource) cacheGet(path, version string) (*internal.Module, error) {
+func (ds *FetchDataSource) cacheGet(path, version string) (fetch.ModuleGetter, *internal.Module, error) {
// Look for an exact match first, then use LocalVersion, as for a
// directory-based or GOPATH-mode module.
for _, v := range []string{version, fetch.LocalVersion} {
if e, ok := ds.cache.Get(internal.Modver{Path: path, Version: v}); ok {
e := e.(cacheEntry)
- return e.module, e.err
+ return e.g, e.module, e.err
}
}
- return nil, nil
+ return nil, nil, nil
}
// cachePut puts information into the cache.
-func (ds *FetchDataSource) cachePut(path, version string, m *internal.Module, err error) {
- ds.cache.Add(internal.Modver{Path: path, Version: version}, cacheEntry{m, err})
+func (ds *FetchDataSource) cachePut(g fetch.ModuleGetter, path, version string, m *internal.Module, err error) {
+ ds.cache.Add(internal.Modver{Path: path, Version: version}, cacheEntry{g, m, err})
}
// getModule gets the module at the given path and version. It first checks the
@@ -90,15 +93,30 @@
func (ds *FetchDataSource) getModule(ctx context.Context, modulePath, vers string) (_ *internal.Module, err error) {
defer derrors.Wrap(&err, "FetchDataSource.getModule(%q, %q)", modulePath, vers)
- mod, err := ds.cacheGet(modulePath, vers)
- if mod != nil || err != nil {
- return mod, err
+ g, mod, err := ds.cacheGet(modulePath, vers)
+ if err != nil {
+ return nil, err
+ }
+ if mod != nil {
+ // For getters supporting invalidation, check whether cached contents have
+ // changed.
+ v, ok := g.(fetch.VolatileModuleGetter)
+ if !ok {
+ return mod, nil
+ }
+ hasChanged, err := v.HasChanged(ctx, mod.ModuleInfo)
+ if err != nil {
+ return nil, err
+ }
+ if !hasChanged {
+ return mod, nil
+ }
}
// There can be a benign race here, where two goroutines both fetch the same
// module. At worst some work will be duplicated, but if that turns out to
// be a problem we could use golang.org/x/sync/singleflight.
- m, err := ds.fetch(ctx, modulePath, vers)
+ m, g, err := ds.fetch(ctx, modulePath, vers)
if m != nil && ds.opts.ProxyClientForLatest != nil {
// Use the go.mod file at the raw latest version to fill in deprecation
// and retraction information. Ignore any problems getting the
@@ -118,11 +136,11 @@
// Cache both successes and failures, but not cancellations.
if !errors.Is(err, context.Canceled) {
- ds.cachePut(modulePath, vers, m, err)
+ ds.cachePut(g, modulePath, vers, m, err)
// Cache the resolved version of "latest" too. A useful optimization
// because the frontend redirects "latest", resulting in another fetch.
if m != nil && vers == version.Latest {
- ds.cachePut(modulePath, m.Version, m, err)
+ ds.cachePut(g, modulePath, m.Version, m, err)
}
}
return m, err
@@ -130,11 +148,11 @@
// fetch fetches a module using the configured ModuleGetters.
// It tries each getter in turn until it finds one that has the module.
-func (ds *FetchDataSource) fetch(ctx context.Context, modulePath, version string) (_ *internal.Module, err error) {
+func (ds *FetchDataSource) fetch(ctx context.Context, modulePath, version string) (_ *internal.Module, g fetch.ModuleGetter, err error) {
log.Infof(ctx, "FetchDataSource: fetching %s@%s", modulePath, version)
start := time.Now()
defer func() {
- log.Infof(ctx, "FetchDataSource: fetched %s@%s in %s with error %v", modulePath, version, time.Since(start), err)
+ log.Infof(ctx, "FetchDataSource: fetched %s@%s using %T in %s with error %v", modulePath, version, g, time.Since(start), err)
}()
for _, g := range ds.opts.Getters {
fr := fetch.FetchModule(ctx, modulePath, version, g)
@@ -148,13 +166,22 @@
} else {
m.RemoveNonRedistributableData()
}
- return m, nil
+ // There is special handling in FetchModule for the standard library,
+ // that bypasses the getter g. Don't record g as having fetch std.
+ //
+ // TODO(rfindley): it would be cleaner if the standard library could be
+ // its own module getter. This could also allow the go/packages getter to
+ // serve existing on-disk content for std. See also golang/go#58923.
+ if modulePath == stdlib.ModulePath {
+ g = nil
+ }
+ return m, g, nil
}
if !errors.Is(fr.Error, derrors.NotFound) {
- return nil, fr.Error
+ return nil, g, fr.Error
}
}
- return nil, fmt.Errorf("%s@%s: %w", modulePath, version, derrors.NotFound)
+ return nil, nil, fmt.Errorf("%s@%s: %w", modulePath, version, derrors.NotFound)
}
func (ds *FetchDataSource) populateUnitSubdirectories(u *internal.Unit, m *internal.Module) {
@@ -350,3 +377,47 @@
func (*FetchDataSource) GetModuleReadme(ctx context.Context, modulePath, resolvedVersion string) (*internal.Readme, error) {
return nil, nil
}
+
+// SupportsSearch reports whether any of the configured Getters are searchable.
+func (ds *FetchDataSource) SupportsSearch() bool {
+ for _, g := range ds.opts.Getters {
+ if _, ok := g.(fetch.SearchableModuleGetter); ok {
+ return true
+ }
+ }
+ return false
+}
+
+// Search delegates search to any configured getters that support the
+// SearchableModuleGetter interface, merging their results.
+func (ds *FetchDataSource) Search(ctx context.Context, q string, opts internal.SearchOptions) (_ []*internal.SearchResult, err error) {
+ var results []*internal.SearchResult
+ // Since results are potentially merged from multiple sources, we can't know
+ // a priori how many results will be used from any particular getter.
+ //
+ // Offset+MaxResults is an upper bound.
+ limit := opts.Offset + opts.MaxResults
+ for _, g := range ds.opts.Getters {
+ if s, ok := g.(fetch.SearchableModuleGetter); ok {
+ rs, err := s.Search(ctx, q, limit)
+ if err != nil {
+ return nil, err
+ }
+ results = append(results, rs...)
+ }
+ }
+ sort.Slice(results, func(i, j int) bool {
+ return results[i].Score > results[j].Score
+ })
+ if opts.Offset > 0 {
+ if len(results) < opts.Offset {
+ return nil, nil
+ }
+ results = results[opts.Offset:]
+ }
+ if opts.MaxResults > 0 && len(results) > opts.MaxResults {
+ results = results[:opts.MaxResults]
+ }
+
+ return results, nil
+}
diff --git a/internal/fetchdatasource/fetchdatasource_test.go b/internal/fetchdatasource/fetchdatasource_test.go
index e433428..ecc63fd 100644
--- a/internal/fetchdatasource/fetchdatasource_test.go
+++ b/internal/fetchdatasource/fetchdatasource_test.go
@@ -95,13 +95,14 @@
dirs []string
getters []fetch.ModuleGetter
)
+ ctx := context.Background()
for _, module := range modules {
directory, err := testhelper.CreateTestDirectory(module)
if err != nil {
log.Fatal(err)
}
dirs = append(dirs, directory)
- mg, err := fetch.NewDirectoryModuleGetter("", directory)
+ mg, err := fetch.NewGoPackagesModuleGetter(ctx, directory, "./...")
if err != nil {
log.Fatal(err)
}
@@ -355,7 +356,6 @@
ModuleInfo: internal.ModuleInfo{
ModulePath: "github.com/my/module",
Version: fetch.LocalVersion,
- CommitTime: fetch.LocalCommitTime,
IsRedistributable: true,
HasGoMod: true,
SourceInfo: sourceInfo,
@@ -372,7 +372,6 @@
ModuleInfo: internal.ModuleInfo{
ModulePath: "github.com/my/module",
Version: fetch.LocalVersion,
- CommitTime: fetch.LocalCommitTime,
IsRedistributable: true,
HasGoMod: true,
SourceInfo: sourceInfo,
@@ -390,7 +389,6 @@
ModulePath: "github.com/my/module",
IsRedistributable: true,
Version: fetch.LocalVersion,
- CommitTime: fetch.LocalCommitTime,
HasGoMod: true,
SourceInfo: sourceInfo,
},
@@ -407,7 +405,6 @@
ModuleInfo: internal.ModuleInfo{
ModulePath: "github.com/my/module",
Version: fetch.LocalVersion,
- CommitTime: fetch.LocalCommitTime,
IsRedistributable: true,
HasGoMod: true,
SourceInfo: sourceInfo,
@@ -453,7 +450,12 @@
if !matched {
t.Errorf("RepoURL: got %q, want match of %q", gotURL, wantRegexp)
}
- diff := cmp.Diff(test.want, got, cmp.AllowUnexported(source.Info{}), cmpopts.IgnoreFields(source.Info{}, "repoURL"))
+ opts := []cmp.Option{
+ cmp.AllowUnexported(source.Info{}),
+ cmpopts.IgnoreFields(source.Info{}, "repoURL"),
+ cmpopts.IgnoreFields(internal.ModuleInfo{}, "CommitTime"), // commit time is volatile, based on file mtimes
+ }
+ diff := cmp.Diff(test.want, got, opts...)
if diff != "" {
t.Errorf("mismatch (-want +got):\n%s", diff)
}
@@ -564,8 +566,8 @@
func TestCache(t *testing.T) {
ds := Options{}.New()
m1 := &internal.Module{}
- ds.cachePut("m1", fetch.LocalVersion, m1, nil)
- ds.cachePut("m2", "v1.0.0", nil, derrors.NotFound)
+ ds.cachePut(nil, "m1", fetch.LocalVersion, m1, nil)
+ ds.cachePut(nil, "m2", "v1.0.0", nil, derrors.NotFound)
for _, test := range []struct {
path, version string
@@ -577,7 +579,7 @@
{"m2", "v1.0.0", nil, derrors.NotFound},
{"m3", "v1.0.0", nil, nil},
} {
- gotm, gote := ds.cacheGet(test.path, test.version)
+ _, gotm, gote := ds.cacheGet(test.path, test.version)
if gotm != test.wantm || gote != test.wante {
t.Errorf("%s@%s: got (%v, %v), want (%v, %v)", test.path, test.version, gotm, gote, test.wantm, test.wante)
}
diff --git a/internal/fetchdatasource/gopath_getter.go b/internal/fetchdatasource/gopath_getter.go
deleted file mode 100644
index 8b4b9a3..0000000
--- a/internal/fetchdatasource/gopath_getter.go
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright 2021 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 fetchdatasource
-
-import (
- "fmt"
- "os"
- "path/filepath"
-
- "golang.org/x/pkgsite/internal/derrors"
- "golang.org/x/pkgsite/internal/fetch"
-)
-
-// NewGOPATHModuleGetter returns a module getter that uses the GOPATH
-// environment variable to find the module with the given import path.
-func NewGOPATHModuleGetter(importPath string) (_ fetch.ModuleGetter, err error) {
- defer derrors.Wrap(&err, "NewGOPATHModuleGetter(%q)", importPath)
-
- dir := getFullPath(importPath)
- if dir == "" {
- return nil, fmt.Errorf("path %s doesn't exist: %w", importPath, derrors.NotFound)
- }
- return fetch.NewDirectoryModuleGetter(importPath, dir)
-}
-
-// getFullPath takes an import path, tests it relative to each GOPATH, and returns
-// a full path to the module. If the given import path doesn't exist in any GOPATH,
-// an empty string is returned.
-func getFullPath(modulePath string) string {
- gopaths := filepath.SplitList(os.Getenv("GOPATH"))
- for _, gopath := range gopaths {
- path := filepath.Join(gopath, "src", modulePath)
- info, err := os.Stat(path)
- if err == nil && info.IsDir() {
- return path
- }
- }
- return ""
-}
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index 5a0778c..a05e350 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -62,8 +62,8 @@
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return nil, &serverError{status: http.StatusMethodNotAllowed}
}
- db, ok := ds.(*postgres.DB)
- if !ok {
+
+ if !ds.SupportsSearch() {
// The proxydatasource does not support the imported by page.
return nil, datasourceNotSupportedErr()
}
@@ -129,7 +129,7 @@
if len(filters) > 0 {
symbol = filters[0]
}
- page, err := fetchSearchPage(ctx, db, cq, symbol, pageParams, mode == searchModeSymbol, vulnClient)
+ page, err := fetchSearchPage(ctx, ds, cq, symbol, pageParams, mode == searchModeSymbol, vulnClient)
if err != nil {
// Instead of returning a 500, return a 408, since symbol searches may
// timeout for very popular symbols.
@@ -231,13 +231,13 @@
// fetchSearchPage fetches data matching the search query from the database and
// returns a SearchPage.
-func fetchSearchPage(ctx context.Context, db *postgres.DB, cq, symbol string,
+func fetchSearchPage(ctx context.Context, ds internal.DataSource, cq, symbol string,
pageParams paginationParams, searchSymbols bool, vulnClient *vuln.Client) (*SearchPage, error) {
maxResultCount := maxSearchOffset + pageParams.limit
// Pageless search: always start from the beginning.
offset := 0
- dbresults, err := db.Search(ctx, cq, postgres.SearchOptions{
+ dbresults, err := ds.Search(ctx, cq, postgres.SearchOptions{
MaxResults: pageParams.limit,
Offset: offset,
MaxResultCount: maxResultCount,
diff --git a/internal/fuzzy/fuzzy.go b/internal/fuzzy/fuzzy.go
new file mode 100644
index 0000000..6f95b45
--- /dev/null
+++ b/internal/fuzzy/fuzzy.go
@@ -0,0 +1,239 @@
+// Copyright 2021 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 fuzzy
+
+// Copied from x/tools/internal/fuzzy.
+
+import (
+ "unicode"
+)
+
+// SymbolMatcher implements a fuzzy matching algorithm optimized for Go symbols
+// of the form:
+//
+// example.com/path/to/package.object.field
+//
+// Knowing that we are matching symbols like this allows us to make the
+// following optimizations:
+// - We can incorporate right-to-left relevance directly into the score
+// calculation.
+// - We can match from right to left, discarding leading bytes if the input is
+// too long.
+// - We just take the right-most match without losing too much precision. This
+// allows us to use an O(n) algorithm.
+// - We can operate directly on chunked strings; in many cases we will
+// be storing the package path and/or package name separately from the
+// symbol or identifiers, so doing this avoids allocating strings.
+// - We can return the index of the right-most match, allowing us to trim
+// irrelevant qualification.
+//
+// This implementation is experimental, serving as a reference fast algorithm
+// to compare to the fuzzy algorithm implemented by Matcher.
+type SymbolMatcher struct {
+ // Using buffers of length 256 is both a reasonable size for most qualified
+ // symbols, and makes it easy to avoid bounds checks by using uint8 indexes.
+ pattern [256]rune
+ patternLen uint8
+ inputBuffer [256]rune // avoid allocating when considering chunks
+ roles [256]uint32 // which roles does a rune play (word start, etc.)
+ segments [256]uint8 // how many segments from the right is each rune
+}
+
+const (
+ segmentStart uint32 = 1 << iota
+ wordStart
+ separator
+)
+
+// NewSymbolMatcher creates a SymbolMatcher that may be used to match the given
+// search pattern.
+//
+// Currently this matcher only accepts case-insensitive fuzzy patterns.
+//
+// An empty pattern matches no input.
+func NewSymbolMatcher(pattern string) *SymbolMatcher {
+ m := &SymbolMatcher{}
+ for _, p := range pattern {
+ m.pattern[m.patternLen] = unicode.ToLower(p)
+ m.patternLen++
+ if m.patternLen == 255 || int(m.patternLen) == len(pattern) {
+ // break at 255 so that we can represent patternLen with a uint8.
+ break
+ }
+ }
+ return m
+}
+
+// Match looks for the right-most match of the search pattern within the symbol
+// represented by concatenating the given chunks, returning its offset and
+// score.
+//
+// If a match is found, the first return value will hold the absolute byte
+// offset within all chunks for the start of the symbol. In other words, the
+// index of the match within strings.Join(chunks, ""). If no match is found,
+// the first return value will be -1.
+//
+// The second return value will be the score of the match, which is always
+// between 0 and 1, inclusive. A score of 0 indicates no match.
+func (m *SymbolMatcher) Match(chunks []string) (int, float64) {
+ // Explicit behavior for an empty pattern.
+ //
+ // As a minor optimization, this also avoids nilness checks later on, since
+ // the compiler can prove that m != nil.
+ if m.patternLen == 0 {
+ return -1, 0
+ }
+
+ // First phase: populate the input buffer with lower-cased runes.
+ //
+ // We could also check for a forward match here, but since we'd have to write
+ // the entire input anyway this has negligible impact on performance.
+
+ var (
+ inputLen = uint8(0)
+ modifiers = wordStart | segmentStart
+ )
+
+input:
+ for _, chunk := range chunks {
+ for _, r := range chunk {
+ if r == '.' || r == '/' {
+ modifiers |= separator
+ }
+ // optimization: avoid calls to unicode.ToLower, which can't be inlined.
+ l := r
+ if r <= unicode.MaxASCII {
+ if 'A' <= r && r <= 'Z' {
+ l = r + 'a' - 'A'
+ }
+ } else {
+ l = unicode.ToLower(r)
+ }
+ if l != r {
+ modifiers |= wordStart
+ }
+ m.inputBuffer[inputLen] = l
+ m.roles[inputLen] = modifiers
+ inputLen++
+ if m.roles[inputLen-1]&separator != 0 {
+ modifiers = wordStart | segmentStart
+ } else {
+ modifiers = 0
+ }
+ // TODO: we should prefer the right-most input if it overflows, rather
+ // than the left-most as we're doing here.
+ if inputLen == 255 {
+ break input
+ }
+ }
+ }
+
+ // Second phase: find the right-most match, and count segments from the
+ // right.
+
+ var (
+ pi = uint8(m.patternLen - 1) // pattern index
+ p = m.pattern[pi] // pattern rune
+ start = -1 // start offset of match
+ rseg = uint8(0)
+ )
+ const maxSeg = 3 // maximum number of segments from the right to count, for scoring purposes.
+
+ for ii := inputLen - 1; ; ii-- {
+ r := m.inputBuffer[ii]
+ if rseg < maxSeg && m.roles[ii]&separator != 0 {
+ rseg++
+ }
+ m.segments[ii] = rseg
+ if p == r {
+ if pi == 0 {
+ start = int(ii)
+ break
+ }
+ pi--
+ p = m.pattern[pi]
+ }
+ // Don't check ii >= 0 in the loop condition: ii is a uint8.
+ if ii == 0 {
+ break
+ }
+ }
+
+ if start < 0 {
+ // no match: skip scoring
+ return -1, 0
+ }
+
+ // Third phase: find the shortest match, and compute the score.
+
+ // Score is the average score for each character.
+ //
+ // A character score is the multiple of:
+ // 1. 1.0 if the character starts a segment, .8 if the character start a
+ // mid-segment word, otherwise 0.6. This carries over to immediately
+ // following characters.
+ // 2. For the final character match, the multiplier from (1) is reduced to
+ // .8 if the next character in the input is a mid-segment word, or 0.6 if
+ // the next character in the input is not a word or segment start. This
+ // ensures that we favor whole-word or whole-segment matches over prefix
+ // matches.
+ // 3. 1.0 if the character is part of the last segment, otherwise
+ // 1.0-.2*<segments from the right>, with a max segment count of 3.
+ //
+ // This is a very naive algorithm, but it is fast. There's lots of prior art
+ // here, and we should leverage it. For example, we could explicitly consider
+ // character distance, and exact matches of words or segments.
+ //
+ // Also note that this might not actually find the highest scoring match, as
+ // doing so could require a non-linear algorithm, depending on how the score
+ // is calculated.
+
+ pi = 0
+ p = m.pattern[pi]
+
+ const (
+ segStreak = 1.0
+ wordStreak = 0.8
+ noStreak = 0.6
+ perSegment = 0.2 // we count at most 3 segments above
+ )
+
+ streakBonus := noStreak
+ totScore := 0.0
+ for ii := uint8(start); ii < inputLen; ii++ {
+ r := m.inputBuffer[ii]
+ if r == p {
+ pi++
+ p = m.pattern[pi]
+ // Note: this could be optimized with some bit operations.
+ switch {
+ case m.roles[ii]&segmentStart != 0 && segStreak > streakBonus:
+ streakBonus = segStreak
+ case m.roles[ii]&wordStart != 0 && wordStreak > streakBonus:
+ streakBonus = wordStreak
+ }
+ finalChar := pi >= m.patternLen
+ // finalCost := 1.0
+ if finalChar && streakBonus > noStreak {
+ switch {
+ case ii == inputLen-1 || m.roles[ii+1]&segmentStart != 0:
+ // Full segment: no reduction
+ case m.roles[ii+1]&wordStart != 0:
+ streakBonus = wordStreak
+ default:
+ streakBonus = noStreak
+ }
+ }
+ totScore += streakBonus * (1.0 - float64(m.segments[ii])*perSegment)
+ if finalChar {
+ break
+ }
+ } else {
+ streakBonus = noStreak
+ }
+ }
+
+ return start, totScore / float64(m.patternLen)
+}
diff --git a/internal/postgres/search.go b/internal/postgres/search.go
index baf1e6c..b561b68 100644
--- a/internal/postgres/search.go
+++ b/internal/postgres/search.go
@@ -94,68 +94,11 @@
"symbol": (*DB).symbolSearch,
}
-// SearchOptions provide information used by db.Search.
-type SearchOptions struct {
- // Maximum number of results to return (page size).
- MaxResults int
+type SearchOptions = internal.SearchOptions
+type SearchResult = internal.SearchResult
- // Offset for DB query.
- Offset int
-
- // Maximum number to use for total result count.
- MaxResultCount int
-
- // If true, perform a symbol search.
- SearchSymbols bool
-
- // SymbolFilter is the word in a search query with a # prefix.
- SymbolFilter string
-}
-
-// SearchResult represents a single search result from SearchDocuments.
-type SearchResult struct {
- Name string
- PackagePath string
- ModulePath string
- Version string
- Synopsis string
- Licenses []string
-
- CommitTime time.Time
-
- // Score is used to sort items in an array of SearchResult.
- Score float64
-
- // NumImportedBy is the number of packages that import PackagePath.
- NumImportedBy uint64
-
- // SameModule is a list of SearchResults from the same module as this one,
- // with lower scores.
- SameModule []*SearchResult
-
- // OtherMajor is a map from module paths with the same series path but at
- // different major versions of this module, to major version.
- // The major version for a non-vN module path (either 0 or 1) is computed
- // based on the version in search documents.
- OtherMajor map[string]int
-
- // NumResults is the total number of packages that were returned for this
- // search.
- NumResults uint64
-
- // Symbol information returned by a search request.
- // Only populated for symbol search mode.
- SymbolName string
- SymbolKind internal.SymbolKind
- SymbolSynopsis string
- SymbolGOOS string
- SymbolGOARCH string
-
- // Offset is the 0-based number of this row in the DB query results, which
- // is the value to use in a SQL OFFSET clause to have this row be the first
- // one returned.
- Offset int
-}
+// SupportsSearch implements the DataSource interface, returning true.
+func (db *DB) SupportsSearch() bool { return true }
// Search executes two search requests concurrently:
// - a sequential scan of packages in descending order of popularity.
diff --git a/internal/testing/testhelper/testhelper.go b/internal/testing/testhelper/testhelper.go
index 52e7003..3ccaef9 100644
--- a/internal/testing/testhelper/testhelper.go
+++ b/internal/testing/testhelper/testhelper.go
@@ -23,6 +23,7 @@
"github.com/google/go-cmp/cmp"
"golang.org/x/pkgsite/internal/derrors"
+ "golang.org/x/tools/txtar"
)
const (
@@ -120,28 +121,56 @@
if err != nil {
return "", err
}
+ if err := writeTestDirectory(tempDir, files); err != nil {
+ return "", err
+ }
+ return tempDir, nil
+}
+
+func writeTestDirectory(tempDir string, files map[string]string) error {
for path, contents := range files {
path = filepath.Join(tempDir, path)
parent, _ := filepath.Split(path)
if err := os.MkdirAll(parent, 0755); err != nil {
- return "", err
+ return err
}
file, err := os.Create(path)
if err != nil {
- return "", err
+ return err
}
if _, err := file.WriteString(contents); err != nil {
- return "", err
+ return err
}
if err := file.Close(); err != nil {
- return "", err
+ return err
}
}
+ return nil
+}
- return tempDir, nil
+// WriteTxtarToTempDir parses data as a txtar archive, and writes the resulting
+// files to a new tempdir created with t.TempDir(). It returns the temp
+// directory and files that were unpacked.
+func WriteTxtarToTempDir(t *testing.T, data string) (string, map[string]string) {
+ t.Helper()
+
+ archive := txtar.Parse([]byte(data))
+ files := make(map[string]string)
+ for _, file := range archive.Files {
+ if _, ok := files[file.Name]; ok {
+ t.Fatalf("file %q occurs twice in the provided archive", file.Name)
+ }
+ files[file.Name] = string(file.Data)
+ }
+
+ tempDir := t.TempDir()
+ if err := writeTestDirectory(tempDir, files); err != nil {
+ t.Fatalf("writing test dir: %v", err)
+ }
+ return tempDir, files
}
func CompareWithGolden(t *testing.T, got, filename string, update bool) {