relnote: account for build in API file

Most lines in api/next/NNN.txt files look like

    pkg PACKAGE, FEATURE #ISSUE

but there can also be build information, like

    pkg PACKAGE (windows-386), FEATURE #ISSUE

Fix the parser to account for that.

For golang/go#64169.

Change-Id: I7b82084f1a9589d162aa7f4fc8abbe5b0199b4d4
Reviewed-on: https://go-review.googlesource.com/c/build/+/564396
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/relnote/relnote.go b/relnote/relnote.go
index 8b926bf..0012df2 100644
--- a/relnote/relnote.go
+++ b/relnote/relnote.go
@@ -327,16 +327,20 @@
 // like the ones in the main go repo in the api directory.
 type APIFeature struct {
 	Package string // package that the feature is in
+	Build   string // build that the symbol is relevant for (e.g. GOOS, GOARCH)
 	Feature string // everything about the feature other than the package
 	Issue   int    // the issue that introduced the feature, or 0 if none
 }
 
-var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^,]+), ([^#]*)(#\d+)?$`)
+// This regexp has four capturing groups: package, build, feature and issue.
+var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^ \t]+)[ \t]*(\([^)]+\))?, ([^#]*)(#\d+)?$`)
 
 // parseAPIFile parses a file in the api format and returns a list of the file's features.
 // A feature is represented by a single line that looks like
 //
-//	PKG WORDS #ISSUE
+//	pkg PKG (BUILD) FEATURE #ISSUE
+//
+// where the BUILD and ISSUE may be absent.
 func parseAPIFile(fsys fs.FS, filename string) ([]APIFeature, error) {
 	f, err := fsys.Open(filename)
 	if err != nil {
@@ -347,20 +351,24 @@
 	scan := bufio.NewScanner(f)
 	for scan.Scan() {
 		line := strings.TrimSpace(scan.Text())
-		if line == "" {
+		if line == "" || line[0] == '#' {
 			continue
 		}
 		matches := apiFileLineRegexp.FindStringSubmatch(line)
 		if len(matches) == 0 {
 			return nil, fmt.Errorf("%s: malformed line %q", filename, line)
 		}
+		if len(matches) != 5 {
+			return nil, fmt.Errorf("wrong number of matches for line %q", line)
+		}
 		f := APIFeature{
 			Package: matches[1],
-			Feature: strings.TrimSpace(matches[2]),
+			Build:   matches[2],
+			Feature: strings.TrimSpace(matches[3]),
 		}
-		if len(matches) > 3 && len(matches[3]) > 0 {
+		if issue := matches[4]; issue != "" {
 			var err error
-			f.Issue, err = strconv.Atoi(matches[3][1:]) // skip leading '#'
+			f.Issue, err = strconv.Atoi(issue[1:]) // skip leading '#'
 			if err != nil {
 				return nil, err
 			}
diff --git a/relnote/relnote_test.go b/relnote/relnote_test.go
index c6000be..34112f7 100644
--- a/relnote/relnote_test.go
+++ b/relnote/relnote_test.go
@@ -7,8 +7,10 @@
 import (
 	"fmt"
 	"io/fs"
+	"os"
 	"path/filepath"
 	"reflect"
+	"runtime"
 	"slices"
 	"strings"
 	"testing"
@@ -208,6 +210,7 @@
 		"123.next": &fstest.MapFile{Data: []byte(`
 pkg p1, type T struct
 pkg p2, func F(int, bool) #123
+pkg syscall (windows-386), const WSAENOPROTOOPT = 10042 #62254
 	`)},
 	}
 	got, err := parseAPIFile(fsys, "123.next")
@@ -215,11 +218,12 @@
 		t.Fatal(err)
 	}
 	want := []APIFeature{
-		{"p1", "type T struct", 0},
-		{"p2", "func F(int, bool)", 123},
+		{"p1", "", "type T struct", 0},
+		{"p2", "", "func F(int, bool)", 123},
+		{"syscall", "(windows-386)", "const WSAENOPROTOOPT = 10042", 62254},
 	}
 	if !reflect.DeepEqual(got, want) {
-		t.Errorf("\ngot  %+v\nwant %+v", got, want)
+		t.Errorf("\ngot  %#v\nwant %#v", got, want)
 	}
 }
 
@@ -250,6 +254,22 @@
 	}
 }
 
+func TestAllAPIFilesForErrors(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping in short mode")
+	}
+	fsys := os.DirFS(filepath.Join(runtime.GOROOT(), "api"))
+	apiFiles, err := fs.Glob(fsys, "*.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, f := range apiFiles {
+		if _, err := parseAPIFile(fsys, f); err != nil {
+			t.Errorf("parseTestFile(%q) failed with %v", f, err)
+		}
+	}
+}
+
 func TestSymbolLinks(t *testing.T) {
 	for _, test := range []struct {
 		in   string