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