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.
12package relnote
13
14import (
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.
31func 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.
38func 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.
52func 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.
82func 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.
92func 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).
117func 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".
181func 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
197func 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.
218func 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
249func 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.
272func 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.
278func addLines(b md.Block, n int) {
279	pos := position(b)
280	pos.StartLine += n
281	pos.EndLine += n
282}
283
284func 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
311func 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.
328type 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.
336var 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.
344func 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".
388func 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.
405func 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.
437func 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
455func 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