Tweaking Emacs for Ruby Development in 2023
Preamble
Since I started a new job in April, I’ve been spending the majority of my time with legacy Ruby code for the first time since 2017 (I’d been mainly working on Elixir and Typescript codebases between then and now).
Before my start-date at the new job, I spent some time setting up a basic Emacs configuration for Ruby. I’d made a good start, but with all those unknown unknowns swimming around, I was only able to make vague assumptions about what I needed. I’ve since done a bunch more tweaking to get a configuration that works well with the development environment in this company.
I thought I’d share where I am with that in case it’s helpful for others. It’s still very much a work in progress (like every Emacs configuration ever), but I’m already getting a lot of value out of it.
Tweak Zero: Rootless Podman & Podman Compose
A lot of Emacs’ container integration is designed to work well with Docker in particular. I wanted to use rootless Podman instead, since it has better Linux integration, it more closely matches the runtime environment in production, it’s safer because it doesn’t need privileged access, and it would simplify the work required to move away from docker-compose.yml
files in the future thanks to Podman’s excellent generating functionality. Refactoring the docker-compose.yml
and Dockerfile
s of all projects such that they’re no-longer Docker specific is a good thing for flexibility at the end of the day. Going through the setup with a fine-toothed comb like this was also a very educational exercise for me, as someone who was in the early stages of understanding the separation of concerns between the services. I also knew I didn’t want to rely on Docker Compose, since this would reqiure an extra compatibility layer for Podman to interact with it, and I would miss out on some caching features that podman-compose provides.
There were quite a few parts to this migration, but I won’t go into that in this article for the sake of brevity and focus. On the Emacs side, all I had to do to make the docker.el
package’s configuration work well enough with Podman for my light use case, was to set docker-command
and docker-compose-command
to podman
and podman-compose
respectively.
Tweak One: Asdf and GNU Direnv Integration
I love to use the excellent asdf version manager as a modular, generic alternative to the myriad of verison manager applications that are needed for any given language. It’s important, then, that the project’s ruby & node versions are used when Emacs switches to a project. Luckily, asdf.el exists. Install it. Enable it. Job done.
On a similar vein, it’s important to inherit project-specific environment variables, commonly stored in a .env
file. I’m using the excellent envrc.el to provide buffer-local GNU direnv integration. It’s a similarly simple affair. .envrc
files are GNU Direnv’s native tongue, but that doesn’t mean it can’t play nice with other formats. One useful tip is to enable the sourcing of more common .env
files, by adding the following script to ~/.config/direnv/direnvrc
:
#!/bin/sh
if [ -f ".env" ]; then
dotenv
fi
# Local Variables:
# mode: sh
# End:
Be careful though, as these files are sometimes actually shell files. If that’s the case, you’ll want to source
them, instead of using the dotenv
command. See here for more info. In case you are wondering, the comments at the end tell Emacs to use sh-mode
to syntax-highlight this ambigous file.
Tweak Two: Using the Solargraph LSP server with bundler
In the project root, I set project-scoped configuration using a .dir-locals.el file. Because I’m using Emacs 30.0.50 which comes with Treesitter and Eglot, I set the eglot-server-programs
variable within the context of ruby-base-mode
, which covers ruby with or without Treesitter enabled.
((ruby-base-mode . ((eglot-server-programs . ((ruby-base-mode "bundle" "exec" "solargraph" "stdio"))))))
Tweak Three: Rspec & Podman Compose
As mentioned above, I’m using podman-compose
to orchestrate the development environment. When I want to run unit tests with RSpec, I really want to do so from inside the container. This ensures all the connectivity and configuration is setup correctly, without creating further complication.
Another .dir-locals.el
incantation is in order for rspec-mode
’s docker configuration:
((ruby-base-mode . ((rspec-use-docker-when-possible . t)
(rspec-docker-command . "podman-compose exec")
(rspec-docker-cwd . "/app/")
(rspec-docker-file-name . "../dev/docker-compose.yml")
(rspec-docker-container . "ruby-app"))))
In my case, I’m using podman-compose
instead of docker
, and the docker-compose.yml
file is in another project. Luckily, I can use relative paths here. It’s worth noting that rspec-docker-container
is actually referring to the docker-compose
service in question. Overall, this seems to work pretty well!
Tweak Four: Encapsulate dev env tasks in a Justfile
I’ve had the pleasure of using Just as a convenient Makefile alternative a few times over the years, so when I saw that I wanted to refactor the dev env scripts, I reached for it right away. I knew that I could refactor the myriad of shell scripts into a Justfile easily, and that using just in Emacs was straightforward thanks to Justl.el. I recommend checking out this EmacsConf talk on justl.el if you’re interested in seeing why I find it so compelling.
The .dir-locals.el
comes to the rescue once again, and this time we use it to setup the justfile (which is outside the project we’re configuring):
((nil . ((justl-justfile . "/home/john/code/project/dev/justfile"))
Notice how I set the mode to nil
, which means that this variable will be enabled for any buffer type within the project. Now I can run M-x justl
to run common tasks like redeploying containers, purging caches, triggering a re-indexing for sourcegraph, and generating documentation.
I submitted a few PRs to make this possible, and I also added support for the --unstable
flag so that I can unlock the ability to split the justfile up into smaller files using include directives.
Tweak Five: Evil Matchit + Ruby
As a chronic vim keybindings addict, I am wedded to Evil for the forseeable future. For me, the evil-matchit package is fundamental to my ability to navigate a file quickly. While evil-matchit
does have a ruby configuration, it needs to be enabled for ruby-base-mode
, and for my usecase the tags it matches needed to be adjusted:
(require 'evil-matchit-ruby)
;; Add ruby matchit to ruby-base-mode so that it works with Treesitter
;; modes too.
(evilmi-load-plugin-rules '(ruby-base-mode ruby-ts-mode) '(simple ruby))
;; Improve the match tags for ruby
(defvar evilmi-ruby-match-tags
'((("unless" "if") ("elsif" "else") "end")
("begin" ("rescue" "ensure") "end")
("case" ("when" "else") "end")
(("class" "def" "while" "do" "module" "for" "until") () "end")
(("describe" "context" "subject" "specify" "it" "let") () "end"))) ;; RSpec
Tweak Six: A rails console REPL
In Emacs parliance, a REPL-type environment would be encapsulated in an “inferior mode”. So I want to be able to run a ruby inferior mode within the context of my project, which is running in a container! Luckily, the inf-ruby package supports containers by default, but I haven’t yet figured out how to configure inf-ruby
to run within the context of a container by default - even if the buffer I’m editing is on the host machine. To work around this, I use the docker
package to browse to the container’s filesystem, then run M-x inf-ruby
from there. It works great, but I will automate this some day, as it’s a little annoying to do.
I setup inf-ruby
such that it automatically steals focus if a breakpoint is triggered.
(use-package inf-ruby
:straight t
:config
(add-hook 'after-init-hook 'inf-ruby-switch-setup)
(add-hook 'compilation-filter-hook 'inf-ruby-auto-enter-and-focus)
(add-hook 'ruby-base-mode 'inf-ruby-minor-mode)
(inf-ruby-enable-auto-breakpoint))
Tweak Seven: Refactoring UI
I started to collect useful functions for modifying the ruby. Many of them came from the ruby-refactor package, which while old seems to work well for me.
I decided to coalesce them all into a Transient popup so that they can be triggered easily. Building a transient popup is rather easy:
(transient-define-prefix jjh/ruby-refactor-transient ()
"My custom Transient menu for Ruby refactoring."
[["Refactor"
("e" "Extract Region to Method" ruby-refactor-extract-to-method)
("v" "Extract Local Variable" ruby-refactor-extract-local-variable)
("l" "Extract to let" ruby-refactor-extract-to-let)
("c" "Extract Constant" ruby-refactor-extract-constant)
("r" "Rename Local Variable or Method (LSP)" eglot-rename)
("{" "Toggle block style" ruby-toggle-block)
("'" "Toggle string quotes" ruby-toggle-string-quotes)
]
["Actions"
("d" "Documentation Buffer" eldoc-doc-buffer)
("q" "Quit" transient-quit-one)
("C" "Run a REPL" inf-ruby-console-auto)
("TAB" "Switch to REPL" ruby-switch-to-inf)]])
I decided to bind the transient popup to C-c r
when in ruby-base-mode
buffers. I find ruby-toggle-block
to be especially useful when working with RSpec specs. Whenever the LSP server has a function available, I will default to using that, since it’s more context-aware. I fully expect to write some Tree Sitter based refactoring functions in the future, which I’d like to call from this popup also. The idea is that I should have a “single pain of glass” for common ruby refactoring tasks.
Bonus Tweak: Clickable JIRA bug references
Did you know that Emacs can auto-detect bug references in your buffers and turn them into clickable links? Here’s what I use for a cloud JIRA instance - again in my .dir-locals.el
:
((nil . ((bug-reference-url-format . "https://example.atlassian.net/browse/%s")
(bug-reference-bug-regexp . "\\(\\[\\([A-Z]+-[0-9]+\\)\\]\\)"))))