internal/postgres: in search, group stdlib packages by top-level dir

When grouping search results, group packages from the standard library
by their top-level directory, instead of treating them all as part of a single module.
So "net", "net/http" and "net/url" will get grouped together.

Change-Id: Ib638c39de56ecde104d569a607255d2b6a74ce55
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/346969
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/postgres/search.go b/internal/postgres/search.go
index 670792c..359c113 100644
--- a/internal/postgres/search.go
+++ b/internal/postgres/search.go
@@ -9,6 +9,7 @@
 	"database/sql"
 	"fmt"
 	"io"
+	"path"
 	"sort"
 	"strings"
 	"time"
@@ -540,29 +541,29 @@
 //
 // Higher tagged major versions of a module replace lower ones.
 func groupSearchResults(rs []*SearchResult) []*SearchResult {
-	bestInSeries := map[string]*SearchResult{} // series path to result with max major version
+	bestInGroup := map[string]*SearchResult{} // series path to result with max major version
 	// Since rs is sorted by score, the first package we see for a series is the
 	// highest-ranked one. However, we may prefer to show a lower-ranked one from a
 	// module in the same series with a higher major version.
 	for _, r := range rs {
-		seriesPath, rMajor := internal.SeriesPathAndMajorVersion(r.ModulePath)
-		b := bestInSeries[seriesPath]
+		group, rMajor := groupAndMajorVersion(r)
+		b := bestInGroup[group]
 		if b == nil {
-			// First result (package) with this series path; remember it.
-			bestInSeries[seriesPath] = r
+			// First result (package) with this key; remember it.
+			bestInGroup[group] = r
 			r.OtherMajor = map[string]bool{}
 		} else {
-			_, bMajor := internal.SeriesPathAndMajorVersion(b.ModulePath)
+			_, bMajor := groupAndMajorVersion(b)
 			switch {
 			case !version.IsPseudo(r.Version) && (rMajor > bMajor || version.IsPseudo(b.Version)):
 				// r is tagged, and is either in a higher major version, or the current best
 				// is not tagged. Either way, prefer r to b.
-				bestInSeries[seriesPath] = r
+				bestInGroup[group] = r
 				r.OtherMajor = b.OtherMajor
 				r.OtherMajor[b.ModulePath] = true
 				r.Score = b.Score // inherit the lower major version's higher score
 			case rMajor == bMajor:
-				// r is another package from the module of b; remember it there.
+				// r is another package in b's group; remember it there.
 				b.SameModule = append(b.SameModule, r)
 
 			default:
@@ -574,7 +575,7 @@
 	}
 	// Collect new results and re-sort by score.
 	var results []*SearchResult
-	for _, r := range bestInSeries {
+	for _, r := range bestInGroup {
 		if len(r.OtherMajor) == 0 {
 			r.OtherMajor = nil
 		}
@@ -586,6 +587,19 @@
 	return results
 }
 
+func groupAndMajorVersion(r *SearchResult) (string, int) {
+	// Packages in the standard library are grouped by their top-level
+	// directory, and we can consider them all part of the same major version.
+	if r.ModulePath == stdlib.ModulePath {
+		dir := r.PackagePath
+		if strings.ContainsRune(dir, '/') {
+			dir = path.Dir(dir)
+		}
+		return dir, 1
+	}
+	return internal.SeriesPathAndMajorVersion(r.ModulePath)
+}
+
 // numRows counts the number of rows in a slice of SearchResults.
 // Grouping will put some rows inside a SearchResult.
 func numRows(rs []*SearchResult) int {
diff --git a/internal/postgres/search_test.go b/internal/postgres/search_test.go
index e5edca6..02a0cba 100644
--- a/internal/postgres/search_test.go
+++ b/internal/postgres/search_test.go
@@ -25,6 +25,7 @@
 	"golang.org/x/pkgsite/internal/derrors"
 	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/licenses"
+	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/testing/sample"
 )
 
@@ -1438,6 +1439,25 @@
 				{Name: "m1", ModulePath: "m1", Version: "v0.0.0", Score: 10, OtherMajor: set("m1/v2", "m1/v3")},
 			},
 		},
+		{
+			name: "stdlib",
+			in: []*SearchResult{
+				{PackagePath: "net", ModulePath: stdlib.ModulePath, Score: 10},
+				{PackagePath: "m1", ModulePath: "m1", Version: "v0.0.0", Score: 9},
+				{PackagePath: "net/http", ModulePath: stdlib.ModulePath, Score: 8},
+				{PackagePath: "encoding/json", ModulePath: stdlib.ModulePath, Score: 7},
+				{PackagePath: "encoding/gob", ModulePath: stdlib.ModulePath, Score: 6},
+			},
+			want: []*SearchResult{
+				{PackagePath: "net", ModulePath: stdlib.ModulePath, Score: 10, SameModule: []*SearchResult{
+					{PackagePath: "net/http", ModulePath: stdlib.ModulePath, Score: 8},
+				}},
+				{PackagePath: "m1", ModulePath: "m1", Version: "v0.0.0", Score: 9},
+				{PackagePath: "encoding/json", ModulePath: stdlib.ModulePath, Score: 7, SameModule: []*SearchResult{
+					{PackagePath: "encoding/gob", ModulePath: stdlib.ModulePath, Score: 6},
+				}},
+			},
+		},
 	} {
 		t.Run(test.name, func(t *testing.T) {
 			got := groupSearchResults(test.in)