detached bea8f48

feat(gen): add wlbgen CLI for all bindings and custom protocol XML

commit
bea8f4800bd3be0ccb122cbf070c5abea1bc5128
author
brice · · 2026-10-01 03:41 UTC
parent
40c4f8d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +546 -2
A cmd/wlbgen/main.go +309 -0
@@ -0,0 +1,309 @@
1+// Command wlbgen generates Go bindings from Wayland protocol XML.
2+//
3+// Usage:
4+//
5+// wlbgen -all [-root DIR]
6+// wlbgen -side client|server -package NAME -out FILE [-import iface=path ...] protocol.xml
7+//
8+// With -all, wlbgen renders client/<pkg>/<pkg>_generated.go and
9+// server/<pkg>/<pkg>_generated.go below DIR for every protocol listed in the
10+// embedded spec manifest, and removes generated files of packages that left
11+// the manifest.
12+//
13+// With -side, wlbgen generates a single file from a private protocol XML.
14+// Interfaces defined in other protocols are resolved from the embedded
15+// manifest (import path .../client/<owner> or .../server/<owner>); each
16+// -import iface=path overrides that resolution for one interface. An
17+// interface that cannot be resolved is an error.
18+package main
19+
20+import (
21+ "flag"
22+ "fmt"
23+ "io"
24+ "os"
25+ "path"
26+ "path/filepath"
27+ "sort"
28+ "strings"
29+
30+ "github.com/bnema/go-wayland-bindings/internal/gen/client"
31+ "github.com/bnema/go-wayland-bindings/internal/gen/server"
32+ "github.com/bnema/go-wayland-bindings/spec"
33+)
34+
35+const tool = "wlbgen"
36+
37+type importFlags map[string]string
38+
39+func (f importFlags) String() string { return fmt.Sprint(map[string]string(f)) }
40+
41+func (f importFlags) Set(v string) error {
42+ iface, p, ok := strings.Cut(v, "=")
43+ if !ok || iface == "" || p == "" {
44+ return fmt.Errorf("want iface=importpath, got %q", v)
45+ }
46+ f[iface] = p
47+ return nil
48+}
49+
50+func main() {
51+ if err := run(os.Args[1:], os.Stderr); err != nil {
52+ fmt.Fprintln(os.Stderr, "wlbgen:", err)
53+ os.Exit(1)
54+ }
55+}
56+
57+func run(args []string, stderr io.Writer) error {
58+ fs := flag.NewFlagSet(tool, flag.ContinueOnError)
59+ fs.SetOutput(stderr)
60+ all := fs.Bool("all", false, "generate client and server bindings for every manifest protocol")
61+ root := fs.String("root", ".", "repository root (with -all)")
62+ side := fs.String("side", "", "`client` or `server` (single file mode)")
63+ pkg := fs.String("package", "", "Go package name (single file mode)")
64+ out := fs.String("out", "", "output file (single file mode)")
65+ imports := importFlags{}
66+ fs.Var(imports, "import", "`iface=importpath` override for a foreign interface (repeatable)")
67+ if err := fs.Parse(args); err != nil {
68+ return err
69+ }
70+ m, err := spec.Load()
71+ if err != nil {
72+ return err
73+ }
74+ if *all {
75+ if fs.NArg() != 0 || *side != "" || *pkg != "" || *out != "" || len(imports) != 0 {
76+ return fmt.Errorf("-all takes no other arguments except -root")
77+ }
78+ return generateAll(m, spec.Open, *root)
79+ }
80+ if fs.NArg() != 1 {
81+ return fmt.Errorf("want -all, or -side/-package/-out and one XML file")
82+ }
83+ if *pkg == "" || *out == "" {
84+ return fmt.Errorf("-package and -out are required")
85+ }
86+ data, err := os.ReadFile(fs.Arg(0))
87+ if err != nil {
88+ return err
89+ }
90+ src, err := generateCustom(m, *side, *pkg, filepath.Base(fs.Arg(0)), data, imports)
91+ if err != nil {
92+ return err
93+ }
94+ return writeFile(*out, src)
95+}
96+
97+func writeFile(name string, data []byte) error {
98+ if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
99+ return err
100+ }
101+ return os.WriteFile(name, data, 0o644)
102+}
103+
104+func clientImport(owner string) string { return spec.ModulePath + "/client/" + owner }
105+func serverImport(owner string) string { return spec.ModulePath + "/server/" + owner }
106+
107+func generatedPath(root, side, pkg string) string {
108+ return filepath.Join(root, side, pkg, pkg+"_generated.go")
109+}
110+
111+// generateAll writes every package of m below root and removes stale output.
112+func generateAll(m *spec.Manifest, open func(string) ([]byte, error), root string) error {
113+ if err := removeStale(m, root); err != nil {
114+ return err
115+ }
116+ owners := m.Owners()
117+ for _, e := range m.Protocols {
118+ data, err := open(e.Package)
119+ if err != nil {
120+ return err
121+ }
122+ p, err := spec.Parse(data)
123+ if err != nil {
124+ return fmt.Errorf("%s: %w", e.Package, err)
125+ }
126+
127+ s := client.NewScanner()
128+ s.Tool = tool
129+ if e.License != "" && e.License != "MIT" {
130+ s.SPDXLicense = e.License
131+ }
132+ s.CrossPackage = map[string]string{}
133+ for iface, owner := range owners {
134+ if owner != e.Package {
135+ s.CrossPackage[iface] = clientImport(owner)
136+ }
137+ }
138+ if err := s.LoadProtocol(p, path.Base(e.Path)); err != nil {
139+ return err
140+ }
141+ src, err := s.Generate(e.Package)
142+ if err != nil {
143+ return fmt.Errorf("client/%s: %w", e.Package, err)
144+ }
145+ if err := writeFile(generatedPath(root, "client", e.Package), src); err != nil {
146+ return err
147+ }
148+
149+ opts := server.Options{Package: e.Package, Imports: map[string]string{}, Owners: map[string]string{}, Tool: tool}
150+ for iface, owner := range owners {
151+ if owner != e.Package {
152+ opts.Owners[iface] = owner
153+ }
154+ }
155+ for _, dep := range e.Deps {
156+ if dep != e.Package {
157+ opts.Imports[dep] = serverImport(dep)
158+ }
159+ }
160+ src, err = server.Generate(p, opts)
161+ if err != nil {
162+ return fmt.Errorf("server/%s: %w", e.Package, err)
163+ }
164+ if err := writeFile(generatedPath(root, "server", e.Package), src); err != nil {
165+ return err
166+ }
167+ }
168+ return nil
169+}
170+
171+// removeStale deletes <side>/<pkg>/<pkg>_generated.go for packages that are
172+// not in m. If such a directory holds anything else, nothing is deleted and an
173+// error lists the remaining files.
174+func removeStale(m *spec.Manifest, root string) error {
175+ known := map[string]bool{}
176+ for _, e := range m.Protocols {
177+ known[e.Package] = true
178+ }
179+ type stale struct{ dir, gen string }
180+ var list []stale
181+ var problems []string
182+ for _, side := range []string{"client", "server"} {
183+ entries, err := os.ReadDir(filepath.Join(root, side))
184+ if os.IsNotExist(err) {
185+ continue
186+ }
187+ if err != nil {
188+ return err
189+ }
190+ for _, d := range entries {
191+ if !d.IsDir() || known[d.Name()] {
192+ continue
193+ }
194+ dir := filepath.Join(root, side, d.Name())
195+ gen := filepath.Join(dir, d.Name()+"_generated.go")
196+ if _, err := os.Stat(gen); err != nil {
197+ continue // not generated output; leave alone
198+ }
199+ files, err := os.ReadDir(dir)
200+ if err != nil {
201+ return err
202+ }
203+ for _, f := range files {
204+ if f.Name() != d.Name()+"_generated.go" {
205+ problems = append(problems, filepath.Join(side, d.Name(), f.Name()))
206+ }
207+ }
208+ list = append(list, stale{dir, gen})
209+ }
210+ }
211+ if len(problems) > 0 {
212+ sort.Strings(problems)
213+ return fmt.Errorf("package removed from manifest but directory still holds other files: %s", strings.Join(problems, ", "))
214+ }
215+ for _, s := range list {
216+ if err := os.Remove(s.gen); err != nil {
217+ return err
218+ }
219+ if err := os.Remove(s.dir); err != nil {
220+ return err
221+ }
222+ }
223+ return nil
224+}
225+
226+// foreignInterfaces returns, sorted, the interfaces referenced by object and
227+// new_id arguments of p that p does not define.
228+func foreignInterfaces(p *spec.Protocol) []string {
229+ local := map[string]bool{}
230+ for _, it := range p.Interfaces {
231+ local[it.Name] = true
232+ }
233+ seen := map[string]bool{}
234+ for _, it := range p.Interfaces {
235+ for _, msgs := range [][]spec.Message{it.Requests, it.Events} {
236+ for _, msg := range msgs {
237+ for _, a := range msg.Args {
238+ if (a.Type == "object" || a.Type == "new_id") && a.Interface != "" && !local[a.Interface] {
239+ seen[a.Interface] = true
240+ }
241+ }
242+ }
243+ }
244+ }
245+ out := make([]string, 0, len(seen))
246+ for n := range seen {
247+ out = append(out, n)
248+ }
249+ sort.Strings(out)
250+ return out
251+}
252+
253+func generateCustom(m *spec.Manifest, side, pkg, source string, data []byte, overrides map[string]string) ([]byte, error) {
254+ p, err := spec.Parse(data)
255+ if err != nil {
256+ return nil, fmt.Errorf("%s: %w", source, err)
257+ }
258+ foreign := foreignInterfaces(p)
259+ switch side {
260+ case "client":
261+ s := client.NewScanner()
262+ s.Tool = tool
263+ s.CrossPackage = map[string]string{}
264+ for _, iface := range foreign {
265+ if p, ok := overrides[iface]; ok {
266+ s.CrossPackage[iface] = p
267+ } else if owner, ok := m.Lookup(iface); ok {
268+ s.CrossPackage[iface] = clientImport(owner)
269+ }
270+ // Unresolved interfaces are reported by the generator unless
271+ // they are transport bootstrap types.
272+ }
273+ if err := s.LoadProtocol(p, source); err != nil {
274+ return nil, err
275+ }
276+ return s.Generate(pkg)
277+ case "server":
278+ opts := server.Options{Package: pkg, Imports: map[string]string{}, Owners: map[string]string{}, Tool: tool}
279+ for _, iface := range foreign {
280+ var alias, importPath string
281+ if p, ok := overrides[iface]; ok {
282+ alias, importPath = importAlias(p), p
283+ } else if owner, ok := m.Lookup(iface); ok {
284+ alias, importPath = owner, serverImport(owner)
285+ } else {
286+ return nil, fmt.Errorf("unresolved interface %q: not in the manifest; add -import %s=<importpath>", iface, iface)
287+ }
288+ if prev, ok := opts.Imports[alias]; ok && prev != importPath {
289+ return nil, fmt.Errorf("import alias %q is used for both %q and %q", alias, prev, importPath)
290+ }
291+ opts.Imports[alias] = importPath
292+ opts.Owners[iface] = alias
293+ }
294+ return server.Generate(p, opts)
295+ default:
296+ return nil, fmt.Errorf("-side must be client or server, got %q", side)
297+ }
298+}
299+
300+// importAlias derives a Go identifier from the last element of an import path.
301+func importAlias(p string) string {
302+ b := []byte(path.Base(p))
303+ for i, c := range b {
304+ if !(c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || i > 0 && c >= '0' && c <= '9') {
305+ b[i] = '_'
306+ }
307+ }
308+ return string(b)
309+}
@@ -0,0 +1,309 @@
1+// Command wlbgen generates Go bindings from Wayland protocol XML.
2+//
3+// Usage:
4+//
5+// wlbgen -all [-root DIR]
6+// wlbgen -side client|server -package NAME -out FILE [-import iface=path ...] protocol.xml
7+//
8+// With -all, wlbgen renders client/<pkg>/<pkg>_generated.go and
9+// server/<pkg>/<pkg>_generated.go below DIR for every protocol listed in the
10+// embedded spec manifest, and removes generated files of packages that left
11+// the manifest.
12+//
13+// With -side, wlbgen generates a single file from a private protocol XML.
14+// Interfaces defined in other protocols are resolved from the embedded
15+// manifest (import path .../client/<owner> or .../server/<owner>); each
16+// -import iface=path overrides that resolution for one interface. An
17+// interface that cannot be resolved is an error.
18+package main
19+
20+import (
21+ "flag"
22+ "fmt"
23+ "io"
24+ "os"
25+ "path"
26+ "path/filepath"
27+ "sort"
28+ "strings"
29+
30+ "github.com/bnema/go-wayland-bindings/internal/gen/client"
31+ "github.com/bnema/go-wayland-bindings/internal/gen/server"
32+ "github.com/bnema/go-wayland-bindings/spec"
33+)
34+
35+const tool = "wlbgen"
36+
37+type importFlags map[string]string
38+
39+func (f importFlags) String() string { return fmt.Sprint(map[string]string(f)) }
40+
41+func (f importFlags) Set(v string) error {
42+ iface, p, ok := strings.Cut(v, "=")
43+ if !ok || iface == "" || p == "" {
44+ return fmt.Errorf("want iface=importpath, got %q", v)
45+ }
46+ f[iface] = p
47+ return nil
48+}
49+
50+func main() {
51+ if err := run(os.Args[1:], os.Stderr); err != nil {
52+ fmt.Fprintln(os.Stderr, "wlbgen:", err)
53+ os.Exit(1)
54+ }
55+}
56+
57+func run(args []string, stderr io.Writer) error {
58+ fs := flag.NewFlagSet(tool, flag.ContinueOnError)
59+ fs.SetOutput(stderr)
60+ all := fs.Bool("all", false, "generate client and server bindings for every manifest protocol")
61+ root := fs.String("root", ".", "repository root (with -all)")
62+ side := fs.String("side", "", "`client` or `server` (single file mode)")
63+ pkg := fs.String("package", "", "Go package name (single file mode)")
64+ out := fs.String("out", "", "output file (single file mode)")
65+ imports := importFlags{}
66+ fs.Var(imports, "import", "`iface=importpath` override for a foreign interface (repeatable)")
67+ if err := fs.Parse(args); err != nil {
68+ return err
69+ }
70+ m, err := spec.Load()
71+ if err != nil {
72+ return err
73+ }
74+ if *all {
75+ if fs.NArg() != 0 || *side != "" || *pkg != "" || *out != "" || len(imports) != 0 {
76+ return fmt.Errorf("-all takes no other arguments except -root")
77+ }
78+ return generateAll(m, spec.Open, *root)
79+ }
80+ if fs.NArg() != 1 {
81+ return fmt.Errorf("want -all, or -side/-package/-out and one XML file")
82+ }
83+ if *pkg == "" || *out == "" {
84+ return fmt.Errorf("-package and -out are required")
85+ }
86+ data, err := os.ReadFile(fs.Arg(0))
87+ if err != nil {
88+ return err
89+ }
90+ src, err := generateCustom(m, *side, *pkg, filepath.Base(fs.Arg(0)), data, imports)
91+ if err != nil {
92+ return err
93+ }
94+ return writeFile(*out, src)
95+}
96+
97+func writeFile(name string, data []byte) error {
98+ if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
99+ return err
100+ }
101+ return os.WriteFile(name, data, 0o644)
102+}
103+
104+func clientImport(owner string) string { return spec.ModulePath + "/client/" + owner }
105+func serverImport(owner string) string { return spec.ModulePath + "/server/" + owner }
106+
107+func generatedPath(root, side, pkg string) string {
108+ return filepath.Join(root, side, pkg, pkg+"_generated.go")
109+}
110+
111+// generateAll writes every package of m below root and removes stale output.
112+func generateAll(m *spec.Manifest, open func(string) ([]byte, error), root string) error {
113+ if err := removeStale(m, root); err != nil {
114+ return err
115+ }
116+ owners := m.Owners()
117+ for _, e := range m.Protocols {
118+ data, err := open(e.Package)
119+ if err != nil {
120+ return err
121+ }
122+ p, err := spec.Parse(data)
123+ if err != nil {
124+ return fmt.Errorf("%s: %w", e.Package, err)
125+ }
126+
127+ s := client.NewScanner()
128+ s.Tool = tool
129+ if e.License != "" && e.License != "MIT" {
130+ s.SPDXLicense = e.License
131+ }
132+ s.CrossPackage = map[string]string{}
133+ for iface, owner := range owners {
134+ if owner != e.Package {
135+ s.CrossPackage[iface] = clientImport(owner)
136+ }
137+ }
138+ if err := s.LoadProtocol(p, path.Base(e.Path)); err != nil {
139+ return err
140+ }
141+ src, err := s.Generate(e.Package)
142+ if err != nil {
143+ return fmt.Errorf("client/%s: %w", e.Package, err)
144+ }
145+ if err := writeFile(generatedPath(root, "client", e.Package), src); err != nil {
146+ return err
147+ }
148+
149+ opts := server.Options{Package: e.Package, Imports: map[string]string{}, Owners: map[string]string{}, Tool: tool}
150+ for iface, owner := range owners {
151+ if owner != e.Package {
152+ opts.Owners[iface] = owner
153+ }
154+ }
155+ for _, dep := range e.Deps {
156+ if dep != e.Package {
157+ opts.Imports[dep] = serverImport(dep)
158+ }
159+ }
160+ src, err = server.Generate(p, opts)
161+ if err != nil {
162+ return fmt.Errorf("server/%s: %w", e.Package, err)
163+ }
164+ if err := writeFile(generatedPath(root, "server", e.Package), src); err != nil {
165+ return err
166+ }
167+ }
168+ return nil
169+}
170+
171+// removeStale deletes <side>/<pkg>/<pkg>_generated.go for packages that are
172+// not in m. If such a directory holds anything else, nothing is deleted and an
173+// error lists the remaining files.
174+func removeStale(m *spec.Manifest, root string) error {
175+ known := map[string]bool{}
176+ for _, e := range m.Protocols {
177+ known[e.Package] = true
178+ }
179+ type stale struct{ dir, gen string }
180+ var list []stale
181+ var problems []string
182+ for _, side := range []string{"client", "server"} {
183+ entries, err := os.ReadDir(filepath.Join(root, side))
184+ if os.IsNotExist(err) {
185+ continue
186+ }
187+ if err != nil {
188+ return err
189+ }
190+ for _, d := range entries {
191+ if !d.IsDir() || known[d.Name()] {
192+ continue
193+ }
194+ dir := filepath.Join(root, side, d.Name())
195+ gen := filepath.Join(dir, d.Name()+"_generated.go")
196+ if _, err := os.Stat(gen); err != nil {
197+ continue // not generated output; leave alone
198+ }
199+ files, err := os.ReadDir(dir)
200+ if err != nil {
201+ return err
202+ }
203+ for _, f := range files {
204+ if f.Name() != d.Name()+"_generated.go" {
205+ problems = append(problems, filepath.Join(side, d.Name(), f.Name()))
206+ }
207+ }
208+ list = append(list, stale{dir, gen})
209+ }
210+ }
211+ if len(problems) > 0 {
212+ sort.Strings(problems)
213+ return fmt.Errorf("package removed from manifest but directory still holds other files: %s", strings.Join(problems, ", "))
214+ }
215+ for _, s := range list {
216+ if err := os.Remove(s.gen); err != nil {
217+ return err
218+ }
219+ if err := os.Remove(s.dir); err != nil {
220+ return err
221+ }
222+ }
223+ return nil
224+}
225+
226+// foreignInterfaces returns, sorted, the interfaces referenced by object and
227+// new_id arguments of p that p does not define.
228+func foreignInterfaces(p *spec.Protocol) []string {
229+ local := map[string]bool{}
230+ for _, it := range p.Interfaces {
231+ local[it.Name] = true
232+ }
233+ seen := map[string]bool{}
234+ for _, it := range p.Interfaces {
235+ for _, msgs := range [][]spec.Message{it.Requests, it.Events} {
236+ for _, msg := range msgs {
237+ for _, a := range msg.Args {
238+ if (a.Type == "object" || a.Type == "new_id") && a.Interface != "" && !local[a.Interface] {
239+ seen[a.Interface] = true
240+ }
241+ }
242+ }
243+ }
244+ }
245+ out := make([]string, 0, len(seen))
246+ for n := range seen {
247+ out = append(out, n)
248+ }
249+ sort.Strings(out)
250+ return out
251+}
252+
253+func generateCustom(m *spec.Manifest, side, pkg, source string, data []byte, overrides map[string]string) ([]byte, error) {
254+ p, err := spec.Parse(data)
255+ if err != nil {
256+ return nil, fmt.Errorf("%s: %w", source, err)
257+ }
258+ foreign := foreignInterfaces(p)
259+ switch side {
260+ case "client":
261+ s := client.NewScanner()
262+ s.Tool = tool
263+ s.CrossPackage = map[string]string{}
264+ for _, iface := range foreign {
265+ if p, ok := overrides[iface]; ok {
266+ s.CrossPackage[iface] = p
267+ } else if owner, ok := m.Lookup(iface); ok {
268+ s.CrossPackage[iface] = clientImport(owner)
269+ }
270+ // Unresolved interfaces are reported by the generator unless
271+ // they are transport bootstrap types.
272+ }
273+ if err := s.LoadProtocol(p, source); err != nil {
274+ return nil, err
275+ }
276+ return s.Generate(pkg)
277+ case "server":
278+ opts := server.Options{Package: pkg, Imports: map[string]string{}, Owners: map[string]string{}, Tool: tool}
279+ for _, iface := range foreign {
280+ var alias, importPath string
281+ if p, ok := overrides[iface]; ok {
282+ alias, importPath = importAlias(p), p
283+ } else if owner, ok := m.Lookup(iface); ok {
284+ alias, importPath = owner, serverImport(owner)
285+ } else {
286+ return nil, fmt.Errorf("unresolved interface %q: not in the manifest; add -import %s=<importpath>", iface, iface)
287+ }
288+ if prev, ok := opts.Imports[alias]; ok && prev != importPath {
289+ return nil, fmt.Errorf("import alias %q is used for both %q and %q", alias, prev, importPath)
290+ }
291+ opts.Imports[alias] = importPath
292+ opts.Owners[iface] = alias
293+ }
294+ return server.Generate(p, opts)
295+ default:
296+ return nil, fmt.Errorf("-side must be client or server, got %q", side)
297+ }
298+}
299+
300+// importAlias derives a Go identifier from the last element of an import path.
301+func importAlias(p string) string {
302+ b := []byte(path.Base(p))
303+ for i, c := range b {
304+ if !(c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || i > 0 && c >= '0' && c <= '9') {
305+ b[i] = '_'
306+ }
307+ }
308+ return string(b)
309+}
A cmd/wlbgen/main_test.go +224 -0
@@ -0,0 +1,224 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/go-wayland-bindings/spec"
11+)
12+
13+const customXML = `<?xml version="1.0" encoding="UTF-8"?>
14+<protocol name="custom_thing">
15+ <copyright>Test copyright.</copyright>
16+ <interface name="custom_thing" version="1">
17+ <request name="attach">
18+ <arg name="surface" type="object" interface="wl_surface"/>
19+ </request>
20+ <event name="done"/>
21+ </interface>
22+</protocol>
23+`
24+
25+func writeXML(t *testing.T, body string) string {
26+ t.Helper()
27+ p := filepath.Join(t.TempDir(), "custom.xml")
28+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+ return p
32+}
33+
34+func TestCustomResolvesFromManifest(t *testing.T) {
35+ for side, want := range map[string]string{
36+ "client": `"github.com/bnema/go-wayland-bindings/client/wayland"`,
37+ "server": `wayland "github.com/bnema/go-wayland-bindings/server/wayland"`,
38+ } {
39+ t.Run(side, func(t *testing.T) {
40+ out := filepath.Join(t.TempDir(), "x", "out.go")
41+ args := []string{"-side", side, "-package", "custom", "-out", out, writeXML(t, customXML)}
42+ if err := run(args, &bytes.Buffer{}); err != nil {
43+ t.Fatal(err)
44+ }
45+ b, err := os.ReadFile(out)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if !strings.Contains(string(b), want) {
50+ t.Errorf("output lacks import %s:\n%s", want, b)
51+ }
52+ if !strings.Contains(string(b), "Code generated by wlbgen") {
53+ t.Error("header does not name wlbgen")
54+ }
55+ })
56+ }
57+}
58+
59+func TestCustomImportOverride(t *testing.T) {
60+ out := filepath.Join(t.TempDir(), "out.go")
61+ args := []string{"-side", "server", "-package", "custom", "-out", out,
62+ "-import", "wl_surface=example.com/mine/surfaces", writeXML(t, customXML)}
63+ if err := run(args, &bytes.Buffer{}); err != nil {
64+ t.Fatal(err)
65+ }
66+ b, _ := os.ReadFile(out)
67+ if !strings.Contains(string(b), `surfaces "example.com/mine/surfaces"`) {
68+ t.Errorf("override not used:\n%s", b)
69+ }
70+}
71+
72+func TestCustomUnknownInterfaceErrors(t *testing.T) {
73+ xml := strings.ReplaceAll(customXML, "wl_surface", "no_such_iface")
74+ for _, side := range []string{"client", "server"} {
75+ t.Run(side, func(t *testing.T) {
76+ out := filepath.Join(t.TempDir(), "out.go")
77+ err := run([]string{"-side", side, "-package", "custom", "-out", out, writeXML(t, xml)}, &bytes.Buffer{})
78+ if err == nil || !strings.Contains(err.Error(), "no_such_iface") {
79+ t.Fatalf("want unresolved-interface error, got %v", err)
80+ }
81+ if _, statErr := os.Stat(out); statErr == nil {
82+ t.Error("output written despite error")
83+ }
84+ })
85+ }
86+}
87+
88+func TestCustomBadSide(t *testing.T) {
89+ out := filepath.Join(t.TempDir(), "out.go")
90+ if err := run([]string{"-side", "both", "-package", "p", "-out", out, writeXML(t, customXML)}, &bytes.Buffer{}); err == nil {
91+ t.Fatal("want error for bad -side")
92+ }
93+}
94+
95+// smallManifest builds a one-package manifest backed by customXML-like input.
96+func smallManifest() (*spec.Manifest, func(string) ([]byte, error)) {
97+ xml := strings.ReplaceAll(customXML, `<arg name="surface" type="object" interface="wl_surface"/>`, ``)
98+ m := &spec.Manifest{Protocols: []spec.ProtocolEntry{{
99+ Package: "custom", XML: "custom.xml", Path: "dir/custom-v1.xml", License: "MIT",
100+ Interfaces: []spec.InterfaceEntry{{Name: "custom_thing", Version: 1}},
101+ }}}
102+ return m, func(string) ([]byte, error) { return []byte(xml), nil }
103+}
104+
105+func TestAllDeterministic(t *testing.T) {
106+ root := t.TempDir()
107+ if err := generateAll(mustLoad(t), spec.Open, root); err != nil {
108+ t.Fatal(err)
109+ }
110+ first := snapshot(t, root)
111+ if len(first) != 2*len(mustLoad(t).Protocols) {
112+ t.Fatalf("got %d files, want 2 per protocol", len(first))
113+ }
114+ if err := generateAll(mustLoad(t), spec.Open, root); err != nil {
115+ t.Fatal(err)
116+ }
117+ second := snapshot(t, root)
118+ for name, b := range first {
119+ if !bytes.Equal(b, second[name]) {
120+ t.Errorf("%s differs between runs", name)
121+ }
122+ }
123+ if len(first) != len(second) {
124+ t.Errorf("file count changed: %d -> %d", len(first), len(second))
125+ }
126+}
127+
128+func mustLoad(t *testing.T) *spec.Manifest {
129+ t.Helper()
130+ m, err := spec.Load()
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ return m
135+}
136+
137+func snapshot(t *testing.T, root string) map[string][]byte {
138+ t.Helper()
139+ out := map[string][]byte{}
140+ err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
141+ if err != nil || d.IsDir() {
142+ return err
143+ }
144+ b, err := os.ReadFile(p)
145+ out[p] = b
146+ return err
147+ })
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ return out
152+}
153+
154+func TestAllSourceNameAndLicense(t *testing.T) {
155+ root := t.TempDir()
156+ m, open := smallManifest()
157+ m.Protocols[0].License = "LGPL-2.1-or-later"
158+ if err := generateAll(m, open, root); err != nil {
159+ t.Fatal(err)
160+ }
161+ b, err := os.ReadFile(filepath.Join(root, "client", "custom", "custom_generated.go"))
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ for _, want := range []string{"custom-v1.xml", "SPDX-License-Identifier: LGPL-2.1-or-later"} {
166+ if !strings.Contains(string(b), want) {
167+ t.Errorf("client header lacks %q", want)
168+ }
169+ }
170+ if _, err := os.Stat(filepath.Join(root, "server", "custom", "custom_generated.go")); err != nil {
171+ t.Error(err)
172+ }
173+}
174+
175+func TestStaleGeneratedRemoved(t *testing.T) {
176+ root := t.TempDir()
177+ for _, side := range []string{"client", "server"} {
178+ dir := filepath.Join(root, side, "gone")
179+ if err := os.MkdirAll(dir, 0o755); err != nil {
180+ t.Fatal(err)
181+ }
182+ if err := os.WriteFile(filepath.Join(dir, "gone_generated.go"), []byte("package gone\n"), 0o600); err != nil {
183+ t.Fatal(err)
184+ }
185+ }
186+ // A directory without a generated file is not ours and stays.
187+ keep := filepath.Join(root, "client", "handwritten")
188+ if err := os.MkdirAll(keep, 0o755); err != nil {
189+ t.Fatal(err)
190+ }
191+ m, open := smallManifest()
192+ if err := generateAll(m, open, root); err != nil {
193+ t.Fatal(err)
194+ }
195+ for _, side := range []string{"client", "server"} {
196+ if _, err := os.Stat(filepath.Join(root, side, "gone")); !os.IsNotExist(err) {
197+ t.Errorf("%s/gone not removed (err=%v)", side, err)
198+ }
199+ }
200+ if _, err := os.Stat(keep); err != nil {
201+ t.Errorf("unrelated dir removed: %v", err)
202+ }
203+}
204+
205+func TestStaleWithHandWrittenFileErrors(t *testing.T) {
206+ root := t.TempDir()
207+ dir := filepath.Join(root, "client", "gone")
208+ if err := os.MkdirAll(dir, 0o755); err != nil {
209+ t.Fatal(err)
210+ }
211+ for _, f := range []string{"gone_generated.go", "table.go"} {
212+ if err := os.WriteFile(filepath.Join(dir, f), []byte("package gone\n"), 0o600); err != nil {
213+ t.Fatal(err)
214+ }
215+ }
216+ m, open := smallManifest()
217+ err := generateAll(m, open, root)
218+ if err == nil || !strings.Contains(err.Error(), filepath.Join("client", "gone", "table.go")) {
219+ t.Fatalf("want error listing table.go, got %v", err)
220+ }
221+ if _, err := os.Stat(filepath.Join(dir, "gone_generated.go")); err != nil {
222+ t.Error("generated file deleted despite error")
223+ }
224+}
@@ -0,0 +1,224 @@
1+package main
2+
3+import (
4+ "bytes"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/go-wayland-bindings/spec"
11+)
12+
13+const customXML = `<?xml version="1.0" encoding="UTF-8"?>
14+<protocol name="custom_thing">
15+ <copyright>Test copyright.</copyright>
16+ <interface name="custom_thing" version="1">
17+ <request name="attach">
18+ <arg name="surface" type="object" interface="wl_surface"/>
19+ </request>
20+ <event name="done"/>
21+ </interface>
22+</protocol>
23+`
24+
25+func writeXML(t *testing.T, body string) string {
26+ t.Helper()
27+ p := filepath.Join(t.TempDir(), "custom.xml")
28+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
29+ t.Fatal(err)
30+ }
31+ return p
32+}
33+
34+func TestCustomResolvesFromManifest(t *testing.T) {
35+ for side, want := range map[string]string{
36+ "client": `"github.com/bnema/go-wayland-bindings/client/wayland"`,
37+ "server": `wayland "github.com/bnema/go-wayland-bindings/server/wayland"`,
38+ } {
39+ t.Run(side, func(t *testing.T) {
40+ out := filepath.Join(t.TempDir(), "x", "out.go")
41+ args := []string{"-side", side, "-package", "custom", "-out", out, writeXML(t, customXML)}
42+ if err := run(args, &bytes.Buffer{}); err != nil {
43+ t.Fatal(err)
44+ }
45+ b, err := os.ReadFile(out)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if !strings.Contains(string(b), want) {
50+ t.Errorf("output lacks import %s:\n%s", want, b)
51+ }
52+ if !strings.Contains(string(b), "Code generated by wlbgen") {
53+ t.Error("header does not name wlbgen")
54+ }
55+ })
56+ }
57+}
58+
59+func TestCustomImportOverride(t *testing.T) {
60+ out := filepath.Join(t.TempDir(), "out.go")
61+ args := []string{"-side", "server", "-package", "custom", "-out", out,
62+ "-import", "wl_surface=example.com/mine/surfaces", writeXML(t, customXML)}
63+ if err := run(args, &bytes.Buffer{}); err != nil {
64+ t.Fatal(err)
65+ }
66+ b, _ := os.ReadFile(out)
67+ if !strings.Contains(string(b), `surfaces "example.com/mine/surfaces"`) {
68+ t.Errorf("override not used:\n%s", b)
69+ }
70+}
71+
72+func TestCustomUnknownInterfaceErrors(t *testing.T) {
73+ xml := strings.ReplaceAll(customXML, "wl_surface", "no_such_iface")
74+ for _, side := range []string{"client", "server"} {
75+ t.Run(side, func(t *testing.T) {
76+ out := filepath.Join(t.TempDir(), "out.go")
77+ err := run([]string{"-side", side, "-package", "custom", "-out", out, writeXML(t, xml)}, &bytes.Buffer{})
78+ if err == nil || !strings.Contains(err.Error(), "no_such_iface") {
79+ t.Fatalf("want unresolved-interface error, got %v", err)
80+ }
81+ if _, statErr := os.Stat(out); statErr == nil {
82+ t.Error("output written despite error")
83+ }
84+ })
85+ }
86+}
87+
88+func TestCustomBadSide(t *testing.T) {
89+ out := filepath.Join(t.TempDir(), "out.go")
90+ if err := run([]string{"-side", "both", "-package", "p", "-out", out, writeXML(t, customXML)}, &bytes.Buffer{}); err == nil {
91+ t.Fatal("want error for bad -side")
92+ }
93+}
94+
95+// smallManifest builds a one-package manifest backed by customXML-like input.
96+func smallManifest() (*spec.Manifest, func(string) ([]byte, error)) {
97+ xml := strings.ReplaceAll(customXML, `<arg name="surface" type="object" interface="wl_surface"/>`, ``)
98+ m := &spec.Manifest{Protocols: []spec.ProtocolEntry{{
99+ Package: "custom", XML: "custom.xml", Path: "dir/custom-v1.xml", License: "MIT",
100+ Interfaces: []spec.InterfaceEntry{{Name: "custom_thing", Version: 1}},
101+ }}}
102+ return m, func(string) ([]byte, error) { return []byte(xml), nil }
103+}
104+
105+func TestAllDeterministic(t *testing.T) {
106+ root := t.TempDir()
107+ if err := generateAll(mustLoad(t), spec.Open, root); err != nil {
108+ t.Fatal(err)
109+ }
110+ first := snapshot(t, root)
111+ if len(first) != 2*len(mustLoad(t).Protocols) {
112+ t.Fatalf("got %d files, want 2 per protocol", len(first))
113+ }
114+ if err := generateAll(mustLoad(t), spec.Open, root); err != nil {
115+ t.Fatal(err)
116+ }
117+ second := snapshot(t, root)
118+ for name, b := range first {
119+ if !bytes.Equal(b, second[name]) {
120+ t.Errorf("%s differs between runs", name)
121+ }
122+ }
123+ if len(first) != len(second) {
124+ t.Errorf("file count changed: %d -> %d", len(first), len(second))
125+ }
126+}
127+
128+func mustLoad(t *testing.T) *spec.Manifest {
129+ t.Helper()
130+ m, err := spec.Load()
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ return m
135+}
136+
137+func snapshot(t *testing.T, root string) map[string][]byte {
138+ t.Helper()
139+ out := map[string][]byte{}
140+ err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
141+ if err != nil || d.IsDir() {
142+ return err
143+ }
144+ b, err := os.ReadFile(p)
145+ out[p] = b
146+ return err
147+ })
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+ return out
152+}
153+
154+func TestAllSourceNameAndLicense(t *testing.T) {
155+ root := t.TempDir()
156+ m, open := smallManifest()
157+ m.Protocols[0].License = "LGPL-2.1-or-later"
158+ if err := generateAll(m, open, root); err != nil {
159+ t.Fatal(err)
160+ }
161+ b, err := os.ReadFile(filepath.Join(root, "client", "custom", "custom_generated.go"))
162+ if err != nil {
163+ t.Fatal(err)
164+ }
165+ for _, want := range []string{"custom-v1.xml", "SPDX-License-Identifier: LGPL-2.1-or-later"} {
166+ if !strings.Contains(string(b), want) {
167+ t.Errorf("client header lacks %q", want)
168+ }
169+ }
170+ if _, err := os.Stat(filepath.Join(root, "server", "custom", "custom_generated.go")); err != nil {
171+ t.Error(err)
172+ }
173+}
174+
175+func TestStaleGeneratedRemoved(t *testing.T) {
176+ root := t.TempDir()
177+ for _, side := range []string{"client", "server"} {
178+ dir := filepath.Join(root, side, "gone")
179+ if err := os.MkdirAll(dir, 0o755); err != nil {
180+ t.Fatal(err)
181+ }
182+ if err := os.WriteFile(filepath.Join(dir, "gone_generated.go"), []byte("package gone\n"), 0o600); err != nil {
183+ t.Fatal(err)
184+ }
185+ }
186+ // A directory without a generated file is not ours and stays.
187+ keep := filepath.Join(root, "client", "handwritten")
188+ if err := os.MkdirAll(keep, 0o755); err != nil {
189+ t.Fatal(err)
190+ }
191+ m, open := smallManifest()
192+ if err := generateAll(m, open, root); err != nil {
193+ t.Fatal(err)
194+ }
195+ for _, side := range []string{"client", "server"} {
196+ if _, err := os.Stat(filepath.Join(root, side, "gone")); !os.IsNotExist(err) {
197+ t.Errorf("%s/gone not removed (err=%v)", side, err)
198+ }
199+ }
200+ if _, err := os.Stat(keep); err != nil {
201+ t.Errorf("unrelated dir removed: %v", err)
202+ }
203+}
204+
205+func TestStaleWithHandWrittenFileErrors(t *testing.T) {
206+ root := t.TempDir()
207+ dir := filepath.Join(root, "client", "gone")
208+ if err := os.MkdirAll(dir, 0o755); err != nil {
209+ t.Fatal(err)
210+ }
211+ for _, f := range []string{"gone_generated.go", "table.go"} {
212+ if err := os.WriteFile(filepath.Join(dir, f), []byte("package gone\n"), 0o600); err != nil {
213+ t.Fatal(err)
214+ }
215+ }
216+ m, open := smallManifest()
217+ err := generateAll(m, open, root)
218+ if err == nil || !strings.Contains(err.Error(), filepath.Join("client", "gone", "table.go")) {
219+ t.Fatalf("want error listing table.go, got %v", err)
220+ }
221+ if _, err := os.Stat(filepath.Join(dir, "gone_generated.go")); err != nil {
222+ t.Error("generated file deleted despite error")
223+ }
224+}
A generate.go +9 -0
@@ -0,0 +1,9 @@
1+// Package bindings is the root of github.com/bnema/go-wayland-bindings, Go
2+// bindings for the Wayland protocols vendored in spec/.
3+//
4+// Client bindings live in client/<pkg> and server bindings in server/<pkg>.
5+// The root package has no API; it only carries the go:generate directive that
6+// regenerates every binding from spec/manifest.json.
7+package bindings
8+
9+//go:generate go run ./cmd/wlbgen -all
@@ -0,0 +1,9 @@
1+// Package bindings is the root of github.com/bnema/go-wayland-bindings, Go
2+// bindings for the Wayland protocols vendored in spec/.
3+//
4+// Client bindings live in client/<pkg> and server bindings in server/<pkg>.
5+// The root package has no API; it only carries the go:generate directive that
6+// regenerates every binding from spec/manifest.json.
7+package bindings
8+
9+//go:generate go run ./cmd/wlbgen -all
M go.mod +2 -0
@@ -7,3 +7,5 @@ require (
77 github.com/bnema/wlturbo v0.5.0
88 golang.org/x/sys v0.48.0
99 )
10+
11+require github.com/bnema/purego v0.13.0-bnema.1 // indirect
@@ -7,3 +7,5 @@ require (
7 github.com/bnema/wlturbo v0.5.07 github.com/bnema/wlturbo v0.5.0
8 golang.org/x/sys v0.48.08 golang.org/x/sys v0.48.0
9 )9 )
10+
11+require github.com/bnema/purego v0.13.0-bnema.1 // indirect
M go.sum +2 -2 generated file: collapsed

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 1

↑↓ moveenter openesc close