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