Files
go-omni/cmd/go-omni/apigen/mappingLoad.go

176 lines
5.5 KiB
Go

package apigen
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/importer"
gotoken "go/token"
"go/types"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/mod/modfile"
)
// loadMappingTypes uses the Go compiler's export data, including the pending
// DTOs via an overlay. Module files and sum updates remain in a temporary dir.
// It compiles only explicitly selected packages and their dependencies; never
// runs project code. Build tags/GOOS follow the caller; GOWORK is disabled.
func loadMappingTypes(o Options, refs []string, generated, seeds map[string][]byte) (types.Importer, func(), error) {
// go list resolves its working directory (notably /var -> /private/var on
// macOS). Overlay keys must name that same physical path or stale DTOs win.
o.Dir = canonical(o.Dir)
temp, err := os.MkdirTemp("", "go-omni-mapping-*")
if err != nil {
return nil, nil, err
}
cleanup := func() { _ = os.RemoveAll(temp) }
fail := func(err error) (types.Importer, func(), error) { cleanup(); return nil, nil, err }
raw, err := os.ReadFile(filepath.Join(o.Dir, "go.mod"))
if err != nil {
return fail(errors.New("mapping generation requires an existing Go module; scaffold first and resolve dependencies"))
}
mf, err := modfile.Parse("go.mod", raw, nil)
if err != nil {
return fail(err)
}
replacements := append([]*modfile.Replace(nil), mf.Replace...)
for _, r := range replacements {
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(o.Dir, r.New.Path), ""); err != nil {
return fail(err)
}
}
}
raw, err = mf.Format()
if err != nil {
return fail(err)
}
modPath := filepath.Join(temp, "go.mod")
if err = os.WriteFile(modPath, raw, 0600); err != nil {
return fail(err)
}
if sums, e := os.ReadFile(filepath.Join(o.Dir, "go.sum")); e == nil {
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
return fail(err)
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
overlay := map[string]string{}
// Hide stale owned Go files, which may otherwise redeclare a renamed DTO.
var previous manifest
if data, e := os.ReadFile(filepath.Join(o.Dir, manifestPath)); e == nil {
if err = json.Unmarshal(data, &previous); err != nil {
return fail(err)
}
for name := range previous.Generated {
if strings.HasSuffix(name, ".go") {
if !ownedPath(name) {
return fail(fmt.Errorf("invalid owned path %s", name))
}
if err = safePath(o.Dir, name); err != nil {
return fail(err)
}
if _, ok := generated[name]; !ok {
overlay[filepath.Join(o.Dir, filepath.FromSlash(name))] = ""
}
}
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
for i, files := range []map[string][]byte{generated, seeds} {
names := make([]string, 0, len(files))
for name := range files {
if strings.HasSuffix(name, ".go") {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
if err = safePath(o.Dir, name); err != nil {
return fail(err)
}
path := filepath.Join(o.Dir, filepath.FromSlash(name))
if i == 1 {
if _, e := os.Stat(path); e == nil {
continue
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
}
backing := filepath.Join(temp, fmt.Sprintf("overlay%d.go", len(overlay)))
if err = os.WriteFile(backing, files[name], 0600); err != nil {
return fail(err)
}
overlay[path] = backing
}
}
raw, err = json.Marshal(map[string]any{"Replace": overlay})
if err != nil {
return fail(err)
}
overlayPath := filepath.Join(temp, "overlay.json")
if err = os.WriteFile(overlayPath, raw, 0600); err != nil {
return fail(err)
}
args := []string{"list", "-deps", "-export", "-json", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath}
// fmt is used for fallible conversions even if neither struct imports it.
args = append(args, "fmt")
args = append(args, refs...)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = o.Dir
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.WaitDelay = 2 * time.Second
stdout := &limitedCatalogBuffer{limit: 32 << 20}
stderr := &limitedCatalogBuffer{limit: 64 << 10}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return fail(fmt.Errorf("mapping type loading: %w", ctx.Err()))
}
// Go download errors may contain authenticated remote URLs. Keep arbitrary
// command output out of diagnostics; exact mapping errors follow type loading.
return fail(fmt.Errorf("mapping type loading failed (%v); run go list -deps -export for the configured source/target/converter packages; check dependencies, build tags and import cycles", err))
}
exports := map[string]string{}
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
for {
var p struct {
ImportPath, Export string
Error *struct{ Err string }
}
if err = dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return fail(err)
}
if p.Error != nil {
return fail(fmt.Errorf("mapping package %s could not be compiled", p.ImportPath))
}
if p.Export != "" {
exports[p.ImportPath] = p.Export
}
}
imp := importer.ForCompiler(gotoken.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) {
file, ok := exports[path]
if !ok {
return nil, fmt.Errorf("no export data for mapping dependency %s", path)
}
return os.Open(file)
})
return imp, cleanup, nil
}