internal/breakings: type strings for interfaces

Interface type identity is defined in terms of type sets, which are
infeasible to compute with syntax. We provide an approximation.
For typical interfaces with just a list of methods, sorting them
will do.

Change-Id: I522bcca7f3265ebc9f3e56e0898a8f0828d3dab4
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/820720
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
TryBot-Bypass: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Ethan Lee <ethanalee@google.com>
diff --git a/internal/breakings/api.go b/internal/breakings/api.go
index 25be067..bdbce71 100644
--- a/internal/breakings/api.go
+++ b/internal/breakings/api.go
@@ -13,6 +13,7 @@
 	"go/printer"
 	"go/token"
 	"reflect"
+	"slices"
 	"strings"
 
 	"golang.org/x/tools/go/ast/astutil"
@@ -29,11 +30,52 @@
 		return "func" + sigString(t)
 	case *ast.StructType:
 		return structString(t)
+	case *ast.InterfaceType:
+		return interfaceString(t)
 	default:
 		return nodeString(t)
 	}
 }
 
+// interfaceString returns a string representation of an interface type
+// with sorted methods and canonical formatting (e.g., "interface{Close() error; Read([]byte) (int, error)}").
+// Embedded interfaces should be expanded to their methods, but that would require
+// access to other packages; their names are included instead.
+func interfaceString(it *ast.InterfaceType) string {
+	methods := interfaceMethods(it)
+	if len(methods) == 0 {
+		return "interface{}"
+	}
+	slices.Sort(methods)
+	methods = slices.Compact(methods)
+	return "interface{" + strings.Join(methods, "; ") + "}"
+}
+
+func interfaceMethods(it *ast.InterfaceType) []string {
+	if it == nil || it.Methods == nil {
+		return nil
+	}
+	var methods []string
+	for _, m := range it.Methods.List {
+		if len(m.Names) > 0 {
+			if ft, ok := m.Type.(*ast.FuncType); ok {
+				for _, name := range m.Names {
+					methods = append(methods, name.Name+sigString(ft))
+				}
+			}
+			continue
+		}
+
+		// Handle embedded types.
+		if embedded, ok := m.Type.(*ast.InterfaceType); ok {
+			methods = append(methods, interfaceMethods(embedded)...)
+		} else {
+			methods = append(methods, typeString(m.Type))
+		}
+	}
+	return methods
+}
+
 // structString returns a string representation of a struct type
 // with expanded field lists and canonical formatting (e.g., "struct{X T; Y T}").
 func structString(st *ast.StructType) string {
diff --git a/internal/breakings/api_test.go b/internal/breakings/api_test.go
index 385c0d5..a9b3a0e 100644
--- a/internal/breakings/api_test.go
+++ b/internal/breakings/api_test.go
@@ -33,6 +33,13 @@
 		{"struct{ X, Y int; Z string }", "struct{X int; Y int; Z string}"},
 		{"struct{ X int `json:\"x\"`; Y string }", "struct{X int `json:\"x\"`; Y string}"},
 		{"struct{ T; U }", "struct{T; U}"},
+		{"interface{}", "interface{}"},
+		{"interface{ Close() error; Read([]byte) (int, error) }", "interface{Close() error; Read([]byte) (int, error)}"},
+		{"interface{ Read([]byte) (int, error); Close() error }", "interface{Close() error; Read([]byte) (int, error)}"},
+		{"interface{ io.Reader; Close() error }", "interface{Close() error; io.Reader}"},
+		{"interface{ B(x, y int) bool; A() }", "interface{A(); B(int, int) bool}"},
+		{"interface{ Close() error; interface{ Read([]byte) (int, error) } }", "interface{Close() error; Read([]byte) (int, error)}"},
+		{"interface{ Close() error; interface{ Close() error } }", "interface{Close() error}"},
 	}
 
 	for _, tc := range testCases {