relnote: handle symbol links with backticks

Support symbol links in release notes that already are written
as a markdown code element.

For example,

   This release note mentions [`byte.Buffer`].

Previously we handled this properly only if the backticks were omitted.

The parsed markdown for this case is not a single Plain element, which
is what the original code was designed for, but a Plain, then a Code,
then another Plain. It was simpler overall to redesign the algorithm,
first preprocessing the input to isolate the square brackets, then
looking for triples of '[', Plain-or-Code, ']'.

For golang/go#64169.

Change-Id: Ia3036cac2d851efb43c625ccb58831a6fe2c00b6
Reviewed-on: https://go-review.googlesource.com/c/build/+/577260
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/relnote/dump.go b/relnote/dump.go
index e91c65a..44a2e53 100644
--- a/relnote/dump.go
+++ b/relnote/dump.go
@@ -6,6 +6,7 @@
 
 import (
 	"fmt"
+	"strings"
 
 	md "rsc.io/markdown"
 )
@@ -24,7 +25,8 @@
 }
 
 func dumpBlock(b md.Block, depth int) {
-	fmt.Printf("%*s%T\n", depth*4, "", b)
+	typeName := strings.TrimPrefix(fmt.Sprintf("%T", b), "*markdown.")
+	dprintf(depth, "%s\n", typeName)
 	switch b := b.(type) {
 	case *md.Paragraph:
 		dumpInlines(b.Text.Inline, depth+1)
@@ -42,12 +44,29 @@
 func dumpInlines(ins []md.Inline, depth int) {
 	for _, in := range ins {
 		switch in := in.(type) {
+		case *md.Plain:
+			dprintf(depth, "Plain(%q)\n", in.Text)
+		case *md.Code:
+			dprintf(depth, "Code(%q)\n", in.Text)
 		case *md.Link:
-			fmt.Printf("%*sLink:\n", depth*4, "")
+			dprintf(depth, "Link:\n")
 			dumpInlines(in.Inner, depth+1)
-			fmt.Printf("%*sURL: %q\n", (depth+1)*4, "", in.URL)
+			dprintf(depth+1, "URL: %q\n", in.URL)
+		case *md.Strong:
+			dprintf(depth, "Strong(%q):\n", in.Marker)
+			dumpInlines(in.Inner, depth+1)
+		case *md.Emph:
+			dprintf(depth, "Emph(%q):\n", in.Marker)
+			dumpInlines(in.Inner, depth+1)
+		case *md.Del:
+			dprintf(depth, "Del(%q):\n", in.Marker)
+			dumpInlines(in.Inner, depth+1)
 		default:
 			fmt.Printf("%*s%#v\n", depth*4, "", in)
 		}
 	}
 }
+
+func dprintf(depth int, format string, args ...any) {
+	fmt.Printf("%*s%s", depth*4, "", fmt.Sprintf(format, args...))
+}
diff --git a/relnote/links.go b/relnote/links.go
index 8971b3c..d638e18 100644
--- a/relnote/links.go
+++ b/relnote/links.go
@@ -55,11 +55,19 @@
 // addSymbolLinksInlines looks for symbol links in the slice of inline markdown
 // elements. It returns a new slice of inline elements with links added.
 func addSymbolLinksInlines(ins []md.Inline, defaultPackage string) []md.Inline {
+	ins = splitAtBrackets(ins)
 	var res []md.Inline
-	for _, in := range ins {
-		switch in := in.(type) {
-		case *md.Plain:
-			res = append(res, addSymbolLinksText(in.Text, defaultPackage)...)
+	for i := 0; i < len(ins); i++ {
+		if txt := symbolLinkText(i, ins); txt != "" {
+			link, ok := symbolLink(txt, defaultPackage)
+			if ok {
+				res = append(res, link)
+				i += 2
+				continue
+			}
+		}
+
+		switch in := ins[i].(type) {
 		case *md.Strong:
 			res = append(res, addSymbolLinksInlines(in.Inner, defaultPackage)...)
 		case *md.Emph:
@@ -74,60 +82,109 @@
 	return res
 }
 
-// addSymbolLinksText converts symbol links in the text to markdown links.
-// The text comes from a single Plain inline element, which may be split
-// into multiple alternating Plain and Link elements.
-func addSymbolLinksText(text, defaultPackage string) []md.Inline {
+// splitAtBrackets rewrites ins so that every '[' and ']' is the only character
+// of its Plain.
+// For example, the element
+//
+//	[Plain("the [Buffer] is")]
+//
+// is rewritten to
+//
+//	[Plain("the "), Plain("["), Plain("Buffer"), Plain("]"), Plain(" is")]
+//
+// This transformation simplifies looking for symbol links.
+func splitAtBrackets(ins []md.Inline) []md.Inline {
 	var res []md.Inline
-	last := 0
-
-	appendPlain := func(j int) {
-		if j-last > 0 {
-			res = append(res, &md.Plain{Text: text[last:j]})
+	for _, in := range ins {
+		if p, ok := in.(*md.Plain); ok {
+			text := p.Text
+			for len(text) > 0 {
+				i := strings.IndexAny(text, "[]")
+				// If there are no brackets, the remaining text is a single
+				// Plain and we are done.
+				if i < 0 {
+					res = append(res, &md.Plain{Text: text})
+					break
+				}
+				// There is a bracket; make Plains for it and the text before it (if any).
+				if i > 0 {
+					res = append(res, &md.Plain{Text: text[:i]})
+				}
+				res = append(res, &md.Plain{Text: text[i : i+1]})
+				text = text[i+1:]
+			}
+		} else {
+			res = append(res, in)
 		}
 	}
-
-	start := -1
-	for i := 0; i < len(text); i++ {
-		switch text[i] {
-		case '[':
-			start = i
-		case ']':
-			if start < 0 {
-				continue
-			}
-			link, ok := symbolLink(text[start+1:i], text[:start], text[i+1:], defaultPackage)
-			if ok {
-				appendPlain(start)
-				res = append(res, link)
-				last = i + 1
-			}
-			start = -1
-		}
-
-	}
-	appendPlain(len(text))
 	return res
 }
 
-// symbolLink convert s into a Link and returns it and true, or nil and false if
+// symbolLinkText returns the text of a possible symbol link.
+// It is given a slice of Inline elements and an index into the slice.
+// If the index refers to a sequence of elements
+//
+//	[Plain("["), Plain_or_Code(text), Plain("]")]
+//
+// and the brackets are adjacent to the right kind of runes for a link, then
+// symbolLinkText returns the text of the middle element.
+// Otherwise it returns the empty string.
+func symbolLinkText(i int, ins []md.Inline) string {
+	// plainText returns the text of ins[j] if it is a Plain element, or "" otherwise.
+	plainText := func(j int) string {
+		if j < 0 || j >= len(ins) {
+			return ""
+		}
+		if p, ok := ins[j].(*md.Plain); ok {
+			return p.Text
+		}
+		return ""
+	}
+
+	// ins[i] must be a "[".
+	if plainText(i) != "[" {
+		return ""
+	}
+	// The open bracket must be preceeded by a link-adjacent rune (or by nothing).
+	if t := plainText(i - 1); t != "" {
+		r, _ := utf8.DecodeLastRuneInString(t)
+		if !isLinkAdjacentRune(r) {
+			return ""
+		}
+	}
+	// The element after the next must be a ']'.
+	if plainText(i+2) != "]" {
+		return ""
+	}
+	// The ']' must be followed by a link-adjacent rune (or by nothing).
+	if t := plainText(i + 3); t != "" {
+		r, _ := utf8.DecodeRuneInString(t)
+		if !isLinkAdjacentRune(r) {
+			return ""
+		}
+	}
+
+	// ins[i+1] must be a Plain or a Code.
+	// Its text is the symbol to link to.
+	if i+1 >= len(ins) {
+		return ""
+	}
+	switch in := ins[i+1].(type) {
+	case *md.Plain:
+		return in.Text
+	case *md.Code:
+		return in.Text
+	default:
+		return ""
+	}
+}
+
+// symbolLink converts s into a Link and returns it and true, or nil and false if
 // s is not a valid link or is surrounded by runes that disqualify it from being
 // converted to a link.
 //
 // The argument s is the text between '[' and ']'.
-func symbolLink(s, before, after, defaultPackage string) (md.Inline, bool) {
-	if before != "" {
-		r, _ := utf8.DecodeLastRuneInString(before)
-		if !isLinkAdjacentRune(r) {
-			return nil, false
-		}
-	}
-	if after != "" {
-		r, _ := utf8.DecodeRuneInString(after)
-		if !isLinkAdjacentRune(r) {
-			return nil, false
-		}
-	}
+func symbolLink(s, defaultPackage string) (md.Inline, bool) {
 	pkg, sym, ok := splitRef(s)
 	if !ok {
 		return nil, false
diff --git a/relnote/relnote_test.go b/relnote/relnote_test.go
index 75b5468..1fa9af9 100644
--- a/relnote/relnote_test.go
+++ b/relnote/relnote_test.go
@@ -279,6 +279,7 @@
 	}{
 		{"a b", "a b"},
 		{"a [b", "a [b"},
+		{"a [b[", "a [b["},
 		{"a b[X]", "a b[X]"},
 		{"a [Buffer] b", "a [`Buffer`](/pkg/bytes#Buffer) b"},
 		{"a [Buffer]\nb", "a [`Buffer`](/pkg/bytes#Buffer)\nb"},
@@ -288,9 +289,8 @@
 		{"a [math] and s[math] and [NewBuffer].", "a [`math`](/pkg/math) and s[math] and [`NewBuffer`](/pkg/bytes#NewBuffer)."},
 		{"A [*log/slog.Logger]", "A [`*log/slog.Logger`](/pkg/log/slog#Logger)"},
 		{"Not in code `[math]`.", "Not in code `[math]`."},
-		// TODO(jba): handle a Code inline between brackets, as in the two tests below.
-		// {"a [`Buffer`] b", "a [`Buffer`](/pkg/bytes#Buffer) b"},
-		// {"[`bytes.Buffer.String`]", "[`bytes.Buffer.String`](/pkg/bytes#Buffer.String)"},
+		{"a [`Buffer`] b", "a [`Buffer`](/pkg/bytes#Buffer) b"},
+		{"[`bytes.Buffer.String`]", "[`bytes.Buffer.String`](/pkg/bytes#Buffer.String)"},
 	} {
 		doc := NewParser().Parse(test.in)
 		addSymbolLinks(doc, "bytes")