Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ These flags are shared by all `preview` subcommands:
| `--scheme` | Xcode scheme to build (required) |
| `--device` | Simulator UDID to use (searches axe set first, then standard Xcode set) |
| `--configuration` | Build configuration (e.g. `Debug`) |
| `--build-info` | BuildInfo JSON of an app built by another build system; skips `xcodebuild` (see [Other Build Systems](#other-build-systems-buildinfo)) |

All flags fall back to `.axerc` values when not specified.

Expand Down Expand Up @@ -308,6 +309,33 @@ CONFIGURATION=Debug
DEVICE=<simulator-udid>
```

## Other Build Systems (BuildInfo)

Projects built by something other than `xcodebuild` (e.g. Bazel) can describe the build to axe with a BuildInfo JSON file
instead. axe then never runs `xcodebuild`; it compiles preview thunks with the given compiler inputs and launches the
given app.

Point axe at the file with `--build-info <file>` or `BUILD_INFO=<file>` in `.axerc`. `PROJECT`, `WORKSPACE` and `SCHEME` are not needed in this mode.

```json
{
"schemaVersion": 1,
"module": "FeatureUI",
"configuration": "Debug",
"provider": "bazel",
"swiftcArguments": ["-module-name", "FeatureUI", "-target", "arm64-apple-ios17.0-simulator", "-swift-version", "6"],
"sourceFiles": ["/abs/path/FeatureUI/View.swift"],
"moduleSearchPaths": ["/abs/path/out/FeatureUI"],
"frameworkSearchPaths": [],
"indexStorePath": "/abs/path/out/index",
"transitiveDependencies": [],
"appBundlePath": "/abs/path/out/MyApp.app",
"appBundleId": "com.example.MyApp"
}
```

`schemaVersion` (`1`), `appBundlePath` and `appBundleId` (the simulator app that links the module) are required, and `swiftcArguments` must include an iOS simulator `-target`; `module` owns the previewed `sourceFiles`, and the rest is optional. axe reads `-target`, `-swift-version` and `-Xcc -fmodule-map-file=` from `swiftcArguments`, and takes `-I` / `-F` paths from `moduleSearchPaths` / `frameworkSearchPaths`. Relative paths (including those in the index store) are resolved against the project root (the directory of `--project`/`--workspace`, or the current directory); absolute paths are used as is. The producer must build the app with `-Xfrontend -enable-implicit-dynamic -Xfrontend -enable-private-imports` on the module, which axe otherwise adds through `xcodebuild`. Read-only build outputs are fine: axe stages a writable copy of the app.

## Known Issues

### Hot Reload (`preview watch`)
Expand Down
19 changes: 19 additions & 0 deletions cmd/axe/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
previewScheme string
previewConfiguration string
previewDevice string
previewBuildInfo string
)

// Oneshot-specific flags.
Expand Down Expand Up @@ -173,6 +174,7 @@ func resolveProjectConfig() (preview.ProjectConfig, error) {
// successive calls (e.g. in tests) and keeps flag state predictable.
project := previewProject
workspace := previewWorkspace
buildInfo := previewBuildInfo
scheme := previewScheme
configuration := previewConfiguration
device := previewDevice
Expand Down Expand Up @@ -219,6 +221,22 @@ func resolveProjectConfig() (preview.ProjectConfig, error) {
previewScheme = scheme
}

if buildInfo == "" && rc["BUILD_INFO"] != "" {
buildInfo = rc["BUILD_INFO"]
}
if buildInfo != "" {
// Build info mode: another build system builds the app; no project or scheme is needed.
pc, err := preview.NewProjectConfig(project, workspace, scheme, configuration)
if err != nil {
return pc, err
}
if pc.BuildInfoPath, err = filepath.Abs(buildInfo); err != nil {
return pc, err
}
pc.Root, err = os.Getwd()
return pc, err
}

if project != "" && workspace != "" {
return preview.ProjectConfig{}, fmt.Errorf("--project and --workspace are mutually exclusive")
}
Expand All @@ -239,6 +257,7 @@ func init() {
previewCmd.PersistentFlags().StringVar(&previewScheme, "scheme", "", "Xcode scheme to build")
previewCmd.PersistentFlags().StringVar(&previewConfiguration, "configuration", "", "build configuration (e.g. Debug, Release)")
previewCmd.PersistentFlags().StringVar(&previewDevice, "device", "", "simulator UDID to use for preview (overrides .axerc DEVICE and global default)")
previewCmd.PersistentFlags().StringVar(&previewBuildInfo, "build-info", "", "BuildInfo JSON describing an already built app; skips xcodebuild (overrides .axerc BUILD_INFO)")

// Oneshot-specific flags.
previewCmd.Flags().StringVar(&previewSelector, "preview", "", "select preview by title or index (e.g. --preview \"Dark Mode\" or --preview 1)")
Expand Down
20 changes: 14 additions & 6 deletions cmd/internal/preview/analysis/index_reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ func xcodeToolchainLibPath() string {
return xcodeLibPathVal
}

// indexReaderCommand builds the axe-index-reader command. The reader resolves relative paths
// recorded in the index store against its working directory, so it runs in sourceRoot, the
// base LoadIndexStore uses to absolutize them. Both paths must be absolute.
func indexReaderCommand(ctx context.Context, binPath, indexStorePath, sourceRoot string) *exec.Cmd {
args := []string{indexStorePath}
if sourceRoot != "" {
args = append(args, "--source-root", sourceRoot)
}
cmd := procgroup.Command(ctx, binPath, args...)
cmd.Dir = sourceRoot
return cmd
}

// readIndexStore invokes axe-index-reader on the given index store path
// and returns the full IndexStoreResult with per-file data and type→file map.
// sourceRoot, if non-empty, limits results to files under that directory.
Expand All @@ -53,15 +66,10 @@ func readIndexStore(ctx context.Context, indexStorePath string, sourceRoot strin
return nil, fmt.Errorf("ensuring index reader: %w", err)
}

args := []string{indexStorePath}
if sourceRoot != "" {
args = append(args, "--source-root", sourceRoot)
}

ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()

cmd := procgroup.Command(ctx, binPath, args...)
cmd := indexReaderCommand(ctx, binPath, indexStorePath, sourceRoot)
if libPath := xcodeToolchainLibPath(); libPath != "" {
if existing := os.Getenv("DYLD_LIBRARY_PATH"); existing != "" {
libPath = libPath + ":" + existing
Expand Down
25 changes: 25 additions & 0 deletions cmd/internal/preview/analysis/index_reader_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package analysis

import (
"context"
"os"
"path/filepath"
"slices"
"testing"
)

Expand All @@ -15,3 +18,25 @@ func TestXcodeToolchainLibPath(t *testing.T) {
t.Errorf("xcodeToolchainLibPath() = %q, but directory does not exist", libPath)
}
}

func TestIndexReaderCommandRunsInSourceRoot(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
cmd := indexReaderCommand(context.Background(), "/bin/reader", "/idx", root)
if cmd.Dir != root {
t.Errorf("Dir = %q, want %q", cmd.Dir, root)
}
if want := []string{"/bin/reader", "/idx", "--source-root", root}; !slices.Equal(cmd.Args, want) {
t.Errorf("Args = %v, want %v", cmd.Args, want)
}

cmd = indexReaderCommand(context.Background(), "/bin/reader", "/idx", "")
if cmd.Dir != "" {
t.Errorf("Dir without sourceRoot = %q, want empty", cmd.Dir)
}
if want := []string{"/bin/reader", "/idx"}; !slices.Equal(cmd.Args, want) {
t.Errorf("Args without sourceRoot = %v, want %v", cmd.Args, want)
}
}
17 changes: 16 additions & 1 deletion cmd/internal/preview/analysis/index_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package analysis
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -87,12 +88,26 @@ func LoadIndexStore(ctx context.Context, indexStorePath, sourceRoot string) (*In
typeMap: make(map[string][]string, len(result.GetTypeFileMap())),
}

// Index stores written with a file prefix map (e.g. Bazel's -file-prefix-map <execroot>=.) record
// paths relative to the workspace; make them absolute so they match source file paths.
base := sourceRoot
if base == "" {
base, _ = os.Getwd()
}
absolute := func(p string) string {
if p == "" || filepath.IsAbs(p) || base == "" {
return p
}
return filepath.Join(base, p)
}

for _, fd := range result.GetFiles() {
fd.FilePath = absolute(fd.GetFilePath())
cache.files[fd.GetFilePath()] = fd
}

for typeName, filePath := range result.GetTypeFileMap() {
cache.typeMap[typeName] = []string{filePath}
cache.typeMap[typeName] = []string{absolute(filePath)}
}

slog.Debug("Loaded index store cache",
Expand Down
12 changes: 12 additions & 0 deletions cmd/internal/preview/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
Expand All @@ -24,6 +25,9 @@ type Result struct {
// Prepare runs the full build pipeline: fetch settings, optionally build,
// and extract compiler paths. This is the high-level entry point.
func Prepare(ctx context.Context, pc ProjectConfig, dirs ProjectDirs, reuse bool, r Runner) (*Result, error) {
if pc.UsesBuildInfo() {
return PrepareFromBuildInfo(pc.BuildInfoPath, pc.ProjectRoot(), dirs)
}
s, err := FetchSettings(ctx, pc, dirs, r)
if err != nil {
return nil, err
Expand Down Expand Up @@ -111,9 +115,17 @@ func FetchSettings(ctx context.Context, pc ProjectConfig, dirs ProjectDirs, r Ru
return s, nil
}

// ErrRebuildUnsupported is returned by Run when the app is described by build info:
// another build system owns the build, so xcodebuild has nothing to rebuild.
var ErrRebuildUnsupported = errors.New("xcodebuild cannot rebuild an app described by --build-info / BUILD_INFO; build it with your build system")

// Run executes "xcodebuild build" with the flags required for axe preview
// (dynamic replacement and private imports).
func Run(ctx context.Context, pc ProjectConfig, dirs ProjectDirs, r Runner) error {
if pc.UsesBuildInfo() {
// The app is built by another build system; there is no project/scheme to hand to xcodebuild.
return ErrRebuildUnsupported
}
lock := buildlock.New(dirs.Build)
if err := lock.Lock(ctx); err != nil {
return fmt.Errorf("acquiring build lock: %w", err)
Expand Down
159 changes: 159 additions & 0 deletions cmd/internal/preview/build/buildinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package build

import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)

// BuildInfo is the compile information an external build tool (e.g. Bazel) emits instead of
// xcodebuild; the producer builds the app with `-enable-implicit-dynamic` and `-enable-private-imports`.
type BuildInfo struct {
SchemaVersion int `json:"schemaVersion"`
Module string `json:"module"`
Configuration string `json:"configuration"`
Provider string `json:"provider"`
SwiftcArguments []string `json:"swiftcArguments"`
SourceFiles []string `json:"sourceFiles"`
ModuleSearchPaths []string `json:"moduleSearchPaths"`
FrameworkSearchPaths []string `json:"frameworkSearchPaths"`
IndexStorePath string `json:"indexStorePath"`
TransitiveDependencies []string `json:"transitiveDependencies"`
AppBundlePath string `json:"appBundlePath"`
AppBundleID string `json:"appBundleId"`
}

// LoadBuildInfo reads and validates a BuildInfo JSON file.
func LoadBuildInfo(path string) (*BuildInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading build info: %w", err)
}
var info BuildInfo
if err := json.Unmarshal(data, &info); err != nil {
return nil, fmt.Errorf("decoding build info %s: %w", path, err)
}
if info.SchemaVersion != 1 {
return nil, fmt.Errorf("build info %s: unsupported schemaVersion %d (expected 1)", path, info.SchemaVersion)
}
if info.AppBundlePath == "" || info.AppBundleID == "" {
return nil, fmt.Errorf("build info %s: appBundlePath and appBundleId are required for previews (is %q linked into an app?)", path, info.Module)
}
return &info, nil
}

// resolvePaths makes every relative path in the build info absolute against root;
// absolute paths are kept. The producer may write paths relative to the project.
func (b *BuildInfo) resolvePaths(root string) {
each := func(ps []string) {
for i, p := range ps {
ps[i] = resolveAgainst(root, p)
}
}
b.IndexStorePath = resolveAgainst(root, b.IndexStorePath)
b.AppBundlePath = resolveAgainst(root, b.AppBundlePath)
each(b.SourceFiles)
each(b.ModuleSearchPaths)
each(b.FrameworkSearchPaths)
}

// resolveAgainst joins a relative path onto root; absolute and empty paths are kept.
func resolveAgainst(root, p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(root, p)
}

var targetTriple = regexp.MustCompile(`-ios([0-9.]+)-simulator$`)

// Settings converts BuildInfo into the Settings axe uses for thunk compilation and launching.
func (b *BuildInfo) Settings() (*Settings, error) {
appName := strings.TrimSuffix(filepath.Base(b.AppBundlePath), ".app")
s := &Settings{
ModuleName: appName,
BundleID: "axe." + b.AppBundleID,
OriginalBundleID: b.AppBundleID,
BuiltProductsDir: filepath.Dir(b.AppBundlePath),
}
// dedup drops repeats and the built products dir (already searched), keeping order.
dedup := func(paths []string) (out []string) {
seen := map[string]bool{s.BuiltProductsDir: true}
for _, p := range paths {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
return out
}
args := b.SwiftcArguments
for i, arg := range args {
next := ""
if i+1 < len(args) {
next = args[i+1]
}
switch {
case arg == "-target" && next != "":
if m := targetTriple.FindStringSubmatch(next); m != nil {
s.DeploymentTarget = m[1]
}
case arg == "-swift-version" && next != "":
s.SwiftVersion = next
case arg == "-Xcc" && strings.HasPrefix(next, "-fmodule-map-file="):
s.ExtraModuleMapFiles = append(s.ExtraModuleMapFiles, strings.TrimPrefix(next, "-fmodule-map-file="))
}
}
s.ExtraIncludePaths = dedup(b.ModuleSearchPaths)
s.ExtraFrameworkPaths = dedup(b.FrameworkSearchPaths)
s.SourceModules = map[string]string{}
for _, f := range b.SourceFiles {
s.SourceModules[f] = b.Module
if resolved, err := filepath.EvalSymlinks(f); err == nil {
s.SourceModules[resolved] = b.Module
}
}
if s.DeploymentTarget == "" {
return nil, fmt.Errorf("build info: no iOS simulator -target in swiftcArguments")
}
return s, nil
}

// PrepareFromBuildInfo is Prepare for projects whose build is driven by another tool.
// Relative paths in the build info are resolved against projectRoot. The index store named
// by the build info is linked to the location axe reads it from.
func PrepareFromBuildInfo(path, projectRoot string, dirs ProjectDirs) (*Result, error) {
info, err := LoadBuildInfo(path)
if err != nil {
return nil, err
}
info.resolvePaths(projectRoot)
s, err := info.Settings()
if err != nil {
return nil, err
}
for i, m := range s.ExtraModuleMapFiles {
s.ExtraModuleMapFiles[i] = resolveAgainst(projectRoot, m)
}
link := dirs.IndexStorePath()
if info.IndexStorePath == "" {
// Drop a link or store left by an earlier run so it is not read as current.
_ = os.RemoveAll(link)
} else {
if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil {
return nil, fmt.Errorf("creating index store directory: %w", err)
}
if current, err := os.Readlink(link); err != nil || current != info.IndexStorePath {
_ = os.RemoveAll(link)
if err := os.Symlink(info.IndexStorePath, link); err != nil {
return nil, fmt.Errorf("linking index store: %w", err)
}
}
}
slog.Info("Using build info", "path", path, "provider", info.Provider, "module", info.Module, "app", info.AppBundlePath)
return &Result{Settings: s, Dirs: dirs, Built: false}, nil
}
Loading