Source file src/cmd/vendor/golang.org/x/build/relnote/relnote.go

     1  // Copyright 2023 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  // Package relnote supports working with release notes.
     6  //
     7  // Its main feature is the ability to merge Markdown fragments into a single
     8  // document. (See [Merge].)
     9  //
    10  // This package has minimal imports, so that it can be vendored into the
    11  // main go repo.
    12  package relnote
    13  
    14  import (
    15  	"bufio"
    16  	"bytes"
    17  	"errors"
    18  	"fmt"
    19  	"io"
    20  	"io/fs"
    21  	"path"
    22  	"regexp"
    23  	"slices"
    24  	"strconv"
    25  	"strings"
    26  
    27  	md "rsc.io/markdown"
    28  )
    29  
    30  // NewParser returns a properly configured Markdown parser.
    31  func NewParser() *md.Parser {
    32  	var p md.Parser
    33  	p.HeadingIDs = true
    34  	return &p
    35  }
    36  
    37  // CheckFragment reports problems in a release-note fragment.
    38  func CheckFragment(data string) error {
    39  	doc := NewParser().Parse(data)
    40  	// Check that the content of the document contains either a TODO or at least one sentence.
    41  	txt := ""
    42  	if len(doc.Blocks) > 0 {
    43  		txt = text(doc)
    44  	}
    45  	if !strings.Contains(txt, "TODO") && !strings.ContainsAny(txt, ".?!") {
    46  		return errors.New("File must contain a complete sentence or a TODO.")
    47  	}
    48  	return nil
    49  }
    50  
    51  // text returns all the text in a block, without any formatting.
    52  func text(b md.Block) string {
    53  	switch b := b.(type) {
    54  	case *md.Document:
    55  		return blocksText(b.Blocks)
    56  	case *md.Heading:
    57  		return text(b.Text)
    58  	case *md.Text:
    59  		return inlineText(b.Inline)
    60  	case *md.CodeBlock:
    61  		return strings.Join(b.Text, "\n")
    62  	case *md.HTMLBlock:
    63  		return strings.Join(b.Text, "\n")
    64  	case *md.List:
    65  		return blocksText(b.Items)
    66  	case *md.Item:
    67  		return blocksText(b.Blocks)
    68  	case *md.Empty:
    69  		return ""
    70  	case *md.Paragraph:
    71  		return text(b.Text)
    72  	case *md.Quote:
    73  		return blocksText(b.Blocks)
    74  	case *md.ThematicBreak:
    75  		return "---"
    76  	default:
    77  		panic(fmt.Sprintf("unknown block type %T", b))
    78  	}
    79  }
    80  
    81  // blocksText returns all the text in a slice of block nodes.
    82  func blocksText(bs []md.Block) string {
    83  	var d strings.Builder
    84  	for _, b := range bs {
    85  		io.WriteString(&d, text(b))
    86  		fmt.Fprintln(&d)
    87  	}
    88  	return d.String()
    89  }
    90  
    91  // inlineText returns all the next in a slice of inline nodes.
    92  func inlineText(ins []md.Inline) string {
    93  	var buf bytes.Buffer
    94  	for _, in := range ins {
    95  		in.PrintText(&buf)
    96  	}
    97  	return buf.String()
    98  }
    99  
   100  // Merge combines the markdown documents (files ending in ".md") in the tree rooted
   101  // at fs into a single document.
   102  // The blocks of the documents are concatenated in lexicographic order by filename.
   103  // Heading with no content are removed.
   104  // The link keys must be unique, and are combined into a single map.
   105  //
   106  // Files in the "minor changes" directory (the unique directory matching the glob
   107  // "*stdlib/*minor") are named after the package to which they refer, and will have
   108  // the package heading inserted automatically and links to other standard library
   109  // symbols expanded automatically. For example, if a file *stdlib/minor/bytes/f.md
   110  // contains the text
   111  //
   112  //	[Reader] implements [io.Reader].
   113  //
   114  // then that will become
   115  //
   116  //	[Reader](/pkg/bytes#Reader) implements [io.Reader](/pkg/io#Reader).
   117  func Merge(fsys fs.FS) (*md.Document, error) {
   118  	filenames, err := sortedMarkdownFilenames(fsys)
   119  	if err != nil {
   120  		return nil, err
   121  	}
   122  	doc := &md.Document{Links: map[string]*md.Link{}}
   123  	var prevPkg string // previous stdlib package, if any
   124  	for _, filename := range filenames {
   125  		newdoc, err := parseMarkdownFile(fsys, filename)
   126  		if err != nil {
   127  			return nil, err
   128  		}
   129  		if len(newdoc.Blocks) == 0 {
   130  			continue
   131  		}
   132  		pkg := stdlibPackage(filename)
   133  		// Autolink Go symbols.
   134  		addSymbolLinks(newdoc, pkg)
   135  		if len(doc.Blocks) > 0 {
   136  			// If this is the first file of a new stdlib package under the "Minor changes
   137  			// to the library" section, insert a heading for the package.
   138  			if pkg != "" && pkg != prevPkg {
   139  				h := stdlibPackageHeading(pkg, lastBlock(doc).Pos().EndLine)
   140  				doc.Blocks = append(doc.Blocks, h)
   141  			}
   142  			prevPkg = pkg
   143  			// Put a blank line between the current and new blocks, so that the end
   144  			// of a file acts as a blank line.
   145  			lastLine := lastBlock(doc).Pos().EndLine
   146  			delta := lastLine + 2 - newdoc.Blocks[0].Pos().StartLine
   147  			for _, b := range newdoc.Blocks {
   148  				addLines(b, delta)
   149  			}
   150  		}
   151  		// Append non-empty blocks to the result document.
   152  		for _, b := range newdoc.Blocks {
   153  			if _, ok := b.(*md.Empty); !ok {
   154  				doc.Blocks = append(doc.Blocks, b)
   155  			}
   156  		}
   157  		// Merge link references.
   158  		for key, link := range newdoc.Links {
   159  			if doc.Links[key] != nil {
   160  				return nil, fmt.Errorf("duplicate link reference %q; second in %s", key, filename)
   161  			}
   162  			doc.Links[key] = link
   163  		}
   164  	}
   165  	// Remove headings with empty contents.
   166  	doc.Blocks = removeEmptySections(doc.Blocks)
   167  	if len(doc.Blocks) > 0 && len(doc.Links) > 0 {
   168  		// Add a blank line to separate the links.
   169  		lastPos := lastBlock(doc).Pos()
   170  		lastPos.StartLine += 2
   171  		lastPos.EndLine += 2
   172  		doc.Blocks = append(doc.Blocks, &md.Empty{Position: lastPos})
   173  	}
   174  	return doc, nil
   175  }
   176  
   177  // stdlibPackage returns the standard library package for the given filename.
   178  // If the filename does not represent a package, it returns the empty string.
   179  // A filename represents package P if it is in a directory matching the glob
   180  // "*stdlib/*minor/P".
   181  func stdlibPackage(filename string) string {
   182  	dir, rest, _ := strings.Cut(filename, "/")
   183  	if !strings.HasSuffix(dir, "stdlib") {
   184  		return ""
   185  	}
   186  	dir, rest, _ = strings.Cut(rest, "/")
   187  	if !strings.HasSuffix(dir, "minor") {
   188  		return ""
   189  	}
   190  	pkg := path.Dir(rest)
   191  	if pkg == "." {
   192  		return ""
   193  	}
   194  	return pkg
   195  }
   196  
   197  func stdlibPackageHeading(pkg string, lastLine int) *md.Heading {
   198  	line := lastLine + 2
   199  	pos := md.Position{StartLine: line, EndLine: line}
   200  	return &md.Heading{
   201  		Position: pos,
   202  		Level:    4,
   203  		Text: &md.Text{
   204  			Position: pos,
   205  			Inline: []md.Inline{
   206  				&md.Link{
   207  					Inner: []md.Inline{&md.Code{Text: pkg}},
   208  					URL:   "/pkg/" + pkg + "/",
   209  				},
   210  			},
   211  		},
   212  	}
   213  }
   214  
   215  // removeEmptySections removes headings with no content. A heading has no content
   216  // if there are no blocks between it and the next heading at the same level, or the
   217  // end of the document.
   218  func removeEmptySections(bs []md.Block) []md.Block {
   219  	res := bs[:0]
   220  	delta := 0 // number of lines by which to adjust positions
   221  
   222  	// Remove preceding headings at same or higher level; they are empty.
   223  	rem := func(level int) {
   224  		for len(res) > 0 {
   225  			last := res[len(res)-1]
   226  			if lh, ok := last.(*md.Heading); ok && lh.Level >= level {
   227  				res = res[:len(res)-1]
   228  				// Adjust subsequent block positions by the size of this block
   229  				// plus 1 for the blank line between headings.
   230  				delta += lh.EndLine - lh.StartLine + 2
   231  			} else {
   232  				break
   233  			}
   234  		}
   235  	}
   236  
   237  	for _, b := range bs {
   238  		if h, ok := b.(*md.Heading); ok {
   239  			rem(h.Level)
   240  		}
   241  		addLines(b, -delta)
   242  		res = append(res, b)
   243  	}
   244  	// Remove empty headings at the end of the document.
   245  	rem(1)
   246  	return res
   247  }
   248  
   249  func sortedMarkdownFilenames(fsys fs.FS) ([]string, error) {
   250  	var filenames []string
   251  	err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
   252  		if err != nil {
   253  			return err
   254  		}
   255  		if !d.IsDir() && strings.HasSuffix(path, ".md") {
   256  			filenames = append(filenames, path)
   257  		}
   258  		return nil
   259  	})
   260  	if err != nil {
   261  		return nil, err
   262  	}
   263  	// '.' comes before '/', which comes before alphanumeric characters.
   264  	// So just sorting the list will put a filename like "net.md" before
   265  	// the directory "net". That is what we want.
   266  	slices.Sort(filenames)
   267  	return filenames, nil
   268  }
   269  
   270  // lastBlock returns the last block in the document.
   271  // It panics if the document has no blocks.
   272  func lastBlock(doc *md.Document) md.Block {
   273  	return doc.Blocks[len(doc.Blocks)-1]
   274  }
   275  
   276  // addLines adds n lines to the position of b.
   277  // n can be negative.
   278  func addLines(b md.Block, n int) {
   279  	pos := position(b)
   280  	pos.StartLine += n
   281  	pos.EndLine += n
   282  }
   283  
   284  func position(b md.Block) *md.Position {
   285  	switch b := b.(type) {
   286  	case *md.Heading:
   287  		return &b.Position
   288  	case *md.Text:
   289  		return &b.Position
   290  	case *md.CodeBlock:
   291  		return &b.Position
   292  	case *md.HTMLBlock:
   293  		return &b.Position
   294  	case *md.List:
   295  		return &b.Position
   296  	case *md.Item:
   297  		return &b.Position
   298  	case *md.Empty:
   299  		return &b.Position
   300  	case *md.Paragraph:
   301  		return &b.Position
   302  	case *md.Quote:
   303  		return &b.Position
   304  	case *md.ThematicBreak:
   305  		return &b.Position
   306  	default:
   307  		panic(fmt.Sprintf("unknown block type %T", b))
   308  	}
   309  }
   310  
   311  func parseMarkdownFile(fsys fs.FS, path string) (*md.Document, error) {
   312  	f, err := fsys.Open(path)
   313  	if err != nil {
   314  		return nil, err
   315  	}
   316  	defer f.Close()
   317  	data, err := io.ReadAll(f)
   318  	if err != nil {
   319  		return nil, err
   320  	}
   321  	in := string(data)
   322  	doc := NewParser().Parse(in)
   323  	return doc, nil
   324  }
   325  
   326  // An APIFeature is a symbol mentioned in an API file,
   327  // like the ones in the main go repo in the api directory.
   328  type APIFeature struct {
   329  	Package string // package that the feature is in
   330  	Build   string // build that the symbol is relevant for (e.g. GOOS, GOARCH)
   331  	Feature string // everything about the feature other than the package
   332  	Issue   int    // the issue that introduced the feature, or 0 if none
   333  }
   334  
   335  // This regexp has four capturing groups: package, build, feature and issue.
   336  var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^ \t]+)[ \t]*(\([^)]+\))?, ([^#]*)(#\d+)?$`)
   337  
   338  // parseAPIFile parses a file in the api format and returns a list of the file's features.
   339  // A feature is represented by a single line that looks like
   340  //
   341  //	pkg PKG (BUILD) FEATURE #ISSUE
   342  //
   343  // where the BUILD and ISSUE may be absent.
   344  func parseAPIFile(fsys fs.FS, filename string) ([]APIFeature, error) {
   345  	f, err := fsys.Open(filename)
   346  	if err != nil {
   347  		return nil, err
   348  	}
   349  	defer f.Close()
   350  	var features []APIFeature
   351  	scan := bufio.NewScanner(f)
   352  	for scan.Scan() {
   353  		line := strings.TrimSpace(scan.Text())
   354  		if line == "" || line[0] == '#' {
   355  			continue
   356  		}
   357  		matches := apiFileLineRegexp.FindStringSubmatch(line)
   358  		if len(matches) == 0 {
   359  			return nil, fmt.Errorf("%s: malformed line %q", filename, line)
   360  		}
   361  		if len(matches) != 5 {
   362  			return nil, fmt.Errorf("wrong number of matches for line %q", line)
   363  		}
   364  		f := APIFeature{
   365  			Package: matches[1],
   366  			Build:   matches[2],
   367  			Feature: strings.TrimSpace(matches[3]),
   368  		}
   369  		if issue := matches[4]; issue != "" {
   370  			var err error
   371  			f.Issue, err = strconv.Atoi(issue[1:]) // skip leading '#'
   372  			if err != nil {
   373  				return nil, err
   374  			}
   375  		}
   376  		features = append(features, f)
   377  	}
   378  	if scan.Err() != nil {
   379  		return nil, scan.Err()
   380  	}
   381  	return features, nil
   382  }
   383  
   384  // GroupAPIFeaturesByFile returns a map of the given features keyed by
   385  // the doc filename that they are associated with.
   386  // A feature with package P and issue N should be documented in the file
   387  // "P/N.md".
   388  func GroupAPIFeaturesByFile(fs []APIFeature) (map[string][]APIFeature, error) {
   389  	m := map[string][]APIFeature{}
   390  	for _, f := range fs {
   391  		if f.Issue == 0 {
   392  			return nil, fmt.Errorf("%+v: zero issue", f)
   393  		}
   394  		filename := fmt.Sprintf("%s/%d.md", f.Package, f.Issue)
   395  		m[filename] = append(m[filename], f)
   396  	}
   397  	return m, nil
   398  }
   399  
   400  // CheckAPIFile reads the api file at filename in apiFS, and checks the corresponding
   401  // release-note files under docFS. It checks that the files exist and that they have
   402  // some minimal content (see [CheckFragment]).
   403  // The docRoot argument is the path from the repo or project root to the root of docFS.
   404  // It is used only for error messages.
   405  func CheckAPIFile(apiFS fs.FS, filename string, docFS fs.FS, docRoot string) error {
   406  	features, err := parseAPIFile(apiFS, filename)
   407  	if err != nil {
   408  		return err
   409  	}
   410  	byFile, err := GroupAPIFeaturesByFile(features)
   411  	if err != nil {
   412  		return err
   413  	}
   414  	var filenames []string
   415  	for fn := range byFile {
   416  		filenames = append(filenames, fn)
   417  	}
   418  	slices.Sort(filenames)
   419  	mcDir, err := minorChangesDir(docFS)
   420  	if err != nil {
   421  		return err
   422  	}
   423  	var errs []error
   424  	for _, fn := range filenames {
   425  		// Use path.Join for consistency with io/fs pathnames.
   426  		fn = path.Join(mcDir, fn)
   427  		// TODO(jba): check that the file mentions each feature?
   428  		if err := checkFragmentFile(docFS, fn); err != nil {
   429  			errs = append(errs, fmt.Errorf("%s: %v\nSee doc/README.md for more information.", path.Join(docRoot, fn), err))
   430  		}
   431  	}
   432  	return errors.Join(errs...)
   433  }
   434  
   435  // minorChangesDir returns the unique directory in docFS that corresponds to the
   436  // "Minor changes to the standard library" section of the release notes.
   437  func minorChangesDir(docFS fs.FS) (string, error) {
   438  	dirs, err := fs.Glob(docFS, "*stdlib/*minor")
   439  	if err != nil {
   440  		return "", err
   441  	}
   442  	var bad string
   443  	if len(dirs) == 0 {
   444  		bad = "No"
   445  	} else if len(dirs) > 1 {
   446  		bad = "More than one"
   447  	}
   448  	if bad != "" {
   449  		return "", fmt.Errorf("%s directory matches *stdlib/*minor.\nThis shouldn't happen; please file a bug at https://go.dev/issues/new.",
   450  			bad)
   451  	}
   452  	return dirs[0], nil
   453  }
   454  
   455  func checkFragmentFile(fsys fs.FS, filename string) error {
   456  	f, err := fsys.Open(filename)
   457  	if err != nil {
   458  		if errors.Is(err, fs.ErrNotExist) {
   459  			err = errors.New("File does not exist. Every API change must have a corresponding release note file.")
   460  		}
   461  		return err
   462  	}
   463  	defer f.Close()
   464  	data, err := io.ReadAll(f)
   465  	return CheckFragment(string(data))
   466  }
   467  

View as plain text