blob: 72d92a4a3c578b42415cef8e8950ee533c5be849 [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 source constructs public URLs that link to the source files in a module. It
// can be used to build references to Go source code, or to any other files in a
// module.
//
// Of course, the module zip file contains all the files in the module. This
// package attempts to find the origin of the zip file, in a repository that is
// publicly readable, and constructs links to that repo. While a module zip file
// could in theory come from anywhere, including a non-public location, this
// package recognizes standard module path patterns and construct repository
// URLs from them, like the go command does.
package source
//
// Much of this code was adapted from
// https://go.googlesource.com/gddo/+/refs/heads/master/gosrc
// and
// https://go.googlesource.com/go/+/refs/heads/master/src/cmd/go/internal/get
import (
"context"
"encoding/json"
"fmt"
"net/http"
"path"
"regexp"
"strconv"
"strings"
"time"
"go.opencensus.io/plugin/ochttp"
"go.opencensus.io/trace"
"golang.org/x/net/context/ctxhttp"
"golang.org/x/pkgsite/internal/derrors"
"golang.org/x/pkgsite/internal/log"
"golang.org/x/pkgsite/internal/stdlib"
"golang.org/x/pkgsite/internal/version"
)
// Info holds source information about a module, used to generate URLs referring
// to directories, files and lines.
type Info struct {
repoURL string // URL of repo containing module; exported for DB schema compatibility
moduleDir string // directory of module relative to repo root
commit string // tag or ID of commit corresponding to version
templates urlTemplates // for building URLs
}
// RepoURL returns a URL for the home page of the repository.
func (i *Info) RepoURL() string {
if i == nil {
return ""
}
if i.templates.Repo == "" {
// The default repo template is just "{repo}".
return i.repoURL
}
return expand(i.templates.Repo, map[string]string{
"repo": i.repoURL,
})
}
// ModuleURL returns a URL for the home page of the module.
func (i *Info) ModuleURL() string {
return i.DirectoryURL("")
}
// DirectoryURL returns a URL for a directory relative to the module's home directory.
func (i *Info) DirectoryURL(dir string) string {
if i == nil {
return ""
}
return strings.TrimSuffix(expand(i.templates.Directory, map[string]string{
"repo": i.repoURL,
"importPath": path.Join(strings.TrimPrefix(i.repoURL, "https://"), dir),
"commit": i.commit,
"dir": path.Join(i.moduleDir, dir),
}), "/")
}
// FileURL returns a URL for a file whose pathname is relative to the module's home directory.
func (i *Info) FileURL(pathname string) string {
if i == nil {
return ""
}
dir, base := path.Split(pathname)
return expand(i.templates.File, map[string]string{
"repo": i.repoURL,
"importPath": path.Join(strings.TrimPrefix(i.repoURL, "https://"), dir),
"commit": i.commit,
"file": path.Join(i.moduleDir, pathname),
"base": base,
})
}
// LineURL returns a URL referring to a line in a file relative to the module's home directory.
func (i *Info) LineURL(pathname string, line int) string {
if i == nil {
return ""
}
dir, base := path.Split(pathname)
return expand(i.templates.Line, map[string]string{
"repo": i.repoURL,
"importPath": path.Join(strings.TrimPrefix(i.repoURL, "https://"), dir),
"commit": i.commit,
"file": path.Join(i.moduleDir, pathname),
"base": base,
"line": strconv.Itoa(line),
})
}
// RawURL returns a URL referring to the raw contents of a file relative to the
// module's home directory.
func (i *Info) RawURL(pathname string) string {
if i == nil {
return ""
}
// Some templates don't support raw content serving.
if i.templates.Raw == "" {
return ""
}
moduleDir := i.moduleDir
// Special case: the standard library's source module path is set to "src",
// which is correct for source file links. But the README is at the repo
// root, not in the src directory. In other words,
// Module.Units[0].Readme.FilePath is not relative to
// Module.Units[0].SourceInfo.moduleDir, as it is for every other module.
// Correct for that here.
if i.repoURL == stdlib.GoSourceRepoURL {
moduleDir = ""
}
return expand(i.templates.Raw, map[string]string{
"repo": i.repoURL,
"commit": i.commit,
"file": path.Join(moduleDir, pathname),
})
}
// map of common urlTemplates
var urlTemplatesByKind = map[string]urlTemplates{
"github": githubURLTemplates,
"gitlab": githubURLTemplates, // preserved for backwards compatibility (DB still has source_info->Kind = "gitlab")
"bitbucket": bitbucketURLTemplates,
}
// jsonInfo is a Go struct describing the JSON structure of an INFO.
type jsonInfo struct {
RepoURL string
ModuleDir string
Commit string
// Store common templates efficiently by setting this to a short string
// we look up in a map. If Kind != "", then Templates == nil.
Kind string `json:",omitempty"`
Templates *urlTemplates `json:",omitempty"`
}
// ToJSONForDB returns the Info encoded for storage in the database.
func (i *Info) MarshalJSON() (_ []byte, err error) {
defer derrors.Wrap(&err, "MarshalJSON")
ji := &jsonInfo{
RepoURL: i.repoURL,
ModuleDir: i.moduleDir,
Commit: i.commit,
}
// Store common templates efficiently, by name.
for kind, templs := range urlTemplatesByKind {
if i.templates == templs {
ji.Kind = kind
break
}
}
// We used to use different templates for GitHub and GitLab. Now that
// they're the same, prefer "github" for consistency (map random iteration
// order means we could get either here).
if ji.Kind == "gitlab" {
ji.Kind = "github"
}
if ji.Kind == "" && i.templates != (urlTemplates{}) {
ji.Templates = &i.templates
}
return json.Marshal(ji)
}
func (i *Info) UnmarshalJSON(data []byte) (err error) {
defer derrors.Wrap(&err, "UnmarshalJSON(data)")
var ji jsonInfo
if err := json.Unmarshal(data, &ji); err != nil {
return err
}
i.repoURL = ji.RepoURL
i.moduleDir = ji.ModuleDir
i.commit = ji.Commit
if ji.Kind != "" {
i.templates = urlTemplatesByKind[ji.Kind]
} else if ji.Templates != nil {
i.templates = *ji.Templates
}
return nil
}
type Client struct {
// client used for HTTP requests. It is mutable for testing purposes.
httpClient *http.Client
}
// New constructs a *Client using the provided timeout.
func NewClient(timeout time.Duration) *Client {
return &Client{
httpClient: &http.Client{
Transport: &ochttp.Transport{},
Timeout: timeout,
},
}
}
// doURL makes an HTTP request using the given url and method. It returns an
// error if the request returns an error. If only200 is true, it also returns an
// error if any status code other than 200 is returned.
func (c *Client) doURL(ctx context.Context, method, url string, only200 bool) (_ *http.Response, err error) {
defer derrors.Wrap(&err, "doURL(ctx, client, %q, %q)", method, url)
if c == nil || c.httpClient == nil {
return nil, fmt.Errorf("c.httpClient cannot be nil")
}
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
resp, err := ctxhttp.Do(ctx, c.httpClient, req)
if err != nil {
return nil, err
}
if only200 && resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("status %s", resp.Status)
}
return resp, nil
}
// ModuleInfo determines the repository corresponding to the module path. It
// returns a URL to that repo, as well as the directory of the module relative
// to the repo root.
//
// ModuleInfo may fetch from arbitrary URLs, so it can be slow.
func ModuleInfo(ctx context.Context, client *Client, modulePath, version string) (info *Info, err error) {
defer derrors.Wrap(&err, "source.ModuleInfo(ctx, %q, %q)", modulePath, version)
ctx, span := trace.StartSpan(ctx, "source.ModuleInfo")
defer span.End()
if modulePath == stdlib.ModulePath {
commit, err := stdlib.TagForVersion(version)
if err != nil {
return nil, err
}
return &Info{
repoURL: stdlib.GoSourceRepoURL,
moduleDir: stdlib.Directory(version),
commit: commit,
templates: githubURLTemplates,
}, nil
}
repo, relativeModulePath, templates, transformCommit, err := matchStatic(modulePath)
if err != nil {
info, err = moduleInfoDynamic(ctx, client, modulePath, version)
if err != nil {
return nil, err
}
} else {
commit, isHash := commitFromVersion(version, relativeModulePath)
if transformCommit != nil {
commit = transformCommit(commit, isHash)
}
info = &Info{
repoURL: "https://" + repo,
moduleDir: relativeModulePath,
commit: commit,
templates: templates,
}
}
adjustVersionedModuleDirectory(ctx, client, info)
return info, nil
// TODO(golang/go#39627): support launchpad.net, including the special case
// in cmd/go/internal/get/vcs.go.
}
// matchStatic matches the given module or repo path against a list of known
// patterns. It returns the repo name, the module path relative to the repo
// root, and URL templates if there is a match.
//
// The relative module path may not be correct in all cases: it is wrong if it
// ends in a version that is not part of the repo directory structure, because
// the repo follows the "major branch" convention for versions 2 and above.
// E.g. this function could return "foo/v2", but the module files live under "foo"; the
// "/v2" is part of the module path (and the import paths of its packages) but
// is not a subdirectory. This mistake is corrected in adjustVersionedModuleDirectory,
// once we have all the information we need to fix it.
//
// repo + "/" + relativeModulePath is often, but not always, equal to
// moduleOrRepoPath. It is not when the argument is a module path that uses the
// go command's general syntax, which ends in a ".vcs" (e.g. ".git", ".hg") that
// is neither part of the repo nor the suffix. For example, if the argument is
// github.com/a/b/c
// then repo="github.com/a/b" and relativeModulePath="c"; together they make up the module path.
// But if the argument is
// example.com/a/b.git/c
// then repo="example.com/a/b" and relativeModulePath="c"; the ".git" is omitted, since it is neither
// part of the repo nor part of the relative path to the module within the repo.
func matchStatic(moduleOrRepoPath string) (repo, relativeModulePath string, _ urlTemplates, transformCommit func(string, bool) string, _ error) {
for _, pat := range patterns {
matches := pat.re.FindStringSubmatch(moduleOrRepoPath)
if matches == nil {
continue
}
var repo string
for i, n := range pat.re.SubexpNames() {
if n == "repo" {
repo = matches[i]
break
}
}
// Special case: git.apache.org has a go-import tag that points to
// github.com/apache, but it's not quite right (the repo prefix is
// missing a ".git"), so handle it here.
const apacheDomain = "git.apache.org/"
if strings.HasPrefix(repo, apacheDomain) {
repo = strings.Replace(repo, apacheDomain, "github.com/apache/", 1)
}
relativeModulePath = strings.TrimPrefix(moduleOrRepoPath, matches[0])
relativeModulePath = strings.TrimPrefix(relativeModulePath, "/")
return repo, relativeModulePath, pat.templates, pat.transformCommit, nil
}
return "", "", urlTemplates{}, nil, derrors.NotFound
}
// moduleInfoDynamic uses the go-import and go-source meta tags to construct an Info.
func moduleInfoDynamic(ctx context.Context, client *Client, modulePath, version string) (_ *Info, err error) {
defer derrors.Wrap(&err, "source.moduleInfoDynamic(ctx, client, %q, %q)", modulePath, version)
sourceMeta, err := fetchMeta(ctx, client, modulePath)
if err != nil {
return nil, err
}
// Don't check that the tag information at the repo root prefix is the same
// as in the module path. It was done for us by the proxy and/or go command.
// (This lets us merge information from the go-import and go-source tags.)
// sourceMeta contains some information about where the module's source lives. But there
// are some problems:
// - We may only have a go-import tag, not a go-source tag, so we don't have URL templates for
// building URLs to files and directories.
// - Even if we do have a go-source tag, its URL template format predates
// versioning, so the URL templates won't provide a way to specify a
// version or commit.
//
// We resolve these problems as follows:
// 1. First look at the repo URL from the tag. If that matches a known hosting site, use the
// URL templates corresponding to that site and ignore whatever's in the tag.
// 2. Then look at the URL templates to see if they match a known pattern, and use the templates
// from that pattern. For example, the meta tags for gopkg.in/yaml.v2 only mention github
// in the URL templates, like "https://github.com/go-yaml/yaml/tree/v2.2.3{/dir}". We can observe
// that that template begins with a known pattern--a GitHub repo, ignore the rest of it, and use the
// GitHub URL templates that we know.
repoURL := sourceMeta.repoURL
_, _, templates, transformCommit, _ := matchStatic(removeHTTPScheme(repoURL))
// If err != nil, templates will be the zero value, so we can ignore it (same just below).
if templates == (urlTemplates{}) {
var repo string
repo, _, templates, transformCommit, _ = matchStatic(removeHTTPScheme(sourceMeta.dirTemplate))
if templates == (urlTemplates{}) {
log.Infof(ctx, "no templates for repo URL %q from meta tag: err=%v", sourceMeta.repoURL, err)
} else {
// Use the repo from the template, not the original one.
repoURL = "https://" + repo
}
}
dir := strings.TrimPrefix(strings.TrimPrefix(modulePath, sourceMeta.repoRootPrefix), "/")
commit, isHash := commitFromVersion(version, dir)
if transformCommit != nil {
commit = transformCommit(commit, isHash)
}
return &Info{
repoURL: strings.TrimSuffix(repoURL, "/"),
moduleDir: dir,
commit: commit,
templates: templates,
}, nil
}
// adjustVersionedModuleDirectory changes info.moduleDir if necessary to
// correctly reflect the repo structure. info.moduleDir will be wrong if it has
// a suffix "/vN" for N > 1, and the repo uses the "major branch" convention,
// where modules at version 2 and higher live on branches rather than
// subdirectories. See https://research.swtch.com/vgo-module for a discussion of
// the "major branch" vs. "major subdirectory" conventions for organizing a
// repo.
func adjustVersionedModuleDirectory(ctx context.Context, client *Client, info *Info) {
dirWithoutVersion := removeVersionSuffix(info.moduleDir)
if info.moduleDir == dirWithoutVersion {
return
}
// moduleDir does have a "/vN" for N > 1. To see if that is the actual directory,
// fetch the go.mod file from it.
res, err := client.doURL(ctx, "HEAD", info.FileURL("go.mod"), true)
// On any failure, assume that the right directory is the one without the version.
if err != nil {
info.moduleDir = dirWithoutVersion
} else {
res.Body.Close()
}
}
// removeHTTPScheme removes an initial "http://" or "https://" from url.
// The result can be used to match against our static patterns.
// If the URL uses a different scheme, it won't be removed and it won't
// match any patterns, as intended.
func removeHTTPScheme(url string) string {
for _, prefix := range []string{"https://", "http://"} {
if strings.HasPrefix(url, prefix) {
return url[len(prefix):]
}
}
return url
}
// removeVersionSuffix returns s with "/vN" removed if N is an integer > 1.
// Otherwise it returns s.
func removeVersionSuffix(s string) string {
dir, base := path.Split(s)
if !strings.HasPrefix(base, "v") {
return s
}
if n, err := strconv.Atoi(base[1:]); err != nil || n < 2 {
return s
}
return strings.TrimSuffix(dir, "/")
}
// Patterns for determining repo and URL templates from module paths or repo
// URLs. Each regexp must match a prefix of the target string, and must have a
// group named "repo".
var patterns = []struct {
pattern string // uncompiled regexp
templates urlTemplates
re *regexp.Regexp
// transformCommit may alter the commit before substitution
transformCommit func(commit string, isHash bool) string
}{
{
pattern: `^(?P<repo>github\.com/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)`,
templates: githubURLTemplates,
},
{
pattern: `^(?P<repo>bitbucket\.org/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)`,
templates: bitbucketURLTemplates,
},
{
pattern: `^(?P<repo>gitlab\.com/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)`,
templates: githubURLTemplates,
},
{
// Assume that any site beginning with "gitlab." works like gitlab.com.
pattern: `^(?P<repo>gitlab\.[a-z0-9A-Z.-]+/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: githubURLTemplates,
},
{
pattern: `^(?P<repo>gitee\.com/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: githubURLTemplates,
},
{
pattern: `^(?P<repo>git\.sr\.ht/~[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)`,
templates: urlTemplates{
Directory: "{repo}/tree/{commit}/{dir}",
File: "{repo}/tree/{commit}/{file}",
Line: "{repo}/tree/{commit}/{file}#L{line}",
Raw: "{repo}/blob/{commit}/{file}",
},
},
{
pattern: `^(?P<repo>git\.fd\.io/[a-z0-9A-Z_.\-]+)`,
templates: urlTemplates{
Directory: "{repo}/tree/{dir}?{commit}",
File: "{repo}/tree/{file}?{commit}",
Line: "{repo}/tree/{file}?{commit}#n{line}",
Raw: "{repo}/plain/{file}?{commit}",
},
transformCommit: func(commit string, isHash bool) string {
// hashes use "?id=", tags use "?h="
p := "h"
if isHash {
p = "id"
}
return fmt.Sprintf("%s=%s", p, commit)
},
},
{
pattern: `^(?P<repo>git\.pirl\.io/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)`,
templates: urlTemplates{
Directory: "{repo}/-/tree/{commit}/{dir}",
File: "{repo}/-/blob/{commit}/{file}",
Line: "{repo}/-/blob/{commit}/{file}#L{line}",
Raw: "{repo}/-/raw/{commit}/{file}",
},
},
{
pattern: `^(?P<repo>gitea\.com/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: giteaURLTemplates,
transformCommit: giteaTransformCommit,
},
{
// Assume that any site beginning with "gitea." works like gitea.com.
pattern: `^(?P<repo>gitea\.[a-z0-9A-Z.-]+/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: giteaURLTemplates,
transformCommit: giteaTransformCommit,
},
{
pattern: `^(?P<repo>go\.isomorphicgo\.org/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: giteaURLTemplates,
transformCommit: giteaTransformCommit,
},
{
pattern: `^(?P<repo>git\.openprivacy\.ca/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
templates: giteaURLTemplates,
transformCommit: giteaTransformCommit,
},
{
pattern: `^(?P<repo>gogs\.[a-z0-9A-Z.-]+/[a-z0-9A-Z_.\-]+/[a-z0-9A-Z_.\-]+)(\.git|$)`,
// Gogs uses the same basic structure as Gitea, but omits the type of
// commit ("tag" or "commit"), so we don't need a transformCommit
// function. Gogs does not support short hashes, but we create those
// URLs anyway. See gogs/gogs#6242.
templates: giteaURLTemplates,
},
{
pattern: `^(?P<repo>dmitri\.shuralyov\.com\/.+)$`,
templates: urlTemplates{
Repo: "{repo}/...",
Directory: "https://gotools.org/{importPath}?rev={commit}",
File: "https://gotools.org/{importPath}?rev={commit}#{base}",
Line: "https://gotools.org/{importPath}?rev={commit}#{base}-L{line}",
},
},
// Patterns that match the general go command pattern, where they must have
// a ".git" repo suffix in an import path. If matching a repo URL from a meta tag,
// there is no ".git".
{
pattern: `^(?P<repo>[^.]+\.googlesource\.com/[^.]+)(\.git|$)`,
templates: urlTemplates{
Directory: "{repo}/+/{commit}/{dir}",
File: "{repo}/+/{commit}/{file}",
Line: "{repo}/+/{commit}/{file}#{line}",
// Gitiles has no support for serving raw content at this time.
},
},
{
pattern: `^(?P<repo>git\.apache\.org/[^.]+)(\.git|$)`,
templates: githubURLTemplates,
},
// General syntax for the go command. We can extract the repo and directory, but
// we don't know the URL templates.
// Must be last in this list.
{
pattern: `(?P<repo>([a-z0-9.\-]+\.)+[a-z0-9.\-]+(:[0-9]+)?(/~?[A-Za-z0-9_.\-]+)+?)\.(bzr|fossil|git|hg|svn)`,
templates: urlTemplates{},
},
}
func init() {
for i := range patterns {
re := regexp.MustCompile(patterns[i].pattern)
// The pattern regexp must contain a group named "repo".
found := false
for _, n := range re.SubexpNames() {
if n == "repo" {
found = true
break
}
}
if !found {
panic(fmt.Sprintf("pattern %s missing <repo> group", patterns[i].pattern))
}
patterns[i].re = re
}
}
// giteaTransformCommit transforms commits for the Gitea code hosting system.
func giteaTransformCommit(commit string, isHash bool) string {
// Hashes use "commit", tags use "tag".
// Short hashes are supported as of v1.14.0.
if isHash {
return "commit/" + commit
}
return "tag/" + commit
}
// urlTemplates describes how to build URLs from bits of source information.
// The fields are exported for JSON encoding.
//
// The template variables are:
//
// • {repo} - Repository URL with "https://" prefix ("https://example.com/myrepo").
// • {importPath} - Package import path ("example.com/myrepo/mypkg").
// • {commit} - Tag name or commit hash corresponding to version ("v0.1.0" or "1234567890ab").
// • {dir} - Path to directory of the package, relative to repo root ("mypkg").
// • {file} - Path to file containing the identifier, relative to repo root ("mypkg/file.go").
// • {base} - Base name of file containing the identifier, including file extension ("file.go").
// • {line} - Line number for the identifier ("41").
//
type urlTemplates struct {
Repo string `json:",omitempty"` // Optional URL template for the repository home page, with {repo}. If left empty, a default template "{repo}" is used.
Directory string // URL template for a directory, with {repo}, {importPath}, {commit}, {dir}.
File string // URL template for a file, with {repo}, {importPath}, {commit}, {file}, {base}.
Line string // URL template for a line, with {repo}, {importPath}, {commit}, {file}, {base}, {line}.
Raw string // Optional URL template for the raw contents of a file, with {repo}, {commit}, {file}.
}
var (
githubURLTemplates = urlTemplates{
Directory: "{repo}/tree/{commit}/{dir}",
File: "{repo}/blob/{commit}/{file}",
Line: "{repo}/blob/{commit}/{file}#L{line}",
Raw: "{repo}/raw/{commit}/{file}",
}
bitbucketURLTemplates = urlTemplates{
Directory: "{repo}/src/{commit}/{dir}",
File: "{repo}/src/{commit}/{file}",
Line: "{repo}/src/{commit}/{file}#lines-{line}",
Raw: "{repo}/raw/{commit}/{file}",
}
giteaURLTemplates = urlTemplates{
Directory: "{repo}/src/{commit}/{dir}",
File: "{repo}/src/{commit}/{file}",
Line: "{repo}/src/{commit}/{file}#L{line}",
Raw: "{repo}/raw/{commit}/{file}",
}
)
// commitFromVersion returns a string that refers to a commit corresponding to version.
// It also reports whether the returned value is a commit hash.
// The string may be a tag, or it may be the hash or similar unique identifier of a commit.
// The second argument is the module path relative to the repo root.
func commitFromVersion(vers, relativeModulePath string) (commit string, isHash bool) {
// Commit for the module: either a sha for pseudoversions, or a tag.
v := strings.TrimSuffix(vers, "+incompatible")
if version.IsPseudo(v) {
// Use the commit hash at the end.
return v[strings.LastIndex(v, "-")+1:], true
} else {
// The tags for a nested module begin with the relative module path of the module,
// removing a "/vN" suffix if N > 1.
prefix := removeVersionSuffix(relativeModulePath)
if prefix != "" {
return prefix + "/" + v, false
}
return v, false
}
}
// The following code copied from cmd/go/internal/get:
// expand rewrites s to replace {k} with match[k] for each key k in match.
func expand(s string, match map[string]string) string {
// We want to replace each match exactly once, and the result of expansion
// must not depend on the iteration order through the map.
// A strings.Replacer has exactly the properties we're looking for.
oldNew := make([]string, 0, 2*len(match))
for k, v := range match {
oldNew = append(oldNew, "{"+k+"}", v)
}
return strings.NewReplacer(oldNew...).Replace(s)
}
// NewGitHubInfo creates a source.Info with GitHub URL templates.
// It is for testing only.
func NewGitHubInfo(repoURL, moduleDir, commit string) *Info {
return &Info{
repoURL: repoURL,
moduleDir: moduleDir,
commit: commit,
templates: githubURLTemplates,
}
}