internal/frontend: support search mode prefixes

When a search query contains a "package: " or "identifier: " prefix,
always default to that search mode, regardless of the `m=<mode>` query
param that is set.

For golang/go#47320
For golang/go#44142

Change-Id: I412772fe264e25a3cb79362eaa4ba992fe273beb
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/341176
Trust: Julie Qiu <julie@golang.org>
Run-TryBot: Julie Qiu <julie@golang.org>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
diff --git a/internal/frontend/paginate.go b/internal/frontend/paginate.go
index 675638b..5114597 100644
--- a/internal/frontend/paginate.go
+++ b/internal/frontend/paginate.go
@@ -43,7 +43,7 @@
 
 // URL constructs a URL that adds limit and mode query parameters to the base
 // URL. Passing a zero value omits the parameter.
-func (p pagination) URL(limit int, mode string) string {
+func (p pagination) URL(limit int, mode, q string) string {
 	newQuery := p.baseURL.Query()
 	if limit != 0 {
 		newQuery.Set("limit", strconv.Itoa(limit))
@@ -51,6 +51,9 @@
 	if mode != "" {
 		newQuery.Set("m", mode)
 	}
+	if q != "" {
+		newQuery.Set("q", q)
+	}
 	p.baseURL.RawQuery = newQuery.Encode()
 	return p.baseURL.String()
 }
diff --git a/internal/frontend/search.go b/internal/frontend/search.go
index 275cf04..615b76a 100644
--- a/internal/frontend/search.go
+++ b/internal/frontend/search.go
@@ -250,6 +250,14 @@
 
 	// maxSearchPageSize is the maximum allowed limit for search results.
 	maxSearchPageSize = 100
+
+	// searchModePackage is the keyword prefix and query param for searching
+	// by packages.
+	searchModePackage = "packages"
+
+	// searchModeSymbol is the keyword prefix and query param for searching
+	// by symbols.
+	searchModeSymbol = "identifiers"
 )
 
 // serveSearch applies database data to the search template. Handles endpoint
@@ -266,7 +274,7 @@
 	}
 
 	ctx := r.Context()
-	query := searchQuery(r)
+	query, searchSymbols := searchQuery(r)
 	if !utf8.ValidString(query) {
 		return &serverError{status: http.StatusBadRequest}
 	}
@@ -308,14 +316,13 @@
 		return nil
 	}
 
-	searchSymbols := shouldSearchSymbols(r)
 	page, err := fetchSearchPage(ctx, db, query, pageParams, searchSymbols)
 	if err != nil {
 		return fmt.Errorf("fetchSearchPage(ctx, db, %q): %v", query, err)
 	}
 	page.basePage = s.newBasePage(r, fmt.Sprintf("%s - Search Results", query))
 	if searchSymbols {
-		page.SearchMode = "identifiers"
+		page.SearchMode = searchModeSymbol
 	}
 
 	tmpl := "legacy_search"
@@ -351,14 +358,22 @@
 	return fmt.Sprintf("/%s", requestedPath)
 }
 
