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