mxlist.go raw

   1  // mxlist.go implements package discovery for .mx source files,
   2  // replacing the external `go list -json -deps` command.
   3  //
   4  // It uses go/build.Context with custom ReadDir/OpenFile hooks (from
   5  // mxbuild.go) to find .mx files, then walks the dependency graph to
   6  // produce the same PackageJSON structures the loader expects.
   7  
   8  package loader
   9  
  10  import (
  11  	"fmt"
  12  	"go/build"
  13  	"os"
  14  	"path/filepath"
  15  	"strings"
  16  
  17  	"moxie/compileopts"
  18  	"moxie/goenv"
  19  )
  20  
  21  // mxListPackages discovers packages and their transitive dependencies,
  22  // returning them in dependency order (dependencies before dependents).
  23  // This replaces `go list -json -deps -e`.
  24  func mxListPackages(config *compileopts.Config, goroot string, inputPkg string) ([]*PackageJSON, error) {
  25  	ctx := mxContext(goroot, config.GOOS(), config.GOARCH(), config.BuildTags())
  26  
  27  	// Determine the working directory and module info.
  28  	wd := config.Options.Directory
  29  	if wd == "" {
  30  		var err error
  31  		wd, err = os.Getwd()
  32  		if err != nil {
  33  			return nil, err
  34  		}
  35  	}
  36  
  37  	modPath, modDir, modGoVersion, err := readModInfo(wd)
  38  	if err != nil {
  39  		return nil, fmt.Errorf("mxlist: %w", err)
  40  	}
  41  
  42  	// Read module cache path for external dependencies.
  43  	modCache := goenv.Get("GOMODCACHE")
  44  	if modCache == "" {
  45  		gopath := goenv.Get("GOPATH")
  46  		if gopath == "" {
  47  			gopath = filepath.Join(os.Getenv("HOME"), "go")
  48  		}
  49  		modCache = filepath.Join(gopath, "pkg", "mod")
  50  	}
  51  
  52  	// Parse module require and replace directives.
  53  	modFile := findModFile(modDir)
  54  	requires, err := readModRequires(modFile)
  55  	if err != nil {
  56  		return nil, fmt.Errorf("mxlist: reading module requires: %w", err)
  57  	}
  58  	replaces, err := readModReplaces(modFile, modDir)
  59  	if err != nil {
  60  		return nil, fmt.Errorf("mxlist: reading module replaces: %w", err)
  61  	}
  62  
  63  	r := &resolver{
  64  		ctx:          ctx,
  65  		modPath:      modPath,
  66  		modDir:       modDir,
  67  		modGoVersion: modGoVersion,
  68  		modCache:     modCache,
  69  		requires:     requires,
  70  		replaces:     replaces,
  71  		goroot:       goroot,
  72  		seen:         make(map[string]*PackageJSON),
  73  	}
  74  
  75  	// The runtime package is an implicit dependency — never explicitly
  76  	// imported in user source, but always required by the compiler.
  77  	// Walk it BEFORE the user package so that MainPkg() (which returns
  78  	// the last element of sorted) correctly identifies the user package.
  79  	if err := r.walk("runtime", ""); err != nil {
  80  		return nil, err
  81  	}
  82  
  83  	// The moxie package is an implicit dependency — the compiler needs it
  84  	// to look up the Codec interface for spawn-boundary type checking.
  85  	// Like runtime, it lives in the synthetic GOROOT at src/moxie/.
  86  	if err := r.walk("moxie", ""); err != nil {
  87  		return nil, err
  88  	}
  89  
  90  	err = r.walk(inputPkg, wd)
  91  	if err != nil {
  92  		return nil, err
  93  	}
  94  
  95  	return r.sorted, nil
  96  }
  97  
  98  // resolver walks the dependency graph, building PackageJSON entries.
  99  type resolver struct {
 100  	ctx          build.Context
 101  	modPath      string
 102  	modDir       string
 103  	modGoVersion string
 104  	modCache     string
 105  	requires     map[string]string // module path → version
 106  	replaces     map[string]string // module path → absolute local dir
 107  	goroot       string
 108  	seen         map[string]*PackageJSON
 109  	sorted       []*PackageJSON
 110  }
 111  
 112  // walk imports a package and recursively walks its dependencies.
 113  func (r *resolver) walk(importPath string, srcDir string) error {
 114  	if r.seen[importPath] != nil {
 115  		return nil
 116  	}
 117  	if importPath == "C" {
 118  		// CGo pseudo-import, skip.
 119  		return nil
 120  	}
 121  	if importPath == "unsafe" {
 122  		// Built-in package, no files to discover.
 123  		pj := &PackageJSON{
 124  			ImportPath: "unsafe",
 125  			Name:       "unsafe",
 126  		}
 127  		r.seen[importPath] = pj
 128  		r.sorted = append(r.sorted, pj)
 129  		return nil
 130  	}
 131  
 132  	// Mark as seen (with nil) to break import cycles.
 133  	r.seen[importPath] = &PackageJSON{}
 134  
 135  	dir, err := r.resolveDir(importPath, srcDir)
 136  	if err != nil {
 137  		return fmt.Errorf("resolving %q: %w", importPath, err)
 138  	}
 139  
 140  	pkg, err := r.ctx.ImportDir(dir, 0)
 141  	if err != nil {
 142  		// Check for NoGoError — might have only test files or only .mx files
 143  		// that our context didn't find (shouldn't happen with our hooks).
 144  		if _, ok := err.(*build.NoGoError); !ok {
 145  			return fmt.Errorf("importing %q from %s: %w", importPath, dir, err)
 146  		}
 147  	}
 148  
 149  	// Walk dependencies first (depth-first, deps before dependents).
 150  	allImports := pkg.Imports
 151  	for _, imp := range allImports {
 152  		if err := r.walk(imp, dir); err != nil {
 153  			return err
 154  		}
 155  	}
 156  
 157  	// Build PackageJSON, mapping .go names back to .mx.
 158  	pj := &PackageJSON{
 159  		Dir:        dir,
 160  		ImportPath: importPath,
 161  		Name:       pkg.Name,
 162  		GoFiles:    goToMx(pkg.GoFiles, dir),
 163  		CgoFiles:   goToMx(pkg.CgoFiles, dir),
 164  		CFiles:     pkg.CFiles,
 165  		Imports:    pkg.Imports,
 166  		EmbedFiles: pkg.EmbedPatterns,
 167  	}
 168  
 169  	// Set module info for packages within our module.
 170  	if r.modPath != "" && (strings.HasPrefix(importPath, r.modPath+"/") || importPath == r.modPath) {
 171  		pj.Module.Path = r.modPath
 172  		pj.Module.Main = true
 173  		pj.Module.Dir = r.modDir
 174  		pj.Module.GoMod = findModFile(r.modDir)
 175  		pj.Module.GoVersion = r.modGoVersion
 176  	}
 177  
 178  	r.seen[importPath] = pj
 179  	r.sorted = append(r.sorted, pj)
 180  	return nil
 181  }
 182  
 183  // resolveDir maps an import path to an on-disk directory.
 184  func (r *resolver) resolveDir(importPath string, srcDir string) (string, error) {
 185  	// 1. Local/relative imports.
 186  	if importPath == "." || strings.HasPrefix(importPath, "./") || strings.HasPrefix(importPath, "../") {
 187  		return filepath.Join(srcDir, importPath), nil
 188  	}
 189  
 190  	// 2. Standard library (in synthetic GOROOT).
 191  	stdDir := filepath.Join(r.goroot, "src", importPath)
 192  	if isDir(stdDir) {
 193  		return stdDir, nil
 194  	}
 195  	// 2b. Stdlib-vendored packages (e.g. golang.org/x/crypto).
 196  	stdVendorDir := filepath.Join(r.goroot, "src", "vendor", importPath)
 197  	if isDir(stdVendorDir) {
 198  		return stdVendorDir, nil
 199  	}
 200  
 201  	// 3. Module-local packages.
 202  	if r.modPath != "" && (strings.HasPrefix(importPath, r.modPath+"/") || importPath == r.modPath) {
 203  		rel := strings.TrimPrefix(importPath, r.modPath)
 204  		rel = strings.TrimPrefix(rel, "/")
 205  		dir := filepath.Join(r.modDir, rel)
 206  		if isDir(dir) {
 207  			return dir, nil
 208  		}
 209  		return "", fmt.Errorf("package %q not found in module at %s", importPath, dir)
 210  	}
 211  
 212  	// 4a. Local replace directives.
 213  	for mod, localDir := range r.replaces {
 214  		if importPath == mod || strings.HasPrefix(importPath, mod+"/") {
 215  			rel := strings.TrimPrefix(importPath, mod)
 216  			rel = strings.TrimPrefix(rel, "/")
 217  			dir := filepath.Join(localDir, rel)
 218  			if isDir(dir) {
 219  				return dir, nil
 220  			}
 221  			return "", fmt.Errorf("package %q not found in replace at %s", importPath, dir)
 222  		}
 223  	}
 224  
 225  	// 4b. External dependencies (module cache).
 226  	// Find the longest matching module path from go.mod requires.
 227  	bestMod := ""
 228  	bestVer := ""
 229  	for mod, ver := range r.requires {
 230  		if (importPath == mod || strings.HasPrefix(importPath, mod+"/")) && len(mod) > len(bestMod) {
 231  			bestMod = mod
 232  			bestVer = ver
 233  		}
 234  	}
 235  	if bestMod != "" {
 236  		// Module cache path: $GOMODCACHE/module@version/subpath
 237  		modCacheDir := filepath.Join(r.modCache, bestMod+"@"+bestVer)
 238  		rel := strings.TrimPrefix(importPath, bestMod)
 239  		rel = strings.TrimPrefix(rel, "/")
 240  		dir := filepath.Join(modCacheDir, rel)
 241  		if isDir(dir) {
 242  			return dir, nil
 243  		}
 244  		return "", fmt.Errorf("package %q not found in module cache at %s", importPath, dir)
 245  	}
 246  
 247  	// 5. Vendored packages.
 248  	if r.modDir != "" {
 249  		vendorDir := filepath.Join(r.modDir, "vendor", importPath)
 250  		if isDir(vendorDir) {
 251  			return vendorDir, nil
 252  		}
 253  	}
 254  
 255  	return "", fmt.Errorf("package %q not found (not in stdlib, module, or cache)", importPath)
 256  }
 257  
 258  // findModFile returns the path of the module file in dir.
 259  // Prefers moxie.mod, falls back to go.mod.
 260  func findModFile(dir string) string {
 261  	mxmod := filepath.Join(dir, "moxie.mod")
 262  	if _, err := os.Stat(mxmod); err == nil {
 263  		return mxmod
 264  	}
 265  	return filepath.Join(dir, "go.mod")
 266  }
 267  
 268  // readModInfo reads the module path and directory from moxie.mod (or go.mod).
 269  func readModInfo(startDir string) (modPath, modDir, goVersion string, err error) {
 270  	dir := startDir
 271  	for {
 272  		modFile := findModFile(dir)
 273  		data, err := os.ReadFile(modFile)
 274  		if err == nil {
 275  			modPath, goVersion = parseGoMod(string(data))
 276  			return modPath, dir, goVersion, nil
 277  		}
 278  		parent := filepath.Dir(dir)
 279  		if parent == dir {
 280  			return "", startDir, "", nil // no module file found, use wd
 281  		}
 282  		dir = parent
 283  	}
 284  }
 285  
 286  // parseGoMod extracts the module path and go version from go.mod content.
 287  func parseGoMod(content string) (modPath, goVersion string) {
 288  	for _, line := range strings.Split(content, "\n") {
 289  		line = strings.TrimSpace(line)
 290  		if strings.HasPrefix(line, "module ") {
 291  			modPath = strings.TrimSpace(strings.TrimPrefix(line, "module"))
 292  		}
 293  		if strings.HasPrefix(line, "go ") {
 294  			goVersion = strings.TrimSpace(strings.TrimPrefix(line, "go"))
 295  		}
 296  	}
 297  	return
 298  }
 299  
 300  // readModRequires parses require directives from go.mod.
 301  func readModRequires(gomodPath string) (map[string]string, error) {
 302  	data, err := os.ReadFile(gomodPath)
 303  	if err != nil {
 304  		if os.IsNotExist(err) {
 305  			return nil, nil
 306  		}
 307  		return nil, err
 308  	}
 309  
 310  	requires := make(map[string]string)
 311  	inRequire := false
 312  	for _, line := range strings.Split(string(data), "\n") {
 313  		line = strings.TrimSpace(line)
 314  
 315  		if line == ")" {
 316  			inRequire = false
 317  			continue
 318  		}
 319  		if strings.HasPrefix(line, "require (") {
 320  			inRequire = true
 321  			continue
 322  		}
 323  		if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") {
 324  			// Single-line require.
 325  			parts := strings.Fields(line)
 326  			if len(parts) >= 3 {
 327  				requires[parts[1]] = parts[2]
 328  			}
 329  			continue
 330  		}
 331  		if inRequire {
 332  			parts := strings.Fields(line)
 333  			if len(parts) >= 2 && !strings.HasPrefix(parts[0], "//") {
 334  				requires[parts[0]] = parts[1]
 335  			}
 336  		}
 337  	}
 338  	return requires, nil
 339  }
 340  
 341  // readModReplaces parses replace directives that point to local directories.
 342  func readModReplaces(gomodPath, modDir string) (map[string]string, error) {
 343  	data, err := os.ReadFile(gomodPath)
 344  	if err != nil {
 345  		if os.IsNotExist(err) {
 346  			return nil, nil
 347  		}
 348  		return nil, err
 349  	}
 350  	replaces := make(map[string]string)
 351  	inReplace := false
 352  	for _, line := range strings.Split(string(data), "\n") {
 353  		line = strings.TrimSpace(line)
 354  		if line == ")" {
 355  			inReplace = false
 356  			continue
 357  		}
 358  		if strings.HasPrefix(line, "replace (") {
 359  			inReplace = true
 360  			continue
 361  		}
 362  		parseLine := ""
 363  		if strings.HasPrefix(line, "replace ") && !strings.Contains(line, "(") {
 364  			parseLine = strings.TrimPrefix(line, "replace ")
 365  		} else if inReplace && !strings.HasPrefix(line, "//") && line != "" {
 366  			parseLine = line
 367  		}
 368  		if parseLine == "" {
 369  			continue
 370  		}
 371  		parts := strings.Split(parseLine, "=>")
 372  		if len(parts) != 2 {
 373  			continue
 374  		}
 375  		lhs := strings.Fields(strings.TrimSpace(parts[0]))
 376  		rhs := strings.Fields(strings.TrimSpace(parts[1]))
 377  		if len(lhs) < 1 || len(rhs) < 1 {
 378  			continue
 379  		}
 380  		replacePath := rhs[0]
 381  		if strings.HasPrefix(replacePath, ".") || strings.HasPrefix(replacePath, "/") {
 382  			if !filepath.IsAbs(replacePath) {
 383  				replacePath = filepath.Join(modDir, replacePath)
 384  			}
 385  			replaces[lhs[0]] = filepath.Clean(replacePath)
 386  		}
 387  	}
 388  	return replaces, nil
 389  }
 390  
 391  func isDir(path string) bool {
 392  	fi, err := os.Stat(path)
 393  	return err == nil && fi.IsDir()
 394  }
 395