| // 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. |
| |
| //go:build go1.18 |
| // +build go1.18 |
| |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "log" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "strings" |
| |
| "golang.org/x/tools/internal/gocommand" |
| difflib "golang.org/x/tools/internal/lsp/diff" |
| "golang.org/x/tools/internal/lsp/diff/myers" |
| "golang.org/x/tools/internal/lsp/source" |
| ) |
| |
| var ( |
| previousVersionFlag = flag.String("prev", "", "version to compare against") |
| versionFlag = flag.String("version", "", "version being tagged, or current version if omitted") |
| ) |
| |
| func main() { |
| flag.Parse() |
| |
| apiDiff, err := diffAPI(*versionFlag, *previousVersionFlag) |
| if err != nil { |
| log.Fatal(err) |
| } |
| fmt.Printf(` |
| %s |
| `, apiDiff) |
| } |
| |
| type JSON interface { |
| String() string |
| Write(io.Writer) |
| } |
| |
| func diffAPI(version, prev string) (string, error) { |
| ctx := context.Background() |
| previousApi, err := loadAPI(ctx, prev) |
| if err != nil { |
| return "", fmt.Errorf("load previous API: %v", err) |
| } |
| var currentApi *source.APIJSON |
| if version == "" { |
| currentApi = source.GeneratedAPIJSON |
| } else { |
| var err error |
| currentApi, err = loadAPI(ctx, version) |
| if err != nil { |
| return "", fmt.Errorf("load current API: %v", err) |
| } |
| } |
| |
| b := &strings.Builder{} |
| if err := diff(b, previousApi.Commands, currentApi.Commands, "command", func(c *source.CommandJSON) string { |
| return c.Command |
| }, diffCommands); err != nil { |
| return "", fmt.Errorf("diff commands: %v", err) |
| } |
| if diff(b, previousApi.Analyzers, currentApi.Analyzers, "analyzer", func(a *source.AnalyzerJSON) string { |
| return a.Name |
| }, diffAnalyzers); err != nil { |
| return "", fmt.Errorf("diff analyzers: %v", err) |
| } |
| if err := diff(b, previousApi.Lenses, currentApi.Lenses, "code lens", func(l *source.LensJSON) string { |
| return l.Lens |
| }, diffLenses); err != nil { |
| return "", fmt.Errorf("diff lenses: %v", err) |
| } |
| for key, prev := range previousApi.Options { |
| current, ok := currentApi.Options[key] |
| if !ok { |
| panic(fmt.Sprintf("unexpected option key: %s", key)) |
| } |
| if err := diff(b, prev, current, "option", func(o *source.OptionJSON) string { |
| return o.Name |
| }, diffOptions); err != nil { |
| return "", fmt.Errorf("diff options (%s): %v", key, err) |
| } |
| } |
| |
| return b.String(), nil |
| } |
| |
| func diff[T JSON](b *strings.Builder, previous, new []T, kind string, uniqueKey func(T) string, diffFunc func(*strings.Builder, T, T)) error { |
| prevJSON := collect(previous, uniqueKey) |
| newJSON := collect(new, uniqueKey) |
| for k := range newJSON { |
| delete(prevJSON, k) |
| } |
| for _, deleted := range prevJSON { |
| b.WriteString(fmt.Sprintf("%s %s was deleted.\n", kind, deleted)) |
| } |
| for _, prev := range previous { |
| delete(newJSON, uniqueKey(prev)) |
| } |
| if len(newJSON) > 0 { |
| b.WriteString("The following commands were added:\n") |
| for _, n := range newJSON { |
| n.Write(b) |
| b.WriteByte('\n') |
| } |
| } |
| previousMap := collect(previous, uniqueKey) |
| for _, current := range new { |
| prev, ok := previousMap[uniqueKey(current)] |
| if !ok { |
| continue |
| } |
| c, p := bytes.NewBuffer(nil), bytes.NewBuffer(nil) |
| prev.Write(p) |
| current.Write(c) |
| if diff, err := diffStr(p.String(), c.String()); err == nil && diff != "" { |
| diffFunc(b, prev, current) |
| b.WriteString("\n--\n") |
| } |
| } |
| return nil |
| } |
| |
| func collect[T JSON](args []T, uniqueKey func(T) string) map[string]T { |
| m := map[string]T{} |
| for _, arg := range args { |
| m[uniqueKey(arg)] = arg |
| } |
| return m |
| } |
| |
| var goCmdRunner = gocommand.Runner{} |
| |
| func loadAPI(ctx context.Context, version string) (*source.APIJSON, error) { |
| tmpGopath, err := ioutil.TempDir("", "gopath*") |
| if err != nil { |
| return nil, fmt.Errorf("temp dir: %v", err) |
| } |
| defer os.RemoveAll(tmpGopath) |
| |
| exampleDir := fmt.Sprintf("%s/src/example.com", tmpGopath) |
| if err := os.MkdirAll(exampleDir, 0776); err != nil { |
| return nil, fmt.Errorf("mkdir: %v", err) |
| } |
| |
| if stdout, err := goCmdRunner.Run(ctx, gocommand.Invocation{ |
| Verb: "mod", |
| Args: []string{"init", "example.com"}, |
| WorkingDir: exampleDir, |
| Env: append(os.Environ(), fmt.Sprintf("GOPATH=%s", tmpGopath)), |
| }); err != nil { |
| return nil, fmt.Errorf("go mod init failed: %v (stdout: %v)", err, stdout) |
| } |
| if stdout, err := goCmdRunner.Run(ctx, gocommand.Invocation{ |
| Verb: "install", |
| Args: []string{fmt.Sprintf("golang.org/x/tools/gopls@%s", version)}, |
| WorkingDir: exampleDir, |
| Env: append(os.Environ(), fmt.Sprintf("GOPATH=%s", tmpGopath)), |
| }); err != nil { |
| return nil, fmt.Errorf("go install failed: %v (stdout: %v)", err, stdout.String()) |
| } |
| cmd := exec.Cmd{ |
| Path: filepath.Join(tmpGopath, "bin", "gopls"), |
| Args: []string{"gopls", "api-json"}, |
| Dir: tmpGopath, |
| } |
| out, err := cmd.Output() |
| if err != nil { |
| return nil, fmt.Errorf("output: %v", err) |
| } |
| apiJson := &source.APIJSON{} |
| if err := json.Unmarshal(out, apiJson); err != nil { |
| return nil, fmt.Errorf("unmarshal: %v", err) |
| } |
| return apiJson, nil |
| } |
| |
| func diffCommands(b *strings.Builder, prev, current *source.CommandJSON) { |
| if prev.Title != current.Title { |
| b.WriteString(fmt.Sprintf("Title changed from %q to %q\n", prev.Title, current.Title)) |
| } |
| if prev.Doc != current.Doc { |
| b.WriteString(fmt.Sprintf("Documentation changed from %q to %q\n", prev.Doc, current.Doc)) |
| } |
| if prev.ArgDoc != current.ArgDoc { |
| b.WriteString("Arguments changed from " + formatBlock(prev.ArgDoc) + " to " + formatBlock(current.ArgDoc)) |
| } |
| if prev.ResultDoc != current.ResultDoc { |
| b.WriteString("Results changed from " + formatBlock(prev.ResultDoc) + " to " + formatBlock(current.ResultDoc)) |
| } |
| } |
| |
| func diffAnalyzers(b *strings.Builder, previous, current *source.AnalyzerJSON) { |
| b.WriteString(fmt.Sprintf("Changes to analyzer %s:\n\n", current.Name)) |
| if previous.Doc != current.Doc { |
| b.WriteString(fmt.Sprintf("Documentation changed from %q to %q\n", previous.Doc, current.Doc)) |
| } |
| if previous.Default != current.Default { |
| b.WriteString(fmt.Sprintf("Default changed from %v to %v\n", previous.Default, current.Default)) |
| } |
| } |
| |
| func diffLenses(b *strings.Builder, previous, current *source.LensJSON) { |
| b.WriteString(fmt.Sprintf("Changes to code lens %s:\n\n", current.Title)) |
| if previous.Title != current.Title { |
| b.WriteString(fmt.Sprintf("Title changed from %q to %q\n", previous.Title, current.Title)) |
| } |
| if previous.Doc != current.Doc { |
| b.WriteString(fmt.Sprintf("Documentation changed from %q to %q\n", previous.Doc, current.Doc)) |
| } |
| } |
| |
| func diffOptions(b *strings.Builder, previous, current *source.OptionJSON) { |
| b.WriteString(fmt.Sprintf("Changes to option %s:\n\n", current.Name)) |
| if previous.Doc != current.Doc { |
| diff, err := diffStr(previous.Doc, current.Doc) |
| if err != nil { |
| panic(err) |
| } |
| b.WriteString(fmt.Sprintf("Documentation changed:\n%s\n", diff)) |
| } |
| if previous.Default != current.Default { |
| b.WriteString(fmt.Sprintf("Default changed from %q to %q\n", previous.Default, current.Default)) |
| } |
| if previous.Hierarchy != current.Hierarchy { |
| b.WriteString(fmt.Sprintf("Categorization changed from %q to %q\n", previous.Hierarchy, current.Hierarchy)) |
| } |
| if previous.Status != current.Status { |
| b.WriteString(fmt.Sprintf("Status changed from %q to %q\n", previous.Status, current.Status)) |
| } |
| if previous.Type != current.Type { |
| b.WriteString(fmt.Sprintf("Type changed from %q to %q\n", previous.Type, current.Type)) |
| } |
| // TODO(rstambler): Handle possibility of same number but different keys/values. |
| if len(previous.EnumKeys.Keys) != len(current.EnumKeys.Keys) { |
| b.WriteString(fmt.Sprintf("Enum keys changed from\n%s\n to \n%s\n", previous.EnumKeys, current.EnumKeys)) |
| } |
| if len(previous.EnumValues) != len(current.EnumValues) { |
| b.WriteString(fmt.Sprintf("Enum values changed from\n%s\n to \n%s\n", previous.EnumValues, current.EnumValues)) |
| } |
| } |
| |
| func formatBlock(str string) string { |
| if str == "" { |
| return `""` |
| } |
| return "\n```\n" + str + "\n```\n" |
| } |
| |
| func diffStr(before, after string) (string, error) { |
| // Add newlines to avoid newline messages in diff. |
| if before == after { |
| return "", nil |
| } |
| before += "\n" |
| after += "\n" |
| d, err := myers.ComputeEdits("", before, after) |
| if err != nil { |
| return "", err |
| } |
| return fmt.Sprintf("%q", difflib.ToUnified("previous", "current", before, d)), err |
| } |