mxbuild.go raw

   1  // mxbuild.go provides a custom go/build.Context that discovers .mx source
   2  // files by presenting them to go/build as .go files. This lets us reuse
   3  // go/build's constraint matching, import resolution, and file classification
   4  // without vendoring or modifying the standard library.
   5  //
   6  // The trick: ReadDir renames .mx → .go in directory listings, and OpenFile
   7  // redirects .go reads to the corresponding .mx file on disk.
   8  
   9  package loader
  10  
  11  import (
  12  	"bytes"
  13  	"go/build"
  14  	"io"
  15  	"io/fs"
  16  	"os"
  17  	"path/filepath"
  18  	"strings"
  19  	"time"
  20  )
  21  
  22  // mxContext returns a go/build.Context configured for the given target
  23  // that transparently finds .mx files wherever go/build expects .go files.
  24  func mxContext(goroot, goos, goarch string, buildTags []string) build.Context {
  25  	ctx := build.Default
  26  	ctx.GOROOT = goroot
  27  	ctx.GOOS = goos
  28  	ctx.GOARCH = goarch
  29  	ctx.BuildTags = buildTags
  30  	ctx.CgoEnabled = false
  31  	ctx.Compiler = "gc"
  32  
  33  	ctx.ReadDir = mxReadDir
  34  	ctx.OpenFile = mxOpenFile
  35  
  36  	return ctx
  37  }
  38  
  39  // mxReadDir reads a directory and presents .mx files as .go files.
  40  // Actual .go files are passed through unchanged (for external deps
  41  // in the module cache that remain .go).
  42  func mxReadDir(dir string) ([]fs.FileInfo, error) {
  43  	entries, err := os.ReadDir(dir)
  44  	if err != nil {
  45  		return nil, err
  46  	}
  47  	// Track .mx base names so we can suppress duplicate .go files
  48  	// if both foo.mx and foo.go somehow exist in the same directory.
  49  	mxBases := make(map[string]bool)
  50  	for _, e := range entries {
  51  		name := e.Name()
  52  		if strings.HasSuffix(name, ".mx") {
  53  			mxBases[strings.TrimSuffix(name, ".mx")] = true
  54  		}
  55  	}
  56  
  57  	infos := make([]fs.FileInfo, 0, len(entries))
  58  	for _, e := range entries {
  59  		name := e.Name()
  60  		info, err := e.Info()
  61  		if err != nil {
  62  			return nil, err
  63  		}
  64  
  65  		if strings.HasSuffix(name, ".mx") {
  66  			// Present .mx as .go so go/build's matchFile accepts it.
  67  			infos = append(infos, renamedFileInfo{
  68  				FileInfo: info,
  69  				name:     strings.TrimSuffix(name, ".mx") + ".go",
  70  			})
  71  		} else if strings.HasSuffix(name, ".go") && mxBases[strings.TrimSuffix(name, ".go")] {
  72  			// Skip .go if a corresponding .mx exists — .mx wins.
  73  			continue
  74  		} else {
  75  			infos = append(infos, info)
  76  		}
  77  	}
  78  	return infos, nil
  79  }
  80  
  81  // mxOpenFile opens a file, redirecting .go requests to .mx when the .mx
  82  // file exists on disk. For files in the module cache (external deps) where
  83  // only .go exists, opens the .go file directly.
  84  func mxOpenFile(path string) (io.ReadCloser, error) {
  85  	if strings.HasSuffix(path, ".go") {
  86  		mxPath := strings.TrimSuffix(path, ".go") + ".mx"
  87  		if data, err := os.ReadFile(mxPath); err == nil {
  88  			rewritten := rewriteBuildDirectives(data)
  89  			rewritten = rewriteMoxiePragmas(rewritten)
  90  			rewritten = rewritePkgMainToInit(rewritten)
  91  			return io.NopCloser(bytes.NewReader(rewritten)), nil
  92  		}
  93  	}
  94  	return os.Open(path)
  95  }
  96  
  97  // rewritePkgMainToInit renames "func main()" to "func init()" in
  98  // non-main packages. Moxie uses func main() for package initialization
  99  // but Go's SSA builder only recognizes init().
 100  func rewritePkgMainToInit(data []byte) []byte {
 101  	// Check if this is the main package — don't rewrite main.main.
 102  	if isMainPackage(data) {
 103  		return data
 104  	}
 105  	// Replace "func main()" declarations with "func init()".
 106  	return bytes.ReplaceAll(data, []byte("func main()"), []byte("func init()"))
 107  }
 108  
 109  // isMainPackage returns true if the source declares "package main".
 110  func isMainPackage(data []byte) bool {
 111  	// Scan past comments and blank lines to find the package declaration.
 112  	rest := data
 113  	for len(rest) > 0 {
 114  		// Skip whitespace/newlines.
 115  		i := 0
 116  		for i < len(rest) && (rest[i] == ' ' || rest[i] == '\t' || rest[i] == '\n' || rest[i] == '\r') {
 117  			i++
 118  		}
 119  		rest = rest[i:]
 120  		if len(rest) == 0 {
 121  			break
 122  		}
 123  		// Skip line comments.
 124  		if len(rest) >= 2 && rest[0] == '/' && rest[1] == '/' {
 125  			nl := bytes.IndexByte(rest, '\n')
 126  			if nl < 0 {
 127  				break
 128  			}
 129  			rest = rest[nl+1:]
 130  			continue
 131  		}
 132  		// Skip block comments.
 133  		if len(rest) >= 2 && rest[0] == '/' && rest[1] == '*' {
 134  			end := bytes.Index(rest[2:], []byte("*/"))
 135  			if end < 0 {
 136  				break
 137  			}
 138  			rest = rest[2+end+2:]
 139  			continue
 140  		}
 141  		// Should be the package declaration.
 142  		if bytes.HasPrefix(rest, []byte("package main")) {
 143  			after := rest[len("package main"):]
 144  			if len(after) == 0 || after[0] == '\n' || after[0] == '\r' || after[0] == ' ' || after[0] == '\t' || after[0] == '/' {
 145  				return true
 146  			}
 147  		}
 148  		break
 149  	}
 150  	return false
 151  }
 152  
 153  // rewriteMoxiePragmas rewrites //:pragma lines to //go:pragma so that
 154  // the Go compiler toolchain recognizes them (linkname, inline, nobounds,
 155  // extern, noinline, section, wasmimport, etc).
 156  func rewriteMoxiePragmas(data []byte) []byte {
 157  	// Fast path: no //: lines.
 158  	if !bytes.Contains(data, []byte("//:")) {
 159  		return data
 160  	}
 161  	var out bytes.Buffer
 162  	out.Grow(len(data))
 163  	rest := data
 164  	for len(rest) > 0 {
 165  		nl := bytes.IndexByte(rest, '\n')
 166  		var line []byte
 167  		if nl >= 0 {
 168  			line = rest[:nl+1]
 169  			rest = rest[nl+1:]
 170  		} else {
 171  			line = rest
 172  			rest = nil
 173  		}
 174  		trimmed := bytes.TrimLeft(line, " \t")
 175  		// Rewrite //:<word> to //go:<word> but NOT //:build (already handled)
 176  		// and NOT //: (bare, no word following).
 177  		if len(trimmed) > 3 && trimmed[0] == '/' && trimmed[1] == '/' && trimmed[2] == ':' &&
 178  			trimmed[3] >= 'a' && trimmed[3] <= 'z' && !bytes.HasPrefix(trimmed, []byte("//:build ")) {
 179  			idx := bytes.Index(line, []byte("//:"))
 180  			out.Write(line[:idx])
 181  			out.WriteString("//go:")
 182  			out.Write(line[idx+3:])
 183  		} else {
 184  			out.Write(line)
 185  		}
 186  	}
 187  	return out.Bytes()
 188  }
 189  
 190  // rewriteBuildDirectives rewrites //:build lines to a single //go:build
 191  // line so that go/build can evaluate Moxie build constraints.
 192  // Multiple //:build lines are combined with &&.
 193  // Only scans before the package clause.
 194  func rewriteBuildDirectives(data []byte) []byte {
 195  	// Fast path: no //:build in the file.
 196  	if !bytes.Contains(data[:min(len(data), 512)], []byte("//:build")) {
 197  		return data
 198  	}
 199  
 200  	// First pass: collect all //:build constraints and non-directive lines.
 201  	var constraints []string
 202  	type lineInfo struct {
 203  		data        []byte
 204  		isDirective bool
 205  	}
 206  	var lines []lineInfo
 207  	rest := data
 208  	pastPackage := false
 209  	for len(rest) > 0 && !pastPackage {
 210  		nl := bytes.IndexByte(rest, '\n')
 211  		var line []byte
 212  		if nl >= 0 {
 213  			line = rest[:nl+1]
 214  			rest = rest[nl+1:]
 215  		} else {
 216  			line = rest
 217  			rest = nil
 218  		}
 219  		trimmed := bytes.TrimSpace(line)
 220  		if bytes.HasPrefix(trimmed, []byte("//:build ")) {
 221  			expr := string(bytes.TrimPrefix(trimmed, []byte("//:build ")))
 222  			constraints = append(constraints, expr)
 223  			lines = append(lines, lineInfo{data: line, isDirective: true})
 224  		} else {
 225  			lines = append(lines, lineInfo{data: line, isDirective: false})
 226  		}
 227  		if bytes.HasPrefix(trimmed, []byte("package ")) {
 228  			pastPackage = true
 229  		}
 230  	}
 231  
 232  	// Build output: emit single //go:build line, then non-directive lines.
 233  	var out bytes.Buffer
 234  	out.Grow(len(data))
 235  	if len(constraints) > 0 {
 236  		combined := constraints[0]
 237  		for _, c := range constraints[1:] {
 238  			combined = "(" + combined + ") && (" + c + ")"
 239  		}
 240  		out.WriteString("//go:build ")
 241  		out.WriteString(combined)
 242  		out.WriteByte('\n')
 243  	}
 244  	for _, li := range lines {
 245  		if !li.isDirective {
 246  			out.Write(li.data)
 247  		}
 248  	}
 249  	out.Write(rest)
 250  	return out.Bytes()
 251  }
 252  
 253  // renamedFileInfo wraps fs.FileInfo with an overridden Name().
 254  type renamedFileInfo struct {
 255  	fs.FileInfo
 256  	name string
 257  }
 258  
 259  func (r renamedFileInfo) Name() string { return r.name }
 260  
 261  // mxFileInfo is a minimal fs.FileInfo for synthetic entries.
 262  type mxFileInfo struct {
 263  	name    string
 264  	size    int64
 265  	mode    fs.FileMode
 266  	modTime time.Time
 267  	isDir   bool
 268  }
 269  
 270  func (fi mxFileInfo) Name() string      { return fi.name }
 271  func (fi mxFileInfo) Size() int64       { return fi.size }
 272  func (fi mxFileInfo) Mode() fs.FileMode { return fi.mode }
 273  func (fi mxFileInfo) ModTime() time.Time { return fi.modTime }
 274  func (fi mxFileInfo) IsDir() bool       { return fi.isDir }
 275  func (fi mxFileInfo) Sys() any          { return nil }
 276  
 277  // goToMx maps a list of .go filenames back to .mx where the .mx file
 278  // exists on disk. Files that are genuinely .go (external deps) stay as-is.
 279  func goToMx(files []string, dir string) []string {
 280  	out := make([]string, len(files))
 281  	for i, f := range files {
 282  		if strings.HasSuffix(f, ".go") {
 283  			mxName := strings.TrimSuffix(f, ".go") + ".mx"
 284  			if _, err := os.Stat(filepath.Join(dir, mxName)); err == nil {
 285  				out[i] = mxName
 286  				continue
 287  			}
 288  		}
 289  		out[i] = f
 290  	}
 291  	return out
 292  }
 293