BLOG ABOUT

Build, run and debug iOS and Mac apps in Zed instead of Xcode

2025-08-04

This article will get you building iOS and Mac apps in the Zed editor, with a proper build / run / debug cycle, on devices and the iOS simulator. With the help of some new tools I've developed, this makes Zed genuinely usable for building apps for Apple platforms. This is something of a first from what I can tell.

With not too much setup, you can:

You will, of course, need to jump back to Xcode for things like SwiftUI previews.

Out of the box, Zed comes with a Swift extension which handles plain Swift projects, but it doesn't understand Xcode projects. There are a couple of articles that get you as far as code completion but no further. So, I've put some work into tools that enable a real development process.

Why Zed? I've been using the Zed editor for writing iOS and Mac apps for a while now. I won't try and convince you to use Zed; suffice to say I find pretty much any other modern editor is better than Xcode for actually writing code. So I've been through AppCode (used it for years although it often didn't work properly; now decommissioned), briefly looked into VSCode (meh), used Fleet for a bit (it doesn't seem to quite know what it's about), and now Zed.

Get started

Now after a minute you should have basic syntax highlighting. Most of your imports won't work yet though – so you'll see unresolved symbols, and code completion and command-clicking to navigate around will be pretty limited.

Code completion and navigation

To get Zed to understand your how your code fits together you need to install xcode-build-server. (I won't get into explaining what language servers and build servers are in this article. Read about those elsewhere if you care.)

(This created a buildServer.json file in your project directory. You probably won't look at it again, but it's good to know it's there. See xcode-build-server's repo if you want to know more.)

You should now be able to command-click symbols to navigate around, and code completion should work. (This can be a little slow at first.)

In future, if your symbols stop resolving at some point, run another Xcode build. You shouldn't need to do this often though.

Building and running

Install xcede

xcede helps you build, run and debug Apple platform apps. It also comes with a debug adapter, which we'll get into soon. Follow xcede's installation instructions and restart Zed.

You can try out xcede from the command line:

It works for the iOS simulator and for Mac apps too:

You need tasks

Building and running aren't really concepts in Zed: they're just examples of tasks. Zed's idea of a task is very generic: it's just a way to run a command in a shell. You define tasks yourself (some are created automatically – but not for Swift). There are project tasks and global tasks.

Build

Define a task to build your app. We'll use project tasks for now. Run the Zed command open project tasks.

(If you don't like the idea of defining tasks for every project, you can skip straight down to the "Use global tasks" section below.)

Zed commands: Hopefully you know this already, but many of Zed's commands are only available from the Command Palette – in the Run menu, or press cmd-shift-P.

Add something like:

{
    "label": "Build MyApp",
    // Other options here are:
    // "command": "xcede build MyScheme sim SomeSimName",
    // "command": "xcede build MyScheme mac",
    "command": "xcede build MyScheme device 'My Phone'",
    "allow_concurrent_runs": false,
    "reveal": "no_focus",
    "hide": "never"
}

Now choose Run -> Spawn task from the menu and select your task. Zed will show your build output in a terminal pane. (Sure it's not as convenient as a list of errors, but at least zed has error indicators in the editor as well.)

More beautiful output: xcodebuild's output is pretty hard to read. But if you have xcbeautify (install it with brew), xcede will see it and use it, and your build output will indeed be more beautiful. If you have a different beautifier (or want to invoke xcbeautify with some flags), specify the command in $XCODEBUILD_BEAUTIFIER. The output will be piped through this command.

Run

Running is a task too. You can buildrun or just plain run:

{
    "label": "Run MyApp",
    // Other options here are:
    // "command": "xcede buildrun MyScheme sim SomeSimName",
    // "command": "xcede buildrun MyScheme mac",
    //
    // or without building:
    // "command": "xcede run MyScheme device 'My Phone'",
    "command": "xcede buildrun MyScheme device 'My Phone'",
    "allow_concurrent_runs": false,
    "reveal": "no_focus",
    "hide": "never"
}

Define more tasks?

In these tasks we've specified a platform and a device. You'll either want to define multiple tasks like "Run on phone", "Run on iPad simulator", etc – or alter the tasks file each time you want to switch. But before you do that, read the next section so you know what your options are. You might decide not to define tasks for every project!

Or, use global tasks

You don't have to define tasks for every project. xcede offers an alternative: you can supply its parameters in a file rather than as arguments, in either

It looks like this:

device=MyPhone
platform=device
scheme=MyApp

Or, because it's just bash code, this:

device=MyPhone; platform=device; scheme="Scheme with spaces"
# device="16 Pro"; platform=sim; scheme="MyApp"
# device="MyPad"; platform=sim; scheme="MyApp"
# platform=mac; scheme="MyAppMac"

which makes it easy to switch by uncommenting a different line. Don't forget to quote values containing spaces!

Now when you run just plain xcede build or xcede run (with no other arguments) in your project directory, it'll use the device and scheme in the file. (You can still provide arguments on the command line – they take precedence over what's in the xcrc.)

This means you can define generic global tasks that work in any project with an xcrc. No need to define tasks separately in each project! Run the open tasks command and add something like this:

{
    "label": "Build",
    "command": "xcede build",
    "allow_concurrent_runs": false,
    "reveal": "no_focus",
    "hide": "never"
}

As you can see, there are lots of ways to slice this depending on what you prefer. You can even go with global tasks in general, and override them at the project level in special cases.

Add keyboard shortcuts

Obviously it's a faff to choose things from menus every time. If you're using global tasks, you can create yourself a keyboard shortcut (run the open keymap command):

"f9": [	"task::Spawn",	{ "task_name": "Build" }],

Or, to autosave before building (because who remembers to do that?) you can do some hackery to work around the fact that Zed doesn't let you bind a key to a sequence of tasks:

// some key combo you won't want for anything else:
"cmd-shift-alt-ctrl-fn-f9": [
	"task::Spawn",
	{ "task_name": "Build" }
],
// and the key you actually want:
"f9": [
	"workspace::SendKeystrokes",
	"cmd-s cmd-shift-alt-ctrl-fn-f9"
]

Keyboard shortcuts for project tasks

There are no project-specific key bindings, but you could:

Speed up your builds

Building with xcodebuild is often slower than with Xcode. A coupe of things can help:

(1) You can usually get a pretty massive performance gain by adding this line to your /etc/hosts:

0.0.0.0 developerservices2.apple.com

Now obviously that's going to stop some stuff working, so don't forget to comment it out when you need to. When you forget (and you will) you'll see what I mean.

(There's a VSCode plugin that actually does this for you, but xcede doesn't go as far as gaining root permission to modify your hosts file 🙀. Not yet, anyway.)

(2) You'll see from your build output (especially if you've beautified it as above) that the "resolve package versions" step can be unnecessarily slow. You might look into some extra xcodebuild flags; you can supply these to xcede by setting $XCODEBUILD_FLAGS.

Bonus: Swift package support

If you define global tasks for build and run, they'll work in a basic way in when you open a plain Swift project (i.e. not an Xcode project) – they'll run swift build and swift run respectively. No xcrc is required for this.

Debugging

First you need to configure zed to use xcede's DAP wrapper. Add this your settings file (Settings -> Open Settings):

"dap": {
    "Swift": {
        "binary": "/path/to/xcede-dap"
    }
}

What's going on here? If you're interested: the DAP (debugger adapter protocol) layer sits between the IDE and lldb. It provides a standard way for the IDE to talk about debugging, instead of implementing a lot of (in this case) lldb-specific stuff. Now, there are two main reasons for xcede-dap:

  • There's no obvious way to attach to a process on a device without hackery with python scripts. xcede-dap takes care of that for you.
  • Even so, attaching kind of sucks because you have to launch the app first – and its output is separate. What I wanted was launch-like behaviour. lldb can do that with a local executable, but not on devices/simulators. xcede-dap takes care of the launching, and merges the app's console output into debugger output as you'd expect. Plus it can use xcrc, which is convenient.

Now define a debug task. Again, you can create global debug tasks (zed command: open debug tasks) or project debug tasks (zed command: open project debug tasks).

If you went with global build and run tasks above, a global debug task will probably suit you:

{
    "label": "Debug",
    "adapter": "Swift",
    "request": "launch",
    // This says we want xcede to manage the process.
    // You could pass the path to an executable here, and that
    // would launch in the usual way, as if you weren't using
    // xcede-dap.
    "program": "xcede:",

    // This will build your app first, and is optional.
    // Or, you can instead refer to an existing task that
    // you've defined, like this:
    // "build": "Build"
    "build": {
        "command": "xcede build"
    }
}

Now start the debugger (Run -> Start Debugger) and choose your task. Set some breakpoints and make sure it works! You should be able to step through your code, see variables' values and the call stack, and do all the usual things.

A project debug task, if you prefer that, might be more specific:

{
    "label": "MyApp on iPhone 16",
    "adapter": "Swift",
    "request": "launch",
    "program": "xcede:",
    "args": ["device", "iPhone 16"],
    // build is optional.
    // And again, task name or command/args json:
    "build": {
        "command": "xcede",
        // Note how even though we're passing an args array
        // we still need to put extra quotes around values with
        // spaces. This seems like a bug in Zed.
        "args": ["build", "'My scheme'", "device", "'iPhone 16'"]
    }
}

Testing

The more gaps you fill, the more obvious the remaining gaps become! For now, xcede has no support for running tests, so write yourself a task to run a suitable xcodebuild command. xcbeautify does a nice job with the output.

At some point I'll look into this more. I suspect it might require mods to Zed's Swift extension. We'll see.

That's it!

And there you have it. Perhaps not quite as convenient as the purpose-specific UI in Xcode, but totally usable I find.

If you have any feedback, join the discussion on Zed's github, or add an issue to the xcede repo.

@lxmn@mastodon.social.