Repository: kythe/kythe
Pull request #5791 (2023-08-18 17:37:08)

chore: match Go MS renderer closer to C++


Patch:
diff --git a/external.bzl b/external.bzl
index 674876300..1dab5a8ae 100644
--- a/external.bzl
+++ b/external.bzl
@@ -1685,6 +1685,12 @@ def _go_dependencies():
         sum = "h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=",
         version = "v0.0.0-20220907171357-04be3eba64a2",
     )
+    go_repository(
+        name = "com_github_html2markdown",
+        importpath = "github.com/JohannesKaufmann/html-to-markdown",
+        sum = "h1:i3Ma4RmIU97gqArbxZXbFqbWKm7XtImlMwVNUouQ7Is=",
+        version = "v1.3.6",
+    )
 
     # gazelle:repository go_repository name=org_golang_x_tools importpath=golang.org/x/tools
     http_archive(
diff --git a/go.mod b/go.mod
index 1925acea0..49e3b64de 100644
--- a/go.mod
+++ b/go.mod
@@ -40,6 +40,7 @@ require (
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/iam v0.8.0 // indirect
 	cloud.google.com/go/storage v1.27.0 // indirect
+	github.com/JohannesKaufmann/html-to-markdown v1.3.6
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/uuid v1.3.0 // indirect
 	github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index f2bfed86f..4e4ec1eca 100644
--- a/go.sum
+++ b/go.sum
@@ -17,6 +17,10 @@ cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/DataDog/zstd v1.4.8 h1:Rpmta4xZ/MgZnriKNd24iZMhGpP5dvUcs/uqfBapKZY=
 github.com/DataDog/zstd v1.4.8/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
+github.com/JohannesKaufmann/html-to-markdown v1.3.6 h1:i3Ma4RmIU97gqArbxZXbFqbWKm7XtImlMwVNUouQ7Is=
+github.com/JohannesKaufmann/html-to-markdown v1.3.6/go.mod h1:Ol3Jv/xw8jt8qsaLeSh/6DBBw4ZBJrTqrOu3wbbUUg8=
+github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
+github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
 github.com/apache/beam v2.31.0+incompatible h1:7C2/JDa+fiRJs8kAcfCHxVTf0xxwKsCFQYDMoRdr/dk=
 github.com/apache/beam v2.31.0+incompatible/go.mod h1:/8NX3Qi8vGstDLLaeaU7+lzVEu/ACaQhYjeefzQ0y1o=
 github.com/bazelbuild/rules_go v0.28.0 h1:fNtx0dJpG5ENGdMj3/GICoi/7z+ixB3IIW5rERTzOgM=
@@ -103,11 +107,15 @@ github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
+github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
+github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
 github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
 github.com/sourcegraph/go-langserver v2.0.0+incompatible h1:lj2sRU7ZMIkW372IDVGb6fE8VAY4c/EMsiDzrB9vmiU=
@@ -117,6 +125,7 @@ github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -124,6 +133,7 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
 github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
+github.com/yuin/goldmark v1.4.14/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -140,6 +150,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -156,11 +168,19 @@ golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5h
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -213,6 +233,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
diff --git a/kythe/go/services/cli/commands_xrefs.go b/kythe/go/services/cli/commands_xrefs.go
index 512f499c7..c148b6089 100644
--- a/kythe/go/services/cli/commands_xrefs.go
+++ b/kythe/go/services/cli/commands_xrefs.go
@@ -28,6 +28,7 @@ import (
 	"kythe.io/kythe/go/util/markedsource"
 	"kythe.io/kythe/go/util/schema/edges"
 	"kythe.io/kythe/go/util/schema/facts"
+
 	cpb "kythe.io/kythe/proto/common_go_proto"
 	xpb "kythe.io/kythe/proto/xref_go_proto"
 )
@@ -283,8 +284,8 @@ func showSignature(signature *cpb.MarkedSource) string {
 	if signature == nil {
 		return "(nil)"
 	}
-	ident := markedsource.RenderSimpleIdentifier(signature)
-	params := markedsource.RenderSimpleParams(signature)
+	ident := markedsource.RenderSimpleIdentifier(signature, markedsource.PlaintextContent, nil)
+	params := markedsource.RenderSimpleParams(signature, markedsource.PlaintextContent, nil)
 	if len(params) == 0 {
 		return ident
 	}
diff --git a/kythe/go/util/markedsource/BUILD b/kythe/go/util/markedsource/BUILD
index 7618ded82..9fd4be87a 100644
--- a/kythe/go/util/markedsource/BUILD
+++ b/kythe/go/util/markedsource/BUILD
@@ -11,10 +11,12 @@ go_library(
     importpath = "kythe.io/kythe/go/util/markedsource",
     deps = [
         "//kythe/go/util/kytheuri",
+        "//kythe/go/util/md",
         "//kythe/go/util/schema/edges",
         "//kythe/go/util/schema/facts",
         "//kythe/proto:common_go_proto",
         "//kythe/proto:storage_go_proto",
+        "@com_github_html2markdown//escape",
         "@org_golang_google_protobuf//proto",
     ],
 )
diff --git a/kythe/go/util/markedsource/markedsource.go b/kythe/go/util/markedsource/markedsource.go
index 492be1f2e..b880c0bc6 100644
--- a/kythe/go/util/markedsource/markedsource.go
+++ b/kythe/go/util/markedsource/markedsource.go
@@ -18,10 +18,15 @@
 package markedsource // import "kythe.io/kythe/go/util/markedsource"
 
 import (
-	"bytes"
-	"io"
+	"fmt"
+	"html"
+	"regexp"
 	"strings"
 
+	"kythe.io/kythe/go/util/md"
+
+	"github.com/JohannesKaufmann/html-to-markdown/escape"
+
 	cpb "kythe.io/kythe/proto/common_go_proto"
 )
 
@@ -30,97 +35,365 @@ import (
 // value matches the kMaxRenderDepth in the C++ implementation.
 const maxRenderDepth = 10
 
-type state struct {
-	w       io.Writer
-	depth   int
-	inIdent bool
+const invalidLookupMarker = "???"
+
+// ContentType is a type of rendering output.
+type ContentType string
+
+// Supported list of ContentTypes
+var (
+	PlaintextContent ContentType = "txt"
+	MarkdownContent  ContentType = "md"
+)
+
+// content holds text and a possible destination to help us build markdown links.
+type content struct {
+	text   string
+	target string
 }
 
-func render(ms *cpb.MarkedSource, st state) {
-	if st.depth > maxRenderDepth {
-		return
-	}
-	io.WriteString(st.w, ms.PreText)
-	st.depth++
-	for n, child := range ms.Child {
-		render(child, st)
-		if ms.AddFinalListToken || n < len(ms.Child)-1 {
-			io.WriteString(st.w, ms.PostChildText)
+// build creates markdown for c. If there is no target, the plain text is returned, if there is a
+// target, a markdown link is returned.
+func (c content) build(format ContentType) string {
+	if format == MarkdownContent {
+		if c.target == "" {
+			return c.text
 		}
+		return md.Link(c.text, c.target)
 	}
-	io.WriteString(st.w, ms.PostText)
+
+	// Assume plain text if markdown isn't set.
+	return c.text
+}
+
+// escapeMD escapes the signature so that 1) generics don't get parsed as HTML by
+// html-to-markdown in the Javadoc parser and 2) markdown characters in code (e.g. *) don't change
+// how the text is displayed.
+func escapeMD(s string) string {
+	return escape.MarkdownCharacters(html.EscapeString(s))
+}
+
+type renderer struct {
+	buffer            *content
+	prependBuffer     string
+	bufferIsNonempty  bool
+	bufferEndsInSpace bool
+	format            ContentType
+	prependSpace      bool
+
+	linkify func(string) string
 }
 
 // Render flattens MarkedSource to a string using reasonable defaults.
 func Render(ms *cpb.MarkedSource) string {
-	var buf bytes.Buffer
-	render(ms, state{w: &buf})
-	return buf.String()
+	enabled := map[cpb.MarkedSource_Kind]bool{}
+	for _, k := range cpb.MarkedSource_Kind_value {
+		enabled[cpb.MarkedSource_Kind(k)] = true
+	}
+	r := &renderer{
+		buffer: &content{},
+		format: PlaintextContent,
+	}
+
+	r.renderRoot(ms, enabled)
+	return r.buffer.build(r.format)
 }
 
-// RenderSimpleIdentifier extracts and renders the simple identifier from a
-// MarkedSource.
-func RenderSimpleIdentifier(ms *cpb.MarkedSource) string {
-	var buf bytes.Buffer
-	renderIdent(ms, state{w: &buf})
-	return buf.String()
+// RenderSignature renders the full signature from node.
+// If not nil, linkify will be used to
+// generate link URIs from semantic node tickets. It may return an empty string if there is no
+// available URI.
+func RenderSignature(node *cpb.MarkedSource, format ContentType, linkify func(string) string) string {
+	enabled := map[cpb.MarkedSource_Kind]bool{
+		cpb.MarkedSource_IDENTIFIER: true,
+		cpb.MarkedSource_TYPE:       true,
+		cpb.MarkedSource_PARAMETER:  true,
+	}
+	r := &renderer{
+		buffer:  &content{},
+		linkify: linkify,
+		format:  format,
+	}
+
+	r.renderRoot(node, enabled)
+	return r.buffer.build(r.format)
 }
 
-func renderIdent(ms *cpb.MarkedSource, st state) {
-	if st.depth >= maxRenderDepth {
-		return
+// RenderCallSiteSignature returns the text snippet for a callsite as plaintext.
+func RenderCallSiteSignature(node *cpb.MarkedSource) string {
+	// This is similar to RenderSignature but does not render types to keep the text a little more
+	// compact.
+	enabled := map[cpb.MarkedSource_Kind]bool{
+		cpb.MarkedSource_IDENTIFIER: true,
+		cpb.MarkedSource_PARAMETER:  true,
+	}
+	r := &renderer{
+		buffer:  &content{},
+		linkify: nil,
+		format:  PlaintextContent,
 	}
-	st.depth++
 
-	// Anything other than identifiers and boxes can be skipped.
-	switch ms.Kind {
-	case cpb.MarkedSource_IDENTIFIER:
-		st.inIdent = true
+	r.renderRoot(node, enabled)
+	return r.buffer.build(r.format)
+}
+
+// RenderInitializer extracts and renders a plaintext initializer from node. If not nil, linkify
+// will be used to generate link URIs from semantic node tickets. It may return an empty string if
+// there is no available URI.
+func RenderInitializer(node *cpb.MarkedSource, format ContentType, linkify func(string) string) string {
+	enabled := map[cpb.MarkedSource_Kind]bool{
+		cpb.MarkedSource_INITIALIZER: true,
+	}
+	r := &renderer{
+		buffer:  &content{},
+		linkify: linkify,
+		format:  format,
+	}
+
+	r.renderRoot(node, enabled)
+	return r.buffer.build(r.format)
+}
+
+// RenderSimpleQualifiedName extracts and renders the simple qualified name from node. If
+// includeIdentifier is true it includes the identifier on the qualified name. If not nil, linkify
+// will be used to generate link URIs from semantic node tickets. It may return an empty string if
+// there is no available URI.
+func RenderSimpleQualifiedName(node *cpb.MarkedSource, includeIdentifier bool, format ContentType, linkify func(string) string) string {
+	enabled := map[cpb.MarkedSource_Kind]bool{
+		cpb.MarkedSource_CONTEXT: true,
+	}
+	if includeIdentifier {
+		enabled[cpb.MarkedSource_IDENTIFIER] = true
+	}
+	r := &renderer{
+		buffer:  &content{},
+		linkify: linkify,
+		format:  format,
+	}
+
+	r.renderRoot(node, enabled)
+	return r.buffer.build(r.format)
+}
+
+// RenderSimpleIdentifier extracts and renders a the simple identifier from node. If not nil,
+// linkify will be used to generate link URIs from semantic node tickets. It may return an empty
+// string if there is no available URI.
+func RenderSimpleIdentifier(node *cpb.MarkedSource, format ContentType, linkify func(string) string) string {
+	enabled := map[cpb.MarkedSource_Kind]bool{
+		cpb.MarkedSource_IDENTIFIER: true,
+	}
+	r := &renderer{
+		buffer:  &content{},
+		linkify: linkify,
+		format:  format,
+	}
+
+	r.renderRoot(node, enabled)
+	return r.buffer.build(r.format)
+}
+
+// RenderSimpleParams extracts and renders the simple identifiers for parameters in node. If not
+// nil, linkify will be used to generate link URIs from semantic node tickets. It may return an
+// empty string if there is no available URI.
+func RenderSimpleParams(node *cpb.MarkedSource, format ContentType, linkify func(string) string) []string {
+	return renderSimpleParams(node, format, linkify, 0)
+}
+
+func renderSimpleParams(node *cpb.MarkedSource, format ContentType, linkify func(string) string, level int) []string {
+	if level >= maxRenderDepth {
+		return nil
+	}
+
+	var out []string
+	switch node.GetKind() {
 	case cpb.MarkedSource_BOX:
-		// good; we can continue
+		for _, child := range node.GetChild() {
+			out = append(out, renderSimpleParams(child, format, linkify, level+1)...)
+		}
+	case cpb.MarkedSource_PARAMETER:
+		for _, child := range node.GetChild() {
+			enabled := map[cpb.MarkedSource_Kind]bool{
+				cpb.MarkedSource_IDENTIFIER: true,
+			}
+			r := &renderer{
+				buffer:  &content{},
+				linkify: linkify,
+				format:  format,
+			}
+			r.renderChild(child, enabled, make(map[cpb.MarkedSource_Kind]bool), level+1)
+			out = append(out, r.buffer.build(r.format))
+		}
+	case
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_PARAM,
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_PARAM_WITH_DEFAULTS,
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_TPARAM:
+		out = append(out, renderInvalidLookup(node, format))
+	}
+
+	return out
+}
+
+func shouldRenderInvalidLookup(k cpb.MarkedSource_Kind, enabled map[cpb.MarkedSource_Kind]bool) bool {
+	switch k {
+	case
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_PARAM,
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_PARAM_WITH_DEFAULTS,
+		cpb.MarkedSource_PARAMETER_LOOKUP_BY_TPARAM,
+		cpb.MarkedSource_LOOKUP_BY_PARAM,
+		cpb.MarkedSource_LOOKUP_BY_TPARAM:
+		return enabled[cpb.MarkedSource_PARAMETER]
+	case cpb.MarkedSource_LOOKUP_BY_TYPED:
+		return enabled[cpb.MarkedSource_TYPE]
 	default:
-		return
+		return false
 	}
+}
+
+func renderInvalidLookup(node *cpb.MarkedSource, format ContentType) string {
+	return fmt.Sprintf("%s%s%s", nodePreText(node, format), invalidLookupMarker, nodePostText(node, format))
+}
+
+func willRender(node *cpb.MarkedSource, enabled, under map[cpb.MarkedSource_Kind]bool, level int) bool {
+	kind := node.GetKind()
+	return level < maxRenderDepth && (kind == cpb.MarkedSource_BOX || kind == cpb.MarkedSource_IDENTIFIER || shouldRenderInvalidLookup(kind, enabled) || enabled[kind])
+}
+
+func (r *renderer) renderRoot(node *cpb.MarkedSource, enabled map[cpb.MarkedSource_Kind]bool) {
+	r.renderChild(node, enabled, map[cpb.MarkedSource_Kind]bool{}, 0)
+}
 
-	if st.inIdent {
-		io.WriteString(st.w, ms.PreText)
+// renderChild renders the children of node up to maxRenderDepth. It only renders nodes that are
+// present in enabled.
+func (r *renderer) renderChild(node *cpb.MarkedSource, enabled, under map[cpb.MarkedSource_Kind]bool, level int) {
+	if level >= maxRenderDepth {
+		return
+	}
+	kind := node.GetKind()
+	if kind != cpb.MarkedSource_BOX || enabled[cpb.MarkedSource_BOX] {
+		if shouldRenderInvalidLookup(kind, enabled) {
+			r.add(renderInvalidLookup(node, r.format))
+			return
+		}
+		if kind != cpb.MarkedSource_IDENTIFIER && !enabled[kind] {
+			return
+		}
+		// Make a copy of under for our recursive tree and add in our new kind.
+		newUnder := make(map[cpb.MarkedSource_Kind]bool)
+		for k, v := range under {
+			newUnder[k] = v
+		}
+		newUnder[kind] = true
+		under = newUnder
+	}
+	var savedContent *content
+	if shouldRender(enabled, under) {
+		l := r.linkForSource(node)
+		if l != "" && r.buffer != nil {
+			savedContent = r.buffer
+			r.buffer = &content{target: l}
+		}
+		r.add(nodePreText(node, r.format))
+	}
+	lastRenderedChild := -1
+	for i, c := range node.GetChild() {
+		if willRender(c, enabled, under, level+1) {
+			lastRenderedChild = i
+		}
 	}
-	for i, child := range ms.Child {
-		renderIdent(child, st)
-		if st.inIdent {
-			if ms.AddFinalListToken || i < len(ms.Child)-1 {
-				io.WriteString(st.w, ms.PostChildText)
+	for i, c := range node.GetChild() {
+		if willRender(c, enabled, under, level+1) {
+			r.renderChild(c, enabled, under, level+1)
+			if lastRenderedChild > i {
+				r.add(nodePostChildText(node, r.format))
+			} else if node.GetAddFinalListToken() {
+				r.addFinalListToken(nodePostChildText(node, r.format))
 			}
 		}
 	}
-	if st.inIdent {
-		io.WriteString(st.w, ms.PostText)
+	if shouldRender(enabled, under) {
+		r.add(nodePostText(node, r.format))
+		if savedContent != nil {
+			r.buffer = &content{
+				text: savedContent.build(r.format) + r.buffer.build(r.format),
+			}
+		}
+		if node.GetKind() == cpb.MarkedSource_TYPE {
+			r.prependSpace = true
+		}
 	}
 }
 
-// RenderSimpleParams extracts and renders the simple identifiers from a
-// MarkedSource's parameters.
-func RenderSimpleParams(ms *cpb.MarkedSource) []string { return renderParams(ms, state{}, nil) }
+func nodePreText(node *cpb.MarkedSource, format ContentType) string {
+	if format == MarkdownContent {
+		return escapeMD(node.GetPreText())
+	}
+	return node.GetPreText()
+}
 
-func renderParams(ms *cpb.MarkedSource, st state, params []string) []string {
-	if st.depth >= maxRenderDepth {
-		return nil
+func nodePostText(node *cpb.MarkedSource, format ContentType) string {
+	if format == MarkdownContent {
+		return escapeMD(node.GetPostText())
 	}
-	st.depth++
+	return node.GetPostText()
+}
 
-	switch ms.Kind {
-	case cpb.MarkedSource_PARAMETER:
-		for _, child := range ms.Child {
-			params = append(params, RenderSimpleIdentifier(child))
+func nodePostChildText(node *cpb.MarkedSource, format ContentType) string {
+	if format == MarkdownContent {
+		return escapeMD(node.GetPostChildText())
+	}
+	return node.GetPostChildText()
+}
+
+func shouldRender(enabled, under map[cpb.MarkedSource_Kind]bool) (v bool) {
+	for kind := range enabled {
+		if under[kind] {
+			return true
 		}
-	case cpb.MarkedSource_BOX:
-		for _, child := range ms.Child {
-			params = append(params, renderParams(child, st, params)...)
+	}
+	return false
+}
+
+// linkForSource uses linkify (if present) to turn the link in node into a like that can be used in
+// the documentation text.
+func (r renderer) linkForSource(node *cpb.MarkedSource) string {
+	if r.linkify == nil {
+		return ""
+	}
+
+	for _, link := range node.GetLink() {
+		for _, def := range link.GetDefinition() {
+			if l := r.linkify(def); l != "" {
+				return l
+			}
 		}
-	default:
-		// do nothing
 	}
-	return params
+	return ""
+}
+
+var excludedSpaceCharacters = regexp.MustCompile(`^[),\]>\s].*`)
+
+// add appends s to the current buffer text.
+func (r *renderer) add(s string) {
+	if r.prependBuffer != "" && s != "" {
+		r.buffer.text += r.prependBuffer
+		r.bufferEndsInSpace = strings.HasSuffix(r.prependBuffer, " ")
+		r.prependBuffer = ""
+		r.bufferIsNonempty = true
+	}
+	if r.prependSpace && s != "" && r.bufferIsNonempty && !r.bufferEndsInSpace && !excludedSpaceCharacters.MatchString(s) {
+		r.buffer.text += " "
+		r.bufferEndsInSpace = true
+	}
+	r.buffer.text += s
+	if s != "" {
+		r.bufferEndsInSpace = strings.HasSuffix(s, " ")
+		r.bufferIsNonempty = true
+		r.prependSpace = false
+	}
+}
+
+func (r *renderer) addFinalListToken(s string) {
+	r.prependBuffer += s
 }
 
 // RenderQualifiedName renders a language-appropriate qualified name from a
diff --git a/kythe/go/util/md/BUILD b/kythe/go/util/md/BUILD
new file mode 100644
index 000000000..ab11db925
--- /dev/null
+++ b/kythe/go/util/md/BUILD
@@ -0,0 +1,9 @@
+load("//tools:build_rules/shims.bzl", "go_library")
+
+package(default_visibility = ["//kythe:default_visibility"])
+
+go_library(
+    name = "md",
+    srcs = ["md.go"],
+    importpath = "kythe.io/kythe/go/util/md",
+)
diff --git a/kythe/go/util/md/md.go b/kythe/go/util/md/md.go
new file mode 100644
index 000000000..923d699b1
--- /dev/null
+++ b/kythe/go/util/md/md.go
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2023 The Kythe Authors. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package md provides some helper functions for creating marked down for Kythe documentation.
+package md
+
+import (
+	"fmt"
+	"sort"
+)
+
+// LinkInfo contains information about the placement and destination of
+// linkable text
+type LinkInfo struct {
+	StartByte   int
+	Length      int
+	Destination string
+}
+
+// Link creates a markdown link. Content must be html and md escaped prior to calling this function.
+func Link(content, destination string) string {
+	return fmt.Sprintf("[%s](%s)", content, destination)
+}
+
+// ProcessLinks adds markdown links to the provided content based on the
+// provided link information. This content can be rendered by the client
+func ProcessLinks(content string, links []LinkInfo) string {
+	mdContent := ""
+	currentPos := 0
+
+	// Ensure links are in order of starting byte
+	sort.Slice(links, func(i, j int) bool {
+		return links[i].StartByte < links[j].StartByte
+	})
+
+	for _, linkInfo := range links {
+		if linkInfo.StartByte >= len(content) {
+			break
+		}
+
+		mdContent += content[currentPos:linkInfo.StartByte]
+		currentPos = linkInfo.StartByte
+		linkContent := content[currentPos : linkInfo.StartByte+linkInfo.Length]
+		mdContent += Link(linkContent, linkInfo.Destination)
+		currentPos += linkInfo.Length
+	}
+
+	if currentPos < len(content) {
+		mdContent += content[currentPos:len(content)]
+	}
+
+	return mdContent
+}


Test patch:
diff --git a/kythe/go/test/services/xrefs/xrefs.go b/kythe/go/test/services/xrefs/xrefs.go
index 29322ca6e..1d478a70a 100644
--- a/kythe/go/test/services/xrefs/xrefs.go
+++ b/kythe/go/test/services/xrefs/xrefs.go
@@ -119,9 +119,9 @@ func (a Atomizer) CrossReferences(ctx context.Context, xrefs *xpb.CrossReference
 
 			rendered := markedsource.Render(xrs.MarkedSource)
 			a.emitFact(ctx, src, facts.Code+"/rendered", []byte(rendered))
-			ident := markedsource.RenderSimpleIdentifier(xrs.MarkedSource)
+			ident := markedsource.RenderSimpleIdentifier(xrs.MarkedSource, markedsource.PlaintextContent, nil)
 			a.emitFact(ctx, src, facts.Code+"/rendered/identifier", []byte(ident))
-			params := markedsource.RenderSimpleParams(xrs.MarkedSource)
+			params := markedsource.RenderSimpleParams(xrs.MarkedSource, markedsource.PlaintextContent, nil)
 			if len(params) > 0 {
 				a.emitFact(ctx, src, facts.Code+"/rendered/params", []byte(strings.Join(params, ",")))
 			}
diff --git a/kythe/go/util/markedsource/markedsource_test.go b/kythe/go/util/markedsource/markedsource_test.go
index 113a13e5c..55558dd9f 100644
--- a/kythe/go/util/markedsource/markedsource_test.go
+++ b/kythe/go/util/markedsource/markedsource_test.go
@@ -22,6 +22,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
 
@@ -157,7 +158,7 @@ func TestInteropt(t *testing.T) {
 			PreText: "*pkg.receiver",
 		}, {
 			Kind:          cpb.MarkedSource_BOX,
-			PostChildText: ".",
+			PostChildText: " ",
 			Child: []*cpb.MarkedSource{{
 				Kind: cpb.MarkedSource_BOX,
 				Child: []*cpb.MarkedSource{{
@@ -167,9 +168,6 @@ func TestInteropt(t *testing.T) {
 					Kind:    cpb.MarkedSource_IDENTIFIER,
 					PreText: "param",
 				}},
-			}, {
-				Kind:    cpb.MarkedSource_BOX,
-				PreText: " ",
 			}, {
 				Kind:    cpb.MarkedSource_TYPE,
 				PreText: "string",
@@ -177,21 +175,23 @@ func TestInteropt(t *testing.T) {
 		}},
 	}}
 
-	for _, test := range tests {
-		oracle := runOracle(t, test)
-		if ident := RenderSimpleIdentifier(test); oracle.SimpleIdentifier != ident {
-			t.Errorf("RenderSimpleIdentifier({%+v}): expected: %q; found %q", test, oracle.SimpleIdentifier, ident)
-		}
-		params := RenderSimpleParams(test)
-		if len(params) != len(oracle.SimpleParams) {
-			t.Errorf("RenderSimpleParams({%+v}); expected: %#v; found: %#v", test, oracle.SimpleParams, params)
-		} else {
-			for i, expected := range oracle.SimpleParams {
-				if expected != params[i] {
-					t.Errorf("RenderSimpleParams({%+v})[%d]; expected: %#v; found: %#v", test, i, expected, params[i])
+	for i, test := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			oracle := runOracle(t, test)
+			if ident := RenderSimpleIdentifier(test, PlaintextContent, nil); oracle.SimpleIdentifier != ident {
+				t.Errorf("RenderSimpleIdentifier({%+v}): expected: %q; found %q", test, oracle.SimpleIdentifier, ident)
+			}
+			params := RenderSimpleParams(test, PlaintextContent, nil)
+			if len(params) != len(oracle.SimpleParams) {
+				t.Errorf("RenderSimpleParams({%+v}); expected: %#v; found: %#v", test, oracle.SimpleParams, params)
+			} else {
+				for i, expected := range oracle.SimpleParams {
+					if expected != params[i] {
+						t.Errorf("RenderSimpleParams({%+v})[%d]; expected: %#v; found: %#v", test, i, expected, params[i])
+					}
 				}
 			}
-		}
+		})
 	}
 }
 
@@ -212,13 +212,13 @@ func TestRender(t *testing.T) {
 			Child: []*cpb.MarkedSource{{PreText: "C1"}, {PreText: "C2"}}}, "PREC1,C2POST"},
 		{&cpb.MarkedSource{PreText: "PRE", PostText: "POST", PostChildText: ",", AddFinalListToken: true,
 			Child: []*cpb.MarkedSource{{PreText: "C1"}, {PreText: "C2"}}}, "PREC1,C2,POST"},
-		{&cpb.MarkedSource{PreText: "PRE", PostChildText: ",", AddFinalListToken: true,
-			Child: []*cpb.MarkedSource{{PreText: "C1"}, {PreText: "C2"}}}, "PREC1,C2,"},
 	}
-	for _, test := range tests {
-		if got := Render(test.in); got != test.out {
-			t.Errorf("from %v: got %q, expected %q", test.in, got, test.out)
-		}
+	for i, test := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			if got := Render(test.in); got != test.out {
+				t.Errorf("from %v: got %q, expected %q", test.in, got, test.out)
+			}
+		})
 	}
 }
 
@@ -263,15 +263,16 @@ func TestQName(t *testing.T) {
 			want: &cpb.SymbolInfo{BaseName: "hello", QualifiedName: ""}},
 	}
 
-	for _, test := range tests {
-		var ms cpb.MarkedSource
-		if err := prototext.Unmarshal([]byte(test.input), &ms); err != nil {
-			t.Errorf("Invalid test input: %v\nInput was %#q", err, test.input)
-			continue
-		}
+	for i, test := range tests {
+		t.Run(strconv.Itoa(i), func(t *testing.T) {
+			var ms cpb.MarkedSource
+			if err := prototext.Unmarshal([]byte(test.input), &ms); err != nil {
+				t.Fatalf("Invalid test input: %v\nInput was %#q", err, test.input)
+			}
 
-		if got := RenderQualifiedName(&ms); !proto.Equal(got, test.want) {
-			t.Errorf("Invalid result: got %q, want %q\nInput was %#q", got, test.want, test.input)
-		}
+			if got := RenderQualifiedName(&ms); !proto.Equal(got, test.want) {
+				t.Errorf("Invalid result: got %q, want %q\nInput was %#q", got, test.want, test.input)
+			}
+		})
 	}
 }