// 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) 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 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 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)) }