1
2
3
4
5
6
7
8
9
10
11
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
31 func NewParser() *md.Parser {
32 var p md.Parser
33 p.HeadingIDs = true
34 return &p
35 }
36
37
38 func CheckFragment(data string) error {
39 doc := NewParser().Parse(data)
40
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
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
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
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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
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
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
134 addSymbolLinks(newdoc, pkg)
135 if len(doc.Blocks) > 0 {
136
137
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
144
145 lastLine := lastBlock(doc).Pos().EndLine
146 delta := lastLine + 2 - newdoc.Blocks[0].Pos().StartLine
147 if pkg != "" {
148
149
150 comment := &md.HTMLBlock{
151 Position: md.Position{StartLine: lastLine + 2, EndLine: lastLine + 2},
152 Text: []string{"<!-- " + filename + " -->"},
153 }
154 delta++
155 doc.Blocks = append(doc.Blocks, comment)
156 }
157 for _, b := range newdoc.Blocks {
158 addLines(b, delta)
159 }
160 }
161
162 for _, b := range newdoc.Blocks {
163 if _, ok := b.(*md.Empty); !ok {
164 doc.Blocks = append(doc.Blocks, b)
165 }
166 }
167
168 for key, link := range newdoc.Links {
169 if doc.Links[key] != nil {
170 return nil, fmt.Errorf("duplicate link reference %q; second in %s", key, filename)
171 }
172 doc.Links[key] = link
173 }
174 }
175
176 doc.Blocks = removeEmptySections(doc.Blocks)
177 if len(doc.Blocks) > 0 && len(doc.Links) > 0 {
178
179 lastPos := lastBlock(doc).Pos()
180 lastPos.StartLine += 2
181 lastPos.EndLine += 2
182 doc.Blocks = append(doc.Blocks, &md.Empty{Position: lastPos})
183 }
184 return doc, nil
185 }
186
187
188
189
190
191 func stdlibPackage(filename string) string {
192 dir, rest, _ := strings.Cut(filename, "/")
193 if !strings.HasSuffix(dir, "stdlib") {
194 return ""
195 }
196 dir, rest, _ = strings.Cut(rest, "/")
197 if !strings.HasSuffix(dir, "minor") {
198 return ""
199 }
200 pkg := path.Dir(rest)
201 if pkg == "." {
202 return ""
203 }
204 return pkg
205 }
206
207 func stdlibPackageHeading(pkg string, lastLine int) *md.Heading {
208 line := lastLine + 2
209 pos := md.Position{StartLine: line, EndLine: line}
210 return &md.Heading{
211 Position: pos,
212 Level: 4,
213 Text: &md.Text{
214 Position: pos,
215 Inline: []md.Inline{
216 &md.Link{
217 Inner: []md.Inline{&md.Code{Text: pkg}},
218 URL: "/pkg/" + pkg + "/",
219 },
220 },
221 },
222 }
223 }
224
225
226
227
228 func removeEmptySections(bs []md.Block) []md.Block {
229 res := bs[:0]
230 delta := 0
231
232
233 rem := func(level int) {
234 for len(res) > 0 {
235 last := res[len(res)-1]
236 if lh, ok := last.(*md.Heading); ok && lh.Level >= level {
237 res = res[:len(res)-1]
238
239
240 delta += lh.EndLine - lh.StartLine + 2
241 } else {
242 break
243 }
244 }
245 }
246
247 for _, b := range bs {
248 if h, ok := b.(*md.Heading); ok {
249 rem(h.Level)
250 }
251 addLines(b, -delta)
252 res = append(res, b)
253 }
254
255 rem(1)
256 return res
257 }
258
259 func sortedMarkdownFilenames(fsys fs.FS) ([]string, error) {
260 var filenames []string
261 err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
262 if err != nil {
263 return err
264 }
265 if !d.IsDir() && strings.HasSuffix(path, ".md") {
266 filenames = append(filenames, path)
267 }
268 return nil
269 })
270 if err != nil {
271 return nil, err
272 }
273
274
275
276 slices.Sort(filenames)
277 return filenames, nil
278 }
279
280
281
282 func lastBlock(doc *md.Document) md.Block {
283 return doc.Blocks[len(doc.Blocks)-1]
284 }
285
286
287
288 func addLines(b md.Block, n int) {
289 pos := position(b)
290 pos.StartLine += n
291 pos.EndLine += n
292 }
293
294 func position(b md.Block) *md.Position {
295 switch b := b.(type) {
296 case *md.Heading:
297 return &b.Position
298 case *md.Text:
299 return &b.Position
300 case *md.CodeBlock:
301 return &b.Position
302 case *md.HTMLBlock:
303 return &b.Position
304 case *md.List:
305 return &b.Position
306 case *md.Item:
307 return &b.Position
308 case *md.Empty:
309 return &b.Position
310 case *md.Paragraph:
311 return &b.Position
312 case *md.Quote:
313 return &b.Position
314 case *md.ThematicBreak:
315 return &b.Position
316 default:
317 panic(fmt.Sprintf("unknown block type %T", b))
318 }
319 }
320
321 func parseMarkdownFile(fsys fs.FS, path string) (*md.Document, error) {
322 f, err := fsys.Open(path)
323 if err != nil {
324 return nil, err
325 }
326 defer f.Close()
327 data, err := io.ReadAll(f)
328 if err != nil {
329 return nil, err
330 }
331 in := string(data)
332 doc := NewParser().Parse(in)
333 return doc, nil
334 }
335
336
337
338 type APIFeature struct {
339 Package string
340 Build string
341 Feature string
342 Issue int
343 }
344
345
346 var apiFileLineRegexp = regexp.MustCompile(`^pkg ([^ \t]+)[ \t]*(\([^)]+\))?, ([^#]*)(#\d+)?$`)
347
348
349
350
351
352
353
354 func parseAPIFile(fsys fs.FS, filename string) ([]APIFeature, error) {
355 f, err := fsys.Open(filename)
356 if err != nil {
357 return nil, err
358 }
359 defer f.Close()
360 var features []APIFeature
361 scan := bufio.NewScanner(f)
362 for scan.Scan() {
363 line := strings.TrimSpace(scan.Text())
364 if line == "" || line[0] == '#' {
365 continue
366 }
367 matches := apiFileLineRegexp.FindStringSubmatch(line)
368 if len(matches) == 0 {
369 return nil, fmt.Errorf("%s: malformed line %q", filename, line)
370 }
371 if len(matches) != 5 {
372 return nil, fmt.Errorf("wrong number of matches for line %q", line)
373 }
374 f := APIFeature{
375 Package: matches[1],
376 Build: matches[2],
377 Feature: strings.TrimSpace(matches[3]),
378 }
379 if issue := matches[4]; issue != "" {
380 var err error
381 f.Issue, err = strconv.Atoi(issue[1:])
382 if err != nil {
383 return nil, err
384 }
385 }
386 features = append(features, f)
387 }
388 if scan.Err() != nil {
389 return nil, scan.Err()
390 }
391 return features, nil
392 }
393
394
395
396
397
398 func GroupAPIFeaturesByFile(fs []APIFeature) (map[string][]APIFeature, error) {
399 m := map[string][]APIFeature{}
400 for _, f := range fs {
401 if f.Issue == 0 {
402 return nil, fmt.Errorf("%+v: zero issue", f)
403 }
404 filename := fmt.Sprintf("%s/%d.md", f.Package, f.Issue)
405 m[filename] = append(m[filename], f)
406 }
407 return m, nil
408 }
409
410
411
412
413
414
415 func CheckAPIFile(apiFS fs.FS, filename string, docFS fs.FS, docRoot string) error {
416 features, err := parseAPIFile(apiFS, filename)
417 if err != nil {
418 return err
419 }
420 byFile, err := GroupAPIFeaturesByFile(features)
421 if err != nil {
422 return err
423 }
424 var filenames []string
425 for fn := range byFile {
426 filenames = append(filenames, fn)
427 }
428 slices.Sort(filenames)
429 mcDir, err := minorChangesDir(docFS)
430 if err != nil {
431 return err
432 }
433 var errs []error
434 for _, fn := range filenames {
435
436 fn = path.Join(mcDir, fn)
437
438 if err := checkFragmentFile(docFS, fn); err != nil {
439 errs = append(errs, fmt.Errorf("%s: %v\nSee doc/README.md for more information.", path.Join(docRoot, fn), err))
440 }
441 }
442 return errors.Join(errs...)
443 }
444
445
446
447 func minorChangesDir(docFS fs.FS) (string, error) {
448 dirs, err := fs.Glob(docFS, "*stdlib/*minor")
449 if err != nil {
450 return "", err
451 }
452 var bad string
453 if len(dirs) == 0 {
454 bad = "No"
455 } else if len(dirs) > 1 {
456 bad = "More than one"
457 }
458 if bad != "" {
459 return "", fmt.Errorf("%s directory matches *stdlib/*minor.\nThis shouldn't happen; please file a bug at https://go.dev/issues/new.",
460 bad)
461 }
462 return dirs[0], nil
463 }
464
465 func checkFragmentFile(fsys fs.FS, filename string) error {
466 f, err := fsys.Open(filename)
467 if err != nil {
468 if errors.Is(err, fs.ErrNotExist) {
469 err = errors.New("File does not exist. Every API change must have a corresponding release note file.")
470 }
471 return err
472 }
473 defer f.Close()
474 data, err := io.ReadAll(f)
475 if err != nil {
476 return err
477 }
478 return CheckFragment(string(data))
479 }
480
View as plain text