-// searchQuery extracts a search query from the request.
-func searchQuery(r *http.Request) string {
-	return strings.TrimSpace(r.FormValue("q"))
-}
+// searchQuery extracts a search query from the request. It also reports
+// whether the search performed should be in symbolSearch mode.
+// See TestSearchQuery for examples.
+func searchQuery(r *http.Request) (q string, searchSymbols bool) {
+	q = strings.TrimSpace(r.FormValue("q"))
+	if !experiment.IsActive(r.Context(), internal.ExperimentSymbolSearch) {
+		return q, false
+	}
 
-// shouldSearchSymbols reports whether the search mode is to search for symbols.
-func shouldSearchSymbols(r *http.Request) bool {
-	return strings.TrimSpace(r.FormValue("m")) == "identifiers"
+	if prefix := searchModeSymbol + ":"; strings.HasPrefix(q, prefix) {
+		return strings.TrimPrefix(q, prefix), true
+	}
+	if prefix := searchModePackage + ":"; strings.HasPrefix(q, prefix) {
+		return strings.TrimPrefix(q, prefix), false
+	}
+	return q, strings.TrimSpace(r.FormValue("m")) == searchModeSymbol
 }
 
 // elapsedTime takes a date and returns returns human-readable,
diff --git a/internal/frontend/search_test.go b/internal/frontend/search_test.go
index afdaaa3..50aac8e 100644
--- a/internal/frontend/search_test.go
+++ b/internal/frontend/search_test.go
@@ -6,17 +6,81 @@
 
 import (
 	"context"
+	"fmt"
+	"net/http/httptest"
 	"testing"
 	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/licenses"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/testing/sample"
 )
 
+func TestSearchQuery(t *testing.T) {
+	for _, test := range []struct {
+		name, m, q, wantQuery string
+		wantSearchSymbols     bool
+	}{
+		{
+			name:              "package: prefix in symbol mode",
+			m:                 searchModeSymbol,
+			q:                 fmt.Sprintf("%s:foo", searchModePackage),
+			wantQuery:         "foo",
+			wantSearchSymbols: false,
+		},
+		{
+			name:              "package: prefix in package mode",
+			m:                 searchModePackage,
+			q:                 fmt.Sprintf("%s:foo", searchModePackage),
+			wantQuery:         "foo",
+			wantSearchSymbols: false,
+		},
+		{
+			name:              "identifier: prefix in symbol mode",
+			m:                 searchModeSymbol,
+			q:                 fmt.Sprintf("%s:foo", searchModeSymbol),
+			wantQuery:         "foo",
+			wantSearchSymbols: true,
+		},
+		{
+			name:              "identifier: prefix in package mode",
+			m:                 searchModeSymbol,
+			q:                 fmt.Sprintf("%s:foo", searchModeSymbol),
+			wantQuery:         "foo",
+			wantSearchSymbols: true,
+		},
+		{
+			name:              "search in package mode",
+			m:                 searchModePackage,
+			q:                 "foo",
+			wantQuery:         "foo",
+			wantSearchSymbols: false,
+		},
+		{
+			name:              "search in symbol mode",
+			m:                 searchModeSymbol,
+			q:                 "foo",
+			wantQuery:         "foo",
+			wantSearchSymbols: true,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			u := fmt.Sprintf("/search?q=%s&m=%s", test.q, test.m)
+			r := httptest.NewRequest("GET", u, nil)
+			r = r.WithContext(experiment.NewContext(r.Context(), internal.ExperimentSymbolSearch))
+			gotQuery, gotSearchSymbols := searchQuery(r)
+			if gotQuery != test.wantQuery || gotSearchSymbols != test.wantSearchSymbols {
+				t.Errorf("searchQuery(%q) = %q, %t; want = %q, %t", u, gotQuery, gotSearchSymbols,
+					test.wantQuery, test.wantSearchSymbols)
+			}
+		})
+	}
+}
+
 func TestFetchSearchPage(t *testing.T) {
 	ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
 	defer cancel()
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 583c934..7e9a43c 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -293,9 +293,10 @@
 
 // newBasePage returns a base page for the given request and title.
 func (s *Server) newBasePage(r *http.Request, title string) basePage {
+	q, _ := searchQuery(r)
 	return basePage{
 		HTMLTitle:          title,
-		Query:              searchQuery(r),
+		Query:              q,
 		Experiments:        experiment.FromContext(r.Context()),
 		DevMode:            s.devMode,
 		AppVersionLabel:    s.appVersionLabel,
diff --git a/static/frontend/search/search.tmpl b/static/frontend/search/search.tmpl
index a7d130d..4322d0c 100644
--- a/static/frontend/search/search.tmpl
+++ b/static/frontend/search/search.tmpl
@@ -29,7 +29,7 @@
 
 {{define "search_identifier"}}
   <h1>Identifiers matching “{{.Query}}”</h1>
-  <a class="SearchResults-instead" href="{{.Pagination.URL .Pagination.Limit "packages"}}">
+  <a class="SearchResults-instead" href="{{.Pagination.URL .Pagination.Limit "packages" .Query}}">
     <span>Search instead for </span>
     “package: {{.Query}}”
   </a>
@@ -82,7 +82,7 @@
 
 {{define "search_package"}}
   <h1>Search results for “{{.Query}}”</h1>
-  <a class="SearchResults-instead" href="{{.Pagination.URL .Pagination.Limit "identifiers"}}">
+  <a class="SearchResults-instead" href="{{.Pagination.URL .Pagination.Limit "identifiers" .Query}}">
     <span class="SearchSnippet-symbolPackagePath">Search instead for </span>
     “identifier: {{.Query}}”
   </a>
@@ -191,7 +191,7 @@
     Show
     <select name="limit" class="go-Select js-selectNav">
       {{range $p.Limits}}
-        <option value="{{$p.URL . $.SearchMode}}"{{if eq . $p.Limit}} selected{{end}}>{{.}}</option>
+        <option value="{{$p.URL . $.SearchMode ""}}"{{if eq . $p.Limit}} selected{{end}}>{{.}}</option>
       {{end}}
     </select>
     results