| // Copyright 2017 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 render |
| |
| import ( |
| "bytes" |
| "errors" |
| "fmt" |
| "go/ast" |
| "go/doc" |
| "go/doc/comment" |
| "go/format" |
| "go/printer" |
| "go/scanner" |
| "go/token" |
| "regexp" |
| "strconv" |
| "strings" |
| "unicode" |
| |
| safe "github.com/google/safehtml" |
| "github.com/google/safehtml/legacyconversions" |
| "github.com/google/safehtml/template" |
| "golang.org/x/pkgsite/internal/log" |
| ) |
| |
| /* |
| This logic is responsible for converting documentation comments and AST nodes |
| into formatted HTML. This relies on identifierResolver.toHTML to do the work |
| of converting words into links. |
| */ |
| |
| // TODO(golang.org/issue/17056): Support hiding deprecated declarations. |
| |
| const ( |
| // Regexp for URLs. |
| // Match any ".,:;?!" within path, but not at end (see #18139, #16565). |
| // This excludes some rare yet valid URLs ending in common punctuation |
| // in order to allow sentences ending in URLs. |
| urlRx = protoPart + `://` + hostPart + pathPart |
| |
| // Protocol (e.g. "http"). |
| protoPart = `(https?|s?ftps?|file|gopher|mailto|nntp)` |
| // Host (e.g. "www.example.com" or "[::1]:8080"). |
| hostPart = `([a-zA-Z0-9_@\-.\[\]:]+)` |
| // Optional path, query, fragment (e.g. "/path/index.html?q=foo#bar"). |
| pathPart = `([.,:;?!]*[a-zA-Z0-9$'()*+&#=@~_/\-\[\]%])*` |
| |
| // Regexp for RFCs. |
| rfcRx = `RFC\s+(\d{3,5})(,?\s+[Ss]ection\s+(\d+(\.\d+)*))?` |
| ) |
| |
| var ( |
| matchRx = regexp.MustCompile(urlRx + `|` + rfcRx) |
| badAnchorRx = regexp.MustCompile(`[^a-zA-Z0-9]`) |
| ) |
| |
| type link struct { |
| Class string |
| Href string |
| Text any // string or safe.HTML |
| } |
| |
| type heading struct { |
| ID safe.Identifier |
| Title safe.HTML |
| } |
| |
| var ( |
| // tocTemplate expects a []heading. |
| tocTemplate = template.Must(template.New("toc").Parse(`<div role="navigation" aria-label="Table of Contents"> |
| <ul class="Documentation-toc{{if gt (len .) 5}} Documentation-toc-columns{{end}}"> |
| {{range . -}} |
| <li class="Documentation-tocItem"> |
| <a href="#{{.ID}}">{{.Title}}</a> |
| </li> |
| {{end -}} |
| </ul> |
| </div> |
| `)) |
| |
| italicTemplate = template.Must(template.New("italics").Parse(`<i>{{.}}</i>`)) |
| |
| codeTemplate = template.Must(template.New("code").Parse(`<pre>{{.}}</pre>`)) |
| |
| paraTemplate = template.Must(template.New("para").Parse("<p>{{.}}\n</p>")) |
| |
| headingTemplate = template.Must(template.New("heading").Parse( |
| `<h4 id="{{.ID}}">{{.Title}} <a class="Documentation-idLink" href="#{{.ID}}" aria-label="Go to {{.Title}}">¶</a></h4>`)) |
| |
| linkTemplate = template.Must(template.New("link").Parse( |
| `<a{{with .Class}}class="{{.}}" {{end}} href="{{.Href}}">{{.Text}}</a>`)) |
| |
| uListTemplate = template.Must(template.New("ulist").Parse( |
| `<ul class="Documentation-bulletList"> |
| {{- range .}} |
| {{.}} |
| {{- end}} |
| </ul>`)) |
| |
| oListTemplate = template.Must(template.New("olist").Parse( |
| `<ol class="Documentation-numberList"> |
| {{- range .}} |
| {{.}} |
| {{- end}} |
| </ol>`)) |
| |
| listItemTemplate = template.Must(template.New("li").Parse( |
| `<li{{with .Number}} value="{{.}}"{{end}}>{{.Content}}</li>`)) |
| ) |
| |
| func (r *Renderer) formatDocHTML(text string, extractLinks bool) safe.HTML { |
| doc := r.commentParser.Parse(text) |
| if extractLinks { |
| r.removeLinks(doc) |
| } |
| var headings []heading |
| for _, b := range doc.Content { |
| if h, ok := b.(*comment.Heading); ok { |
| headings = append(headings, r.newHeading(h)) |
| } |
| } |
| h := r.blocksToHTML(doc.Content, true, extractLinks) |
| if len(headings) > 0 { |
| h = safe.HTMLConcat(ExecuteToHTML(tocTemplate, headings), h) |
| } |
| return h |
| } |
| |
| func (r *Renderer) removeLinks(doc *comment.Doc) { |
| var bs []comment.Block |
| inLinks := false |
| for _, b := range doc.Content { |
| switch b := b.(type) { |
| case *comment.Heading: |
| if textsToString(b.Text) == "Links" { |
| inLinks = true |
| } else { |
| inLinks = false |
| bs = append(bs, b) |
| } |
| case *comment.List: |
| if inLinks { |
| for _, item := range b.Items { |
| if link, ok := itemLink(item); ok { |
| r.links = append(r.links, link) |
| } |
| } |
| } else { |
| bs = append(bs, b) |
| } |
| case *comment.Paragraph: |
| if inLinks { |
| // Links section doesn't require leading whitespace, so |
| // the link may be in a paragraph. |
| s := textsToString(b.Text) |
| r.links = append(r.links, parseLinks(strings.Split(s, "\n"))...) |
| } else { |
| bs = append(bs, b) |
| } |
| |
| default: |
| if !inLinks { |
| bs = append(bs, b) |
| } |
| } |
| } |
| doc.Content = bs |
| } |
| |
| func itemLink(item *comment.ListItem) (l Link, ok bool) { |
| // Should be a single Paragraph. |
| if len(item.Content) != 1 { |
| return l, false |
| } |
| p, ok := item.Content[0].(*comment.Paragraph) |
| if !ok { |
| return l, false |
| } |
| // TODO: clean up. |
| if lp := parseLink("- " + textsToString(p.Text)); lp != nil { |
| return *lp, true |
| } |
| return l, false |
| } |
| |
| // parseLinks extracts links from lines. |
| func parseLinks(lines []string) []Link { |
| var links []Link |
| for _, l := range lines { |
| if link := parseLink(l); link != nil { |
| links = append(links, *link) |
| } |
| } |
| return links |
| } |
| |
| // If line is of the form "- title, url", then parseLink returns |
| // a Link with the title and url. Otherwise it returns nil. |
| // The line already has leading whitespace trimmed. |
| func parseLink(line string) *Link { |
| if !strings.HasPrefix(line, "- ") && !strings.HasPrefix(line, "-\t") { |
| return nil |
| } |
| text, href, found := strings.Cut(line[2:], ",") |
| if !found { |
| return nil |
| } |
| return &Link{ |
| Text: strings.TrimSpace(text), |
| Href: strings.TrimSpace(href), |
| } |
| } |
| |
| func (r *Renderer) blocksToHTML(bs []comment.Block, useParagraph, extractLinks bool) safe.HTML { |
| return concatHTML(bs, func(b comment.Block) safe.HTML { |
| return r.blockToHTML(b, useParagraph, extractLinks) |
| }) |
| } |
| |
| func (r *Renderer) blockToHTML(b comment.Block, useParagraph, extractLinks bool) safe.HTML { |
| switch b := b.(type) { |
| case *comment.Paragraph: |
| th := r.textsToHTML(b.Text) |
| if useParagraph { |
| return ExecuteToHTML(paraTemplate, th) |
| } |
| return th |
| |
| case *comment.Code: |
| return ExecuteToHTML(codeTemplate, b.Text) |
| |
| case *comment.Heading: |
| return ExecuteToHTML(headingTemplate, r.newHeading(b)) |
| |
| case *comment.List: |
| var items []safe.HTML |
| useParagraph = b.BlankBetween() |
| for _, item := range b.Items { |
| items = append(items, ExecuteToHTML(listItemTemplate, struct { |
| Number string |
| Content safe.HTML |
| }{item.Number, r.blocksToHTML(item.Content, useParagraph, false)})) |
| } |
| t := oListTemplate |
| if b.Items[0].Number == "" { |
| t = uListTemplate |
| } |
| return ExecuteToHTML(t, items) |
| default: |
| return badType(b) |
| } |
| } |
| |
| func (r *Renderer) newHeading(h *comment.Heading) heading { |
| return heading{headingID(h), r.textsToHTML(h.Text)} |
| } |
| |
| func (r *Renderer) textsToHTML(ts []comment.Text) safe.HTML { |
| return concatHTML(ts, r.textToHTML) |
| } |
| |
| func (r *Renderer) textToHTML(t comment.Text) safe.HTML { |
| switch t := t.(type) { |
| case comment.Plain: |
| // Don't auto-link URLs. The doc/comment package already does that. |
| return linkRFCs(string(t)) |
| case comment.Italic: |
| return ExecuteToHTML(italicTemplate, t) |
| case *comment.Link: |
| return ExecuteToHTML(linkTemplate, link{"", t.URL, r.textsToHTML(t.Text)}) |
| case *comment.DocLink: |
| url := r.docLinkURL(t) |
| return ExecuteToHTML(linkTemplate, link{"", url, r.textsToHTML(t.Text)}) |
| default: |
| return badType(t) |
| } |
| } |
| |
| func (r *Renderer) docLinkURL(dl *comment.DocLink) string { |
| var url string |
| if dl.ImportPath != "" { |
| url = "/" + dl.ImportPath |
| if r.packageURL != nil { |
| url = r.packageURL(dl.ImportPath) |
| } |
| } |
| id := dl.Name |
| if dl.Recv != "" { |
| id = dl.Recv + "." + id |
| } |
| if id != "" { |
| url += "#" + id |
| } |
| return url |
| } |
| |
| // TODO: any -> *comment.Text | *comment.Block |
| func concatHTML[T any](xs []T, toHTML func(T) safe.HTML) safe.HTML { |
| var hs []safe.HTML |
| for _, x := range xs { |
| hs = append(hs, toHTML(x)) |
| } |
| return safe.HTMLConcat(hs...) |
| } |
| |
| func badType(x any) safe.HTML { |
| return safe.HTMLEscaped(fmt.Sprintf("bad type %T", x)) |
| } |
| |
| func headingID(h *comment.Heading) safe.Identifier { |
| s := textsToString(h.Text) |
| id := badAnchorRx.ReplaceAllString(s, "_") |
| return safe.IdentifierFromConstantPrefix("hdr", id) |
| } |
| |
| func textsToString(ts []comment.Text) string { |
| var b strings.Builder |
| for _, t := range ts { |
| switch t := t.(type) { |
| case comment.Plain: |
| b.WriteString(string(t)) |
| case comment.Italic: |
| b.WriteString(string(t)) |
| case *comment.Link: |
| b.WriteString(textsToString(t.Text)) |
| case *comment.DocLink: |
| b.WriteString(textsToString(t.Text)) |
| default: |
| fmt.Fprintf(&b, "bad text type %T", t) |
| } |
| } |
| return b.String() |
| } |
| |
| var rfcRegexp = regexp.MustCompile(rfcRx) |
| |
| // TODO: merge/replace Renderer.formatLineHTML. |
| // TODO: make more efficient. |
| func linkRFCs(s string) safe.HTML { |
| var hs []safe.HTML |
| for len(s) > 0 { |
| m0, m1 := len(s), len(s) |
| if m := rfcRegexp.FindStringIndex(s); m != nil { |
| m0, m1 = m[0], m[1] |
| } |
| if m0 > 0 { |
| hs = append(hs, safe.HTMLEscaped(s[:m0])) |
| } |
| if m1 > m0 { |
| word := s[m0:m1] |
| // Strip all characters except for letters, numbers, and '.' to |
| // obtain RFC fields. |
| rfcFields := strings.FieldsFunc(word, func(c rune) bool { |
| return !unicode.IsLetter(c) && !unicode.IsNumber(c) && c != '.' |
| }) |
| var url string |
| if len(rfcFields) >= 4 { |
| // RFC x Section y |
| url = fmt.Sprintf("https://rfc-editor.org/rfc/rfc%s.html#section-%s", |
| rfcFields[1], rfcFields[3]) |
| } else if len(rfcFields) >= 2 { |
| url = fmt.Sprintf("https://rfc-editor.org/rfc/rfc%s.html", rfcFields[1]) |
| } |
| if url != "" { |
| hs = append(hs, ExecuteToHTML(linkTemplate, link{"", url, word})) |
| } |
| } |
| s = s[m1:] |
| } |
| return safe.HTMLConcat(hs...) |
| } |
| |
| func (r *Renderer) declHTML(doc string, decl ast.Decl, extractLinks bool) (out struct{ Doc, Decl safe.HTML }) { |
| if doc != "" { |
| out.Doc = r.formatDocHTML(doc, extractLinks) |
| } |
| if decl != nil { |
| idr := &identifierResolver{r.pids, newDeclIDs(decl), r.packageURL} |
| out.Decl = r.formatDeclHTML(decl, idr) |
| } |
| return out |
| } |
| |
| func (r *Renderer) codeString(ex *doc.Example) (string, error) { |
| if ex == nil || ex.Code == nil { |
| return "", errors.New("please include an example with code") |
| } |
| var buf bytes.Buffer |
| |
| if ex.Play != nil { |
| if err := format.Node(&buf, r.fset, ex.Play); err != nil { |
| return "", err |
| } |
| } else { |
| n := &printer.CommentedNode{ |
| Node: ex.Code, |
| Comments: ex.Comments, |
| } |
| if err := format.Node(&buf, r.fset, n); err != nil { |
| return "", err |
| } |
| } |
| |
| return buf.String(), nil |
| } |
| |
| func (r *Renderer) codeHTML(ex *doc.Example) safe.HTML { |
| codeStr, err := r.codeString(ex) |
| if err != nil { |
| log.Errorf(r.ctx, "Error converting *doc.Example into string: %v", err) |
| return template.MustParseAndExecuteToHTML(`<pre class="Documentation-exampleCode">Error rendering example code.</pre>`) |
| } |
| return codeHTML(codeStr, r.exampleTmpl) |
| } |
| |
| type codeElement struct { |
| Text string |
| Comment bool |
| } |
| |
| func codeHTML(src string, codeTmpl *template.Template) safe.HTML { |
| var els []codeElement |
| // If code is an *ast.BlockStmt, then trim the braces. |
| var indent string |
| if len(src) >= 4 && strings.HasPrefix(src, "{\n") && strings.HasSuffix(src, "\n}") { |
| src = strings.Trim(src[2:len(src)-2], "\n") |
| indent = src[:indentLength(src)] |
| if len(indent) > 0 { |
| src = strings.TrimPrefix(src, indent) // handle remaining indents later |
| } |
| } |
| |
| // Scan through the source code, adding comment spans for comments, |
| // and stripping the trailing example output. |
| var lastOffset int // last src offset copied to output buffer |
| var outputOffset int = -1 // index in els of last output comment |
| var s scanner.Scanner |
| fset := token.NewFileSet() |
| file := fset.AddFile("", fset.Base(), len(src)) |
| s.Init(file, []byte(src), nil, scanner.ScanComments) |
| indent = "\n" + indent // prepend newline for easier search-and-replace. |
| scan: |
| for { |
| p, tok, lit := s.Scan() |
| offset := file.Offset(p) // current offset into source file |
| prev := src[lastOffset:offset] |
| prev = strings.Replace(prev, indent, "\n", -1) |
| els = append(els, codeElement{prev, false}) |
| lastOffset = offset |
| switch tok { |
| case token.EOF: |
| break scan |
| case token.COMMENT: |
| if exampleOutputRx.MatchString(lit) { |
| outputOffset = len(els) |
| } |
| lit = strings.Replace(lit, indent, "\n", -1) |
| els = append(els, codeElement{lit, true}) |
| lastOffset += len(lit) |
| case token.STRING: |
| // Avoid replacing indents in multi-line string literals. |
| els = append(els, codeElement{lit, false}) |
| lastOffset += len(lit) |
| } |
| } |
| |
| if outputOffset >= 0 { |
| els = els[:outputOffset] |
| } |
| // Trim trailing newlines. |
| if len(els) > 0 { |
| els[len(els)-1].Text = strings.TrimRight(els[len(els)-1].Text, "\n") |
| } |
| return ExecuteToHTML(codeTmpl, els) |
| } |
| |
| // formatLineHTML formats the line as HTML-annotated text. |
| // URLs and Go identifiers are linked to corresponding declarations. |
| // If pre is true no conversion of doubled ` and ' to “ and ” is performed. |
| func (r *Renderer) formatLineHTML(line string, pre bool) safe.HTML { |
| var htmls []safe.HTML |
| addLink := func(href, text string) { |
| htmls = append(htmls, ExecuteToHTML(LinkTemplate, Link{Href: href, Text: text})) |
| } |
| |
| if !pre { |
| line = convertQuotes(line) |
| } |
| for len(line) > 0 { |
| m0, m1 := len(line), len(line) |
| if m := matchRx.FindStringIndex(line); m != nil { |
| m0, m1 = m[0], m[1] |
| } |
| if m0 > 0 { |
| nonWord := line[:m0] |
| htmls = append(htmls, safe.HTMLEscaped(nonWord)) |
| } |
| if m1 > m0 { |
| word := line[m0:m1] |
| switch { |
| case strings.Contains(word, "://"): |
| // Forbid closing brackets without prior opening brackets. |
| // See https://golang.org/issue/22285. |
| if i := strings.IndexByte(word, ')'); i >= 0 && i < strings.IndexByte(word, '(') { |
| m1 = m0 + i |
| word = line[m0:m1] |
| } |
| if i := strings.IndexByte(word, ']'); i >= 0 && i < strings.IndexByte(word, '[') { |
| m1 = m0 + i |
| word = line[m0:m1] |
| } |
| |
| // Require balanced pairs of parentheses. |
| // See https://golang.org/issue/5043. |
| for i := 0; strings.Count(word, "(") != strings.Count(word, ")") && i < 10; i++ { |
| m1 = strings.LastIndexAny(line[:m1], "()") |
| word = line[m0:m1] |
| } |
| for i := 0; strings.Count(word, "[") != strings.Count(word, "]") && i < 10; i++ { |
| m1 = strings.LastIndexAny(line[:m1], "[]") |
| word = line[m0:m1] |
| } |
| |
| addLink(word, word) |
| // Match "RFC ..." to link RFCs. |
| case strings.HasPrefix(word, "RFC") && len(word) > 3 && unicode.IsSpace(rune(word[3])): |
| // Strip all characters except for letters, numbers, and '.' to |
| // obtain RFC fields. |
| rfcFields := strings.FieldsFunc(word, func(c rune) bool { |
| return !unicode.IsLetter(c) && !unicode.IsNumber(c) && c != '.' |
| }) |
| if len(rfcFields) >= 4 { |
| // RFC x Section y |
| addLink(fmt.Sprintf("https://rfc-editor.org/rfc/rfc%s.html#section-%s", rfcFields[1], rfcFields[3]), word) |
| } else if len(rfcFields) >= 2 { |
| // RFC x |
| addLink(fmt.Sprintf("https://rfc-editor.org/rfc/rfc%s.html", rfcFields[1]), word) |
| } |
| default: |
| htmls = append(htmls, safe.HTMLEscaped(word)) |
| } |
| } |
| line = line[m1:] |
| } |
| return safe.HTMLConcat(htmls...) |
| } |
| |
| func ExecuteToHTML(tmpl *template.Template, data any) safe.HTML { |
| h, err := tmpl.ExecuteToHTML(data) |
| if err != nil { |
| return safe.HTMLEscaped("[" + err.Error() + "]") |
| } |
| return h |
| } |
| |
| // formatDeclHTML formats the decl as HTML-annotated source code for the |
| // provided decl. Type identifiers are linked to corresponding declarations. |
| func (r *Renderer) formatDeclHTML(decl ast.Decl, idr *identifierResolver) safe.HTML { |
| // Generate all anchor points and links for the given decl. |
| anchorPointsMap := generateAnchorPoints(decl) |
| anchorLinksMap := generateAnchorLinks(idr, decl) |
| |
| // Convert the maps (keyed by *ast.Ident) to slices of idKinds or URLs. |
| // |
| // This relies on the ast.Inspect and scanner.Scanner both |
| // visiting *ast.Ident and token.IDENT nodes in the same order. |
| var anchorPoints []idKind |
| var anchorLinks []string |
| ast.Inspect(decl, func(node ast.Node) bool { |
| if id, ok := node.(*ast.Ident); ok { |
| anchorPoints = append(anchorPoints, anchorPointsMap[id]) |
| anchorLinks = append(anchorLinks, anchorLinksMap[id]) |
| } |
| return true |
| }) |
| |
| // Trim large string literals and composite literals. |
| const ( |
| maxStringSize = 125 |
| maxElements = 100 |
| ) |
| decl = rewriteDecl(decl, maxStringSize, maxElements) |
| // Format decl as Go source code file. |
| p := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 4} |
| var b bytes.Buffer |
| p.Fprint(&b, r.fset, decl) |
| src := b.Bytes() |
| fset := token.NewFileSet() |
| file := fset.AddFile("", fset.Base(), b.Len()) |
| |
| // anchorLines is a list of anchor IDs that should be placed for each line. |
| // lineTypes is a list of the type (e.g., comment or code) of each line. |
| type lineType byte |
| const codeType, commentType lineType = 1 << 0, 1 << 1 // may OR together |
| numLines := bytes.Count(src, []byte("\n")) + 1 |
| anchorLines := make([][]idKind, numLines) |
| lineTypes := make([]lineType, numLines) |
| htmlLines := make([][]safe.HTML, numLines) |
| |
| // Scan through the source code, appropriately annotating it with HTML spans |
| // for comments, and HTML links and anchors for relevant identifiers. |
| var idIdx int // current index in anchorPoints and anchorLinks |
| var lastOffset int // last src offset copied to output buffer |
| var s scanner.Scanner |
| s.Init(file, src, nil, scanner.ScanComments) |
| scan: |
| for { |
| p, tok, lit := s.Scan() |
| line := file.Line(p) - 1 // current 0-indexed line number |
| offset := file.Offset(p) // current offset into source file |
| tokType := codeType // current token type (assume source code) |
| if lastOffset > offset { |
| continue |
| } |
| // Add traversed bytes from src to the appropriate line. |
| prevLines := strings.SplitAfter(string(src[lastOffset:offset]), "\n") |
| for i, ln := range prevLines { |
| n := line - len(prevLines) + i + 1 |
| if n < 0 { // possible at EOF |
| n = 0 |
| } |
| htmlLines[n] = append(htmlLines[n], safe.HTMLEscaped(ln)) |
| } |
| |
| lastOffset = offset |
| switch tok { |
| case token.EOF: |
| break scan |
| case token.COMMENT: |
| tokType = commentType |
| htmlLines[line] = append(htmlLines[line], |
| template.MustParseAndExecuteToHTML(`<span class="comment">`), |
| r.formatLineHTML(lit, false), |
| template.MustParseAndExecuteToHTML(`</span>`)) |
| lastOffset += len(lit) |
| case token.IDENT: |
| if idIdx < len(anchorPoints) && anchorPoints[idIdx].ID.String() != "" { |
| anchorLines[line] = append(anchorLines[line], anchorPoints[idIdx]) |
| } |
| if idIdx < len(anchorLinks) && anchorLinks[idIdx] != "" { |
| htmlLines[line] = append(htmlLines[line], ExecuteToHTML(LinkTemplate, Link{Href: anchorLinks[idIdx], Text: lit})) |
| lastOffset += len(lit) |
| } |
| idIdx++ |
| } |
| for i := strings.Count(strings.TrimSuffix(lit, "\n"), "\n"); i >= 0; i-- { |
| lineTypes[line+i] |= tokType |
| } |
| } |
| |
| // Move anchor points up to the start of a comment |
| // if the next line has no anchors. |
| for i := range anchorLines { |
| if i+1 == len(anchorLines) || len(anchorLines[i+1]) == 0 { |
| j := i |
| for j > 0 && lineTypes[j-1] == commentType { |
| j-- |
| } |
| anchorLines[i], anchorLines[j] = anchorLines[j], anchorLines[i] |
| } |
| } |
| |
| // Emit anchor IDs and data-kind attributes for each relevant line. |
| var htmls []safe.HTML |
| for line, iks := range anchorLines { |
| inAnchor := false |
| for _, ik := range iks { |
| // Attributes for types and functions are handled in the template |
| // that generates the full documentation HTML. |
| if ik.Kind == "function" || ik.Kind == "type" { |
| continue |
| } |
| // Top-level methods are handled in the template, but interface methods |
| // are handled here. |
| if fd, ok := decl.(*ast.FuncDecl); ok && fd.Recv != nil { |
| continue |
| } |
| htmls = append(htmls, ExecuteToHTML(anchorTemplate, ik)) |
| inAnchor = true |
| } |
| htmls = append(htmls, htmlLines[line]...) |
| if inAnchor { |
| htmls = append(htmls, template.MustParseAndExecuteToHTML("</span>")) |
| } |
| } |
| return safe.HTMLConcat(htmls...) |
| } |
| |
| var anchorTemplate = template.Must(template.New("anchor").Parse(`<span id="{{.ID}}" data-kind="{{.Kind}}">`)) |
| |
| // rewriteDecl rewrites n by removing strings longer than maxStringSize and |
| // composite literals longer than maxElements. |
| func rewriteDecl(n ast.Decl, maxStringSize, maxElements int) ast.Decl { |
| v := &rewriteVisitor{maxStringSize, maxElements} |
| ast.Walk(v, n) |
| return n |
| } |
| |
| type rewriteVisitor struct { |
| maxStringSize, maxElements int |
| } |
| |
| func (v *rewriteVisitor) Visit(n ast.Node) ast.Visitor { |
| switch n := n.(type) { |
| case *ast.ValueSpec: |
| for _, val := range n.Values { |
| v.rewriteLongValue(val, &n.Comment) |
| } |
| case *ast.Field: |
| if n.Tag != nil { |
| v.rewriteLongValue(n.Tag, &n.Comment) |
| } |
| } |
| return v |
| } |
| |
| func (v *rewriteVisitor) rewriteLongValue(n ast.Node, pcg **ast.CommentGroup) { |
| switch n := n.(type) { |
| case *ast.BasicLit: |
| if n.Kind != token.STRING { |
| return |
| } |
| size := len(n.Value) - 2 // subtract quotation marks |
| if size <= v.maxStringSize { |
| return |
| } |
| addComment(pcg, n.ValuePos, fmt.Sprintf("/* %d-byte string literal not displayed */", size)) |
| if len(n.Value) == 0 { |
| // Impossible, but avoid the panic just in case. |
| return |
| } |
| if quote := n.Value[0]; quote == '`' { |
| n.Value = "``" |
| } else { |
| n.Value = `""` |
| } |
| case *ast.CompositeLit: |
| if len(n.Elts) > v.maxElements { |
| addComment(pcg, n.Lbrace, fmt.Sprintf("/* %d elements not displayed */", len(n.Elts))) |
| n.Elts = n.Elts[:0] |
| } |
| } |
| } |
| |
| func addComment(cg **ast.CommentGroup, pos token.Pos, text string) { |
| if *cg == nil { |
| *cg = &ast.CommentGroup{} |
| } |
| (*cg).List = append((*cg).List, &ast.Comment{Slash: pos, Text: text}) |
| } |
| |
| // An idKind holds an anchor ID and the kind of the identifier being anchored. |
| // The valid kinds are: "constant", "variable", "type", "function", "method" and "field". |
| type idKind struct { |
| ID safe.Identifier |
| Kind string |
| } |
| |
| // SafeGoID constructs a safe identifier from a Go symbol or dotted concatenation of symbols |
| // (e.g. "Time.Equal"). |
| func SafeGoID(s string) safe.Identifier { |
| ValidateGoDottedExpr(s) |
| return legacyconversions.RiskilyAssumeIdentifier(s) |
| } |
| |
| var badIDRx = regexp.MustCompile(`[^_\pL\pN.]`) |
| |
| // ValidateGoDottedExpr panics if s contains characters other than '.' plus the valid Go identifier characters. |
| func ValidateGoDottedExpr(s string) { |
| if badIDRx.MatchString(s) { |
| panic(fmt.Sprintf("invalid identifier characters: %q", s)) |
| } |
| } |
| |
| // generateAnchorPoints returns a mapping of *ast.Ident objects to the |
| // qualified ID that should be set as an anchor point, as well as the kind |
| // of identifier, used in the data-kind attribute. |
| func generateAnchorPoints(decl ast.Decl) map[*ast.Ident]idKind { |
| m := map[*ast.Ident]idKind{} |
| switch decl := decl.(type) { |
| case *ast.GenDecl: |
| for _, sp := range decl.Specs { |
| switch decl.Tok { |
| case token.CONST, token.VAR: |
| kind := "constant" |
| if decl.Tok == token.VAR { |
| kind = "variable" |
| } |
| for _, name := range sp.(*ast.ValueSpec).Names { |
| m[name] = idKind{SafeGoID(name.Name), kind} |
| } |
| case token.TYPE: |
| ts := sp.(*ast.TypeSpec) |
| m[ts.Name] = idKind{SafeGoID(ts.Name.Name), "type"} |
| |
| var fs []*ast.Field |
| var kind string |
| switch tx := ts.Type.(type) { |
| case *ast.StructType: |
| fs = tx.Fields.List |
| kind = "field" |
| case *ast.InterfaceType: |
| fs = tx.Methods.List |
| kind = "method" |
| } |
| for _, f := range fs { |
| for _, id := range f.Names { |
| m[id] = idKind{SafeGoID(ts.Name.String() + "." + id.String()), kind} |
| } |
| // if f.Names == nil, we have an embedded struct field or embedded |
| // interface. |
| // |
| // Don't generate anchor points for embedded interfaces. They |
| // aren't interesting in and of themselves; they just represent an |
| // additional list of methods added to the interface. |
| // |
| // Do generate anchor points for embedded fields: they are |
| // interesting, because their names can be used in selector |
| // expressions and struct literals. |
| if f.Names == nil && kind == "field" { |
| // The name of an embedded field is the type name. |
| typeName, id := nodeName(f.Type) |
| typeName = typeName[strings.LastIndexByte(typeName, '.')+1:] |
| m[id] = idKind{SafeGoID(ts.Name.String() + "." + typeName), kind} |
| } |
| } |
| } |
| } |
| case *ast.FuncDecl: |
| anchorID := decl.Name.Name |
| kind := "function" |
| if decl.Recv != nil && len(decl.Recv.List) > 0 { |
| recvName, _ := nodeName(decl.Recv.List[0].Type) |
| recvName = recvName[strings.LastIndexByte(recvName, '.')+1:] |
| anchorID = recvName + "." + anchorID |
| kind = "method" |
| } |
| m[decl.Name] = idKind{SafeGoID(anchorID), kind} |
| } |
| return m |
| } |
| |
| // generateAnchorLinks returns a mapping of *ast.Ident objects to the URL |
| // that the identifier should link to. |
| func generateAnchorLinks(idr *identifierResolver, decl ast.Decl) map[*ast.Ident]string { |
| m := map[*ast.Ident]string{} |
| ignore := map[ast.Node]bool{} |
| ast.Inspect(decl, func(node ast.Node) bool { |
| if ignore[node] { |
| return false |
| } |
| switch node := node.(type) { |
| case *ast.SelectorExpr: |
| // Package qualified identifier (e.g., "io.EOF"). |
| if prefix, _ := node.X.(*ast.Ident); prefix != nil { |
| if obj := prefix.Obj; obj != nil && obj.Kind == ast.Pkg { |
| if spec, _ := obj.Decl.(*ast.ImportSpec); spec != nil { |
| if path, err := strconv.Unquote(spec.Path.Value); err == nil { |
| // Register two links, one for the package |
| // and one for the qualified identifier. |
| m[prefix] = idr.toURL(path, "") |
| m[node.Sel] = idr.toURL(path, node.Sel.Name) |
| return false |
| } |
| } |
| } |
| } |
| case *ast.Ident: |
| if node.Obj == nil && doc.IsPredeclared(node.Name) { |
| m[node] = idr.toURL("builtin", node.Name) |
| } else if node.Obj != nil && idr.topLevelDecls[node.Obj.Decl] { |
| m[node] = "#" + node.Name |
| } |
| case *ast.FuncDecl: |
| ignore[node.Name] = true // E.g., "func NoLink() int" |
| case *ast.TypeSpec: |
| ignore[node.Name] = true // E.g., "type NoLink int" |
| case *ast.ValueSpec: |
| for _, n := range node.Names { |
| ignore[n] = true // E.g., "var NoLink1, NoLink2 int" |
| } |
| case *ast.AssignStmt: |
| for _, n := range node.Lhs { |
| ignore[n] = true // E.g., "NoLink1, NoLink2 := 0, 1" |
| } |
| } |
| return true |
| }) |
| return m |
| } |
| |
| const ( |
| ulquo = "“" |
| urquo = "”" |
| ) |
| |
| var unicodeQuoteReplacer = strings.NewReplacer("``", ulquo, "''", urquo) |
| |
| // convertQuotes turns doubled ` and ' into “ and ”. |
| func convertQuotes(text string) string { |
| return unicodeQuoteReplacer.Replace(text) |
| } |