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