Source file src/go/doc/comment.go

Documentation: go/doc

     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  		i := strings.IndexRune(b, '\'')
   240  		if i < 0 {
   241  			break
   242  		}
   243  		if i+1 >= len(b) || b[i+1] != 's' || (i+2 < len(b) && b[i+2] != ' ') {
   244  			return "" // not followed by "s "
   245  		}
   246  		b = b[i+2:]
   247  	}
   248  
   249  	// allow "." when followed by non-space
   250  	for b := line; ; {
   251  		i := strings.IndexRune(b, '.')
   252  		if i < 0 {
   253  			break
   254  		}
   255  		if i+1 >= len(b) || b[i+1] == ' ' {
   256  			return "" // not followed by non-space
   257  		}
   258  		b = b[i+1:]
   259  	}
   260  
   261  	return line
   262  }
   263  
   264  type op int
   265  
   266  const (
   267  	opPara op = iota
   268  	opHead
   269  	opPre
   270  )
   271  
   272  type block struct {
   273  	op    op
   274  	lines []string
   275  }
   276  
   277  var nonAlphaNumRx = lazyregexp.New(`[^a-zA-Z0-9]`)
   278  
   279  func anchorID(line string) string {
   280  	// Add a "hdr-" prefix to avoid conflicting with IDs used for package symbols.
   281  	return "hdr-" + nonAlphaNumRx.ReplaceAllString(line, "_")
   282  }
   283  
   284  // ToHTML converts comment text to formatted HTML.
   285  // The comment was prepared by DocReader,
   286  // so it is known not to have leading, trailing blank lines
   287  // nor to have trailing spaces at the end of lines.
   288  // The comment markers have already been removed.
   289  //
   290  // Each span of unindented non-blank lines is converted into
   291  // a single paragraph. There is one exception to the rule: a span that
   292  // consists of a single line, is followed by another paragraph span,
   293  // begins with a capital letter, and contains no punctuation
   294  // other than parentheses and commas is formatted as a heading.
   295  //
   296  // A span of indented lines is converted into a <pre> block,
   297  // with the common indent prefix removed.
   298  //
   299  // URLs in the comment text are converted into links; if the URL also appears
   300  // in the words map, the link is taken from the map (if the corresponding map
   301  // value is the empty string, the URL is not converted into a link).
   302  //
   303  // Go identifiers that appear in the words map are italicized; if the corresponding
   304  // map value is not the empty string, it is considered a URL and the word is converted
   305  // into a link.
   306  func ToHTML(w io.Writer, text string, words map[string]string) {
   307  	for _, b := range blocks(text) {
   308  		switch b.op {
   309  		case opPara:
   310  			w.Write(html_p)
   311  			for _, line := range b.lines {
   312  				emphasize(w, line, words, true)
   313  			}
   314  			w.Write(html_endp)
   315  		case opHead:
   316  			w.Write(html_h)
   317  			id := ""
   318  			for _, line := range b.lines {
   319  				if id == "" {
   320  					id = anchorID(line)
   321  					w.Write([]byte(id))
   322  					w.Write(html_hq)
   323  				}
   324  				commentEscape(w, line, true)
   325  			}
   326  			if id == "" {
   327  				w.Write(html_hq)
   328  			}
   329  			w.Write(html_endh)
   330  		case opPre:
   331  			w.Write(html_pre)
   332  			for _, line := range b.lines {
   333  				emphasize(w, line, nil, false)
   334  			}
   335  			w.Write(html_endpre)
   336  		}
   337  	}
   338  }
   339  
   340  func blocks(text string) []block {
   341  	var (
   342  		out  []block
   343  		para []string
   344  
   345  		lastWasBlank   = false
   346  		lastWasHeading = false
   347  	)
   348  
   349  	close := func() {
   350  		if para != nil {
   351  			out = append(out, block{opPara, para})
   352  			para = nil
   353  		}
   354  	}
   355  
   356  	lines := strings.SplitAfter(text, "\n")
   357  	unindent(lines)
   358  	for i := 0; i < len(lines); {
   359  		line := lines[i]
   360  		if isBlank(line) {
   361  			// close paragraph
   362  			close()
   363  			i++
   364  			lastWasBlank = true
   365  			continue
   366  		}
   367  		if indentLen(line) > 0 {
   368  			// close paragraph
   369  			close()
   370  
   371  			// count indented or blank lines
   372  			j := i + 1
   373  			for j < len(lines) && (isBlank(lines[j]) || indentLen(lines[j]) > 0) {
   374  				j++
   375  			}
   376  			// but not trailing blank lines
   377  			for j > i && isBlank(lines[j-1]) {
   378  				j--
   379  			}
   380  			pre := lines[i:j]
   381  			i = j
   382  
   383  			unindent(pre)
   384  
   385  			// put those lines in a pre block
   386  			out = append(out, block{opPre, pre})
   387  			lastWasHeading = false
   388  			continue
   389  		}
   390  
   391  		if lastWasBlank && !lastWasHeading && i+2 < len(lines) &&
   392  			isBlank(lines[i+1]) && !isBlank(lines[i+2]) && indentLen(lines[i+2]) == 0 {
   393  			// current line is non-blank, surrounded by blank lines
   394  			// and the next non-blank line is not indented: this
   395  			// might be a heading.
   396  			if head := heading(line); head != "" {
   397  				close()
   398  				out = append(out, block{opHead, []string{head}})
   399  				i += 2
   400  				lastWasHeading = true
   401  				continue
   402  			}
   403  		}
   404  
   405  		// open paragraph
   406  		lastWasBlank = false
   407  		lastWasHeading = false
   408  		para = append(para, lines[i])
   409  		i++
   410  	}
   411  	close()
   412  
   413  	return out
   414  }
   415  
   416  // ToText prepares comment text for presentation in textual output.
   417  // It wraps paragraphs of text to width or fewer Unicode code points
   418  // and then prefixes each line with the indent. In preformatted sections
   419  // (such as program text), it prefixes each non-blank line with preIndent.
   420  func ToText(w io.Writer, text string, indent, preIndent string, width int) {
   421  	l := lineWrapper{
   422  		out:    w,
   423  		width:  width,
   424  		indent: indent,
   425  	}
   426  	for _, b := range blocks(text) {
   427  		switch b.op {
   428  		case opPara:
   429  			// l.write will add leading newline if required
   430  			for _, line := range b.lines {
   431  				line = convertQuotes(line)
   432  				l.write(line)
   433  			}
   434  			l.flush()
   435  		case opHead:
   436  			w.Write(nl)
   437  			for _, line := range b.lines {
   438  				line = convertQuotes(line)
   439  				l.write(line + "\n")
   440  			}
   441  			l.flush()
   442  		case opPre:
   443  			w.Write(nl)
   444  			for _, line := range b.lines {
   445  				if isBlank(line) {
   446  					w.Write([]byte("\n"))
   447  				} else {
   448  					w.Write([]byte(preIndent))
   449  					w.Write([]byte(line))
   450  				}
   451  			}
   452  		}
   453  	}
   454  }
   455  
   456  type lineWrapper struct {
   457  	out       io.Writer
   458  	printed   bool
   459  	width     int
   460  	indent    string
   461  	n         int
   462  	pendSpace int
   463  }
   464  
   465  var nl = []byte("\n")
   466  var space = []byte(" ")
   467  var prefix = []byte("// ")
   468  
   469  func (l *lineWrapper) write(text string) {
   470  	if l.n == 0 && l.printed {
   471  		l.out.Write(nl) // blank line before new paragraph
   472  	}
   473  	l.printed = true
   474  
   475  	needsPrefix := false
   476  	isComment := strings.HasPrefix(text, "//")
   477  	for _, f := range strings.Fields(text) {
   478  		w := utf8.RuneCountInString(f)
   479  		// wrap if line is too long
   480  		if l.n > 0 && l.n+l.pendSpace+w > l.width {
   481  			l.out.Write(nl)
   482  			l.n = 0
   483  			l.pendSpace = 0
   484  			needsPrefix = isComment
   485  		}
   486  		if l.n == 0 {
   487  			l.out.Write([]byte(l.indent))
   488  		}
   489  		if needsPrefix {
   490  			l.out.Write(prefix)
   491  			needsPrefix = false
   492  		}
   493  		l.out.Write(space[:l.pendSpace])
   494  		l.out.Write([]byte(f))
   495  		l.n += l.pendSpace + w
   496  		l.pendSpace = 1
   497  	}
   498  }
   499  
   500  func (l *lineWrapper) flush() {
   501  	if l.n == 0 {
   502  		return
   503  	}
   504  	l.out.Write(nl)
   505  	l.pendSpace = 0
   506  	l.n = 0
   507  }
   508  

View as plain text