Skip to content

Commit a383c72

Browse files
committed
relnote: add Merge function
Add a function that merges a tree of markdown files into a single file. For golang/go#64169. Change-Id: Ie3200d6cbe0e65f9c878de92c2d812b0ffbccc83 Reviewed-on: https://go-review.googlesource.com/c/build/+/556159 Reviewed-by: Dmitri Shuralyov <[email protected]> LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Dmitri Shuralyov <[email protected]>
1 parent 2e1eb85 commit a383c72

File tree

6 files changed

+682
-3
lines changed

6 files changed

+682
-3
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ require (
7070
google.golang.org/grpc v1.58.2
7171
google.golang.org/protobuf v1.31.0
7272
gopkg.in/inf.v0 v0.9.1
73-
rsc.io/markdown v0.0.0-20231215200646-988871efbd85
73+
rsc.io/markdown v0.0.0-20240117044121-669d2fdf1650
7474
)
7575

7676
require (

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,8 +1434,8 @@ modernc.org/z v1.0.1-0.20210308123920-1f282aa71362/go.mod h1:8/SRk5C/HgiQWCgXdfp
14341434
modernc.org/z v1.0.1/go.mod h1:8/SRk5C/HgiQWCgXdfpb+1RvhORdkz5sw72d3jjtyqA=
14351435
modernc.org/zappy v1.0.0/go.mod h1:hHe+oGahLVII/aTTyWK/b53VDHMAGCBYYeZ9sn83HC4=
14361436
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
1437-
rsc.io/markdown v0.0.0-20231215200646-988871efbd85 h1:Dxuj19cMfSZFI3G8f4Q59tfHqctUo9zE94x1N2k6JTQ=
1438-
rsc.io/markdown v0.0.0-20231215200646-988871efbd85/go.mod h1:8xcPgWmwlZONN1D9bjxtHEjrUtSEa3fakVF8iaewYKQ=
1437+
rsc.io/markdown v0.0.0-20240117044121-669d2fdf1650 h1:fuOABZYWclLVNotDsHVaFixLdtoC7+UQZJ0KSC1ocm0=
1438+
rsc.io/markdown v0.0.0-20240117044121-669d2fdf1650/go.mod h1:8xcPgWmwlZONN1D9bjxtHEjrUtSEa3fakVF8iaewYKQ=
14391439
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
14401440
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
14411441
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=

relnote/relnote.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import (
2929
"errors"
3030
"fmt"
3131
"io"
32+
"io/fs"
33+
"slices"
3234
"strings"
3335

3436
md "rsc.io/markdown"
@@ -138,3 +140,111 @@ func inlineText(ins []md.Inline) string {
138140
}
139141
return buf.String()
140142
}
143+
144+
// Merge combines the markdown documents (files ending in ".md") in the tree rooted
145+
// at fs into a single document.
146+
// The blocks of the documents are concatenated in lexicographic order by filename.
147+
// Heading with no content are removed.
148+
// The link keys must be unique, and are combined into a single map.
149+
func Merge(fsys fs.FS) (*md.Document, error) {
150+
filenames, err := sortedMarkdownFilenames(fsys)
151+
if err != nil {
152+
return nil, err
153+
}
154+
doc := &md.Document{}
155+
for _, filename := range filenames {
156+
fd, err := parseFile(fsys, filename)
157+
if err != nil {
158+
return nil, err
159+
}
160+
if len(fd.Blocks) == 0 {
161+
continue
162+
}
163+
if len(doc.Blocks) > 0 {
164+
// Put a blank line between the current and new blocks.
165+
lastLine := lastBlock(doc).Pos().EndLine
166+
delta := lastLine + 2 - fd.Blocks[0].Pos().StartLine
167+
for _, b := range fd.Blocks {
168+
addLines(b, delta)
169+
}
170+
}
171+
doc.Blocks = append(doc.Blocks, fd.Blocks...)
172+
// TODO(jba): merge links
173+
// TODO(jba): add headings for package sections under "Minor changes to the library".
174+
}
175+
// TODO(jba): remove headings with empty contents
176+
return doc, nil
177+
}
178+
179+
func sortedMarkdownFilenames(fsys fs.FS) ([]string, error) {
180+
var filenames []string
181+
err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
182+
if err != nil {
183+
return err
184+
}
185+
if !d.IsDir() && strings.HasSuffix(path, ".md") {
186+
filenames = append(filenames, path)
187+
}
188+
return nil
189+
})
190+
if err != nil {
191+
return nil, err
192+
}
193+
// '.' comes before '/', which comes before alphanumeric characters.
194+
// So just sorting the list will put a filename like "net.md" before
195+
// the directory "net". That is what we want.
196+
slices.Sort(filenames)
197+
return filenames, nil
198+
}
199+
200+
// lastBlock returns the last block in the document.
201+
// It panics if the document has no blocks.
202+
func lastBlock(doc *md.Document) md.Block {
203+
return doc.Blocks[len(doc.Blocks)-1]
204+
}
205+
206+
func addLines(b md.Block, n int) {
207+
pos := position(b)
208+
pos.StartLine += n
209+
pos.EndLine += n
210+
}
211+
212+
func position(b md.Block) *md.Position {
213+
switch b := b.(type) {
214+
case *md.Heading:
215+
return &b.Position
216+
case *md.Text:
217+
return &b.Position
218+
case *md.CodeBlock:
219+
return &b.Position
220+
case *md.HTMLBlock:
221+
return &b.Position
222+
case *md.List:
223+
return &b.Position
224+
case *md.Item:
225+
return &b.Position
226+
case *md.Empty:
227+
return &b.Position
228+
case *md.Paragraph:
229+
return &b.Position
230+
case *md.Quote:
231+
return &b.Position
232+
default:
233+
panic(fmt.Sprintf("unknown block type %T", b))
234+
}
235+
}
236+
237+
func parseFile(fsys fs.FS, path string) (*md.Document, error) {
238+
f, err := fsys.Open(path)
239+
if err != nil {
240+
return nil, err
241+
}
242+
defer f.Close()
243+
data, err := io.ReadAll(f)
244+
if err != nil {
245+
return nil, err
246+
}
247+
in := string(data)
248+
doc := NewParser().Parse(in)
249+
return doc, nil
250+
}

