header.go raw
1 package main
2
3 import (
4 "fmt"
5 "go/types"
6 "hash/fnv"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12
13 "moxie/builder"
14 "moxie/compileopts"
15 "moxie/loader"
16 "moxie/parse"
17 )
18
19 // Header scans a package for codec types and emits a .mxh protocol header.
20 // outdir is the directory to write <pkgname>.mxh into; "-" writes to stdout.
21 // prevPath is an optional path to an old .mxh for backward-compat checking.
22 func Header(pkgName, outdir, prevPath string, options *compileopts.Options) error {
23 config, err := builder.NewConfig(options)
24 if err != nil {
25 return err
26 }
27 prog, err := loader.Load(config, pkgName, types.Config{})
28 if err != nil {
29 return err
30 }
31 prog.SkipMainNameCheck = true
32 if err := prog.Parse(); err != nil {
33 return err
34 }
35
36 // Find the target package by import path or by resolving a local dir.
37 // Do not use MainPkg() — that returns the last dependency-ordered package,
38 // which may not be the requested package when the input is a stdlib package.
39 targetPkg := findTargetPkg(prog, pkgName)
40 if targetPkg == nil {
41 return fmt.Errorf("header: package %q not found in program", pkgName)
42 }
43
44 types_, err := collectCodecTypes(targetPkg, prog)
45 if err != nil {
46 return err
47 }
48
49 // Build body.
50 var body strings.Builder
51 // Wire protocol declaration.
52 body.WriteString("// wire: uint32-length-prefixed EncodeTo/DecodeFrom at cross-binary spawn boundaries\n")
53 for _, td := range types_ {
54 body.WriteString("type ")
55 body.WriteString(td.name)
56 switch {
57 case len(td.fields) > 0 && td.fields[0].name == "_":
58 // Named basic type: "type Bool bool"
59 body.WriteString(" ")
60 body.WriteString(td.fields[0].typ)
61 body.WriteString("\n")
62 case len(td.fields) > 0:
63 // Struct type with exported fields.
64 body.WriteString(" struct {\n")
65 for _, f := range td.fields {
66 body.WriteString("\t")
67 body.WriteString(f.name)
68 body.WriteString(" ")
69 body.WriteString(f.typ)
70 body.WriteString("\n")
71 }
72 body.WriteString("}\n")
73 default:
74 // Opaque type (e.g. type Bytes []byte): wire format via methods only.
75 body.WriteString(" opaque\n")
76 }
77 body.WriteString("func (")
78 body.WriteString(td.name)
79 body.WriteString(") EncodeTo(w io.Writer) error\n")
80 body.WriteString("func (*")
81 body.WriteString(td.name)
82 body.WriteString(") DecodeFrom(r io.Reader) error\n")
83 }
84
85 // Hash the body with FNV-1a.
86 h := fnv.New64a()
87 io.WriteString(h, body.String())
88 hash := fmt.Sprintf("%016x", h.Sum64())
89
90 full := "// mxh v1 " + hash + "\n" + body.String()
91
92 // Compat check against previous version.
93 if prevPath != "" {
94 f, err := os.Open(prevPath)
95 if err != nil {
96 return fmt.Errorf("header: opening -prev: %w", err)
97 }
98 defer f.Close()
99 prev, err := parse.ParseMXH(f)
100 f.Close()
101 if err != nil {
102 return fmt.Errorf("header: parsing -prev: %w", err)
103 }
104 if err := compatCheck(prev, types_); err != nil {
105 return err
106 }
107 }
108
109 if outdir == "-" {
110 fmt.Print(full)
111 return nil
112 }
113
114 pkgBase := filepath.Base(targetPkg.Dir)
115 outPath := filepath.Join(outdir, pkgBase+".mxh")
116 return os.WriteFile(outPath, []byte(full), 0644)
117 }
118
119 type codecTypeDef struct {
120 name string
121 fields []codecField
122 }
123
124 type codecField struct {
125 name string
126 typ string
127 }
128
129 func collectCodecTypes(pkg *loader.Package, prog *loader.Program) ([]codecTypeDef, error) {
130 scope := pkg.Pkg.Scope()
131 names := scope.Names()
132 sort.Strings(names)
133
134 var out []codecTypeDef
135 for _, name := range names {
136 obj := scope.Lookup(name)
137 tn, ok := obj.(*types.TypeName)
138 if !ok {
139 continue
140 }
141 t := tn.Type()
142 if !loader.ImplementsCodec(t, prog) {
143 continue
144 }
145
146 // Skip interface types — they can't cross spawn boundaries as data.
147 if _, ok := t.Underlying().(*types.Interface); ok {
148 continue
149 }
150
151 var fields []codecField
152 if st, ok := t.Underlying().(*types.Struct); ok {
153 // Struct type: emit exported fixed-width fields for wire-layout compat.
154 for i := 0; i < st.NumFields(); i++ {
155 f := st.Field(i)
156 if !f.Exported() {
157 continue
158 }
159 ft := f.Type()
160 if isVariableLength(ft) {
161 return nil, fmt.Errorf("header: variable-length field %s.%s cannot appear in .mxh struct layout; encode it in EncodeTo/DecodeFrom instead", name, f.Name())
162 }
163 fields = append(fields, codecField{name: f.Name(), typ: types.TypeString(ft, nil)})
164 }
165 } else if !isVariableLength(t.Underlying()) {
166 // Non-struct fixed-width named type (e.g. type Bool bool, type Int32 int32):
167 // emit the underlying type string for compat detection.
168 underlyingStr := types.TypeString(t.Underlying(), nil)
169 fields = append(fields, codecField{name: "_", typ: underlyingStr})
170 }
171 // else: variable-length named type (e.g. type Bytes []byte): emit as opaque.
172 // Wire format is defined by EncodeTo/DecodeFrom, not struct layout.
173 out = append(out, codecTypeDef{name: name, fields: fields})
174 }
175 return out, nil
176 }
177
178 // findTargetPkg finds the package the user requested for header extraction.
179 // It checks by exact import path, then by the working directory.
180 func findTargetPkg(prog *loader.Program, pkgName string) *loader.Package {
181 // Direct import path lookup.
182 if pkg, ok := prog.Packages[pkgName]; ok {
183 return pkg
184 }
185 // Relative or absolute directory: find by resolved directory.
186 abs, err := filepath.Abs(pkgName)
187 if err == nil {
188 for _, pkg := range prog.Sorted() {
189 if pkg.Dir == abs || filepath.Base(pkg.Dir) == pkgName {
190 return pkg
191 }
192 }
193 }
194 return nil
195 }
196
197 // isVariableLength returns true for slice and map types.
198 func isVariableLength(t types.Type) bool {
199 switch t.Underlying().(type) {
200 case *types.Slice, *types.Map:
201 return true
202 }
203 return false
204 }
205
206 // compatCheck verifies backward-compat: fields can only be appended, never
207 // removed, reordered, or changed in type.
208 func compatCheck(prev *parse.MXH, cur []codecTypeDef) error {
209 curByName := make(map[string]*codecTypeDef, len(cur))
210 for i := range cur {
211 curByName[cur[i].name] = &cur[i]
212 }
213
214 for _, pt := range prev.Types {
215 ct, ok := curByName[pt.Name]
216 if !ok {
217 return fmt.Errorf("compat: type %s was removed (breaking change)", pt.Name)
218 }
219 for i, pf := range pt.Fields {
220 if i >= len(ct.fields) {
221 return fmt.Errorf("compat: %s.%s (field %d) was removed (breaking change)", pt.Name, pf.Name, i)
222 }
223 cf := ct.fields[i]
224 if cf.name != pf.Name {
225 return fmt.Errorf("compat: %s field %d: was %q, now %q (reorder is breaking)", pt.Name, i, pf.Name, cf.name)
226 }
227 if cf.typ != pf.Type {
228 return fmt.Errorf("compat: %s.%s type changed from %q to %q (breaking)", pt.Name, pf.Name, pf.Type, cf.typ)
229 }
230 }
231 }
232 return nil
233 }
234