x/tools/go/analysis: extend json output by SuggestedFixes

+ The JSON output now contains SuggestedFixes from the Diagnostics. The edits
  are encoded as replacements for ranges defined by 0 based byte start and end
  indicies into the original file.
+ This change also exports the structs that are used for JSON encoding
  and thus documents the JSON schema.

Fixes golang/go#55138

Change-Id: I93411626279a866de2986ff78d93775a86ae2213
Reviewed-on: https://go-review.googlesource.com/c/tools/+/435255
Reviewed-by: Tim King <taking@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
gopls-CI: kokoro <noreply+kokoro@google.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Cherry Mui <cherryyz@google.com>
diff --git a/go/analysis/internal/analysisflags/flags.go b/go/analysis/internal/analysisflags/flags.go
index 4b7be2d..2ea6306 100644
--- a/go/analysis/internal/analysisflags/flags.go
+++ b/go/analysis/internal/analysisflags/flags.go
@@ -339,9 +339,38 @@
 }
 
 // A JSONTree is a mapping from package ID to analysis name to result.
-// Each result is either a jsonError or a list of jsonDiagnostic.
+// Each result is either a jsonError or a list of JSONDiagnostic.
 type JSONTree map[string]map[string]interface{}
 
+// A TextEdit describes the replacement of a portion of a file.
+// Start and End are zero-based half-open indices into the original byte
+// sequence of the file, and New is the new text.
+type JSONTextEdit struct {
+	Filename string `json:"filename"`
+	Start    int    `json:"start"`
+	End      int    `json:"end"`
+	New      string `json:"new"`
+}
+
+// A JSONSuggestedFix describes an edit that should be applied as a whole or not
+// at all. It might contain multiple TextEdits/text_edits if the SuggestedFix
+// consists of multiple non-contiguous edits.
+type JSONSuggestedFix struct {
+	Message string         `json:"message"`
+	Edits   []JSONTextEdit `json:"edits"`
+}
+
+// A JSONDiagnostic can be used to encode and decode analysis.Diagnostics to and
+// from JSON.
+// TODO(matloob): Should the JSON diagnostics contain ranges?
+// If so, how should they be formatted?
+type JSONDiagnostic struct {
+	Category       string             `json:"category,omitempty"`
+	Posn           string             `json:"posn"`
+	Message        string             `json:"message"`
+	SuggestedFixes []JSONSuggestedFix `json:"suggested_fixes,omitempty"`
+}
+
 // Add adds the result of analysis 'name' on package 'id'.
 // The result is either a list of diagnostics or an error.
 func (tree JSONTree) Add(fset *token.FileSet, id, name string, diags []analysis.Diagnostic, err error) {
@@ -352,20 +381,31 @@
 		}
 		v = jsonError{err.Error()}
 	} else if len(diags) > 0 {
-		type jsonDiagnostic struct {
-			Category string `json:"category,omitempty"`
-			Posn     string `json:"posn"`
-			Message  string `json:"message"`
-		}
-		var diagnostics []jsonDiagnostic
-		// TODO(matloob): Should the JSON diagnostics contain ranges?
-		// If so, how should they be formatted?
+		diagnostics := make([]JSONDiagnostic, 0, len(diags))
 		for _, f := range diags {
-			diagnostics = append(diagnostics, jsonDiagnostic{
-				Category: f.Category,
-				Posn:     fset.Position(f.Pos).String(),
-				Message:  f.Message,
-			})
+			var fixes []JSONSuggestedFix
+			for _, fix := range f.SuggestedFixes {
+				var edits []JSONTextEdit
+				for _, edit := range fix.TextEdits {
+					edits = append(edits, JSONTextEdit{
+						Filename: fset.Position(edit.Pos).Filename,
+						Start:    fset.Position(edit.Pos).Offset,
+						End:      fset.Position(edit.End).Offset,
+						New:      string(edit.NewText),
+					})
+				}
+				fixes = append(fixes, JSONSuggestedFix{
+					Message: fix.Message,
+					Edits:   edits,
+				})
+			}
+			jdiag := JSONDiagnostic{
+				Category:       f.Category,
+				Posn:           fset.Position(f.Pos).String(),
+				Message:        f.Message,
+				SuggestedFixes: fixes,
+			}
+			diagnostics = append(diagnostics, jdiag)
 		}
 		v = diagnostics
 	}
