// Copyright 2024 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package relnote import ( "fmt" "strings" "unicode" "unicode/utf8" "golang.org/x/mod/module" md "rsc.io/markdown" ) // addSymbolLinks looks for text like [Buffer] and // [math.Max] and replaces them with links to standard library // symbols and packages. // It uses the given default package for links without a package. func addSymbolLinks(doc *md.Document, defaultPackage string) { addSymbolLinksBlocks(doc.Blocks, defaultPackage) } func addSymbolLinksBlocks(bs []md.Block, defaultPackage string) { for _, b := range bs { addSymbolLinksBlock(b, defaultPackage) } } func addSymbolLinksBlock(b md.Block, defaultPackage string) { switch b := b.(type) { case *md.Heading: addSymbolLinksBlock(b.Text, defaultPackage) case *md.Text: b.Inline = addSymbolLinksInlines(b.Inline, defaultPackage) case *md.List: addSymbolLinksBlocks(b.Items, defaultPackage) case *md.Item: addSymbolLinksBlocks(b.Blocks, defaultPackage) case *md.Paragraph: addSymbolLinksBlock(b.Text, defaultPackage) case *md.Quote: addSymbolLinksBlocks(b.Blocks, defaultPackage) // no links in these blocks case *md.CodeBlock: case *md.HTMLBlock: case *md.Empty: case *md.ThematicBreak: default: panic(fmt.Sprintf("unknown block type %T", b)) } } // 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 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 } } // Handle inline elements with nested content. switch in := ins[i].(type) { case *md.Strong: res = append(res, &md.Strong{ Marker: in.Marker, Inner: addSymbolLinksInlines(in.Inner, defaultPackage), }) case *md.Emph: res = append(res, &md.Emph{ Marker: in.Marker, Inner: addSymbolLinksInlines(in.Inner, defaultPackage), }) // Currently we don't support Del nodes because we don't enable the Strikethrough // extension. But this can't hurt. case *md.Del: res = append(res, &md.Del{ Marker: in.Marker, Inner: addSymbolLinksInlines(in.Inner, defaultPackage), }) // Don't look for links in anything else. default: res = append(res, in) } } return res } // 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 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) } } return res } // 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, defaultPackage string) (md.Inline, bool) { pkg, sym, ok := splitRef(s) if !ok { return nil, false } if pkg == "" { if defaultPackage == "" { return nil, false } pkg = defaultPackage } if sym != "" { sym = "#" + sym } return &md.Link{ Inner: []md.Inline{&md.Code{Text: s}}, URL: fmt.Sprintf("/pkg/%s%s", pkg, sym), }, true } // isLinkAdjacentRune reports whether r can be adjacent to a symbol link. // The logic is the same as the go/doc/comment package. func isLinkAdjacentRune(r rune) bool { return unicode.IsPunct(r) || r == ' ' || r == '\t' || r == '\n' } // splitRef splits s into a package and possibly a symbol. // Examples: // // splitRef("math.Max") => ("math", "Max", true) // splitRef("bytes.Buffer.String") => ("bytes", "Buffer.String", true) // splitRef("math") => ("math", "", true) func splitRef(s string) (pkg, name string, ok bool) { s = strings.TrimPrefix(s, "*") pkg, name, ok = splitDocName(s) var recv string if ok { pkg, recv, _ = splitDocName(pkg) } if pkg != "" { if err := module.CheckImportPath(pkg); err != nil { return "", "", false } } if recv != "" { name = recv + "." + name } return pkg, name, true } // The following functions were copied from go/doc/comment/parse.go. // If text is of the form before.Name, where Name is a capitalized Go identifier, // then splitDocName returns before, name, true. // Otherwise it returns text, "", false. func splitDocName(text string) (before, name string, foundDot bool) { i := strings.LastIndex(text, ".") name = text[i+1:] if !isName(name) { return text, "", false } if i >= 0 { before = text[:i] } return before, name, true } // isName reports whether s is a capitalized Go identifier (like Name). func isName(s string) bool { t, ok := ident(s) if !ok || t != s { return false } r, _ := utf8.DecodeRuneInString(s) return unicode.IsUpper(r) } // ident checks whether s begins with a Go identifier. // If so, it returns the identifier, which is a prefix of s, and ok == true. // Otherwise it returns "", false. // The caller should skip over the first len(id) bytes of s // before further processing. func ident(s string) (id string, ok bool) { // Scan [\pL_][\pL_0-9]* n := 0 for n < len(s) { if c := s[n]; c < utf8.RuneSelf { if isIdentASCII(c) && (n > 0 || c < '0' || c > '9') { n++ continue } break } r, nr := utf8.DecodeRuneInString(s[n:]) if unicode.IsLetter(r) { n += nr continue } break } return s[:n], n > 0 } // isIdentASCII reports whether c is an ASCII identifier byte. func isIdentASCII(c byte) bool { // mask is a 128-bit bitmap with 1s for allowed bytes, // so that the byte c can be tested with a shift and an and. // If c > 128, then 1<>64)) != 0 }