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 Dockerfiles 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]+\\)\\]\\)"))))