Track Go APIs and dependencies
- gocode: one page per package of a Go module with every exported
declaration, its doc comment and no function bodies. Files are chosen
for linux/amd64 so every machine writes the same pages; main packages,
tests and nested modules are skipped. The note leads with what can
break importers: "Breaking: changed func New, removed func Old.
Changed: type Thing. Added: type Option".
- deps: go.mod (with replace lines) and package.json dependencies, one
per line; the note names what was updated, added and removed.
- summary: the capped "a, b and 3 more" phrases shared by the notes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
24 Oct 2026, 02:14 -0300
commit
9e470ae984a919b681a4fd68461e115fc5a07193
parent
9e6502c600d7431211c32f85aeac4f43c53695be
changes
25 files +825 −25
@@ -8,3 +8,5 @@ golang.org/x/net v0.61.0 golang.org/x/text v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
+
+require golang.org/x/mod v0.42.0 // indirect
@@ -1,5 +1,7 @@ github.com/andybalholm/cascadia v1.3.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8=
github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
@@ -15,6 +15,8 @@ "strconv" "strings"
"gopkg.in/yaml.v3"
+
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
)
// Format guesses the format from a file name or Content-Type: json, yaml
@@ -243,36 +245,18 @@ if _, ok := cur[k]; !ok { removed = append(removed, k)
}
}
- var parts []string
- named := 0
- list := func(label string, items []string) {
- if len(items) == 0 {
- return
- }
- sort.Strings(items)
- room := max(maxNamed-named, 0)
- shown := items[:min(len(items), room)]
- named += len(shown)
- s := label + strings.Join(shown, ", ")
- if rest := len(items) - len(shown); rest > 0 {
- if len(shown) > 0 {
- s += " and "
- }
- s += strconv.Itoa(rest) + " more"
- }
- parts = append(parts, s)
- }
- list("", changed)
- list("added ", added)
- list("removed ", removed)
- if len(parts) == 0 {
+ b := summary.Builder{Max: maxNamed}
+ b.Add("", changed)
+ b.Add("added ", added)
+ b.Add("removed ", removed)
+ if b.Empty() {
return "fields reordered", ""
}
kind := ""
if len(changed) == 0 && len(removed) == 0 {
kind = "expansion"
}
- return strings.Join(parts, "; "), kind
+ return b.Join("; "), kind
}
func short(s string) string {
@@ -0,0 +1,159 @@ +// Package deps lists the dependencies a project declares, one list item
+// each, so a revision reads "Updated golang.org/x/net v0.60.0 → v0.61.0".
+package deps
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "path"
+ "sort"
+ "strings"
+
+ "golang.org/x/mod/modfile"
+
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
+)
+
+// Convert reads a go.mod or package.json, told apart by its file name.
+func Convert(name string, raw []byte) (string, error) {
+ var deps map[string]string
+ var err error
+ switch path.Base(name) {
+ case "go.mod":
+ deps, err = goMod(raw)
+ case "package.json":
+ deps, err = packageJSON(raw)
+ default:
+ return "", fmt.Errorf("%s: only go.mod and package.json are read", name)
+ }
+ if err != nil {
+ return "", err
+ }
+ names := make([]string, 0, len(deps))
+ for n := range deps {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ var b strings.Builder
+ for _, n := range names {
+ b.WriteString("- " + n + " " + deps[n] + "\n")
+ }
+ return b.String(), nil
+}
+
+func goMod(raw []byte) (map[string]string, error) {
+ f, err := modfile.Parse("go.mod", raw, nil)
+ if err != nil {
+ // Directives newer than this build make Parse fail; ParseLax skips
+ // them, but drops replace lines too.
+ if f, err = modfile.ParseLax("go.mod", raw, nil); err != nil {
+ return nil, err
+ }
+ }
+ deps := map[string]string{}
+ if f.Go != nil {
+ deps["go"] = f.Go.Version
+ }
+ if f.Toolchain != nil {
+ deps["toolchain"] = f.Toolchain.Name
+ }
+ for _, r := range f.Require {
+ v := r.Mod.Version
+ if r.Indirect {
+ v += " (indirect)"
+ }
+ deps[r.Mod.Path] = v
+ }
+ for _, r := range f.Replace {
+ to := r.New.Path
+ if r.New.Version != "" {
+ to += " " + r.New.Version
+ }
+ deps["replace "+r.Old.Path] = to
+ }
+ return deps, nil
+}
+
+// packageJSON reads the four dependency maps; anything but runtime
+// dependencies is labeled.
+func packageJSON(raw []byte) (map[string]string, error) {
+ var p map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &p); err != nil {
+ return nil, err
+ }
+ deps := map[string]string{}
+ found := false
+ for _, group := range []struct{ key, label string }{
+ {"dependencies", ""}, {"devDependencies", " (dev)"},
+ {"peerDependencies", " (peer)"}, {"optionalDependencies", " (optional)"},
+ } {
+ rawGroup, ok := p[group.key]
+ if !ok {
+ continue
+ }
+ found = true
+ var m map[string]string
+ if err := json.Unmarshal(rawGroup, &m); err != nil {
+ return nil, fmt.Errorf("%s: %w", group.key, err)
+ }
+ for n, v := range m {
+ deps[n] = v + group.label
+ }
+ }
+ if !found && p["name"] == nil {
+ return nil, errors.New("not a package.json")
+ }
+ return deps, nil
+}
+
+// parse reads back a body Convert wrote: name, then version.
+func parse(body string) map[string]string {
+ deps := map[string]string{}
+ for _, l := range strings.Split(body, "\n") {
+ l, ok := strings.CutPrefix(l, "- ")
+ if !ok {
+ continue
+ }
+ // "replace" entries have a space in their name.
+ if rest, ok := strings.CutPrefix(l, "replace "); ok {
+ n, v, _ := strings.Cut(rest, " ")
+ deps["replace "+n] = v
+ continue
+ }
+ n, v, _ := strings.Cut(l, " ")
+ deps[n] = v
+ }
+ return deps
+}
+
+// maxNamed bounds how many dependencies a summary names.
+const maxNamed = 6
+
+// Summary names the dependencies that changed.
+func Summary(before, after string) (string, string) {
+ old, cur := parse(before), parse(after)
+ var updated, added, removed []string
+ for n, v := range cur {
+ o, had := old[n]
+ switch {
+ case !had:
+ added = append(added, n+" "+v)
+ case o != v:
+ updated = append(updated, n+" "+o+" → "+v)
+ }
+ }
+ for n := range old {
+ if _, ok := cur[n]; !ok {
+ removed = append(removed, n)
+ }
+ }
+ b := summary.Builder{Max: maxNamed}
+ b.Add("Updated ", updated)
+ b.Add("added ", added)
+ b.Add("removed ", removed)
+ if b.Empty() {
+ return "dependencies reordered", ""
+ }
+ return b.Join("; "), ""
+}
@@ -0,0 +1,68 @@ +package deps
+
+import "testing"
+
+const goModV1 = `module example.com/app
+
+go 1.26.4
+
+require (
+ golang.org/x/net v0.60.0
+ gopkg.in/yaml.v3 v3.0.1
+ golang.org/x/text v0.42.0 // indirect
+)
+
+replace example.com/old => ../old
+`
+
+func TestGoMod(t *testing.T) {
+ got, err := Convert("app/go.mod", []byte(goModV1))
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := `- go 1.26.4
+- golang.org/x/net v0.60.0
+- golang.org/x/text v0.42.0 (indirect)
+- gopkg.in/yaml.v3 v3.0.1
+- replace example.com/old ../old
+`
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+}
+
+func TestPackageJSON(t *testing.T) {
+ got, err := Convert("package.json", []byte(`{"name":"site","dependencies":{"astro":"^5.1.0"},"devDependencies":{"typescript":"~5.6.0"}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := "- astro ^5.1.0\n- typescript ~5.6.0 (dev)\n"; got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+ if _, err := Convert("package.json", []byte(`{"version":"1"}`)); err == nil {
+ t.Error("a JSON file with no name and no dependencies is not a package.json")
+ }
+ if _, err := Convert("Cargo.toml", nil); err == nil {
+ t.Error("unknown manifests must be refused")
+ }
+}
+
+func TestSummary(t *testing.T) {
+ before, _ := Convert("go.mod", []byte(goModV1))
+ after, _ := Convert("go.mod", []byte(`module example.com/app
+
+go 1.26.4
+
+require (
+ golang.org/x/net v0.61.0
+ golang.org/x/mod v0.42.0
+)
+
+replace example.com/old => ../old2
+`))
+ got, kind := Summary(before, after)
+ want := "Updated golang.org/x/net v0.60.0 → v0.61.0, replace example.com/old ../old → ../old2; added golang.org/x/mod v0.42.0; removed golang.org/x/text, gopkg.in/yaml.v3"
+ if got != want || kind != "" {
+ t.Errorf("got %q\nwant %q", got, want)
+ }
+}
@@ -0,0 +1,247 @@ +// Package gocode describes the exported API of a Go module, one page per
+// package: every exported constant, variable, type, function and method,
+// with its declaration and doc comment, and nothing from function bodies.
+// Git already keeps the bodies; this is what the module promises to the
+// code that imports it.
+package gocode
+
+import (
+ "bytes"
+ "go/ast"
+ "go/build"
+ "go/doc"
+ "go/doc/comment"
+ "go/parser"
+ "go/printer"
+ "go/token"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "golang.org/x/mod/modfile"
+
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/summary"
+)
+
+// Package is one package's API page.
+type Package struct {
+ Dir string // relative to the module root, '/'-separated; "" for the root
+ ImportPath string
+ Body string
+}
+
+// buildContext fixes the platform, so the same code gives the same pages
+// on every machine.
+func buildContext() build.Context {
+ c := build.Default
+ c.GOOS, c.GOARCH, c.CgoEnabled = "linux", "amd64", false
+ return c
+}
+
+// Module reads every package of the module rooted at root. Main packages,
+// test files and nested modules are left out.
+func Module(root string) ([]Package, error) {
+ modPath := "."
+ if raw, err := os.ReadFile(filepath.Join(root, "go.mod")); err == nil {
+ modPath = modfile.ModulePath(raw)
+ }
+ ctx := buildContext()
+ var pkgs []Package
+ err := filepath.WalkDir(root, func(dir string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if !d.IsDir() {
+ return nil
+ }
+ name := d.Name()
+ if dir != root {
+ if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") || name == "testdata" || name == "vendor" || name == "node_modules" {
+ return filepath.SkipDir
+ }
+ if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
+ return filepath.SkipDir // another module
+ }
+ }
+ rel, _ := filepath.Rel(root, dir) // dir is under root
+ rel = filepath.ToSlash(rel)
+ if rel == "." {
+ rel = ""
+ }
+ p, ok, err := readPackage(ctx, dir, path.Join(modPath, rel))
+ if err != nil || !ok {
+ return err
+ }
+ p.Dir = rel
+ pkgs = append(pkgs, p)
+ return nil
+ })
+ return pkgs, err
+}
+
+func readPackage(ctx build.Context, dir, importPath string) (Package, bool, error) {
+ bp, err := ctx.ImportDir(dir, 0)
+ if err != nil {
+ if _, none := err.(*build.NoGoError); none {
+ return Package{}, false, nil
+ }
+ return Package{}, false, err
+ }
+ if bp.Name == "main" {
+ return Package{}, false, nil
+ }
+ fset := token.NewFileSet()
+ var files []*ast.File
+ for _, name := range bp.GoFiles {
+ f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, parser.ParseComments)
+ if err != nil {
+ return Package{}, false, err
+ }
+ files = append(files, f)
+ }
+ dp, err := doc.NewFromFiles(fset, files, importPath)
+ if err != nil {
+ return Package{}, false, err
+ }
+ return Package{ImportPath: importPath, Body: render(fset, dp)}, true, nil
+}
+
+func render(fset *token.FileSet, p *doc.Package) string {
+ var b strings.Builder
+ b.WriteString("# package " + p.Name + "\n\n")
+ b.WriteString("`import \"" + p.ImportPath + "\"`\n")
+ if text := docText(p, p.Doc); text != "" {
+ b.WriteString("\n" + text)
+ }
+ values := func(kind string, vs []*doc.Value) {
+ for _, v := range vs {
+ section(&b, kind+" "+strings.Join(v.Names, ", "), decl(fset, v.Decl), docText(p, v.Doc))
+ }
+ }
+ values("const", p.Consts)
+ values("var", p.Vars)
+ for _, f := range p.Funcs {
+ section(&b, "func "+f.Name, decl(fset, f.Decl), docText(p, f.Doc))
+ }
+ for _, t := range p.Types {
+ section(&b, "type "+t.Name, decl(fset, t.Decl), docText(p, t.Doc))
+ values("const", t.Consts)
+ values("var", t.Vars)
+ for _, f := range t.Funcs {
+ section(&b, "func "+f.Name, decl(fset, f.Decl), docText(p, f.Doc))
+ }
+ for _, m := range t.Methods {
+ section(&b, "method ("+m.Recv+") "+m.Name, decl(fset, m.Decl), docText(p, m.Doc))
+ }
+ }
+ return b.String()
+}
+
+// section writes one declaration: a heading that names it, the declaration
+// itself, then its documentation.
+func section(b *strings.Builder, name, code, text string) {
+ b.WriteString("\n## " + name + "\n\n```go\n" + code + "\n```\n")
+ if text != "" {
+ b.WriteString("\n" + text)
+ }
+}
+
+// decl prints a declaration without its doc comment or function body.
+func decl(fset *token.FileSet, node ast.Decl) string {
+ switch d := node.(type) {
+ case *ast.FuncDecl:
+ c := *d
+ c.Doc, c.Body = nil, nil
+ node = &c
+ case *ast.GenDecl:
+ c := *d
+ c.Doc = nil
+ node = &c
+ }
+ var buf bytes.Buffer
+ cfg := printer.Config{Mode: printer.UseSpaces | printer.TabIndent, Tabwidth: 8}
+ if err := cfg.Fprint(&buf, fset, node); err != nil {
+ return "// " + err.Error()
+ }
+ return buf.String()
+}
+
+// docText renders a doc comment as Markdown, its headings below ours.
+func docText(p *doc.Package, text string) string {
+ if strings.TrimSpace(text) == "" {
+ return ""
+ }
+ pr := p.Printer()
+ pr.HeadingLevel = 3
+ pr.HeadingID = func(*comment.Heading) string { return "" } // no {#anchors}
+ return string(pr.Markdown(p.Parser().Parse(text)))
+}
+
+// Section is one declaration read back from a page.
+type Section struct {
+ Name string // "func Retract", "method (*Engine) Ingest", "type Engine"
+ Code string
+ Doc string
+}
+
+// Sections reads back the declarations of a page Module wrote.
+func Sections(body string) map[string]Section {
+ out := map[string]Section{}
+ parts := strings.Split("\n"+body, "\n## ")
+ for _, part := range parts[1:] {
+ name, rest, _ := strings.Cut(part, "\n")
+ s := Section{Name: name}
+ if start := strings.Index(rest, "```go\n"); start >= 0 {
+ if end := strings.Index(rest[start+6:], "\n```\n"); end >= 0 {
+ s.Code = rest[start+6 : start+6+end]
+ s.Doc = strings.TrimSpace(rest[start+6+end+5:])
+ }
+ }
+ out[name] = s
+ }
+ return out
+}
+
+// maxNamed bounds how many declarations a summary names before counting
+// the rest.
+const maxNamed = 6
+
+// Summary names what changed in the API. Removing anything or changing a
+// function's signature can break importers, so those lead as "Breaking".
+func Summary(before, after string) (string, string) {
+ old, cur := Sections(before), Sections(after)
+ var breaking, added, changed, docs []string
+ for name, s := range cur {
+ o, had := old[name]
+ switch {
+ case !had:
+ added = append(added, name)
+ case o.Code != s.Code && (strings.HasPrefix(name, "func ") || strings.HasPrefix(name, "method ")):
+ breaking = append(breaking, "changed "+name)
+ case o.Code != s.Code:
+ changed = append(changed, name)
+ case o.Doc != s.Doc:
+ docs = append(docs, name)
+ }
+ }
+ for name := range old {
+ if _, ok := cur[name]; !ok {
+ breaking = append(breaking, "removed "+name)
+ }
+ }
+ b := summary.Builder{Max: maxNamed}
+ b.Add("Breaking: ", breaking)
+ b.Add("Changed: ", changed)
+ b.Add("Added: ", added)
+ b.Add("Docs: ", docs)
+ if b.Empty() {
+ return "package overview changed", ""
+ }
+ kind := ""
+ if len(breaking) == 0 && len(changed) == 0 && len(docs) == 0 {
+ kind = "expansion"
+ }
+ return b.Join(". "), kind
+}
@@ -0,0 +1,56 @@ +package gocode
+
+import (
+ "flag"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+var update = flag.Bool("update", false, "rewrite golden files")
+
+func TestModule(t *testing.T) {
+ pkgs, err := Module(filepath.Join("testdata", "v1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pkgs) != 2 || pkgs[0].Dir != "" || pkgs[1].Dir != "sub" {
+ t.Fatalf("packages %+v", pkgs)
+ }
+ if pkgs[0].ImportPath != "example.com/lib" || pkgs[1].ImportPath != "example.com/lib/sub" {
+ t.Errorf("import paths %q %q", pkgs[0].ImportPath, pkgs[1].ImportPath)
+ }
+ golden := filepath.Join("testdata", "v1.md")
+ if *update {
+ os.WriteFile(golden, []byte(pkgs[0].Body), 0o644)
+ }
+ want, err := os.ReadFile(golden)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pkgs[0].Body != string(want) {
+ t.Errorf("mismatch with %s:\n%s", golden, pkgs[0].Body)
+ }
+}
+
+func TestSummary(t *testing.T) {
+ v1, err := Module(filepath.Join("testdata", "v1"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ v2, err := Module(filepath.Join("testdata", "v2"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, kind := Summary(v1[0].Body, v2[0].Body)
+ want := "Breaking: changed func New, removed func Old. Changed: type Thing. Added: type Option. Docs: method (*Thing) Hello"
+ if got != want || kind != "" {
+ t.Errorf("got %q %q\nwant %q", got, kind, want)
+ }
+ if got, kind := Summary(v1[1].Body, v2[1].Body); got != "package overview changed" || kind != "" {
+ t.Errorf("identical pages: %q %q", got, kind)
+ }
+ if got, kind := Summary(v2[0].Body, v2[0].Body+"\n## func Extra\n\n```go\nfunc Extra()\n```\n"); got != "Added: func Extra" || kind != "expansion" {
+ t.Errorf("addition only: %q %q", got, kind)
+ }
+}
@@ -0,0 +1,60 @@ +# package lib
+
+`import "example.com/lib"`
+
+Package lib does things.
+
+### Usage
+
+Call New.
+
+## const Version
+
+```go
+const Version = "1"
+```
+
+Version is the version.
+
+## func Old
+
+```go
+func Old()
+```
+
+Old is on its way out.
+
+## func Platform
+
+```go
+func Platform() string
+```
+
+Platform names the platform.
+
+## type Thing
+
+```go
+type Thing struct {
+ Name string
+ // contains filtered or unexported fields
+}
+```
+
+Thing is a thing.
+
+## func New
+
+```go
+func New(name string) *Thing
+```
+
+New makes a Thing.
+
+## method (*Thing) Hello
+
+```go
+func (t *Thing) Hello() string
+```
+
+Hello says hello.
@@ -0,0 +1,3 @@ +package main
+
+func main() {}
@@ -0,0 +1,3 @@ +module example.com/lib
+
+go 1.26
@@ -0,0 +1,26 @@ +// Package lib does things.
+//
+// # Usage
+//
+// Call New.
+package lib
+
+// Version is the version.
+const Version = "1"
+
+// Thing is a thing.
+type Thing struct {
+ Name string
+ secret int
+}
+
+// New makes a Thing.
+func New(name string) *Thing { return &Thing{Name: name} }
+
+// Hello says hello.
+func (t *Thing) Hello() string { return "hi " + t.Name }
+
+// Old is on its way out.
+func Old() {}
+
+func unexported() {}
@@ -0,0 +1,6 @@ +//go:build linux
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "linux" }
@@ -0,0 +1,5 @@ +package lib
+
+import "testing"
+
+func TestX(t *testing.T) {}
@@ -0,0 +1,6 @@ +//go:build windows
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "windows" }
@@ -0,0 +1,5 @@ +// Package sub is nested.
+package sub
+
+// Answer is 42.
+var Answer = 42
@@ -0,0 +1,3 @@ +package main
+
+func main() {}
@@ -0,0 +1,3 @@ +module example.com/lib
+
+go 1.26
@@ -0,0 +1,27 @@ +// Package lib does things.
+//
+// # Usage
+//
+// Call New.
+package lib
+
+// Version is the version.
+const Version = "1"
+
+// Thing is a thing.
+type Thing struct {
+ Name string
+ Size int
+ secret int
+}
+
+// Option changes a Thing.
+type Option func(*Thing)
+
+// New makes a Thing.
+func New(name string, opts ...Option) *Thing { return &Thing{Name: name} }
+
+// Hello says hello, politely.
+func (t *Thing) Hello() string { return "hi " + t.Name }
+
+func unexported() {}
@@ -0,0 +1,6 @@ +//go:build linux
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "linux" }
@@ -0,0 +1,5 @@ +package lib
+
+import "testing"
+
+func TestX(t *testing.T) {}
@@ -0,0 +1,6 @@ +//go:build windows
+
+package lib
+
+// Platform names the platform.
+func Platform() string { return "windows" }
@@ -0,0 +1,5 @@ +// Package sub is nested.
+package sub
+
+// Answer is 42.
+var Answer = 42
@@ -0,0 +1,41 @@ +// Package summary builds the short phrases revision notes are made of:
+// "Updated a v1 → v2, b v3 → v4 and 2 more; added c".
+package summary
+
+import (
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// Builder names up to Max items across all its groups and counts the rest,
+// so a note stays one line however much changed.
+type Builder struct {
+ Max int
+ named int
+ parts []string
+}
+
+// Add appends a group: label followed by the items, sorted.
+func (b *Builder) Add(label string, items []string) {
+ if len(items) == 0 {
+ return
+ }
+ sort.Strings(items)
+ shown := items[:min(len(items), max(b.Max-b.named, 0))]
+ b.named += len(shown)
+ s := label + strings.Join(shown, ", ")
+ if rest := len(items) - len(shown); rest > 0 {
+ if len(shown) > 0 {
+ s += " and "
+ }
+ s += strconv.Itoa(rest) + " more"
+ }
+ b.parts = append(b.parts, s)
+}
+
+// Empty reports whether no group had items.
+func (b *Builder) Empty() bool { return len(b.parts) == 0 }
+
+// Join returns the groups joined by sep.
+func (b *Builder) Join(sep string) string { return strings.Join(b.parts, sep) }
@@ -0,0 +1,67 @@ +package track
+
+import (
+ "context"
+ "fmt"
+ "path"
+
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/config"
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/deps"
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/fetch"
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/gocode"
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/out"
+ "git.pablomurad.com/pablo/patchwork-turbo/internal/slug"
+)
+
+// goAPI tracks the exported API of a Go module on disk, one page per
+// package.
+func (r *Runner) goAPI(ctx context.Context, t config.Track) (Result, error) {
+ pkgs, err := gocode.Module(t.Go)
+ if err != nil {
+ return Result{}, err
+ }
+ if len(pkgs) == 0 {
+ return Result{}, fmt.Errorf("%s: no importable packages (main packages are left out)", t.Go)
+ }
+ res := Result{Complete: true}
+ for _, p := range pkgs {
+ name := "index"
+ if p.Dir != "" {
+ name = pathSlug(p.Dir)
+ }
+ res.Pages = append(res.Pages, out.Page{Path: name, Title: p.ImportPath, Body: p.Body, Summary: gocode.Summary})
+ }
+ return res, nil
+}
+
+// pathSlug slugifies each segment of a '/'-separated path.
+func pathSlug(p string) string {
+ dir, file := path.Split(p)
+ if dir == "" {
+ return slug.Slugify(file)
+ }
+ return pathSlug(path.Clean(dir)) + "/" + slug.Slugify(file)
+}
+
+// dependencies tracks a go.mod or package.json.
+func (r *Runner) dependencies(ctx context.Context, t config.Track) (Result, error) {
+ res := Result{Complete: true}
+ s, err := r.load(ctx, t, t.Deps)
+ if err != nil && fetch.Gone(err) {
+ res.warn("%v; page removed", err)
+ return res, nil
+ }
+ if err != nil {
+ return Result{}, err
+ }
+ if s == nil {
+ res.Keep = []string{"index"}
+ return res, nil
+ }
+ body, err := deps.Convert(s.name, s.body)
+ if err != nil {
+ return Result{}, err
+ }
+ res.Pages = []out.Page{{Path: "index", Title: "Dependencies (" + s.name + ")", URL: s.url, Body: body, Summary: deps.Summary}}
+ return res, nil
+}
@@ -75,8 +75,13 @@ case config.KindData: return r.data(ctx, t)
case config.KindDoc:
return r.doc(ctx, t)
+ case config.KindGo:
+ return r.goAPI(ctx, t)
+ case config.KindDeps:
+ return r.dependencies(ctx, t)
}
- return Result{}, fmt.Errorf("track %q: %s tracks are not implemented yet", t.Name, t.Kind())
+ // Validate accepts only the kinds above.
+ return Result{}, fmt.Errorf("track %q has no source", t.Name)
}
// pollEvery is how often run looks for tracks that are due.