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")