diff --git a/go/analysis/unitchecker/unitchecker_test.go b/go/analysis/unitchecker/unitchecker_test.go
index 7e5b848..a2393b7 100644
--- a/go/analysis/unitchecker/unitchecker_test.go
+++ b/go/analysis/unitchecker/unitchecker_test.go
@@ -20,6 +20,7 @@
 	"strings"
 	"testing"
 
+	"golang.org/x/tools/go/analysis/passes/assign"
 	"golang.org/x/tools/go/analysis/passes/findcall"
 	"golang.org/x/tools/go/analysis/passes/printf"
 	"golang.org/x/tools/go/analysis/unitchecker"
@@ -41,6 +42,7 @@
 	unitchecker.Main(
 		findcall.Analyzer,
 		printf.Analyzer,
+		assign.Analyzer,
 	)
 }
 
@@ -75,6 +77,13 @@
 
 func MyFunc123() {}
 `,
+			"c/c.go": `package c
+
+func _() {
+    i := 5
+    i = i
+}
+`,
 		}}})
 	defer exported.Cleanup()
 
@@ -85,19 +94,59 @@
 ([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?b/b.go:6:13: call of MyFunc123\(...\)
 ([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?b/b.go:7:11: call of MyFunc123\(...\)
 `
+	const wantC = `# golang.org/fake/c
+([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?c/c.go:5:5: self-assignment of i to i
+`
 	const wantAJSON = `# golang.org/fake/a
 \{
 	"golang.org/fake/a": \{
 		"findcall": \[
 			\{
 				"posn": "([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?a/a.go:4:11",
-				"message": "call of MyFunc123\(...\)"
+				"message": "call of MyFunc123\(...\)",
+				"suggested_fixes": \[
+					\{
+						"message": "Add '_TEST_'",
+						"edits": \[
+							\{
+								"filename": "([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?a/a.go",
+								"start": 32,
+								"end": 32,
+								"new": "_TEST_"
+							\}
+						\]
+					\}
+				\]
 			\}
 		\]
 	\}
 \}
 `
-
+	const wantCJSON = `# golang.org/fake/c
+\{
+	"golang.org/fake/c": \{
+		"assign": \[
+			\{
+				"posn": "([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?c/c.go:5:5",
+				"message": "self-assignment of i to i",
+				"suggested_fixes": \[
+					\{
+						"message": "Remove",
+						"edits": \[
+							\{
+								"filename": "([/._\-a-zA-Z0-9]+[\\/]fake[\\/])?c/c.go",
+								"start": 37,
+								"end": 42,
+								"new": ""
+							\}
+						\]
+					\}
+				\]
+			\}
+		\]
+	\}
+\}
+`
 	for _, test := range []struct {
 		args     string
 		wantOut  string
@@ -105,8 +154,10 @@
 	}{
 		{args: "golang.org/fake/a", wantOut: wantA, wantExit: 2},
 		{args: "golang.org/fake/b", wantOut: wantB, wantExit: 2},
+		{args: "golang.org/fake/c", wantOut: wantC, wantExit: 2},
 		{args: "golang.org/fake/a golang.org/fake/b", wantOut: wantA + wantB, wantExit: 2},
 		{args: "-json golang.org/fake/a", wantOut: wantAJSON, wantExit: 0},
+		{args: "-json golang.org/fake/c", wantOut: wantCJSON, wantExit: 0},
 		{args: "-c=0 golang.org/fake/a", wantOut: wantA + "4		MyFunc123\\(\\)\n", wantExit: 2},
 	} {
 		cmd := exec.Command("go", "vet", "-vettool="+os.Args[0], "-findcall.name=MyFunc123")
@@ -125,7 +176,7 @@
 
 		matched, err := regexp.Match(test.wantOut, out)
 		if err != nil {
-			t.Fatal(err)
+			t.Fatalf("regexp.Match(<<%s>>): %v", test.wantOut, err)
 		}
 		if !matched {
 			t.Errorf("%s: got <<%s>>, want match of regexp <<%s>>", test.args, out, test.wantOut)