Programming in Ruby

Table of Contents

A literate programming file for configuring Emacs to support the Ruby programming language.

Getting Started

Ruby is probably already installed on the system, and if not, we certainly can download it from ruby-lang.org, but since I need to juggle different versions for each project, I use direnv and ruby-install:

brew install ruby-install

And then install one or more versions:

ruby-install -U
ruby-install ruby 3

New Project

While we could use a large project templating system, I keep it simple. For each project, create the following directory structure:

├── Gemfile
├── Rakefile
├── lib
│   └── hello_world.rb
└── test
    └── hello_world_test.rb

For instance:

mkdir -p ~/src/ruby-xp    # Change me
cd ~/src/ruby-xp
mkdir -p lib test

Now, do the following steps.

  1. Create a .envrc file with the Ruby you want to use:

    use ruby 2.6.10
    
  2. Next, get Bundler:

    gem install bundle
    
  3. Create a minimal Gemfile:

    source 'https://rubygems.org'
    
    gem 'rake', group: :development
    gem 'rubocop', group: :development
    gem 'solargraph', group: :development
    
  4. Grab all the dependencies:

    bundle install
    
  5. Create a minimal Rakefile:

    task default: %w[test]
    
    task :run do
      ruby 'lib/hello_world.rb'
    end
    
    task :test do
      ruby 'test/hello_world_test.rb'
    end
    
  6. Create the first program:

    # frozen_string_literal: true
    
    # The basic greeting class
    class HelloWorld
      attr_reader :name
    
      def initialize(name = nil)
        @name = name
      end
    
      def greeting
        if @name
          "Hello there, #{@name}"
        else
          'Hello World!'
        end
      end
    end
    
    puts HelloWorld.new(ARGV.first).greeting
    
  7. Create the first test:

    require 'test/unit'
    require_relative '../lib/hello_world'
    
    class TestHelloWorld < Test::Unit::TestCase
      def test_default
        assert_equal 'Hello World!', HelloWorld.new.greeting
      end
    
      def test_name
        assert_equal 'Hello there, Bob', HelloWorld.new('Bob').greeting
      end
    end
    

Or something like that.

Existing Projects

For projects from work, I have found we need to isolate the Ruby environment. Once we use rbenv or rvm, but now, I just use direnv. Approach one is to use a local directory structure. Assuming I have a use_ruby function in ~/.config/direnv/direnvrc:

# From Apple: /usr/bin/ruby
use ruby 2.6.10
# Could use 3.2.1 from /opt/homebrew/bin/ruby

A better solution is to create a container to hold the Ruby environment. Begin with a Dockerfile:

## -*- dockerfile-image-name: "ruby-xp" -*-
FROM alpine:3.13

ENV NOKOGIRI_USE_SYSTEM_LIBRARIES=1

ADD Gemfile /

RUN apk update \
&& apk add ruby \
           ruby-etc \
           ruby-bigdecimal \
           ruby-io-console \
           ruby-irb \
           ca-certificates \
           libressl \
           bash \
&& apk add --virtual .build-dependencies \
           build-base \
           ruby-dev \
           libressl-dev \