relnote/relnote_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,17 @@
55
package relnote
66

77
import (
8+
"fmt"
9+
"io/fs"
10+
"path/filepath"
11+
"slices"
812
"strings"
913
"testing"
14+
"testing/fstest"
15+
16+
"github.com/google/go-cmp/cmp"
17+
"golang.org/x/tools/txtar"
18+
md "rsc.io/markdown"
1019
)
1120

1221
func TestCheckFragment(t *testing.T) {
@@ -71,3 +80,69 @@ func TestCheckFragment(t *testing.T) {
7180
}
7281
}
7382
}
83+
84+
func TestMerge(t *testing.T) {
85+
testFiles, err := filepath.Glob(filepath.Join("testdata", "*.txt"))
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
for _, f := range testFiles {
90+
t.Run(strings.TrimSuffix(filepath.Base(f), ".txt"), func(t *testing.T) {
91+
fsys, want, err := parseTestFile(f)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
gotDoc, err := Merge(fsys)
96+
if err != nil {
97+
t.Fatal(err)
98+
}
99+
got := md.ToMarkdown(gotDoc)
100+
if diff := cmp.Diff(want, got); diff != "" {
101+
t.Errorf("mismatch (-want, +got)\n%s", diff)
102+
}
103+
})
104+
}
105+
}
106+
107+
func parseTestFile(filename string) (fsys fs.FS, want string, err error) {
108+
ar, err := txtar.ParseFile(filename)
109+
if err != nil {
110+
return nil, "", err
111+
}
112+
mfs := make(fstest.MapFS)
113+
for _, f := range ar.Files {
114+
if f.Name == "want" {
115+
want = string(f.Data)
116+
} else {
117+
mfs[f.Name] = &fstest.MapFile{Data: f.Data}
118+
}
119+
}
120+
if want == "" {
121+
return nil, "", fmt.Errorf("%s: missing 'want'", filename)
122+
}
123+
return mfs, want, nil
124+
}
125+
126+
func TestSortedMarkdownFilenames(t *testing.T) {
127+
want := []string{
128+
"a.md",
129+
"b.md",
130+
"b/a.md",
131+
"b/c.md",
132+
"ba/a.md",
133+
}
134+
mfs := make(fstest.MapFS)
135+
for _, fn := range want {
136+
mfs[fn] = &fstest.MapFile{}
137+
}
138+
mfs["README"] = &fstest.MapFile{}
139+
mfs["b/other.txt"] = &fstest.MapFile{}
140+
got, err := sortedMarkdownFilenames(mfs)
141+
if err != nil {
142+
t.Fatal(err)
143+
}
144+
if !slices.Equal(got, want) {
145+
t.Errorf("\ngot %v\nwant %v", got, want)
146+
}
147+
148+
}

0 commit comments

Comments
 (0)