dmitri.shuralyov.com/website/gido/...

add support for multiple comma-separated import path patterns

The go command doesn't impose a limit of at most one import path pattern
to be provided. We shouldn't either.
dmitshur committed 1 year ago commit f460621dc784d8a8f3a1593a068115b62baf214d
changes.go
@@ -5,10 +5,11 @@ import (
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"

	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
)
@@ -36,14 +37,15 @@ var changesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html
			<p>Go Changes shows changes for Go packages.
			It's just like <a href="https://goissues.org">goissues.org</a>, but for changes (CLs, PRs, etc.).</p>

			<p>To view changes of a Go package with a given import path, navigate to <code>gochanges.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).
			You may specify an
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path pattern</a>
			You may specify comma-separated
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path patterns</a>
			to view changes for all matching packages
			(e.g., <a href="/image/..."><code>gochanges.org/image/...</code></a>).</p>
			(such as <a href="/image/..."><code>image/...</code></a> or
			<a href="/cmd/compile/...,cmd/link/...,runtime"><code>cmd/compile/...</code>,<code>cmd/link/...</code>,<code>runtime</code></a>).</p>

			<p>Supported packages include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
@@ -113,38 +115,25 @@ func (h *handler) serveChangesPkg(w http.ResponseWriter, req *http.Request, pkg
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// serveChangesPattern serves a list of changes for packages matching import path pattern.
func (h *handler) serveChangesPattern(w http.ResponseWriter, req *http.Request, pattern string) error {
// serveChangesPatterns serves a list of changes for packages matching import path patterns.
func (h *handler) serveChangesPatterns(w http.ResponseWriter, req *http.Request, patterns []string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := changeStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	var pkgs []string
	switch pattern {
	case "all", "...":
		// "all" expands to all packages found in all the GOPATH trees.
		pkgs = h.s.AllPackages
	case "std":
		// "std" is like all but expands to just the packages in the standard Go library.
		pkgs = h.s.StdPackages
	case "cmd":
		// "cmd" expands to the Go repository's commands and their internal libraries.
		pkgs = h.s.CmdPackages
	default:
		pkgs = expandPattern(h.s.AllPackages, pattern)
	}
	pkgs := expandPatterns(h.s.AllPackages, h.s.StdPackages, h.s.CmdPackages, patterns)

	var cs []change.Change
	var openChanges, closedChanges, openIssues int
	match := matchPaths(pattern)
	match := matchPatterns(patterns)
	h.s.IssuesAndChangesMu.RLock()
	switch {
	case filter == change.FilterOpen:
		for _, c := range h.s.OpenChanges {
			if !match(c.Paths) {
@@ -198,20 +187,20 @@ func (h *handler) serveChangesPattern(w http.ResponseWriter, req *http.Request,
	h.s.IssuesAndChangesMu.RUnlock()
	sort.Slice(cs, func(i, j int) bool { return cs[i].CreatedAt.After(cs[j].CreatedAt) })

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pattern,
		"PageName":      strings.Join(patterns, " "),
		"AnalyticsHTML": h.analyticsHTML,
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{PkgOrPattern: pattern},
		subheadingPattern{Pattern: pattern, Pkgs: pkgs, pkgURL: h.rtr.ChangesURL},
		renderTabnav(changesTab, openIssues, openChanges, pattern, h.rtr),
		heading{PkgOrPattern: strings.Join(patterns, " ")},
		subheadingPattern{Pattern: strings.Join(patterns, " "), Pkgs: pkgs, pkgURL: h.rtr.ChangesURL},
		renderTabnav(changesTab, openIssues, openChanges, strings.Join(patterns, ","), h.rtr),
		renderChanges(cs, openChanges, closedChanges, req.URL, filter),
	)
	if err != nil {
		return err
	}
issues.go
@@ -5,10 +5,11 @@ import (
	"html/template"
	"net/http"
	"net/url"
	"os"
	"sort"
	"strings"

	"github.com/shurcooL/htmlg"
	"github.com/shurcooL/httperror"
	"github.com/shurcooL/issues"
)
@@ -36,14 +37,15 @@ var issuesHTML = template.Must(template.New("").Parse(`{{define "Header"}}<html
			<p>Go Issues shows issues for Go packages.
			Documentation and other information for Go packages is available at <a href="https://pkg.go.dev">pkg.go.dev</a>.</p>

			<p>To view issues of a Go package with a given import path, navigate to <code>goissues.org/import/path</code>
			using your browser's address bar (<kbd>⌘Cmd</kbd>+<kbd>L</kbd> or <kbd>Ctrl</kbd>+<kbd>L</kbd>).
			You may specify an
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path pattern</a>
			You may specify comma-separated
			<a href="https://golang.org/cmd/go/#hdr-Package_lists_and_patterns">import path patterns</a>
			to view issues for all matching packages
			(e.g., <a href="/image/..."><code>goissues.org/image/...</code></a>).</p>
			(such as <a href="/image/..."><code>image/...</code></a> or
			<a href="/cmd/compile/...,cmd/link/...,runtime"><code>cmd/compile/...</code>,<code>cmd/link/...</code>,<code>runtime</code></a>).</p>

			<p>Supported packages include:</p>

			<ul>
			<li><a href="/-/packages#stdlib">Standard library</a> (e.g., <code>io</code>, <code>net/http</code>, etc.),</li>
@@ -114,38 +116,25 @@ func (h *handler) serveIssuesPkg(w http.ResponseWriter, req *http.Request, pkg s
	}
	err = h.executeTemplate(w, req, "Trailer", nil)
	return err
}

// serveIssuesPattern serves a list of issues for packages matching import path pattern.
func (h *handler) serveIssuesPattern(w http.ResponseWriter, req *http.Request, pattern string) error {
// serveIssuesPatterns serves a list of issues for packages matching import path patterns.
func (h *handler) serveIssuesPatterns(w http.ResponseWriter, req *http.Request, patterns []string) error {
	if req.Method != http.MethodGet {
		return httperror.Method{Allowed: []string{http.MethodGet}}
	}
	filter, err := issueStateFilter(req.URL.Query())
	if err != nil {
		return httperror.BadRequest{Err: err}
	}

	var pkgs []string
	switch pattern {
	case "all", "...":
		// "all" expands to all packages found in all the GOPATH trees.
		pkgs = h.s.AllPackages
	case "std":
		// "std" is like all but expands to just the packages in the standard Go library.
		pkgs = h.s.StdPackages
	case "cmd":
		// "cmd" expands to the Go repository's commands and their internal libraries.
		pkgs = h.s.CmdPackages
	default:
		pkgs = expandPattern(h.s.AllPackages, pattern)
	}
	pkgs := expandPatterns(h.s.AllPackages, h.s.StdPackages, h.s.CmdPackages, patterns)

	var is []issues.Issue
	var openIssues, closedIssues, openChanges int
	match := matchPaths(pattern)
	match := matchPatterns(patterns)
	h.s.IssuesAndChangesMu.RLock()
	switch {
	case filter == issues.StateFilter(issues.OpenState):
		for _, i := range h.s.OpenIssues {
			if !match(i.Paths) {
@@ -199,20 +188,20 @@ func (h *handler) serveIssuesPattern(w http.ResponseWriter, req *http.Request, p
	h.s.IssuesAndChangesMu.RUnlock()
	sort.Slice(is, func(i, j int) bool { return is[i].CreatedAt.After(is[j].CreatedAt) })

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	err = h.executeTemplate(w, req, "Header", map[string]interface{}{
		"PageName":      pattern,
		"PageName":      strings.Join(patterns, " "),
		"AnalyticsHTML": h.analyticsHTML,
	})
	if err != nil {
		return err
	}
	err = htmlg.RenderComponents(w,
		heading{PkgOrPattern: pattern},
		subheadingPattern{Pattern: pattern, Pkgs: pkgs, pkgURL: h.rtr.IssuesURL},
		renderTabnav(issuesTab, openIssues, openChanges, pattern, h.rtr),
		heading{PkgOrPattern: strings.Join(patterns, " ")},
		subheadingPattern{Pattern: strings.Join(patterns, " "), Pkgs: pkgs, pkgURL: h.rtr.IssuesURL},
		renderTabnav(issuesTab, openIssues, openChanges, strings.Join(patterns, ","), h.rtr),
		renderIssues(is, openIssues, closedIssues, req.URL, filter),
	)
	if err != nil {
		return err
	}
main.go
@@ -120,12 +120,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	// Redirect to canonical path (no trailing slash, etc.) if needed.
	if canonicalPath := path.Clean(req.URL.Path); req.URL.Path != canonicalPath {
		if req.URL.RawQuery != "" {
			canonicalPath += "?" + req.URL.RawQuery
		}
		http.Redirect(w, req, canonicalPath, http.StatusFound)
		return nil
		return httperror.Redirect{URL: canonicalPath}
	}

	// Handle "/".
	if req.URL.Path == "/" {
		return h.ServeIndex(w, req)
@@ -147,18 +146,31 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) error {
	// Handle "/-/packages".
	if req.URL.Path == "/-/packages" {
		return h.ServePackages(w, req)
	}

	// Handle "/{pattern}[,{pattern}]" URLs.

	// Redirect to canonical path (no trailing slash, no spaces, etc.) if needed.
	patterns := strings.Split(req.URL.Path[1:], ",")
	for i := range patterns {
		patterns[i] = path.Clean("/" + strings.TrimSpace(patterns[i]))[1:]
	}
	if canonicalPath := "/" + strings.Join(patterns, ","); req.URL.Path != canonicalPath {
		if req.URL.RawQuery != "" {
			canonicalPath += "?" + req.URL.RawQuery
		}
		return httperror.Redirect{URL: canonicalPath}
	}

	// Handle "/..." URLs.
	switch isImportPathPattern(req.URL.Path[1:]) {
	case false:
		pkg := req.URL.Path[1:]
	switch {
	case len(patterns) == 1 && !isImportPathPattern(patterns[0]):
		pkg := patterns[0]
		return h.ServeIssuesOrChangesPkg(w, req, pkg)
	case true:
		pattern := req.URL.Path[1:]
		return h.ServeIssuesOrChangesPattern(w, req, pattern)
		return h.ServeIssuesOrChangesPatterns(w, req, patterns)
	default:
		panic("unreachable")
	}
}

@@ -346,17 +358,17 @@ func (h *handler) ServeIssuesOrChangesPkg(w http.ResponseWriter, req *http.Reque
	default:
		panic("unreachable")
	}
}

// ServeIssuesOrChangesPattern serves a list of issues or changes for packages matching import path pattern.
func (h *handler) ServeIssuesOrChangesPattern(w http.ResponseWriter, req *http.Request, pattern string) error {
// ServeIssuesOrChangesPatterns serves a list of issues or changes for packages matching import path patterns.
func (h *handler) ServeIssuesOrChangesPatterns(w http.ResponseWriter, req *http.Request, patterns []string) error {
	switch changes := h.rtr.WantChanges(req); {
	case !changes:
		return h.serveIssuesPattern(w, req, pattern)
		return h.serveIssuesPatterns(w, req, patterns)
	case changes:
		return h.serveChangesPattern(w, req, pattern)
		return h.serveChangesPatterns(w, req, patterns)
	default:
		panic("unreachable")
	}
}

packages.go
@@ -127,10 +127,70 @@ func subTree(r *git.Repository, t *object.Tree, name string) (*object.Tree, erro
		return r.TreeObject(e.Hash)
	}
	return nil, os.ErrNotExist
}

// expandPatterns returns a list of Go packages matched by
// the specified import path patterns.
func expandPatterns(all, std, cmd []string, patterns []string) []string {
	switch len(patterns) {
	// Faster path for a single pattern.
	case 1:
		switch patterns[0] {
		case "all", "...":
			// "all" or "..." expands to all packages found in all the GOPATH trees.
			return all
		case "std":
			// "std" is like all but expands to just the packages in the standard Go library.
			return std
		case "cmd":
			// "cmd" expands to the Go repository's commands and their internal libraries.
			return cmd
		default:
			return expandPattern(all, patterns[0])
		}

	// Multiple import path patterns.
	default:
		var hasStd, hasCmd bool
		for _, pattern := range patterns {
			switch pattern {
			case "all", "...":
				return all
			case "std":
				hasStd = true
			case "cmd":
				hasCmd = true
			}
		}

		var ms []func(path string) bool
		if hasStd {
			ms = append(ms, isStandard)
		} else if hasCmd {
			// cmd is a subset of std, so add iff hasCmd && !hasStd.
			ms = append(ms, isCommand)
		}
		for _, pattern := range patterns {
			if pattern == "std" || pattern == "cmd" {
				continue
			}
			ms = append(ms, matchPattern(pattern))
		}
		var matched []string
		for _, match := range ms {
			for _, pkg := range all {
				if !match(pkg) {
					continue
				}
				matched = append(matched, pkg)
			}
		}
		return matched
	}
}

// expandPattern returns a list of Go packages matched by specified
// import path pattern, which may have the following forms:
//
// 	example.org/single/package     # a single package
// 	example.org/dir/...            # all packages beneath dir
@@ -148,55 +208,95 @@ func expandPattern(allPackages []string, pattern string) []string {
		matched = append(matched, pkg)
	}
	return matched
}

// matchPaths(pattern)(paths) reports whether any of the import paths paths
// match the import path pattern pattern.
// It uses the same rules for pattern matching as matchPattern.
func matchPaths(pattern string) func(paths []string) bool {
	switch pattern {
	case "all", "...":
		// "all" expands to all packages found in all the GOPATH trees.
		return func([]string) bool { return true }
	case "std":
		// "std" is like all but expands to just the packages in the standard Go library.
		return func(paths []string) bool {
			for _, p := range paths {
				if isStandard(p) {
					return true
// matchPatterns(patterns)(paths) reports whether any of the import paths paths
// match any of the import path patterns patterns.
// It uses the same rules for pattern matching as matchPattern,
// but also understands "all", "std", and "cmd" meta-patterns.
func matchPatterns(patterns []string) func(paths []string) bool {
	switch len(patterns) {
	// Faster path for a single pattern.
	case 1:
		switch patterns[0] {
		case "all", "...":
			return func([]string) bool { return true }
		case "std":
			return func(paths []string) bool {
				for _, p := range paths {
					if isStandard(p) {
						return true
					}
				}
				return false
			}
			return false
		}
	case "cmd":
		// "cmd" expands to the Go repository's commands and their internal libraries.
		return func(paths []string) bool {
			for _, p := range paths {
				if p == "cmd" || strings.HasPrefix(p, "cmd/") {
					return true
		case "cmd":
			return func(paths []string) bool {
				for _, p := range paths {
					if isCommand(p) {
						return true
					}
				}
				return false
			}
		default:
			match := matchPattern(patterns[0])
			return func(paths []string) bool {
				for _, p := range paths {
					if match(p) {
						return true
					}
				}
				return false
			}
			return false
		}

	// Multiple import path patterns.
	default:
		match := matchPattern(pattern)
		var std, cmd bool
		for _, pattern := range patterns {
			switch pattern {
			case "all", "...":
				return func([]string) bool { return true }
			case "std":
				std = true
			case "cmd":
				cmd = true
			}
		}

		var ms []func(path string) bool
		if std {
			ms = append(ms, isStandard)
		} else if cmd {
			// cmd is a subset of std, so add iff hasCmd && !hasStd.
			ms = append(ms, isCommand)
		}
		for _, pattern := range patterns {
			if pattern == "std" || pattern == "cmd" {
				continue
			}
			ms = append(ms, matchPattern(pattern))
		}
		return func(paths []string) bool {
			for _, p := range paths {
				if match(p) {
					return true
			for _, match := range ms {
				for _, p := range paths {
					if match(p) {
						return true
					}
				}
			}
			return false
		}
	}
}

// matchPattern(pattern)(name) reports whether name matches pattern.
// matchPattern(pattern)(path) reports whether path matches pattern.
// Pattern is a limited glob pattern in which '...' means 'any string',
// foo/... matches foo too, and there is no other special syntax.
func matchPattern(pattern string) func(name string) bool {
func matchPattern(pattern string) func(path string) bool {
	re := regexp.QuoteMeta(pattern)
	re = strings.Replace(re, `\.\.\.`, `.*`, -1)
	// Special case: foo/... matches foo too.
	if strings.HasSuffix(re, `/.*`) {
		re = re[:len(re)-len(`/.*`)] + `(/.*)?`
service.go
@@ -114,10 +114,17 @@ func isStandard(importPath string) bool {
		importPath = importPath[:i]
	}
	return !strings.Contains(importPath, ".")
}

// isCommand reports whether import path importPath is one of
// Go repository's commands or their internal libraries.
// It's determined by whether importPath equals "cmd" or has "cmd/" prefix.
func isCommand(importPath string) bool {
	return importPath == "cmd" || strings.HasPrefix(importPath, "cmd/")
}

func (s *service) poll(ctx context.Context) {
	corpus, repo, err := initCorpus(ctx)
	if err != nil {
		log.Fatalln("poll: initial initCorpus failed:", err)
	}
util.go
@@ -41,11 +41,11 @@ func (h *errorHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	if err, ok := httperror.IsMethod(err); ok {
		httperror.HandleMethod(w, err)
		return
	}
	if err, ok := httperror.IsRedirect(err); ok {
		http.Redirect(w, req, err.URL, http.StatusSeeOther)
		http.Redirect(w, req, err.URL, http.StatusFound)
		return
	}
	if err, ok := httperror.IsBadRequest(err); ok {
		httperror.HandleBadRequest(w, err)
		return