| // Copyright 2021 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. | |
| // Command api computes the exported API of a set of Go packages. | |
| package main | |
| import ( | |
| "archive/zip" | |
| "context" | |
| "errors" | |
| "flag" | |
| "fmt" | |
| "io" | |
| "log" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "sort" | |
| "strings" | |
| "go.opencensus.io/plugin/ochttp" | |
| "golang.org/x/mod/semver" | |
| "golang.org/x/pkgsite/internal" | |
| "golang.org/x/pkgsite/internal/derrors" | |
| "golang.org/x/pkgsite/internal/frontend/client" | |
| "golang.org/x/pkgsite/internal/frontend/versions" | |
| "golang.org/x/pkgsite/internal/proxy" | |
| "golang.org/x/pkgsite/internal/symbol" | |
| "golang.org/x/pkgsite/internal/version" | |
| ) | |
| var ( | |
| compareAll = flag.Bool("all", false, | |
| "compare all packages in tests/api/testdata if true") | |
| frontendHost = flag.String("frontend", "http://localhost:8080", | |
| "Use the frontend host referred to by this URL for comparing data") | |
| proxyURL = flag.String("proxy", "https://proxy.golang.org", | |
| "Use the module proxy referred to by this URL for fetching packages") | |
| ) | |
| func main() { | |
| flag.Usage = func() { | |
| out := flag.CommandLine.Output() | |
| fmt.Fprintln(out, "api [cmd] [module path]:[package path suffix]") | |
| fmt.Fprintf(out, " generate: generates the API history for a package and writes to %s\n", testdataDir) | |
| fmt.Fprintf(out, " compare: compares the API history for a package in %s to %s\n", testdataDir, *frontendHost) | |
| fmt.Fprintln(out) | |
| flag.PrintDefaults() | |
| } | |
| flag.Parse() | |
| if *compareAll { | |
| if flag.NArg() != 1 { | |
| flag.Usage() | |
| log.Fatalf("unexpected number of arguments for -all: %v", flag.Args()) | |
| } | |
| } else if flag.NArg() != 2 { | |
| flag.Usage() | |
| log.Fatalf("unexpected number of arguments: %v", flag.Args()) | |
| } | |
| ctx := context.Background() | |
| cmd := flag.Args()[0] | |
| var pkgPath, modulePath string | |
| if !*compareAll { | |
| pkgPath, modulePath = parsePath(flag.Args()[1]) | |
| } | |
| if err := run(ctx, cmd, pkgPath, modulePath, *frontendHost, *proxyURL, *compareAll); err != nil { | |
| log.Fatal(err) | |
| } | |
| } | |
| func parsePath(arg string) (pkgPath, modulePath string) { | |
| if !strings.Contains(arg, ":") { | |
| return arg, arg | |
| } | |
| parts := strings.SplitN(arg, ":", 2) | |
| return strings.Join(parts, "/"), parts[0] | |
| } | |
| const ( | |
| testdataDir = "tests/api/testdata" | |
| tmpDir = "/tmp/api" | |
| ) | |
| func run(ctx context.Context, cmd, pkgPath, modulePath, frontendHost, proxyURL string, compareAll bool) error { | |
| switch cmd { | |
| case "compare": | |
| if compareAll { | |
| pkgPaths, err := allPackages() | |
| if err != nil { | |
| return err | |
| } | |
| for _, p := range pkgPaths { | |
| if err := compare(frontendHost, p); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| return compare(frontendHost, pkgPath) | |
| case "generate": | |
| return generate(ctx, pkgPath, modulePath, tmpDir, proxyURL) | |
| } | |
| return fmt.Errorf("unsupported command: %q", cmd) | |
| } | |
| func generate(ctx context.Context, pkgPath, modulePath, tmpPath, proxyURL string) (err error) { | |
| defer derrors.Wrap(&err, "generate(ctx, %q, %q, %q, %q)", pkgPath, modulePath, tmpPath, proxyURL) | |
| proxyClient, err := proxy.New(proxyURL, new(ochttp.Transport)) | |
| if err != nil { | |
| return err | |
| } | |
| versions, err := proxyClient.Versions(ctx, modulePath) | |
| if err != nil { | |
| return err | |
| } | |
| versions = sortVersion(versions) | |
| fmt.Printf("Processing %d versions\n\n", len(versions)) | |
| prevFeatureSet := map[string]bool{} | |
| for _, ver := range versions { | |
| typ, err := version.ParseType(ver) | |
| if err != nil { | |
| return err | |
| } | |
| if typ != version.TypeRelease { | |
| fmt.Printf("----- %s (SKIPPED, not a release version) -----\n", ver) | |
| continue | |
| } | |
| if version.IsIncompatible(ver) { | |
| fmt.Printf("----- %s (SKIPPED, incompatible) -----\n", ver) | |
| continue | |
| } | |
| featureCtx, err := fetchFeatureContext(ctx, proxyClient, modulePath, pkgPath, ver, tmpPath) | |
| if errors.Is(err, derrors.NotFound) { | |
| continue | |
| } | |
| if err != nil { | |
| return err | |
| } | |
| newFeatures, featureSet := symbol.FeaturesForVersion(featureCtx, prevFeatureSet) | |
| prevFeatureSet = featureSet | |
| if len(newFeatures) == 0 { | |
| fmt.Println("No features for this version.") | |
| continue | |
| } | |
| if err := writeFeatures(newFeatures, pkgPath, ver, testdataDir); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| // allPackages returns all package paths in tests/api/testdata. | |
| func allPackages() (_ []string, err error) { | |
| defer derrors.Wrap(&err, "allPackages") | |
| dirToFiles := map[string][]string{} | |
| err = filepath.Walk( | |
| "tests/api/testdata", | |
| func(path string, info os.FileInfo, err error) error { | |
| if err != nil { | |
| return err | |
| } | |
| if info.IsDir() { | |
| return nil | |
| } | |
| dir := filepath.Dir(path) | |
| dirToFiles[dir] = append(dirToFiles[dir], path) | |
| return nil | |
| }) | |
| if err != nil { | |
| return nil, err | |
| } | |
| var paths []string | |
| for p := range dirToFiles { | |
| paths = append(paths, strings.TrimPrefix(p, testdataDir+"/")) | |
| } | |
| return paths, nil | |
| } | |
| // compare compares data from the testdata directory with the frontend. | |
| func compare(frontendHost, pkgPath string) (err error) { | |
| defer derrors.Wrap(&err, "compare(ctx, %q, %q, %q)", frontendHost, pkgPath, testdataDir) | |
| files, err := symbol.LoadAPIFiles(pkgPath, testdataDir) | |
| if err != nil { | |
| return err | |
| } | |
| apiVersions, err := symbol.ParsePackageAPIInfo(files) | |
| if err != nil { | |
| return err | |
| } | |
| // Parse API data from the frontend versions page. | |
| client := client.New(frontendHost) | |
| vd, err := client.GetVersions(pkgPath) | |
| if err != nil { | |
| return err | |
| } | |
| sh, err := versions.ParseVersionsDetails(vd) | |
| if err != nil { | |
| return err | |
| } | |
| // Compare the output of these two data sources. | |
| errors, err := symbol.CompareAPIVersions(pkgPath, apiVersions[pkgPath], sh) | |
| if err != nil { | |
| return err | |
| } | |
| if len(errors) == 0 { | |
| fmt.Printf("The APIs match for %s!\n", pkgPath) | |
| return nil | |
| } | |
| fmt.Printf("---------- Errors for %s\n", pkgPath) | |
| for _, e := range errors { | |
| fmt.Print(e) | |
| } | |
| return nil | |
| } | |
| func fetchFeatureContext(ctx context.Context, proxyClient *proxy.Client, | |
| modulePath, pkgPath, ver, dirPath string) (_ map[string]map[string]bool, err error) { | |
| defer derrors.Wrap(&err, "fetchFeatureContext(ctx, proxyClient, %q, %q, %q, %q)", | |
| modulePath, pkgPath, ver, dirPath) | |
| r, err := proxyClient.Zip(ctx, modulePath, ver) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if err := writeZip(r, dirPath); err != nil { | |
| return nil, err | |
| } | |
| modver := fmt.Sprintf("%s@%s", modulePath, ver) | |
| modDir := fmt.Sprintf("%s/%s", dirPath, modver) | |
| pkgDir := fmt.Sprintf("%s/%s", modDir, internal.Suffix(pkgPath, modulePath)) | |
| fmt.Printf("----- %s ----- (source: %s)\n", ver, pkgDir) | |
| if fi, err := os.Stat(pkgDir); err != nil || !fi.IsDir() { | |
| fmt.Printf("package %q is not present in this version\n", pkgPath) | |
| return nil, derrors.NotFound | |
| } | |
| modFile := modDir + "/go.mod" | |
| if _, err := os.Stat(modFile); os.IsNotExist(err) { | |
| fmt.Printf("%q not found; running go mod init in %q\n", modFile, modDir) | |
| cmd := exec.Command("go", "mod", "init", modulePath) | |
| cmd.Dir = modDir | |
| out, err := cmd.CombinedOutput() | |
| if err != nil { | |
| return nil, fmt.Errorf("error running go mod init: %v \n %s", err, string(out)) | |
| } | |
| } | |
| return symbol.GenerateFeatureContexts(ctx, pkgPath, pkgDir) | |
| } | |
| func writeFeatures(features []string, pkgPath, ver, outDir string) (err error) { | |
| defer derrors.Wrap(&err, "writeFeatures(%v, %q, %q, %q)", features, pkgPath, ver, outDir) | |
| if outDir == "" { | |
| sort.Strings(features) | |
| for _, s := range features { | |
| fmt.Println(s) | |
| } | |
| return nil | |
| } | |
| out := fmt.Sprintf("%s/%s/%s.txt", outDir, pkgPath, ver) | |
| if err := os.MkdirAll(filepath.Dir(out), os.ModePerm); err != nil { | |
| return fmt.Errorf("os.MkdirAll: %v", err) | |
| } | |
| file, err := os.Create(out) | |
| if err != nil { | |
| return fmt.Errorf("os.Create: %v", err) | |
| } | |
| defer func() { | |
| cerr := file.Close() | |
| if err == nil { | |
| err = cerr | |
| } | |
| }() | |
| sort.Strings(features) | |
| for _, s := range features { | |
| if _, err := file.WriteString(s + "\n"); err != nil { | |
| return err | |
| } | |
| } | |
| fmt.Printf("Found %d features\n", len(features)) | |
| return nil | |
| } | |
| func writeZip(r *zip.Reader, destination string) (err error) { | |
| defer derrors.Wrap(&err, "writeZip(r, %q)", destination) | |
| for _, f := range r.File { | |
| fpath := filepath.Join(destination, f.Name) | |
| if !strings.HasPrefix(fpath, filepath.Clean(destination)+string(os.PathSeparator)) { | |
| return fmt.Errorf("%s is an illegal filepath", fpath) | |
| } | |
| if f.FileInfo().IsDir() { | |
| os.MkdirAll(fpath, os.ModePerm) | |
| continue | |
| } | |
| if err := os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil { | |
| return err | |
| } | |
| outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) | |
| if err != nil { | |
| return err | |
| } | |
| rc, err := f.Open() | |
| if err != nil { | |
| return err | |
| } | |
| if _, err := io.Copy(outFile, rc); err != nil { | |
| return err | |
| } | |
| if err := outFile.Close(); err != nil { | |
| return err | |
| } | |
| if err := rc.Close(); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| func sortVersion(versions []string) []string { | |
| sort.Slice(versions, func(i, j int) bool { | |
| return semver.Compare(versions[i], versions[j]) < 0 | |
| }) | |
| return versions | |
| } |