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