Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, AI-assisted, and human-written content
AI likelihood · overall
MixedArticle text · 1,409 words · 7 segments analyzed
I shipped SPEEM, my first iOS app, from Doom Emacs. Not from Xcode.I don’t mean I just edited a few files in Emacs and switched back when it was time to build. I mean the whole loop: write Swift, build, boot a simulator, install the app, launch it, stream logs, restart LSP, scaffold new projects. All from inside Emacs, all driven by SPC i keybindings I wrote myself.This post is about how, and why it’s even possible.TL;DRApple ships a small army of command-line tools, xcodebuild, xcrun simctl, xcrun swift-format, sourcekit-lsp, and most iOS developers never touch them directly. Xcode is just a fancy wrapper around them (of course with many other utilities built into it). If you’re willing to glue those tools together yourself, you can build a real iOS workflow in any editor that lets you run shell commands and read process output. Doom Emacs happens to be very, very good at that (or any Emacs distro for that matter really).The result, for me, is a ~1000-line modules/ios.el that gives me an SPC i b to build, SPC i s to install and launch on selected simulators, SPC i l to stream filtered logs, SPC i n to scaffold a new SwiftUI project, and a few more things I’ll walk through below.Why I did thisI live in Emacs. I’ve been using Doom Emacs daily for 4 years: Rust, Elixir, Kotlin/Android, web, org-mode, Magit, all of it. When I came back to iOS development for SPEEM, I tried Xcode for two weeks and it felt like wearing someone else’s shoes. Not bad shoes. Just not mine.I also have a multi-language config. I have a modules/rust.el, a modules/elixir.el, a modules/kotlin-android.el, a modules/web.el, and so on. Each language has the same shape: a major mode, LSP, keybindings for build/run/test. iOS being the one exception felt wrong.So I sat down and wrote modules/ios.el. This is what’s in it.What Apple actually gives you on the command lineBefore any Emacs Lisp, it helps to know what’s even there.
When you xcode-select -p you get the active developer directory, which contains all the tools Xcode uses internally:$ xcode-select -p /Applications/Xcode.app/Contents/Developer Inside that, the tools that matter for building and running an iOS app from a terminal are:xcodebuild, compiles your .xcodeproj or .xcworkspace. It accepts a scheme, an SDK, a destination, and a list of actions like build, clean, test. This is what Xcode itself calls when you press Run.xcrun simctl, controls the iOS simulators. Boot, install, uninstall, launch, terminate, take screenshots, override the status bar. Anything you can do clicking around in the Simulator app, you can do with simctl.xcrun swift-format, Apple’s official Swift formatter. Works on stdin/stdout.sourcekit-lsp, Apple’s language server for Swift. Ships with Xcode at Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp. This is what gives me completion, jump-to-definition, errors, refactors.xcode-build-server, not from Apple, but essential. It generates a buildServer.json that tells sourcekit-lsp how the project is actually configured (build flags, modules, etc). Without it, LSP works but it’s blind to half your project. brew install xcode-build-server.xcodegen, also not from Apple. Generates an .xcodeproj from a small YAML file. I use it to scaffold new projects so I don’t have to ever open Xcode’s project editor.Once you know those exist, the rest is just orchestration.The structure of my Doom configMy Doom config is split into modules. The top-level config.el is tiny:;;; config.el -*- lexical-binding: t; -*-
(setq doom-font (font-spec :family "Fira Code" :size 13 :weight 'semi-light) doom-variable-pitch-font (font-spec :family "Fira Sans" :size 14) doom-theme 'doom-monokai-pro display-line-numbers-type t org-directory "~/org/")
(load! "modules/core") (load! "modules/lsp") (load! "modules/rust") (load! "modules/elixir") (load! "
modules/ios") (load! "modules/kotlin-android") (load! "modules/web") (load! "modules/slint") (load! "modules/projectile") (load! "modules/org") (load! "modules/tools") (load! "modules/keybindings") ; all map! calls live here, loaded last The iOS-specific bits live in modules/ios.el, and the keybindings for them in modules/keybindings.el. I keep keybindings separate from feature code so that everything map! is in one place, easier to grep, easier to reason about.On the Doom-modules side, init.el enables the standard (swift +lsp) language module::lang (swift +lsp) ; who asked for emoji variables? That gives me swift-mode and LSP plumbing for free. Everything below is built on top of that.Pointing LSP at the right sourcekit-lspThere’s a subtlety here.
There are usually two sourcekit-lsp binaries on a Mac: the one in /Library/Developer/CommandLineTools/... and the one inside the active Xcode. They’re not always the same version, and using the wrong one can break diagnostics in confusing ways. I want the one that matches my active Xcode.(let* ((dev (string-trim (shell-command-to-string "xcode-select -p"))) (sklsp (expand-file-name "Toolchains/XcodeDefault.xctoolchain/usr/bin/sourcekit-lsp" dev))) (setenv "DEVELOPER_DIR" dev) (setq lsp-sourcekit-executable sklsp lsp-sourcekit-extra-args nil)) I set DEVELOPER_DIR so any subprocess (xcodebuild, xcrun ...) inherits the same Xcode. Then I pin lsp-sourcekit-executable to that exact path.For swift-mode itself the setup is small, turn on lsp-deferred, disable inlay hints (I find them noisy), set indentation to 4 spaces:(use-package! swift-mode :mode "\\.swift\\'" :hook ((swift-mode . lsp-deferred) (swift-mode . (lambda () (setq-local lsp-inlay-hint-enable nil)))) :config (setq swift-mode:basic-offset 4 indent-tabs-mode nil)) Format-on-save is handled by apheleia, calling swift-format via xcrun:(after! apheleia (setf (alist-get 'swift-format apheleia-formatters) '("xcrun" "swift-format" "format" "--assume-filename" filepath "-")) (setf (alist-get 'swift-mode apheleia-mode-alist) '(swift-format))) (add-hook 'swift-mode-hook #'apheleia-mode) That’s it for the editor surface. Open a .swift file, get completion, jump-to-def, errors, format on save. Nothing exotic so far.Auto-generating buildServer.jsonThis is the part that surprises people. Out of the box, sourcekit-lsp doesn’t know what your project’s build flags are.
It guesses. The fix is xcode-build-server: it reads your .xcodeproj, asks xcodebuild how the project actually compiles, and writes a buildServer.json that LSP can read.I don’t want to remember to run it. So I hook it into swift-mode: the first time I open a Swift file in a project without a buildServer.json, generate one.(defun wassim/+swift-ensure-build-server-json () "Generate buildServer.json for the current Xcode project if missing." (let* ((root (wassim/swift-xcode-root)) (bsp-file (expand-file-name "buildServer.json" root)) (xcodeproj (car (directory-files root t "\\.xcodeproj\\'")))) (when (and xcodeproj (not (file-exists-p bsp-file))) (let* ((proj-name (file-name-base xcodeproj)) (cmd (format "cd %s && xcode-build-server config -project %s.xcodeproj -scheme %s" (shell-quote-argument root) (shell-quote-argument proj-name) (shell-quote-argument proj-name)))) (when (executable-find "xcode-build-server") (message "Generating buildServer.json for sourcekit-lsp...") (shell-command cmd) (message "✅ buildServer.json generated.
Restart LSP with SPC l R"))))))
(add-hook 'swift-mode-hook #'wassim/+swift-ensure-build-server-json) The companion command for when something changes (new file added, scheme renamed) is bound to SPC i g:(defun wassim/swift-regenerate-build-server () "Manually regenerate buildServer.json for the current Xcode project." (interactive) ;; ... deletes the existing file and re-runs xcode-build-server ... ) Finding the project rootThis one is more annoying than it sounds. The “root” of an iOS project is the directory containing the .xcworkspace or .xcodeproj. But if your cursor is somewhere deep inside App/Features/Auth/Views/LoginView.swift, you have to climb up to find it. Worse, if you happen to be editing a file inside the .xcodeproj bundle (which happens, Xcode stores some files there), you need to climb out of the bundle first.I wrote a small set of helpers for this:(defun wassim/xcode--unbundle (dir) "Climb out if DIR is inside a .xcodeproj/.xcworkspace bundle." (let ((d (file-name-as-directory (expand-file-name dir)))) (while (string-match-p "\\.xcodeproj/\\|\\.xcworkspace/" d) (setq d (file-name-directory (directory-file-name d)))) d))
(defun wassim/swift-xcode-root () "Project root containing a top-level .xcworkspace or .xcodeproj." (let* ((start (wassim/xcode--unbundle (if buffer-file-name (file-name-directory buffer-file-name) default-directory))) (root (or (locate-dominating-file start (lambda (dir) (or (wassim/xcode--find-top-workspace dir) (wassim/xcode--find-project dir)))) (vc-root-dir) (ignore-errors (projectile-project-root)) default-directory))) (file-name-as-directory (expand-file-name root)))) Everything that builds, runs, installs, or talks to the simulator calls wassim/swift-xcode-root first.
I also force default-directory to that root for any Swift buffer, so M-x compile and friends always behave correctly:(add-hook 'swift-mode-hook #'wassim/swift-set-project-root-default-directory) (add-hook 'find-file-hook #'wassim/swift-set-project-root-default-directory) (add-hook 'after-change-major-mode-hook #'wassim/swift-set-project-root-default-directory) The build command is just xcodebuild with the right arguments. There are two non-obvious things to get right:Pick the .xcworkspace if there is one (CocoaPods / SPM projects), otherwise the .xcodeproj.Pick a scheme. If the project has shared schemes (in xcshareddata/xcschemes), use them. Otherwise fall back to the user’s schemes or the target name.I have a helper wassim/xcode--container+selector that returns (kind container selector-flag selector-value) so the rest of the code doesn’t have to care:(defun wassim/xcode--container+selector () "Return (kind container selector sel-arg), using a scheme when available." (let* ((root (wassim/swift-xcode-root)) (ws (wassim/xcode--find-top-workspace root)) (proj (wassim/xcode--find-project root)) (kind (if ws "workspace" "project")) (cont (or ws proj (user-error "No top-level .xcworkspace/.xcodeproj in %s" root))) (scheme (wassim/xcode--prefer-scheme cont kind))) (if scheme (list kind cont "-scheme" scheme) (list "project" (or proj cont) "-target" (file-name-base cont))))) The build itself is SPC i b. It resolves Swift Package dependencies first, then builds, then (on success) restarts sourcekit-lsp so the diagnostics refresh immediately:(defun wassim/ios-build (&optional clean) "Resolve packages then build. C-u to clean first. Restarts sourcekit-lsp on success so diagnostics refresh immediately."