2026-03-14
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 a new tool 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.
Out of the box, Zed comes with a Swift extension which handles plain Swift projects, but it doesn't understand Xcode projects.
But with a little setup, you can have a real development process:
- Code completion and navigation that understands your project
- Build Xcode projects just as fast as Xcode
- Run iOS apps on the simulator and real devices
- Run Mac apps
- Debug your apps on the simulator, devices and the Mac
- Run tests right from your code, just like in Xcode
- Use SwiftUI previews (after a fashion)
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 didn't quite know what it was about, and now it's been canned), and now Zed.
Used in the real world
This isn't just an article about what you could do in theory – I built my new app, DelayDrop, mainly in Zed. It's a pleasure to work in a genuinely good code editor, and switching into Xcode for things like previews really isn't an imposition.
DelayDrop is an iOS and Mac app for sending anything to your other Apple devices – even if they're locked, off or elsewhere – in two taps. It beats AirDrop because your other device can be anywhere in the world; and even if it is nearby, you're still winning because you don't have to get up and unlock it.
And if that sounds like a plug for DelayDrop, that's because it is!
Get started
- Open your project's folder in Zed.
- Open a Swift file. Zed will offer to install the Swift extension. Do that.
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.
Install xcode-build-server
This enhances the language server so it understands your project, which gives you code navigation and completion and fixes syntax highlighting. (I didn't write this tool.)
- 🔧
brew install xcode-build-server - 🔧 Run
xcode-build-server config -scheme MySchemein your project directory (there are also arguments for workspace and project if you have multiple candidates) - 🔧 Do a build in Xcode
- 🔧 Restart Zed
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.)
(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.)
Install xcede
I wrote xcede to help build, run and debug Apple platform apps from the command line. We'll use it for our Zed tasks.
(It's also a debug adapter; we'll get into soon.)
🔧 Follow xcede's installation instructions, then restart Zed.
🔧 brew install xcbeautify This is optional, but your build logs will be much easier to read.
🔧 Now in your project's root folder add a config file xcede.yml
(you have options: [.]xcede.yml|yaml|toml|json).
Something like this:
scheme: MyApp
# device, sim or mac:
platform: device
# not required for Mac apps:
device: "Tarquin's iPhone 19"
# Faster builds: xcede uses Xcode to build your project via Applescript.
# If you don't like this idea you can leave it out and xcede will
# use xcodebuild instead. That's quite a lot slower, but it works just
# as well.
buildwithxcode: true
# There are many more options, including profiles (groups of settings) -
# which make it much easier to switch between different devices and
# simulators for example. Also options for file naming, location and
# format (toml, json). See `xcede help`.
#
# Note that these options are available on the command line too, like
# `xcede --scheme MyApp`, but a config file works better for our
# purposes.
Now try out xcede from the command line:
xcede buildxcede run- or both at once:
xcede buildrun
Create Zed tasks
You probably know by now that 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 (although some are created automagically by language plugins).
🔧 Add this to your global tasks file:
{
"label": "SwiftBuild",
"command": "xcede build",
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "never"
},
{
"label": "SwiftRun",
"command": "xcede buildrun",
"allow_concurrent_runs": false,
"reveal": "no_focus",
"hide": "never"
}
Now run each of your tasks. You should get nicely formatted build output, and Zed should launch your app and show its console output.
💡 You can command-click the file location on errors and warnings in the build log to jump to their location in code.
💡 Do you log with Apple's unified logging system (oslog)? You need some extra settings to show that; see xcede help for details.
💡 Running Mac apps? Make sure you read that section of the xcede help.
Add keyboard shortcuts for your tasks
Notice we save files first, because who saves manually? (Also, you'll want to choose your own key bindings of course.)
"cmd-b": [
"action::Sequence",
["workspace::Save", ["task::Spawn", { "task_name": "SwiftBuild" }]]
],
"cmd-r": [
"action::Sequence",
["workspace::Save", ["task::Spawn", { "task_name": "SwiftRun" }]]
]
Note this is only one way of organising things. I use global tasks, defined once, and an
xcede.ymlfor each project. You might prefer something different, say project tasks instead with all settings as command line arguments. Or some other variation. It's up to you.
Bonus: Basic Swift package support
If you open a Swift package rather than an Xcode project, your global build and run tasks will still work. You don't
need xcede.yml for this.
xcede just runs plain swift build and swift run, so if you need anything more elaborate, define a project task.
Debugging
🔧 First you need to configure zed to use xcede's DAP wrapper. Add this your zed settings file (Settings → Open Settings File):
"dap": {
"Swift": {
"binary": "/path/to/xcede"
}
}
🔧 Now define a debug task. Like our build and run tasks, this works globally and isn't project-specific – the project settings come from the config file.
{
"label": "SwiftDebug",
"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 just launch lldb on that executable, as if you
// weren't using xcede.
"program": "xcede:",
// This will build your app first, and is optional.
"build": "SwiftBuild"
}
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.
Aside: 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: without this, IDEs would have to implement a different protocol for every debugger they talked to. Communication looks like this: IDE (Zed) → lldb-dap → lldb.
When launched without arguments, xcede runs as a DAP server. This adds another layer, so then communication looks like this: IDE (Zed) → xcede → lldb-dap → lldb. Mostly xcede passes commands straight through to lldb-dap, but it adds some useful functionality:
- With
lldb-dapthere's no obvious way to attach to a process on a device without hackery with python scripts. xcede takes care of that for you.- Even so, attaching kind of sucks because you'd have two separate manual steps to do: first launch the app, then attach with the debugger. Also, its console output would be in a separate zed terminal panel. What I wanted was launch-like behaviour (like you get in Xcode). lldb can do that with a macOS executable, but not on devices/simulators. xcede takes care of the launching, and merges the app's console output into debugger output as you'd expect. Plus it uses
xcede.yml, which is convenient.
Running tests
Open some test code and you should see runnable triangles in the gutter next to your tests. Out of the box these work for Swift packages but not Xcode projects, so let's take care of that.
🔧 Add this to your global tasks file:
{
"label": "$ZED_CUSTOM_SWIFT_TEST_CLASS test",
"command": "xcede",
"args": ["test", "$ZED_CUSTOM_SWIFT_TEST_CLASS"],
"tags": ["swift-xctest-class", "swift-testing-suite"]
},
{
"label": "$ZED_CUSTOM_SWIFT_TEST_CLASS.$ZED_CUSTOM_SWIFT_TEST_FUNC test",
"command": "xcede",
"args": [
"test",
"$ZED_CUSTOM_SWIFT_TEST_CLASS",
"$ZED_CUSTOM_SWIFT_TEST_FUNC"
],
"tags": ["swift-xctest-func", "swift-testing-member-func"]
},
{
"label": "$ZED_CUSTOM_SWIFT_TEST_FUNC test",
"command": "xcede",
"args": ["test", "-", "$ZED_CUSTOM_SWIFT_TEST_FUNC"],
"tags": ["swift-testing-bare-func"]
}
🔧 Add a setting or two to xcede.yml:
testtarget: MyAppTests
# Depending on how you've arranged things, you might have a separate
# scheme for your tests, in which case define this:
# testscheme: MyAppTestScheme
That's it. Click those triangles and run some tests!
💡 If you're using Swift Testing, annotate your test structs/classes with @Suite, otherwise Zed won't see them as runnable.
💡 You can run all tests in your target with xcede test. You could create a task for that. (If your scheme contains
tests for multiple targets, you can override the xcede.yml setting like this: xcede test --testtarget '')
How this works: The runnables you see in Zed are created by the Swift plugin. Each one has an associated tag, and also sets variables $ZED_CUSTOM_* for things we need to know – test class and test function. The plugin also defined tasks to execute these runnables using
swift test; here, we're replacing the plugin's tasks with versions that use xcede instead.Note that you haven't lost the ability to run Swift package tests – if you've opened a package rather than an Xcode project, xcede will see that and run the appropriate
swift testcommand.
UI Previews
Here's a brief video with no sound:
Wait, that's just an Xcode window behind a Zed window!
Yes indeed. Xcode helpfully updates the preview whenever you save a file. Changes are a bit slower to appear, but it's actually quite usable and it's nice staying in Zed when the situation allows.
Bonus points: preview a different file without leaving Zed
If you were watching carefully, you'll have noticed that I switched to working on an entirely different app screen part way through the video.
I did that with a small shell script that I've named xcode-open-file:
#!/bin/sh
osascript <<END
tell application "Xcode"
open POSIX file "$1"
end tell
END
and a Zed task:
{
"label": "Open current file in Xcode",
"command": "xcode-open-file",
"args": ["$ZED_FILE"],
"hide": "always",
"reveal": "never"
}
which of course I created a key binding for.
That's it!
Maybe it looks like a whole bunch of moving parts, but don't be put off – most of it is one-off setup.
After that, each new project you work just needs an xcede.yml config file.
Here is xcede's documentation page.
For bug reports or to discuss possible enhancements, you can engage with the project at the xcede Codeberg repo.
@lxmn@mastodon.social