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 // Include module version in .mxh header if available.
91 wd, _ := os.Getwd()
92 modVersion := loader.ParseMoxieModVersion(wd)
93 headerLine := "// mxh v1 " + hash
94 if modVersion != "" {
95 headerLine += " " + modVersion
96 }
97 full := headerLine + "\n" + body.String()
98
99 // Compat check against previous version.
100 if prevPath != "" {
101 f, err := os.Open(prevPath)
102 if err != nil {
103 return fmt.Errorf("header: opening -prev: %w", err)
104 }
105 defer f.Close()
106 prev, err := parse.ParseMXH(f)
107 f.Close()
108 if err != nil {
109 return fmt.Errorf("header: parsing -prev: %w", err)
110 }
111 if err := compatCheck(prev, types_); err != nil {
112 return err
113 }
114 }
115
116 if outdir == "-" {
117 fmt.Print(full)
118 return nil
119 }
120
121 pkgBase := filepath.Base(targetPkg.Dir)
122 outPath := filepath.Join(outdir, pkgBase+".mxh")
123 return os.WriteFile(outPath, []byte(full), 0644)
124 }
125
126 type codecTypeDef struct {
127 name string
128 fields []codecField
129 }
130
131 type codecField struct {
132 name string
133 typ string
134 }
135
136 func collectCodecTypes(pkg *loader.Package, prog *loader.Program) ([]codecTypeDef, error) {
137 scope := pkg.Pkg.Scope()
138 names := scope.Names()
139 sort.Strings(names)
140
141 var out []codecTypeDef
142 for _, name := range names {
143 obj := scope.Lookup(name)
144 tn, ok := obj.(*types.TypeName)
145 if !ok {
146 continue
147 }
148 t := tn.Type()
149 if !loader.ImplementsCodec(t, prog) {
150 continue
151 }
152
153 // Skip interface types — they can't cross spawn boundaries as data.
154 if _, ok := t.Underlying().(*types.Interface); ok {
155 continue
156 }
157
158 var fields []codecField
159 if st, ok := t.Underlying().(*types.Struct); ok {
160 // Struct type: emit exported fixed-width fields for wire-layout compat.
161 for i := 0; i < st.NumFields(); i++ {
162 f := st.Field(i)
163 if !f.Exported() {
164 continue
165 }
166 ft := f.Type()
167 if isVariableLength(ft) {
168 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())
169 }
170 fields = append(fields, codecField{name: f.Name(), typ: types.TypeString(ft, nil)})
171 }
172 } else if !isVariableLength(t.Underlying()) {
173 // Non-struct fixed-width named type (e.g. type Bool bool, type Int32 int32):
174 // emit the underlying type string for compat detection.
175 underlyingStr := types.TypeString(t.Underlying(), nil)
176 fields = append(fields, codecField{name: "_", typ: underlyingStr})
177 }
178 // else: variable-length named type (e.g. type Bytes []byte): emit as opaque.
179 // Wire format is defined by EncodeTo/DecodeFrom, not struct layout.
180 out = append(out, codecTypeDef{name: name, fields: fields})
181 }
182 return out, nil
183 }
184
185 // findTargetPkg finds the package the user requested for header extraction.
186 // It checks by exact import path, then by the working directory.
187 func findTargetPkg(prog *loader.Program, pkgName string) *loader.Package {
188 // Direct import path lookup.
189 if pkg, ok := prog.Packages[pkgName]; ok {
190 return pkg
191 }
192 // Relative or absolute directory: find by resolved directory.
193 abs, err := filepath.Abs(pkgName)
194 if err == nil {
195 for _, pkg := range prog.Sorted() {
196 if pkg.Dir == abs || filepath.Base(pkg.Dir) == pkgName {
197 return pkg
198 }
199 }
200 }
201 return nil
202 }
203
204 // isVariableLength returns true for slice and map types.
205 func isVariableLength(t types.Type) bool {
206 switch t.Underlying().(type) {
207 case *types.Slice, *types.Map:
208 return true
209 }
210 return false
211 }
212
213 // compatCheck verifies backward-compat: fields can only be appended, never
214 // removed, reordered, or changed in type.
215 func compatCheck(prev *parse.MXH, cur []codecTypeDef) error {
216 curByName := make(map[string]*codecTypeDef, len(cur))
217 for i := range cur {
218 curByName[cur[i].name] = &cur[i]
219 }
220
221 for _, pt := range prev.Types {
222 ct, ok := curByName[pt.Name]
223 if !ok {
224 return fmt.Errorf("compat: type %s was removed (breaking change)", pt.Name)
225 }
226 for i, pf := range pt.Fields {
227 if i >= len(ct.fields) {
228 return fmt.Errorf("compat: %s.%s (field %d) was removed (breaking change)", pt.Name, pf.Name, i)
229 }
230 cf := ct.fields[i]
231 if cf.name != pf.Name {
232 return fmt.Errorf("compat: %s field %d: was %q, now %q (reorder is breaking)", pt.Name, i, pf.Name, cf.name)
233 }
234 if cf.typ != pf.Type {
235 return fmt.Errorf("compat: %s.%s type changed from %q to %q (breaking)", pt.Name, pf.Name, pf.Type, cf.typ)
236 }
237 }
238 }
239 return nil
240 }
241