Using Emacs for Swift development

October 12th, 2020 · 4 minute read

I’m a big fan of Emacs and have been using it to do web development with Ruby and JavaScript for years. But about a year ago I started doing Swift development to work on a Mac app and, naturally, wanted to continue using Emacs as my primary editor.

This presents quite a challenge in Apple’s development ecosystem where Xcode is the heavily favored editor and (as far as I know) there are some tasks you can only perform with Xcode. And even for the tasks that can technically be done with any tool, like editing the Info.plist file, the knowledge required is so arcane that it’s a practical impossibility if not a technical one.

But the situation isn’t hopeless. I recognized that 95% of my time is spent writing code, so I’ve optimized for that and accepted the fact that 5% of the time I’ll have to pick up my mouse and click on things in Xcode like a barbarian.

Here are a few things I’ve cobbled together to make the process easier:

swift-mode

I won’t spend much time on this: swift-mode is great and provides the basics like font lock, indentation, and s-expressions. Table stakes for any language.

Inspired by js2-refactor mode’s log-this command, I wrote a simple elisp function for logging the thing under my cursor.

Quick disclaimer to cover up my insecurities: I’m not good at elisp and I bet there’s a much better way to accomplish this (and every other elisp thing in this post) than what I wrote. Feedback is welcome!

