discover.go raw

   1  // discover.go — Moxie-native package discovery for moxiejs.
   2  // Finds .mx packages and dependencies without go/packages (which
   3  // requires `go list` and a go.mod file). Supports moxie.mod.
   4  
   5  package main
   6  
   7  import (
   8  	"fmt"
   9  	"go/build"
  10  	"io"
  11  	"io/fs"
  12  	"os"
  13  	"path/filepath"
  14  	"strings"
  15  )
  16  
  17  // packageJSON holds package metadata for a discovered package.
  18  type packageJSON struct {
  19  	Dir        string
  20  	ImportPath string
  21  	Name       string
  22  	Module     struct {
  23  		Path      string
  24  		Main      bool
  25  		Dir       string
  26  		GoMod     string
  27  		GoVersion string
  28  	}
  29  	GoFiles  []string
  30  	CgoFiles []string
  31  	CFiles   []string
  32  	Imports  []string
  33  }
  34  
  35  // discoverPackages finds all packages transitively imported by inputPkg,
  36  // returning them in dependency order (deps before dependents).
  37  func discoverPackages(goroot, workDir, inputPkg string) ([]*packageJSON, error) {
  38  	ctx := mxBuildContext(goroot)
  39  
  40  	modPath, modDir, modGoVersion, err := readModInfo(workDir)
  41  	if err != nil {
  42  		return nil, fmt.Errorf("discover: %w", err)
  43  	}
  44  
  45  	modCache := os.Getenv("GOMODCACHE")
  46  	if modCache == "" {
  47  		gopath := os.Getenv("GOPATH")
  48  		if gopath == "" {
  49  			gopath = filepath.Join(os.Getenv("HOME"), "go")
  50  		}
  51  		modCache = filepath.Join(gopath, "pkg", "mod")
  52  	}
  53  
  54  	modFile := findModFile(modDir)
  55  	requires, err := readModRequires(modFile)
  56  	if err != nil {
  57  		return nil, fmt.Errorf("discover: reading module requires: %w", err)
  58  	}
  59  
  60  	replaces, err := readModReplaces(modFile, modDir)
  61  	if err != nil {
  62  		return nil, fmt.Errorf("discover: reading module replaces: %w", err)
  63  	}
  64  
  65  	r := &pkgResolver{
  66  		ctx:          ctx,
  67  		modPath:      modPath,
  68  		modDir:       modDir,
  69  		modGoVersion: modGoVersion,
  70  		modCache:     modCache,
  71  		requires:     requires,
  72  		replaces:     replaces,
  73  		goroot:       goroot,
  74  		seen:         make(map[string]*packageJSON),
  75  	}
  76  
  77  	if err := r.walk(inputPkg, workDir); err != nil {
  78  		return nil, err
  79  	}
  80  
  81  	return r.sorted, nil
  82  }
  83  
  84  type pkgResolver struct {
  85  	ctx          build.Context
  86  	modPath      string
  87  	modDir       string
  88  	modGoVersion string
  89  	modCache     string
  90  	requires     map[string]string
  91  	replaces     map[string]string // module path → absolute local dir
  92  	goroot       string
  93  	seen         map[string]*packageJSON
  94  	sorted       []*packageJSON
  95  }
  96  
  97  func (r *pkgResolver) walk(importPath, srcDir string) error {
  98  	if r.seen[importPath] != nil {
  99  		return nil
 100  	}
 101  	if importPath == "C" {
 102  		return nil
 103  	}
 104  	if importPath == "unsafe" {
 105  		pj := &packageJSON{ImportPath: "unsafe", Name: "unsafe"}
 106  		r.seen[importPath] = pj
 107  		r.sorted = append(r.sorted, pj)
 108  		return nil
 109  	}
 110  
 111  	r.seen[importPath] = &packageJSON{} // cycle breaker
 112  
 113  	dir, err := r.resolveDir(importPath, srcDir)
 114  	if err != nil {
 115  		return fmt.Errorf("resolving %q: %w", importPath, err)
 116  	}
 117  
 118  	pkg, err := r.ctx.ImportDir(dir, 0)
 119  	if err != nil {
 120  		if _, ok := err.(*build.NoGoError); !ok {
 121  			return fmt.Errorf("importing %q from %s: %w", importPath, dir, err)
 122  		}
 123  	}
 124  
 125  	for _, imp := range pkg.Imports {
 126  		if err := r.walk(imp, dir); err != nil {
 127  			return err
 128  		}
 129  	}
 130  
 131  	pj := &packageJSON{
 132  		Dir:        dir,
 133  		ImportPath: importPath,
 134  		Name:       pkg.Name,
 135  		GoFiles:    goToMx(pkg.GoFiles, dir),
 136  		CgoFiles:   goToMx(pkg.CgoFiles, dir),
 137  		CFiles:     pkg.CFiles,
 138  		Imports:    pkg.Imports,
 139  	}
 140  
 141  	if r.modPath != "" && (strings.HasPrefix(importPath, r.modPath+"/") || importPath == r.modPath) {
 142  		pj.Module.Path = r.modPath
 143  		pj.Module.Main = true
 144  		pj.Module.Dir = r.modDir
 145  		pj.Module.GoMod = findModFile(r.modDir)
 146  		pj.Module.GoVersion = r.modGoVersion
 147  	}
 148  
 149  	r.seen[importPath] = pj
 150  	r.sorted = append(r.sorted, pj)
 151  	return nil
 152  }
 153  
 154  func (r *pkgResolver) resolveDir(importPath, srcDir string) (string, error) {
 155  	if importPath == "." || strings.HasPrefix(importPath, "./") || strings.HasPrefix(importPath, "../") {
 156  		return filepath.Join(srcDir, importPath), nil
 157  	}
 158  
 159  	stdDir := filepath.Join(r.goroot, "src", importPath)
 160  	if isDir(stdDir) {
 161  		return stdDir, nil
 162  	}
 163  	stdVendorDir := filepath.Join(r.goroot, "src", "vendor", importPath)
 164  	if isDir(stdVendorDir) {
 165  		return stdVendorDir, nil
 166  	}
 167  
 168  	if r.modPath != "" && (strings.HasPrefix(importPath, r.modPath+"/") || importPath == r.modPath) {
 169  		rel := strings.TrimPrefix(importPath, r.modPath)
 170  		rel = strings.TrimPrefix(rel, "/")
 171  		dir := filepath.Join(r.modDir, rel)
 172  		if isDir(dir) {
 173  			return dir, nil
 174  		}
 175  		return "", fmt.Errorf("package %q not found in module at %s", importPath, dir)
 176  	}
 177  
 178  	// 4a. Local replace directives.
 179  	for mod, localDir := range r.replaces {
 180  		if importPath == mod || strings.HasPrefix(importPath, mod+"/") {
 181  			rel := strings.TrimPrefix(importPath, mod)
 182  			rel = strings.TrimPrefix(rel, "/")
 183  			dir := filepath.Join(localDir, rel)
 184  			if isDir(dir) {
 185  				return dir, nil
 186  			}
 187  			return "", fmt.Errorf("package %q not found in replace at %s", importPath, dir)
 188  		}
 189  	}
 190  
 191  	// 4b. External dependencies (module cache).
 192  	bestMod := ""
 193  	bestVer := ""
 194  	for mod, ver := range r.requires {
 195  		if (importPath == mod || strings.HasPrefix(importPath, mod+"/")) && len(mod) > len(bestMod) {
 196  			bestMod = mod
 197  			bestVer = ver
 198  		}
 199  	}
 200  	if bestMod != "" {
 201  		modCacheDir := filepath.Join(r.modCache, bestMod+"@"+bestVer)
 202  		rel := strings.TrimPrefix(importPath, bestMod)
 203  		rel = strings.TrimPrefix(rel, "/")
 204  		dir := filepath.Join(modCacheDir, rel)
 205  		if isDir(dir) {
 206  			return dir, nil
 207  		}
 208  		return "", fmt.Errorf("package %q not found in module cache at %s", importPath, dir)
 209  	}
 210  
 211  	if r.modDir != "" {
 212  		vendorDir := filepath.Join(r.modDir, "vendor", importPath)
 213  		if isDir(vendorDir) {
 214  			return vendorDir, nil
 215  		}
 216  	}
 217  
 218  	return "", fmt.Errorf("package %q not found (not in stdlib, module, or cache)", importPath)
 219  }
 220  
 221  // --- Build context hooks (present .mx as .go to go/build) ---
 222  
 223  func mxBuildContext(goroot string) build.Context {
 224  	ctx := build.Default
 225  	ctx.GOROOT = goroot
 226  	ctx.GOOS = "js"
 227  	ctx.GOARCH = "wasm"
 228  	ctx.CgoEnabled = false
 229  	ctx.ReadDir = mxReadDir
 230  	ctx.OpenFile = mxOpenFile
 231  	// Strip host goexperiment.* tags. The host Go toolchain injects tags
 232  	// reflecting how *it* was built (regabiargs, regabiwrappers, swissmap,
 233  	// dwarf5, etc.), but those are host-specific and wrong for js/wasm:
 234  	// register-based ABI doesn't apply, and the moxie stdlib rewrite ships
 235  	// matched _on/_off pairs per experiment — with regabi tags set, the _on
 236  	// versions get selected and conflict with js/wasm reality. Keep only
 237  	// non-goexperiment entries (e.g. arch-level "amd64.v1" isn't relevant to
 238  	// js/wasm either but costs nothing to leave in).
 239  	filtered := make([]string, 0, len(ctx.ToolTags))
 240  	for _, t := range ctx.ToolTags {
 241  		if !strings.HasPrefix(t, "goexperiment.") {
 242  			filtered = append(filtered, t)
 243  		}
 244  	}
 245  	ctx.ToolTags = filtered
 246  	return ctx
 247  }
 248  
 249  func mxReadDir(dir string) ([]fs.FileInfo, error) {
 250  	entries, err := os.ReadDir(dir)
 251  	if err != nil {
 252  		return nil, err
 253  	}
 254  	mxBases := make(map[string]bool)
 255  	for _, e := range entries {
 256  		if strings.HasSuffix(e.Name(), ".mx") {
 257  			mxBases[strings.TrimSuffix(e.Name(), ".mx")] = true
 258  		}
 259  	}
 260  	infos := make([]fs.FileInfo, 0, len(entries))
 261  	for _, e := range entries {
 262  		name := e.Name()
 263  		info, err := e.Info()
 264  		if err != nil {
 265  			return nil, err
 266  		}
 267  		if strings.HasSuffix(name, ".mx") {
 268  			infos = append(infos, renamedFileInfo{
 269  				FileInfo: info,
 270  				name:     strings.TrimSuffix(name, ".mx") + ".go",
 271  			})
 272  		} else if strings.HasSuffix(name, ".go") && mxBases[strings.TrimSuffix(name, ".go")] {
 273  			continue
 274  		} else {
 275  			infos = append(infos, info)
 276  		}
 277  	}
 278  	return infos, nil
 279  }
 280  
 281  func mxOpenFile(path string) (io.ReadCloser, error) {
 282  	if strings.HasSuffix(path, ".go") {
 283  		mxPath := strings.TrimSuffix(path, ".go") + ".mx"
 284  		if f, err := os.Open(mxPath); err == nil {
 285  			return f, nil
 286  		}
 287  	}
 288  	return os.Open(path)
 289  }
 290  
 291  type renamedFileInfo struct {
 292  	fs.FileInfo
 293  	name string
 294  }
 295  
 296  func (r renamedFileInfo) Name() string { return r.name }
 297  
 298  func goToMx(files []string, dir string) []string {
 299  	out := make([]string, len(files))
 300  	for i, f := range files {
 301  		if strings.HasSuffix(f, ".go") {
 302  			mxName := strings.TrimSuffix(f, ".go") + ".mx"
 303  			if _, err := os.Stat(filepath.Join(dir, mxName)); err == nil {
 304  				out[i] = mxName
 305  				continue
 306  			}
 307  		}
 308  		out[i] = f
 309  	}
 310  	return out
 311  }
 312  
 313  // --- Module info helpers ---
 314  
 315  func findModFile(dir string) string {
 316  	mxmod := filepath.Join(dir, "moxie.mod")
 317  	if _, err := os.Stat(mxmod); err == nil {
 318  		return mxmod
 319  	}
 320  	return filepath.Join(dir, "go.mod")
 321  }
 322  
 323  func readModInfo(startDir string) (modPath, modDir, goVersion string, err error) {
 324  	dir := startDir
 325  	for {
 326  		modFile := findModFile(dir)
 327  		data, err := os.ReadFile(modFile)
 328  		if err == nil {
 329  			modPath, goVersion = parseGoMod(string(data))
 330  			return modPath, dir, goVersion, nil
 331  		}
 332  		parent := filepath.Dir(dir)
 333  		if parent == dir {
 334  			return "", startDir, "", nil
 335  		}
 336  		dir = parent
 337  	}
 338  }
 339  
 340  func parseGoMod(content string) (modPath, goVersion string) {
 341  	for _, line := range strings.Split(content, "\n") {
 342  		line = strings.TrimSpace(line)
 343  		if strings.HasPrefix(line, "module ") {
 344  			modPath = strings.TrimSpace(strings.TrimPrefix(line, "module"))
 345  		}
 346  		if strings.HasPrefix(line, "go ") {
 347  			goVersion = strings.TrimSpace(strings.TrimPrefix(line, "go"))
 348  		}
 349  	}
 350  	return
 351  }
 352  
 353  func readModRequires(gomodPath string) (map[string]string, error) {
 354  	data, err := os.ReadFile(gomodPath)
 355  	if err != nil {
 356  		if os.IsNotExist(err) {
 357  			return nil, nil
 358  		}
 359  		return nil, err
 360  	}
 361  	requires := make(map[string]string)
 362  	inRequire := false
 363  	for _, line := range strings.Split(string(data), "\n") {
 364  		line = strings.TrimSpace(line)
 365  		if line == ")" {
 366  			inRequire = false
 367  			continue
 368  		}
 369  		if strings.HasPrefix(line, "require (") {
 370  			inRequire = true
 371  			continue
 372  		}
 373  		if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") {
 374  			parts := strings.Fields(line)
 375  			if len(parts) >= 3 {
 376  				requires[parts[1]] = parts[2]
 377  			}
 378  			continue
 379  		}
 380  		if inRequire {
 381  			parts := strings.Fields(line)
 382  			if len(parts) >= 2 && !strings.HasPrefix(parts[0], "//") {
 383  				requires[parts[0]] = parts[1]
 384  			}
 385  		}
 386  	}
 387  	return requires, nil
 388  }
 389  
 390  // readModReplaces parses replace directives that point to local directories.
 391  func readModReplaces(gomodPath, modDir string) (map[string]string, error) {
 392  	data, err := os.ReadFile(gomodPath)
 393  	if err != nil {
 394  		if os.IsNotExist(err) {
 395  			return nil, nil
 396  		}
 397  		return nil, err
 398  	}
 399  	replaces := make(map[string]string)
 400  	inReplace := false
 401  	for _, line := range strings.Split(string(data), "\n") {
 402  		line = strings.TrimSpace(line)
 403  		if line == ")" {
 404  			inReplace = false
 405  			continue
 406  		}
 407  		if strings.HasPrefix(line, "replace (") {
 408  			inReplace = true
 409  			continue
 410  		}
 411  		parseLine := ""
 412  		if strings.HasPrefix(line, "replace ") && !strings.Contains(line, "(") {
 413  			parseLine = strings.TrimPrefix(line, "replace ")
 414  		} else if inReplace && !strings.HasPrefix(line, "//") && line != "" {
 415  			parseLine = line
 416  		}
 417  		if parseLine == "" {
 418  			continue
 419  		}
 420  		// Format: module [version] => replacement [version]
 421  		parts := strings.Split(parseLine, "=>")
 422  		if len(parts) != 2 {
 423  			continue
 424  		}
 425  		lhs := strings.Fields(strings.TrimSpace(parts[0]))
 426  		rhs := strings.Fields(strings.TrimSpace(parts[1]))
 427  		if len(lhs) < 1 || len(rhs) < 1 {
 428  			continue
 429  		}
 430  		replacePath := rhs[0]
 431  		// Only handle local path replacements (starting with . or /).
 432  		if strings.HasPrefix(replacePath, ".") || strings.HasPrefix(replacePath, "/") {
 433  			if !filepath.IsAbs(replacePath) {
 434  				replacePath = filepath.Join(modDir, replacePath)
 435  			}
 436  			replaces[lhs[0]] = filepath.Clean(replacePath)
 437  		}
 438  	}
 439  	return replaces, nil
 440  }
 441  
 442  func isDir(path string) bool {
 443  	fi, err := os.Stat(path)
 444  	return err == nil && fi.IsDir()
 445  }
 446