aboutsummaryrefslogtreecommitdiff
path: root/src/cmd/vendor/golang.org/x/build/relnote/relnote.go
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd/vendor/golang.org/x/build/relnote/relnote.go')
-rw-r--r--src/cmd/vendor/golang.org/x/build/relnote/relnote.go420
1 files changed, 420 insertions, 0 deletions
diff --git a/src/cmd/vendor/golang.org/x/build/relnote/relnote.go b/src/cmd/vendor/golang.org/x/build/relnote/relnote.go
new file mode 100644
index 0000000000..63791a7036
--- /dev/null
+++ b/src/cmd/vendor/golang.org/x/build/relnote/relnote.go
@@ -0,0 +1,420 @@
+// Copyright 2023 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 relnote supports working with release notes.
+//
+// Its main feature is the ability to merge Markdown fragments into a single
+// document. (See [Merge].)
+//
+// This package has minimal imports, so that it can be vendored into the
+// main go repo.
+package relnote
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "path"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+
+ md "rsc.io/markdown"
+)
+
+// NewParser returns a properly configured Markdown parser.
+func NewParser() *md.Parser {
+ var p md.Parser
+ p.HeadingIDs = true
+ return &p
+}
+
+// CheckFragment reports problems in a release-note fragment.
+func CheckFragment(data string) error {
+ doc := NewParser().Parse(data)
+ if len(doc.Blocks) == 0 {
+ return errors.New("empty content")
+ }
+ // Check that the content of the document contains either a TODO or at least one sentence.
+ txt := text(doc)
+ if !strings.Contains(txt, "TODO") && !strings.ContainsAny(txt, ".?!") {
+ return errors.New("needs a TODO or a sentence")
+ }
+ return nil
+}
+
+// text returns all the text in a block, without any formatting.
+func text(b md.Block) string {
+ switch b := b.(type) {
+ case *md.Document:
+ return blocksText(b.Blocks)
+ case *md.Heading:
+ return text(b.Text)
+ case *md.Text:
+ return inlineText(b.Inline)
+ case *md.CodeBlock:
+ return strings.Join(b.Text, "\n")
+ case *md.HTMLBlock:
+ return strings.Join(b.Text, "\n")
+ case *md.List:
+ return blocksText(b.Items)
+ case *md.Item:
+ return blocksText(b.Blocks)
+ case *md.Empty:
+ return ""
+ case *md.Paragraph:
+ return text(b.Text)
+ case *md.Quote:
+ return blocksText(b.Blocks)
+ case *md.ThematicBreak:
+ return "---"
+ default:
+ panic(fmt.Sprintf("unknown block type %T", b))
+ }
+}
+
+// blocksText returns all the text in a slice of block nodes.
+func blocksText(bs []md.Block) string {
+ var d strings.Builder
+ for _, b := range bs {
+ io.WriteString(&d, text(b))
+ fmt.Fprintln(&d)
+ }
+ return d.String()
+}
+
+// inlineText returns all the next in a slice of inline nodes.
+func inlineText(ins []md.Inline) string {
+ var buf bytes.Buffer
+ for _, in := range ins {
+ in.PrintText(&buf)
+ }
+ return buf.String()
+}
+
+// Merge combines the markdown documents (files ending in ".md") in the tree rooted
+// at fs into a single document.
+// The blocks of the documents are concatenated in lexicographic order by filename.
+// Heading with no content are removed.
+// The link keys must be unique, and are combined into a single map.
+//
+// Files in the "minor changes" directory (the unique directory matching the glob
+// "*stdlib/*minor") are named after the package to which they refer, and will have
+// the package heading inserted automatically.
+func Merge(fsys fs.FS) (*md.Document, error) {
+ filenames, err := sortedMarkdownFilenames(fsys)
+ if err != nil {
+ return nil, err
+ }
+ doc := &md.Document{Links: map[string]*md.Link{}}
+ var prevPkg string // previous stdlib package, if any
+ for _, filename := range filenames {
+ newdoc, err := parseMarkdownFile(fsys, filename)
+ if err != nil {
+ return nil, err
+ }
+ if len(newdoc.Blocks) == 0 {
+ continue
+ }
+ if len(doc.Blocks) > 0 {
+ // If this is the first file of a new stdlib package under the "Minor changes
+ // to the library" section, insert a heading for the package.
+ pkg := stdlibPackage(filename)
+ if pkg != "" && pkg != prevPkg {
+ h := stdlibPackageHeading(pkg, lastBlock(doc).Pos().EndLine)
+ doc.Blocks = append(doc.Blocks, h)
+ }
+ prevPkg = pkg
+ // Put a blank line between the current and new blocks, so that the end
+ // of a file acts as a blank line.
+ lastLine := lastBlock(doc).Pos().EndLine
+ delta := lastLine + 2 - newdoc.Blocks[0].Pos().StartLine
+ for _, b := range newdoc.Blocks {
+ addLines(b, delta)
+ }
+ }
+ // Append non-empty blocks to the result document.
+ for _, b := range newdoc.Blocks {
+ if _, ok := b.(*md.Empty); !ok {
+ doc.Blocks = append(doc.Blocks, b)
+ }
+ }
+ // Merge link references.
+ for key, link := range newdoc.Links {
+ if doc.Links[key] != nil {
+ return nil, fmt.Errorf("duplicate link reference %q; second in %s", key, filename)
+ }
+ doc.Links[key] = link
+ }
+ }
+ // Remove headings with empty contents.
+ doc.Blocks = removeEmptySections(doc.Blocks)
+ if len(doc.Blocks) > 0 && len(doc.Links) > 0 {
+ // Add a blank line to separate the links.
+ lastPos := lastBlock(doc).Pos()
+ lastPos.StartLine += 2
+ lastPos.EndLine += 2
+ doc.Blocks = append(doc.Blocks, &md.Empty{Position: lastPos})
+ }
+ return doc, nil
+}
+
+// stdlibPackage returns the standard library package for the given filename.
+// If the filename does not represent a package, it returns the empty string.
+// A filename represents package P if it is in a directory matching the glob
+// "*stdlib/*minor/P".
+func stdlibPackage(filename string) string {
+ dir, rest, _ := strings.Cut(filename, "/")
+ if !strings.HasSuffix(dir, "stdlib") {
+ return ""
+ }
+ dir, rest, _ = strings.Cut(rest, "/")
+ if !strings.HasSuffix(dir, "minor") {
+ return ""
+ }
+ pkg := path.Dir(rest)
+ if pkg == "." {
+ return ""
+ }
+ return pkg
+}
+
+func stdlibPackageHeading(pkg string, lastLine int) *md.Heading {
+ line := lastLine + 2
+ pos := md.Position{StartLine: line, EndLine: line}
+ return &md.Heading{
+ Position: pos,
+ Level: 4,
+ Text: &md.Text{
+ Position: pos,
+ Inline: []md.Inline{
+ &md.Link{
+ Inner: []md.Inline{&md.Plain{Text: pkg}},
+ URL: "/pkg/" + pkg + "/",
+ },
+ },
+ },
+ }
+}
+
+// removeEmptySections removes headings with no content. A heading has no content
+// if there are no blocks between it and the next heading at the same level, or the
+// end of the document.
+func removeEmptySections(bs []md.Block) []md.Block {
+ res := bs[:0]
+ delta := 0 // number of lines by which to adjust positions
+
+ // Remove preceding headings at same or higher level; they are empty.
+ rem := func(level int) {
+ for len(res) > 0 {
+ last := res[len(res)-1]
+ if lh, ok := last.(*md.Heading); ok && lh.Level >= level {
+ res = res[:len(res)-1]
+ // Adjust subsequent block positions by the size of this block
+ // plus 1 for the blank line between headings.
+ delta += lh.EndLine - lh.StartLine + 2
+ } else {
+ break
+ }
+ }
+ }
+
+ for _, b := range bs {
+ if h, ok := b.(*md.Heading); ok {
+ rem(h.Level)
+ }
+ addLines(b, -delta)
+ res = append(res, b)
+ }
+ // Remove empty headings at the end of the document.
+ rem(1)
+ return res
+}
+
+func sortedMarkdownFilenames(fsys fs.FS) ([]string, error) {
+ var filenames []string
+ err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() && strings.HasSuffix(path, ".md") {
+ filenames = append(filenames, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ // '.' comes before '/', which comes before alphanumeric characters.
+ // So just sorting the list will put a filename like "net.md" before
+ // the directory "net". That is what we want.
+ slices.Sort(filenames)
+ return filenames, nil
+}
+
+// lastBlock returns the last block in the document.
+// It panics if the document has no blocks.
+func lastBlock(doc *md.Document) md.Block {
+ return doc.Blocks[len(doc.Blocks)-1]
+}
+
+// addLines adds n lines to the position of b.
+// n can be negative.
+func addLines(b md.Block, n int) {
+ pos := position(b)
+ pos.StartLine += n
+ pos.EndLine += n
+}
+
+func position(b md.Block) *md.Position {
+ switch b := b.(type) {
+ case *md.Heading:
+ return &b.Position
+ case *md.Text:
+ return &b.Position
+ case *md.CodeBlock:
+ return &b.Position
+ case *md.HTMLBlock:
+ return &b.Position
+ case *md.List:
+ return &b.Position
+ case *md.Item:
+ return &b.Position
+ case *md.Empty:
+ return &b.Position
+ case *md.Paragraph:
+ return &b.Position
+ case *md.Quote:
+ return &b.Position
+ case *md.ThematicBreak:
+ return &b.Position
+ default:
+ panic(fmt.Sprintf("unknown block type %T", b))
+ }
+}
+
+func parseMarkdownFile(fsys fs.FS, path string) (*md.Document, error) {
+ f, err := fsys.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ data, err := io.ReadAll(f)
+ if err != nil {
+ return nil, err
+ }
+ in := string(data)
+ doc := NewParser().Parse(in)
+ return doc, nil
+}
+
+// An APIFeature is a symbol mentioned in an API file,
+// like the ones in the main go repo in the api directory.
+type APIFeature struct {
+ Package string // package that the feature is in
+ Feature string // everything about the feature other than the package
+ Issue int // the issue that introduced the feature, or 0 if none
+}
+
+var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^,]+), ([^#]*)(#\d+)?$`)
+
+// parseAPIFile parses a file in the api format and returns a list of the file's features.
+// A feature is represented by a single line that looks like
+//
+// PKG WORDS #ISSUE
+func parseAPIFile(fsys fs.FS, filename string) ([]APIFeature, error) {
+ f, err := fsys.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ var features []APIFeature
+ scan := bufio.NewScanner(f)
+ for scan.Scan() {
+ line := strings.TrimSpace(scan.Text())
+ if line == "" {
+ continue
+ }
+ matches := apiFileLineRegexp.FindStringSubmatch(line)
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("%s: malformed line %q", filename, line)
+ }
+ f := APIFeature{
+ Package: matches[1],
+ Feature: strings.TrimSpace(matches[2]),
+ }
+ if len(matches) > 3 && len(matches[3]) > 0 {
+ var err error
+ f.Issue, err = strconv.Atoi(matches[3][1:]) // skip leading '#'
+ if err != nil {
+ return nil, err
+ }
+ }
+ features = append(features, f)
+ }
+ if scan.Err() != nil {
+ return nil, scan.Err()
+ }
+ return features, nil
+}
+
+// GroupAPIFeaturesByFile returns a map of the given features keyed by
+// the doc filename that they are associated with.
+// A feature with package P and issue N should be documented in the file
+// "P/N.md".
+func GroupAPIFeaturesByFile(fs []APIFeature) (map[string][]APIFeature, error) {
+ m := map[string][]APIFeature{}
+ for _, f := range fs {
+ if f.Issue == 0 {
+ return nil, fmt.Errorf("%+v: zero issue", f)
+ }
+ filename := fmt.Sprintf("%s/%d.md", f.Package, f.Issue)
+ m[filename] = append(m[filename], f)
+ }
+ return m, nil
+}
+
+// CheckAPIFile reads the api file at filename in apiFS, and checks the corresponding
+// release-note files under docFS. It checks that the files exist and that they have
+// some minimal content (see [CheckFragment]).
+func CheckAPIFile(apiFS fs.FS, filename string, docFS fs.FS) error {
+ features, err := parseAPIFile(apiFS, filename)
+ if err != nil {
+ return err
+ }
+ byFile, err := GroupAPIFeaturesByFile(features)
+ if err != nil {
+ return err
+ }
+ var filenames []string
+ for fn := range byFile {
+ filenames = append(filenames, fn)
+ }
+ slices.Sort(filenames)
+ var errs []error
+ for _, filename := range filenames {
+ // TODO(jba): check that the file mentions each feature?
+ if err := checkFragmentFile(docFS, filename); err != nil {
+ errs = append(errs, fmt.Errorf("%s: %v", filename, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func checkFragmentFile(fsys fs.FS, filename string) error {
+ f, err := fsys.Open(filename)
+ if err != nil {
+ if errors.Is(err, fs.ErrNotExist) {
+ err = fs.ErrNotExist
+ }
+ return err
+ }
+ defer f.Close()
+ data, err := io.ReadAll(f)
+ return CheckFragment(string(data))
+}