&& gem install bundler || apk add ruby-bundler \
&& bundle config build.nokogiri --use-system-libraries \
&& bundle config git.allow_insecure true \
&& gem install json \
&& bundle install \
&& gem cleanup \
&& apk del .build-dependencies \
&& rm -rf /usr/lib/ruby/gems/*/cache/* \
          /var/cache/apk/* \
          /tmp/* \
          /var/tmp/*

Next, create a .envrc in the project’s directory:

CONTAINER_NAME=ruby-xp:latest
CONTAINER_WRAPPERS=(bash ruby irb gem bundle rake solargraph rubocop)

container_layout

While that approach works fairly well with , seems to want the checkers to be installed globally.

Configuration

While Emacs supplies a Ruby editing environment, we’ll still use use-package to grab the latest:

(use-package ruby-mode
  :mode (rx "." (optional "e") "rb" eos)
  :mode (rx "Rakefile" eos)
  :mode (rx "Gemfile" eos)
  :mode (rx "Berksfile" eos)
  :mode (rx "Vagrantfile" eos)
  :interpreter "ruby"

  :init
  (setq ruby-indent-level 2
        ruby-indent-tabs-mode nil)

  :hook (ruby-mode . superword-mode))

Ruby REPL

I am not sure I can learn a new language without a REPL connected to my editor, and for Ruby, this is inf-ruby:

(use-package inf-ruby
  :config
  (ha-local-leader 'ruby-mode-map
    "R" '("REPL" . inf-ruby)))

Electric Ruby

The ruby-electric project is a minor mode that aims to add the extra syntax when typing Ruby code.

(use-package ruby-electric
  :hook (ruby-mode . ruby-electric-mode))

Testing

The ruby-test-mode project aims a running Ruby test from Emacs seemless:

(use-package ruby-test-mode
  :hook (ruby-mode . ruby-test-mode)

  :config
  (ha-local-leader 'ruby-mode-map
    "t"  '(:ignore t :which-key "test")
    "t t" '("test one" . ruby-test-run-at-point)
    "t g" '("toggle code/test" . ruby-test-toggle-implementation-and-specification)
    "t A" '("test all" . ruby-test-run)
    "t a" '("retest" . ruby-test-rerun)))

Robe

The Robe project can be used instead of LSP.

(use-package robe

  :config
  (ha-local-leader 'ruby-mode-map
   "w"  '(:ignore t :which-key "robe")
   "ws" '("start" . robe-start))

  ;; The following leader-like keys, are only available when I have
  ;; started LSP, and is an alternate to Command-m:
  :general
  (:states 'normal :keymaps 'robe-mode-map
           ", w r" '("restart"  . lsp-reconnect)
           ", w b" '("events"   . lsp-events-buffer)
           ", w e" '("errors"   . lsp-stderr-buffer)
           ", w q" '("quit"     . lsp-shutdown)
           ", w l" '("load file" . ruby-load-file)

           ", l r" '("rename"   . lsp-rename)
           ", l f" '("format"   . lsp-format)
           ", l a" '("actions"  . lsp-code-actions)
           ", l i" '("imports"  . lsp-code-action-organize-imports)
           ", l d" '("doc"      . lsp-lookup-documentation)))

Do we want to load Robe automatically?

(use-package robe :hook (ruby-mode . robe-mode))

Bundler

The Bundler project integrates bundler to install a projects Gems.

(use-package bundler
  :config
  (ha-local-leader 'ruby-mode-map
    "g"  '(:ignore t :which-key "bundler")
    "g o" '("open" . bundle-open)
    "g g" '("console" . bundle-console)
    "g c" '("check" . bundle-check)
    "g i" '("install" . bundle-install)
    "g u" '("update" . bundle-update)))

Rubocop?

The lint-like style checker of choice for Ruby is Rubocop. The rubocop.el mode should work with Flycheck. First install it with:

gem install rubocop

And then we may or may not need to enable the rubocop-mode:

(use-package rubocop
  :hook (ruby-mode . rubocop-mode))

Auxiliary Support

Cucumber

Seems that to understand and edit Cucumber feature definitions, you need cucumber.el:

(use-package feature-mode)

LSP

Need to install Solargraph for the LSP server experience:

gem install solargraph

Or add it to your Gemfile:

gem 'solargraph', group: :development

XRef Interface with GNU Global

The GNU Global has the ability to generate a tags file for large, multi-project Ruby code bases.

First, issue these two:

find . -name .git | while read DOTGIT
do
  REPO=$(dirname $DOTGIT)
  (cd $REPO && git pull origin master)
done

find . -name "*.rb" > gtags.files
gtags --gtagslabel=new-ctags --file gtags.files

And now we need the GNU Global for Emacs, we are using the most up-to-date version of ggtags.

(use-package ggtags
  :hook ((ruby-mode . #'ggtags-mode)))

Careful observers will note that