(defun print-swift-var-under-point()
  (interactive)
  (if (string-match-p (string (preceding-char)) "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
      (backward-sexp)
    nil)
  (kill-sexp)
  (yank)
  (move-end-of-line nil)
  (newline)
  (insert "print(\"")
  (yank)
  (insert ": \\(")
  (yank)
  (insert ")\")")
  (indent-for-tab-command))

And I use use-package to bind it to C-c l in swift-mode:

(use-package swift-mode
  :bind (("C-c l" . print-swift-var-under-point)))

A gif showing the log var under point command at work

Building, running, and testing

The thing that annoyed me most quickly about using Emacs was having to switch back to Xcode and hit ⌘B / ⌘R to build/run. This is something I was doing frequently.

It turns out you can pretty easily tell Xcode to build, run, and test with AppleScript, so I wrote a few functions that execute a shell command using the osascript command to do just that:

(defun xcode-build()
  (interactive)
  (shell-command-to-string
    "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'build targetProject' -e 'end tell'"))
(defun xcode-run()
  (interactive)
  (shell-command-to-string
    "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'stop targetProject' -e 'run targetProject' -e 'end tell'"))
(defun xcode-test()
  (interactive)
  (shell-command-to-string
    "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'stop targetProject' -e 'test targetProject' -e 'end tell'"))

Note that these tell Xcode to build, run, or test the frontmost project. There’s almost certainly some way to figure out what Xcode project Emacs has open and tell it to run that one instead, but I’ve never bothered to do it.

I’ve built on Projectile’s excellent keybindings for these commands:

(global-set-key (kbd "C-c p b") 'xcode-build)
(global-set-key (kbd "C-c p r") 'xcode-run)
(global-set-key (kbd "C-c p t") 'xcode-test)

Opening a file in Xcode

Sometimes you simply must open Xcode to get something done, like for debugging, autocomplete, or documentation lookup. I found myself doing this enough times that I wrote a function that opens the file I’m currently viewing in Xcode:

(defun xcode-open-current-file()
  (interactive)
  (shell-command-to-string
    (concat "open -a \"/Applications/Xcode.app\" " (buffer-file-name))))

And again I’ve bound this to a Projectile-like hotkey:

(global-set-key (kbd "C-c p o") 'xcode-open-current-file)

This function worked great for me for a while, but I recently extended it even further to open the current file and jump to the current line. The xed command is supposed to be able to do this, but it was really flaky for me, so I used Keysmith (an app I built) to do this instead.

I have Emacs (1) open the file in Xcode, (2) copy the current line to my clipboard, and (3) run this Keysmith shortcut:

A screenshot of a Keysmith macro to go to a line in Xcode

(defun xcode-open-current-file()
  (interactive)
  (shell-command-to-string
    (concat "open -a \"/Applications/Xcode.app\" " (buffer-file-name)))
  (kill-new (car (cdr (split-string (what-line)))))
  (shell-command-to-string
    "open keysmith://run-shortcut/796BB627-5433-48E4-BB54-1AA6C54A14E8"))

And here it is in action:

The new jump to line command in action

Bonus: Add TODO comment

This one isn’t Swift specific, but I like to share this tip whenever I can. A few years ago I wrote this really simple elisp function, and it’s had a surprisingly large effect on my workflow:

(defun insert-todo-comment ()
  (interactive)
  (indent-for-tab-command)
  (insert "TODO: ")
  (back-to-indentation)
  (set-mark-command nil)
  (move-end-of-line nil)
  (comment-dwim nil))
(defun todo-comment-on-next-line ()
  "Insert a TODO comment on the next line at the proper indentation"
  (interactive)
  (move-end-of-line nil)
  (newline)
  (insert-todo-comment))
(add-hook 'prog-mode-hook (lambda () (local-set-key (kbd "") #'todo-comment-on-next-line)))

This command, which I’ve bound to Control+Shift+Return, simply inserts a new TODO: comment on the very next line. And since it uses the comment-dwim function, this works for any language.

In combination with magit-todos this function lets me offload a lot more cognitive overhead from my brain to Emacs, and in a way that ensures nothing gets dropped. When I’m planning a large feature or refactoring I start by working my way through the codebase adding TODO comments with brief descriptions of the work to be done. magit-todos displays these TODO comments in my magit-status buffer which I then use as a checklist to complete my task.

The new jump to line command in action

I’ve often found it difficult to get across the impact that this simple command has had for me, so I’ll stress again that the cognitive relief you feel by dropping a TODO comment down instead of having to track something else in your mind is huge. I strongly recommend you give this workflow a try.

Conclusion

If you’ve got any comments or suggestions please let me know - Twitter is the best way to reach me. I’d especially love to hear from you if you end up using any of these functions for yourself!

And finally, if you want everything I discussed, here’s one big chunk of elisp you can copy and paste:

(defun print-swift-var-under-point()
    (interactive)
    (if (string-match-p (string (preceding-char)) "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_")
        (backward-sexp)
      nil)
    (kill-sexp)
    (yank)
    (move-end-of-line nil)
    (newline)
    (insert "print(\"")
    (yank)
    (insert ": \\(")
    (yank)
    (insert ")\")")
    (indent-for-tab-command))
(use-package swift-mode
  :bind (("C-c l" . print-swift-var-under-point)))

(defun xcode-build()
  (interactive)
  (shell-command-to-string
     "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'build targetProject' -e 'end tell'"))
(defun xcode-run()
  (interactive)
  (shell-command-to-string
     "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'stop targetProject' -e 'run targetProject' -e 'end tell'"))
(defun xcode-test()
  (interactive)
  (shell-command-to-string
     "osascript -e 'tell application \"Xcode\"' -e 'set targetProject to active workspace document' -e 'stop targetProject' -e 'test targetProject' -e 'end tell'"))
(global-set-key (kbd "C-c p b") 'xcode-build)
(global-set-key (kbd "C-c p r") 'xcode-run)
(global-set-key (kbd "C-c p t") 'xcode-test)

(defun xcode-open-current-file()
  (interactive)
  (shell-command-to-string
    (concat "open -a \"/Applications/Xcode.app\" " (buffer-file-name)))
  (kill-new (car (cdr (split-string (what-line)))))
  (shell-command-to-string
     "open keysmith://run-shortcut/796BB627-5433-48E4-BB54-1AA6C54A14E8"))
(global-set-key (kbd "C-c p o") 'xcode-open-current-file)

(defun insert-todo-comment ()
  (interactive)
  (indent-for-tab-command)
  (insert "TODO: ")
  (back-to-indentation)
  (set-mark-command nil)
  (move-end-of-line nil)
  (comment-dwim nil))
(defun todo-comment-on-next-line ()
  "Insert a TODO comment on the next line at the proper indentation"
  (interactive)
  (move-end-of-line nil)
  (newline)
  (insert-todo-comment))
(add-hook 'prog-mode-hook (lambda () (local-set-key (kbd "") #'todo-comment-on-next-line)))