blob: 6978ac0425c434bd3ac93a426d610e3254988703 [file] [log] [blame]
// Copyright 2019 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 frontend
import (
"context"
"fmt"
"path"
"sort"
"strings"
"unicode"
"golang.org/x/mod/semver"
"golang.org/x/pkgsite/internal"
"golang.org/x/pkgsite/internal/experiment"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/postgres"
"golang.org/x/pkgsite/internal/stdlib"
"golang.org/x/pkgsite/internal/version"
)
// VersionsDetails contains the hierarchy of version summary information used
// to populate the version tab. Version information is organized into separate
// lists, one for each (ModulePath, Major Version) pair.
type VersionsDetails struct {
// ThisModule is the slice of VersionLists with the same module path as the
// current package.
ThisModule []*VersionList
// IncompatibleModules is the slice of the VersionsLists with the same
// module path as the current package, but with incompatible versions.
IncompatibleModules []*VersionList
// OtherModules is the slice of VersionLists with a different module path
// from the current package.
OtherModules []string
}
// VersionListKey identifies a version list on the versions tab. We have a
// separate VersionList for each major version of a module series. Notably we
// have more version lists than module paths: v0 and v1 module versions are in
// separate version lists, despite having the same module path.
type VersionListKey struct {
// ModulePath is the module path of this major version.
ModulePath string
// Major is the major version string (e.g. v1, v2)
Major string
// Incompatible indicates whether the VersionListKey represents an
// incompatible module version.
Incompatible bool
// Deprecated indicates whether the major version is deprecated.
Deprecated bool
// DeprecationComment holds the reason for deprecation, if any.
DeprecationComment string
}
// VersionList holds all versions corresponding to a unique (module path,
// major version) tuple in the version hierarchy.
type VersionList struct {
VersionListKey
// Versions holds the nested version summaries, organized in descending
// semver order.
Versions []*VersionSummary
}
// VersionSummary holds data required to format the version link on the
// versions tab.
type VersionSummary struct {
CommitTime string
// Link to this version, for use in the anchor href.
Link string
Version string
Retracted bool
RetractionRationale string
IsMinor bool
Symbols [][]*Symbol
}
func fetchVersionsDetails(ctx context.Context, ds internal.DataSource, fullPath, modulePath string) (*VersionsDetails, error) {
db, ok := ds.(*postgres.DB)
if !ok {
// The proxydatasource does not support the imported by page.
return nil, proxydatasourceNotSupportedErr()
}
versions, err := db.GetVersionsForPath(ctx, fullPath)
if err != nil {
return nil, err
}
outVersionToNameToUnitSymbol := map[string]map[string]*internal.UnitSymbol{}
if experiment.IsActive(ctx, internal.ExperimentSymbolHistoryVersionsPage) {
outVersionToNameToUnitSymbol, err = db.LegacyGetSymbolHistory(ctx, fullPath, modulePath)
if err != nil {
return nil, err
}
}
linkify := func(mi *internal.ModuleInfo) string {
// Here we have only version information, but need to construct the full
// import path of the package corresponding to this version.
var versionPath string
if mi.ModulePath == stdlib.ModulePath {
versionPath = fullPath
} else {
versionPath = pathInVersion(internal.V1Path(fullPath, modulePath), mi)
}
return constructUnitURL(versionPath, mi.ModulePath, linkVersion(mi.Version, mi.ModulePath))
}
return buildVersionDetails(modulePath, versions, outVersionToNameToUnitSymbol, linkify), nil
}
// pathInVersion constructs the full import path of the package corresponding
// to mi, given its v1 path. To do this, we first compute the suffix of the
// package path in the given module series, and then append it to the real
// (versioned) module path.
//
// For example: if we're considering package foo.com/v3/bar/baz, and encounter
// module version foo.com/bar/v2, we do the following:
// 1) Start with the v1Path foo.com/bar/baz.
// 2) Trim off the version series path foo.com/bar to get 'baz'.
// 3) Join with the versioned module path foo.com/bar/v2 to get
// foo.com/bar/v2/baz.
// ...being careful about slashes along the way.
func pathInVersion(v1Path string, mi *internal.ModuleInfo) string {
suffix := internal.Suffix(v1Path, mi.SeriesPath())
if suffix == "" {
return mi.ModulePath
}
return path.Join(mi.ModulePath, suffix)
}
// buildVersionDetails constructs the version hierarchy to be rendered on the
// versions tab, organizing major versions into those that have the same module
// path as the package version under consideration, and those that don't. The
// given versions MUST be sorted first by module path and then by semver.
func buildVersionDetails(currentModulePath string,
modInfos []*internal.ModuleInfo,
versionToNameToSymbol map[string]map[string]*internal.UnitSymbol,
linkify func(v *internal.ModuleInfo) string) *VersionsDetails {
// lists organizes versions by VersionListKey. Note that major version isn't
// sufficient as a key: there are packages contained in the same major
// version of different modules, for example github.com/hashicorp/vault/api,
// which exists in v1 of both of github.com/hashicorp/vault and
// github.com/hashicorp/vault/api.
lists := make(map[VersionListKey][]*VersionSummary)
// seenLists tracks the order in which we encounter entries of each version
// list. We want to preserve this order.
var seenLists []VersionListKey
for _, mi := range modInfos {
// Try to resolve the most appropriate major version for this version. If
// we detect a +incompatible version (when the path version does not match
// the sematic version), we prefer the path version.
major := semver.Major(mi.Version)
if mi.ModulePath == stdlib.ModulePath {
var err error
major, err = stdlib.MajorVersionForVersion(mi.Version)
if err != nil {
panic(err)
}
}
// We prefer the path major version except for v1 import paths where the
// semver major version is v0. In this case, we prefer the more specific
// semver version.
pathMajor := internal.MajorVersionForModule(mi.ModulePath)
if pathMajor != "" {
major = pathMajor
} else if version.IsIncompatible(mi.Version) {
major = semver.Major(mi.Version)
} else if major != "v0" && !strings.HasPrefix(major, "go") {
major = "v1"
}
key := VersionListKey{
ModulePath: mi.ModulePath,
Major: major,
Incompatible: version.IsIncompatible(mi.Version),
}
vs := &VersionSummary{
Link: linkify(mi),
CommitTime: absoluteTime(mi.CommitTime),
Version: linkVersion(mi.Version, mi.ModulePath),
IsMinor: isMinor(mi.Version),
}
key.Deprecated = mi.Deprecated
key.DeprecationComment = shortRationale(mi.DeprecationComment)
vs.Retracted = mi.Retracted
vs.RetractionRationale = shortRationale(mi.RetractionRationale)
if nts, ok := versionToNameToSymbol[mi.Version]; ok {
vs.Symbols = symbolsForVersion(linkify(mi), nts)
}
if _, ok := lists[key]; !ok {
seenLists = append(seenLists, key)
}
lists[key] = append(lists[key], vs)
}
var details VersionsDetails
other := map[string]bool{}
for _, key := range seenLists {
vl := &VersionList{
VersionListKey: key,
Versions: lists[key],
}
if key.ModulePath == currentModulePath {
if key.Incompatible {
details.IncompatibleModules = append(details.IncompatibleModules, vl)
} else {
details.ThisModule = append(details.ThisModule, vl)
}
} else {
other[key.ModulePath] = true
}
}
for m := range other {
details.OtherModules = append(details.OtherModules, m)
}
// Sort for testing.
sort.Strings(details.OtherModules)
return &details
}
// isMinor reports whether v is a release version where the patch version is 0.
// It is assumed that v is a valid semantic version.
func isMinor(v string) bool {
if version.IsIncompatible(v) {
return false
}
typ, err := version.ParseType(v)
if err != nil {
// This should never happen because v will always be a valid semantic
// version.
return false
}
if typ == version.TypePrerelease || typ == version.TypePseudo {
return false
}
return strings.HasSuffix(strings.TrimPrefix(v, semver.MajorMinor(v)), ".0")
}
// formatVersion formats a more readable representation of the given version
// string. On any parsing error, it simply returns the input unmodified.
//
// For pseudo versions, the version string will use a shorten commit hash of 7
// characters to identify the version, and hide timestamp using ellipses.
//
// For any version string longer than 25 characters, the pre-release string will be
// truncated, such that the string displayed is exactly 25 characters, including the ellipses.
//
// See TestFormatVersion for examples.
func formatVersion(v string) string {
const maxLen = 25
if len(v) <= maxLen {
return v
}
vType, err := version.ParseType(v)
if err != nil {
log.Errorf(context.TODO(), "formatVersion(%q): error parsing version: %v", v, err)
return v
}
if vType != version.TypePseudo {
// If the version is release or prerelease, return a version string of
// maxLen by truncating the end of the string. maxLen is inclusive of
// the "..." characters.
return v[:maxLen-3] + "..."
}
// The version string will have a max length of 25:
// base: "vX.Y.Z-prerelease.0" = up to 15
// ellipse: "..." = 3
// commit: "-abcdefa" = 7
commit := shorten(pseudoVersionRev(v), 7)
base := shorten(pseudoVersionBase(v), 15)
return fmt.Sprintf("%s...-%s", base, commit)
}
// shorten shortens the string s to maxLen by removing the trailing characters.
func shorten(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen]
}
return s
}
// shortRationale returns a rationale string that is safe
// to print in a terminal. It returns hard-coded strings if the rationale
// is empty, too long, or contains non-printable characters.
func shortRationale(rationale string) string {
// Copied with slight modifications from
// https://go.googlesource.com/go/+/87c6fa4f473f178f7d931ddadd10c76444f8dc7b/src/cmd/go/internal/modload/modfile.go#208.
const maxRationaleBytes = 500
if i := strings.Index(rationale, "\n"); i >= 0 {
rationale = rationale[:i]
}
rationale = strings.TrimSpace(rationale)
if rationale == "" {
return ""
}
if len(rationale) > maxRationaleBytes {
return "(rationale omitted: too long)"
}
for _, r := range rationale {
if !unicode.IsGraphic(r) && !unicode.IsSpace(r) {
return "(rationale omitted: contains non-printable characters)"
}
}
// NOTE: the go.mod parser rejects invalid UTF-8, so we don't check that here.
return rationale
}
// pseudoVersionRev extracts the pseudo version base, excluding the timestamp.
// It assumes the pseudo version is correctly formatted.
//
// See TestPseudoVersionBase for examples.
func pseudoVersionBase(v string) string {
parts := strings.Split(v, "-")
if len(parts) != 3 {
mid := strings.Join(parts[1:len(parts)-1], "-")
parts = []string{parts[0], mid, parts[2]}
}
// The version string will always be split into one
// of these 3 parts:
// 1. [vX.0.0, yyyymmddhhmmss, abcdefabcdef]
// 2. [vX.Y.Z, pre.0.yyyymmddhhmmss, abcdefabcdef]
// 3. [vX.Y.Z, 0.yyyymmddhhmmss, abcdefabcdef]
p := strings.Split(parts[1], ".")
var suffix string
if len(p) > 0 {
// There is a "pre.0" or "0" prefix in the second element.
suffix = strings.Join(p[0:len(p)-1], ".")
}
return fmt.Sprintf("%s-%s", parts[0], suffix)
}
// pseudoVersionRev extracts the first 7 characters of the commit identifier
// from a pseudo version string. It assumes the pseudo version is correctly
// formatted.
func pseudoVersionRev(v string) string {
v = strings.TrimSuffix(v, "+incompatible")
j := strings.LastIndex(v, "-")
return v[j+1:]
}
// displayVersion returns the version string, formatted for display.
func displayVersion(v string, modulePath string) string {
if modulePath == stdlib.ModulePath {
if strings.HasPrefix(v, "v0.0.0") {
return strings.Split(v, "-")[2]
}
return goTagForVersion(v)
}
return formatVersion(v)
}
// linkVersion returns the version string, suitable for use in
// a link to this site.
// TODO(golang/go#41855): Clarify definition / use case for linkVersion and
// other version strings.
func linkVersion(v string, modulePath string) string {
if modulePath == stdlib.ModulePath {
if strings.HasPrefix(v, "go") {
return v
}
return goTagForVersion(v)
}
return v
}
// goTagForVersion returns the Go tag corresponding to a given semantic
// version. It should only be used if we are 100% sure the version will
// correspond to a Go tag, such as when we are fetching the version from the
// database.
func goTagForVersion(v string) string {
tag, err := stdlib.TagForVersion(v)
if err != nil {
log.Errorf(context.TODO(), "goTagForVersion(%q): %v", v, err)
return "unknown"
}
return tag
}