Source file src/go/doc/comment.go

     1  // Copyright 2009 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // Godoc comment extraction and comment -> HTML formatting.
     6  
     7  package doc
     8  
     9  import (
    10  	"bytes"
    11  	"internal/lazyregexp"
    12  	"io"
    13  	"strings"
    14  	"text/template" // for HTMLEscape
    15  	"unicode"
    16  	"unicode/utf8"
    17  )
    18  
    19  const (
    20  	ldquo = "“"
    21  	rdquo = "”"
    22  	ulquo = "“"
    23  	urquo = "”"
    24  )
    25  
    26  var (
    27  	htmlQuoteReplacer    = strings.NewReplacer(ulquo, ldquo, urquo, rdquo)
    28  	unicodeQuoteReplacer = strings.NewReplacer("``", ulquo, "''", urquo)
    29  )
    30  
    31  // Escape comment text for HTML. If nice is set,
    32  // also turn `` into “ and '' into ”.
    33  func commentEscape(w io.Writer, text string, nice bool) {
    34  	if nice {
    35  		// In the first pass, we convert `` and '' into their unicode equivalents.
    36  		// This prevents them from being escaped in HTMLEscape.
    37  		text = convertQuotes(text)
    38  		var buf bytes.Buffer
    39  		template.HTMLEscape(&buf, []byte(text))
    40  		// Now we convert the unicode quotes to their HTML escaped entities to maintain old behavior.
    41  		// We need to use a temp buffer to read the string back and do the conversion,
    42  		// otherwise HTMLEscape will escape & to &
    43  		htmlQuoteReplacer.WriteString(w, buf.String())
    44  		return
    45  	}
    46  	template.HTMLEscape(w, []byte(text))
    47  }
    48  
    49  func convertQuotes(text string) string {
    50  	return unicodeQuoteReplacer.Replace(text)
    51  }
    52  
    53  const (
    54  	// Regexp for Go identifiers
    55  	identRx = `[\pL_][\pL_0-9]*`
    56  
    57  	// Regexp for URLs
    58  	// Match parens, and check later for balance - see #5043, #22285
    59  	// Match .,:;?! within path, but not at end - see #18139, #16565
    60  	// This excludes some rare yet valid urls ending in common punctuation
    61  	// in order to allow sentences ending in URLs.
    62  
    63  	// protocol (required) e.g. http
    64  	protoPart = `(https?|ftp|file|gopher|mailto|nntp)`
    65  	// host (required) e.g. www.example.com or [::1]:8080
    66  	hostPart = `([a-zA-Z0-9_@\-.\[\]:]+)`
    67  	// path+query+fragment (optional) e.g. /path/index.html?q=foo#bar
    68  	pathPart = `([.,:;?!]*[a-zA-Z0-9$'()*+&#=@~_/\-\[\]%])*`
    69  
    70  	urlRx = protoPart + `://` + hostPart + pathPart
    71  )
    72  
    73  var matchRx = lazyregexp.New(`(` + urlRx + `)|(` + identRx + `)`)
    74  
    75  var (
    76  	html_a      = []byte(`<a href="`)
    77  	html_aq     = []byte(`">`)
    78  	html_enda   = []byte("</a>")
    79  	html_i      = []byte("<i>")
    80  	html_endi   = []byte("</i>")
    81  	html_p      = []byte("<p>\n")
    82  	html_endp   = []byte("</p>\n")
    83  	html_pre    = []byte("<pre>")
    84  	html_endpre = []byte("</pre>\n")
    85  	html_h      = []byte(`<h3 id="`)
    86  	html_hq     = []byte(`">`)
    87  	html_endh   = []byte("</h3>\n")
    88  )
    89  
    90  // Emphasize and escape a line of text for HTML. URLs are converted into links;
    91  // if the URL also appears in the words map, the link is taken from the map (if
    92  // the corresponding map value is the empty string, the URL is not converted
    93  // into a link). Go identifiers that appear in the words map are italicized; if
    94  // the corresponding map value is not the empty string, it is considered a URL
    95  // and the word is converted into a link. If nice is set, the remaining text's
    96  // appearance is improved where it makes sense (e.g., `` is turned into &ldquo;
    97  // and '' into &rdquo;).
    98  func emphasize(w io.Writer, line string, words map[string]string, nice bool) {
    99  	for {
   100  		m := matchRx.FindStringSubmatchIndex(line)
   101  		if m == nil {
   102  			break
   103  		}
   104  		// m >= 6 (two parenthesized sub-regexps in matchRx, 1st one is urlRx)
   105  
   106  		// write text before match
   107  		commentEscape(w, line[0:m[0]], nice)
   108  
   109  		// adjust match for URLs
   110  		match := line[m[0]:m[1]]
   111  		if strings.Contains(match, "://") {
   112  			m0, m1 := m[0], m[1]
   113  			for _, s := range []string{"()", "{}", "[]"} {
   114  				open, close := s[:1], s[1:] // E.g., "(" and ")"
   115  				// require opening parentheses before closing parentheses (#22285)
   116  				if i := strings.Index(match, close); i >= 0 && i < strings.Index(match, open) {
   117  					m1 = m0 + i
   118  					match = line[m0:m1]
   119  				}
   120  				// require balanced pairs of parentheses (#5043)
   121  				for i := 0; strings.Count(match, open) != strings.Count(match, close) && i < 10; i++ {
   122  					m1 = strings.LastIndexAny(line[:m1], s)
   123  					match = line[m0:m1]
   124  				}
   125  			}
   126  			if m1 != m[1] {
   127  				// redo matching with shortened line for correct indices
   128  				m = matchRx.FindStringSubmatchIndex(line[:m[0]+len(match)])
   129  			}
   130  		}
   131  
   132  		// analyze match
   133  		url := ""
   134  		italics := false
   135  		if words != nil {
   136  			url, italics = words[match]
   137  		}
   138  		if m[2] >= 0 {
   139  			// match against first parenthesized sub-regexp; must be match against urlRx
   140  			if !italics {
   141  				// no alternative URL in words list, use match instead
   142  				url = match
   143  			}
   144  			italics = false // don't italicize URLs
   145  		}
   146  
   147  		// write match
   148  		if len(url) > 0 {
   149  			w.Write(html_a)
   150  			template.HTMLEscape(w, []byte(url))
   151  			w.Write(html_aq)
   152  		}
   153  		if italics {
   154  			w.Write(html_i)
   155  		}
   156  		commentEscape(w, match, nice)
   157  		if italics {
   158  			w.Write(html_endi)
   159  		}
   160  		if len(url) > 0 {
   161  			w.Write(html_enda)
   162  		}
   163  
   164  		// advance
   165  		line = line[m[1]:]
   166  	}
   167  	commentEscape(w, line, nice)
   168  }
   169  
   170  func indentLen(s string) int {
   171  	i := 0
   172  	for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
   173  		i++
   174  	}
   175  	return i
   176  }
   177  
   178  func isBlank(s string) bool {
   179  	return len(s) == 0 || (len(s) == 1 && s[0] == '\n')
   180  }
   181  
   182  func commonPrefix(a, b string) string {
   183  	i := 0
   184  	for i < len(a) && i < len(b) && a[i] == b[i] {
   185  		i++
   186  	}
   187  	return a[0:i]
   188  }
   189  
   190  func unindent(block []string) {
   191  	if len(block) == 0 {
   192  		return
   193  	}
   194  
   195  	// compute maximum common white prefix
   196  	prefix := block[0][0:indentLen(block[0])]
   197  	for _, line := range block {
   198  		if !isBlank(line) {
   199  			prefix = commonPrefix(prefix, line[0:indentLen(line)])
   200  		}
   201  	}
   202  	n := len(prefix)
   203  
   204  	// remove
   205  	for i, line := range block {
   206  		if !isBlank(line) {
   207  			block[i] = line[n:]
   208  		}
   209  	}
   210  }
   211  
   212  // heading returns the trimmed line if it passes as a section heading;
   213  // otherwise it returns the empty string.
   214  func heading(line string) string {
   215  	line = strings.TrimSpace(line)
   216  	if len(line) == 0 {
   217  		return ""
   218  	}
   219  
   220  	// a heading must start with an uppercase letter
   221  	r, _ := utf8.DecodeRuneInString(line)
   222  	if !unicode.IsLetter(r) || !unicode.IsUpper(r) {
   223  		return ""
   224  	}
   225  
   226  	// it must end in a letter or digit:
   227  	r, _ = utf8.DecodeLastRuneInString(line)
   228  	if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
   229  		return ""
   230  	}
   231  
   232  	// exclude lines with illegal characters. we allow "(),"
   233  	if strings.ContainsAny(line, ";:!?+*/=[]{}_^°&§~%#@<\">\\") {
   234  		return ""
   235  	}
   236  
   237  	// allow "'" for possessive "'s" only
   238  	for b := line; ; {
   239  		var ok bool
   240  		if _, b, ok = strings.Cut(b, "'"); !ok {
   241  			break
   242  		}
   243  		if b != "s" && !strings.HasPrefix(b, "s ") {
   244  			return "" // ' not followed by s and then end-of-word
   245  		}
   246  	}
   247  
   248  	// allow "." when followed by non-space
   249  	for b := line; ; {
   250  		var ok bool
   251  		if _, b, ok = strings.Cut(b, "."); !ok {
   252  			break
   253  		}
   254  		if b == "" || strings.HasPrefix(b, " ") {
   255  			return "" // not followed by non-space
   256  		}
   257  	}
   258  
   259  	return line
   260  }
   261  
   262  type op int
   263  
   264  const (
   265  	opPara op = iota
   266  	opHead
   267  	opPre
   268  )
   269  
   270  type block struct {
   271  	op    op
   272  	lines []string
   273  }
   274  
   275  var nonAlphaNumRx = lazyregexp.New(`[^a-zA-Z0-9]`)
   276  
   277  func anchorID(line string) string {
   278  	// Add a "hdr-" prefix to avoid conflicting with IDs used for package symbols.
   279  	return "hdr-" + nonAlphaNumRx.ReplaceAllString(line, "_")
   280  }
   281  
   282  // ToHTML converts comment text to formatted HTML.
   283  // The comment was prepared by DocReader,
   284  // so it is known not to have leading, trailing blank lines
   285  // nor to have trailing spaces at the end of lines.
   286  // The comment markers have already been removed.
   287  //
   288  // Each span of unindented non-blank lines is converted into
   289  // a single paragraph. There is one exception to the rule: a span that
   290  // consists of a single line, is followed by another paragraph span,
   291  // begins with a capital letter, and contains no punctuation
   292  // other than parentheses and commas is formatted as a heading.
   293  //
   294  // A span of indented lines is converted into a <pre> block,
   295  // with the common indent prefix removed.
   296  //
   297  // URLs in the comment text are converted into links; if the URL also appears
   298  // in the words map, the link is taken from the map (if the corresponding map
   299  // value is the empty string, the URL is not converted into a link).
   300  //
   301  // A pair of (consecutive) backticks (`) is converted to a unicode left quote (“), and a pair of (consecutive)
   302  // single quotes (') is converted to a unicode right quote (”).
   303  //
   304  // Go identifiers that appear in the words map are italicized; if the corresponding
   305  // map value is not the empty string, it is considered a URL and the word is converted
   306  // into a link.
   307  func ToHTML(w io.Writer, text string, words map[string]string) {
   308  	for _, b := range blocks(text) {
   309  		switch b.op {
   310  		case opPara:
   311  			w.Write(html_p)
   312  			for _, line := range b.lines {
   313  				emphasize(w, line, words, true)
   314  			}
   315  			w.Write(html_endp)
   316  		case opHead:
   317  			w.Write(html_h)
   318  			id := ""
   319  			for _, line := range b.lines {
   320  				if id == "" {
   321  					id = anchorID(line)
   322  					w.Write([]byte(id))
   323  					w.Write(html_hq)
   324  				}
   325  				commentEscape(w, line, true)
   326  			}
   327  			if id == "" {
   328  				w.Write(html_hq)
   329  			}
   330  			w.Write(html_endh)
   331  		case opPre:
   332  			w.Write(html_pre)
   333  			for _, line := range b.lines {
   334  				emphasize(w, line, nil, false)
   335  			}
   336  			w.Write(html_endpre)
   337  		}
   338  	}
   339  }
   340  
   341  func blocks(text string) []block {
   342  	var (
   343  		out  []block
   344  		para []string
   345  
   346  		lastWasBlank   = false
   347  		lastWasHeading = false
   348  	)
   349  
   350  	close := func() {
   351  		if para != nil {
   352  			out = append(out, block{opPara, para})
   353  			para = nil
   354  		}
   355  	}
   356  
   357  	lines := strings.SplitAfter(text, "\n")
   358  	unindent(lines)
   359  	for i := 0; i < len(lines); {
   360  		line := lines[i]
   361  		if isBlank(line) {
   362  			// close paragraph
   363  			close()
   364  			i++
   365  			lastWasBlank = true
   366  			continue
   367  		}
   368  		if indentLen(line) > 0 {
   369  			// close paragraph
   370  			close()
   371  
   372  			// count indented or blank lines
   373  			j := i + 1
   374  			for j < len(lines) && (isBlank(lines[j]) || indentLen(lines[j]) > 0) {
   375  				j++
   376  			}
   377  			// but not trailing blank lines
   378  			for j > i && isBlank(lines[j-1]) {
   379  				j--
   380  			}
   381  			pre := lines[i:j]
   382  			i = j
   383  
   384  			unindent(pre)
   385  
   386  			// put those lines in a pre block
   387  			out = append(out, block{opPre, pre})
   388  			lastWasHeading = false
   389  			continue
   390  		}
   391  
   392  		if lastWasBlank && !lastWasHeading && i+2 < len(lines) &&
   393  			isBlank(lines[i+1]) && !isBlank(lines[i+2]) && indentLen(lines[i+2]) == 0 {
   394  			// current line is non-blank, surrounded by blank lines
   395  			// and the next non-blank line is not indented: this
   396  			// might be a heading.
   397  			if head := heading(line); head != "" {
   398  				close()
   399  				out = append(out, block{opHead, []string{head}})
   400  				i += 2
   401  				lastWasHeading = true
   402  				continue
   403  			}
   404  		}
   405  
   406  		// open paragraph
   407  		lastWasBlank = false
   408  		lastWasHeading = false
   409  		para = append(para, lines[i])
   410  		i++
   411  	}
   412  	close()
   413  
   414  	return out
   415  }
   416  
   417  // ToText prepares comment text for presentation in textual output.
   418  // It wraps paragraphs of text to width or fewer Unicode code points
   419  // and then prefixes each line with the indent. In preformatted sections
   420  // (such as program text), it prefixes each non-blank line with preIndent.
   421  //
   422  // A pair of (consecutive) backticks (`) is converted to a unicode left quote (“), and a pair of (consecutive)
   423  // single quotes (') is converted to a unicode right quote (”).
   424  func ToText(w io.Writer, text string, indent, preIndent string, width int) {
   425  	l := lineWrapper{
   426  		out:    w,
   427  		width:  width,
   428  		indent: indent,
   429  	}
   430  	for _, b := range blocks(text) {
   431  		switch b.op {
   432  		case opPara:
   433  			// l.write will add leading newline if required
   434  			for _, line := range b.lines {
   435  				line = convertQuotes(line)
   436  				l.write(line)
   437  			}
   438  			l.flush()
   439  		case opHead:
   440  			w.Write(nl)
   441  			for _, line := range b.lines {
   442  				line = convertQuotes(line)
   443  				l.write(line + "\n")
   444  			}
   445  			l.flush()
   446  		case opPre:
   447  			w.Write(nl)
   448  			for _, line := range b.lines {
   449  				if isBlank(line) {
   450  					w.Write([]byte("\n"))
   451  				} else {
   452  					w.Write([]byte(preIndent))
   453  					w.Write([]byte(line))
   454  				}
   455  			}
   456  		}
   457  	}
   458  }
   459  
   460  type lineWrapper struct {
   461  	out       io.Writer
   462  	printed   bool
   463  	width     int
   464  	indent    string
   465  	n         int
   466  	pendSpace int
   467  }
   468  
   469  var nl = []byte("\n")
   470  var space = []byte(" ")
   471  var prefix = []byte("// ")
   472  
   473  func (l *lineWrapper) write(text string) {
   474  	if l.n == 0 && l.printed {
   475  		l.out.Write(nl) // blank line before new paragraph
   476  	}
   477  	l.printed = true
   478  
   479  	needsPrefix := false
   480  	isComment := strings.HasPrefix(text, "//")
   481  	for _, f := range strings.Fields(text) {
   482  		w := utf8.RuneCountInString(f)
   483  		// wrap if line is too long
   484  		if l.n > 0 && l.n+l.pendSpace+w > l.width {
   485  			l.out.Write(nl)
   486  			l.n = 0
   487  			l.pendSpace = 0
   488  			needsPrefix = isComment && !strings.HasPrefix(f, "//")
   489  		}
   490  		if l.n == 0 {
   491  			l.out.Write([]byte(l.indent))
   492  		}
   493  		if needsPrefix {
   494  			l.out.Write(prefix)
   495  			needsPrefix = false
   496  		}
   497  		l.out.Write(space[:l.pendSpace])
   498  		l.out.Write([]byte(f))
   499  		l.n += l.pendSpace + w
   500  		l.pendSpace = 1
   501  	}
   502  }
   503  
   504  func (l *lineWrapper) flush() {
   505  	if l.n == 0 {
   506  		return
   507  	}
   508  	l.out.Write(nl)
   509  	l.pendSpace = 0
   510  	l.n = 0
   511  }
   512  

View as plain text