| // 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) | |
| // Check that the content of the document contains either a TODO or at least one sentence. | |
| txt := "" | |
| if len(doc.Blocks) > 0 { | |
| txt = text(doc) | |
| } | |
| if !strings.Contains(txt, "TODO") && !strings.ContainsAny(txt, ".?!") { | |
| return errors.New("File must contain a complete sentence or a TODO.") | |
| } | |
| 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 and links to other standard library | |
| // symbols expanded automatically. For example, if a file *stdlib/minor/bytes/f.md | |
| // contains the text | |
| // | |
| // [Reader] implements [io.Reader]. | |
| // | |
| // then that will become | |
| // | |
| // [Reader](/pkg/bytes#Reader) implements [io.Reader](/pkg/io#Reader). | |
| 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 | |
| } | |
| pkg := stdlibPackage(filename) | |
| // Autolink Go symbols. | |
| addSymbolLinks(newdoc, pkg) | |
| 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. | |
| 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.Code{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 | |
| Build string // build that the symbol is relevant for (e.g. GOOS, GOARCH) | |
| Feature string // everything about the feature other than the package | |
| Issue int // the issue that introduced the feature, or 0 if none | |
| } | |
| // This regexp has four capturing groups: package, build, feature and issue. | |
| var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^ \t]+)[ \t]*(\([^)]+\))?, ([^#]*)(#\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 PKG (BUILD) FEATURE #ISSUE | |
| // | |
| // where the BUILD and ISSUE may be absent. | |
| 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 == "" || line[0] == '#' { | |
| continue | |
| } | |
| matches := apiFileLineRegexp.FindStringSubmatch(line) | |
| if len(matches) == 0 { | |
| return nil, fmt.Errorf("%s: malformed line %q", filename, line) | |
| } | |
| if len(matches) != 5 { | |
| return nil, fmt.Errorf("wrong number of matches for line %q", line) | |
| } | |
| f := APIFeature{ | |
| Package: matches[1], | |
| Build: matches[2], | |
| Feature: strings.TrimSpace(matches[3]), | |
| } | |
| if issue := matches[4]; issue != "" { | |
| var err error | |
| f.Issue, err = strconv.Atoi(issue[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]). | |
| // The docRoot argument is the path from the repo or project root to the root of docFS. | |
| // It is used only for error messages. | |
| func CheckAPIFile(apiFS fs.FS, filename string, docFS fs.FS, docRoot string) 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) | |
| mcDir, err := minorChangesDir(docFS) | |
| if err != nil { | |
| return err | |
| } | |
| var errs []error | |
| for _, fn := range filenames { | |
| // Use path.Join for consistency with io/fs pathnames. | |
| fn = path.Join(mcDir, fn) | |
| // TODO(jba): check that the file mentions each feature? | |
| if err := checkFragmentFile(docFS, fn); err != nil { | |
| errs = append(errs, fmt.Errorf("%s: %v\nSee doc/README.md for more information.", path.Join(docRoot, fn), err)) | |
| } | |
| } | |
| return errors.Join(errs...) | |
| } | |
| // minorChangesDir returns the unique directory in docFS that corresponds to the | |
| // "Minor changes to the standard library" section of the release notes. | |
| func minorChangesDir(docFS fs.FS) (string, error) { | |
| dirs, err := fs.Glob(docFS, "*stdlib/*minor") | |
| if err != nil { | |
| return "", err | |
| } | |
| var bad string | |
| if len(dirs) == 0 { | |
| bad = "No" | |
| } else if len(dirs) > 1 { | |
| bad = "More than one" | |
| } | |
| if bad != "" { | |
| return "", fmt.Errorf("%s directory matches *stdlib/*minor.\nThis shouldn't happen; please file a bug at https://go.dev/issues/new.", | |
| bad) | |
| } | |
| return dirs[0], nil | |
| } | |
| func checkFragmentFile(fsys fs.FS, filename string) error { | |
| f, err := fsys.Open(filename) | |
| if err != nil { | |
| if errors.Is(err, fs.ErrNotExist) { | |
| err = errors.New("File does not exist. Every API change must have a corresponding release note file.") | |
| } | |
| return err | |
| } | |
| defer f.Close() | |
| data, err := io.ReadAll(f) | |
| if err != nil { | |
| return err | |
| } | |
| return CheckFragment(string(data)) | |
| } |