Mike Slinn
Mike Slinn

Debugging Jekyll Plugins with an IDE

Published 2022-02-21. Last modified 2022-04-17.
Time to read: 9 minutes.

This page is part of the jekyll collection, categorized under Jekyll, Ruby, Visual Studio Code.

This blog post is the last in a three-part series. The first post describes how to install and use the jekyll_bootstrap5_tabs plugin. The second post describes how the Jekyll plugin was constructed. The plugin is built and published as an open-source Ruby gem. This blog post is dedicated to demonstrating a straightforward way of debugging Jekyll plugins, which are always written in Ruby.

Many open-source projects fall far short of their true potential because no-one bothers to tell the story, completely and thoughtfully, in depth. Hopefully this post will in some small way improve Jekyll’s circumstance in the F/OSS world.

The Jekyll documentation provides absolutely no commentary on how to debug plugins. Perhaps the Jekyll developers felt the information would be obvious to sufficiently experienced programmers. Because so many debugging possibilities exist, I did not find it obvious.

These Instructions Work Everywhere

The instructions in this post should work on every OS that Jekyll runs on, using Visual Studio Code, RubyMine, IntelliJ IDEA, or any other IDE that supports normal Ruby debugging.

Most problems with debugging Jekyll plugins are related to the plugin’s need to receive parameters from Jekyll. We do not need to dig into Jekyll itself to get this sorted out; all we need to do is to find where the gem you want to debug was installed, and set breakpoints. The information that the IDE will then present to you will far exceed what the Jekyll plugin documentation provides.

Prerequisites

The Setting Up a Ruby Development Environment post describes the necessary preparations.

Plugins Run In the Jekyll Address Space

The most important thing to know about debugging Jekyll plugins is that they run in the same address space as Jekyll itself. That means to debug a plugin you must actually debug the Jekyll process, and it will load the most recently created version of your plugin.

Orientation

For the purposes of this blog post, I assume that you have a Jekyll plugin that you want to debug. It does not matter if you wrote the plugin or not. All that matters is that the plugin was installed properly. Throughout this blog post, I will refer to this gem as “the subject gem”. I wrote this post to figure out how to debug my subject gem, which is jekyll_bootstrap5_tabs, so you will see references to that gem in this post whenever I discuss what needs to be done with your subject gem.

Move to your Jekyll project directory, this is where we will work. For me, that meant:

Shell
$ cd $jekyll_bootstrap5_tabs

If you are curious where the environment variable above was defined, when my Bash shells start, they source $work/.evars. The following lines are within:

/mnt/_/work/.evars
export work=/var/work
export jekyll=$work/jekyll
export jekyll_flexible_include_plugin=$jekyll/jekyll-flexible-include-plugin
export jekyll_bootstrap5_tabs=$jekyll/jekyll_bootstrap5_tabs
export jekyll_template=$sites/jekyll_template

That is part of a directory structure I maintain across several machines.

Jekyll Launcher

Jekyll is provided as a Ruby gem. When the Jekyll gem is installed, it also creates a launcher at /usr/local/bin/jekyll, which is actually a small Ruby program that loads the gem.

/usr/local/bin/jekyll
#!/usr/bin/env ruby2.7
#
# This file was generated by RubyGems.
#
# The application 'jekyll' is installed as part of a gem, and
# this file is here to facilitate running it.
#

require 'rubygems'

version = ">= 0.a"

str = ARGV.first
if str
  str = str.b[/\A_(.*)_\z/, 1]
  if str and Gem::Version.correct?(str)
    version = str
    ARGV.shift
  end
end

if Gem.respond_to?(:activate_bin_path)
  load Gem.activate_bin_path('jekyll', 'jekyll', version)
else
  gem "jekyll", version
  load Gem.bin_path("jekyll", "jekyll", version)
end

Jekyll is best debugged when launched via the normal means, that is, via /usr/local/bin/jekyll. The launcher figures out where the Jekyll gem resides, and loads it.

🙏

If you set a breakpoint on the installed plugin's source code, execution will halt when the plugin is loaded or invoked. You will be able to see the call stack and the variables at each level of the call stack. This is super helpful!

Locating the Subject Gem

You need to know where the subject gem was installed to set breakpoints in it. From within a Jekyll project that uses the subject gem, discover the location of the Jekyll entry points within the gem as follows:

Shell
$ bundle info jekyll_bootstrap5_tabs
  * jekyll_bootstrap5_tabs (1.1.0)
        Summary: Jekyll plugin for Bootstrap 5 tabs
        Homepage: https://mslinn.com/blog/2022/02/13/jekyll-gem.html
        Source Code: https://github.com/mslinn/jekyll_bootstrap5_tabs
        Path: /home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0 

The jekyll_bootstrap5_tabs gem is located at /home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0.

Now we need to find the entry point for the plugin, which is where Jekyll invokes the functionality of the plugin. If we search for Liquid:: we'll find the source files containing the entry points.

Shell
$ $ grep -rl 'Liquid::' /home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0/*
/home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0/lib/jekyll_bootstrap5_tabs.rb 

Now we know we need to open /home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0/lib/jekyll_bootstrap5_tabs.rb in Visual Studio to set breakpoints.

Here is a one-line command that will tell you that same information:

Shell
$ grep -rl 'Liquid::' "$( bundle info jekyll_bootstrap5_tabs | \
  grep Path | awk '{print $2}' )"
/home/mslinn/gems/gems/jekyll_bootstrap5_tabs-1.1.0/lib/jekyll_bootstrap5_tabs.rb 

Launching vs. Attaching

For programs that you built, you would start the debug server; it would launch the program and provide normal Ruby debugging features. Your IDE (Visual Studio Code, RubyMine or IDEA) attaches to the debug server. Most IDEs simplify this process by allowing you to provide a run configuration for launching a Ruby program / gem; the IDE takes care of the plumbing.

Because the Jekyll launch process is complex, we will start it up and then attach to its process instead.

Debugging using Visual Studio Code and Docker on Debian Distros

Documentation on how to set up Jekyll for debugging using Visual Studio Code has been available for Debian-derived Linux distros since February 2020. However, the docs are thin and are not applicable to many circumstances. Following the instructions causes Docker and other dependencies to be installed on your machine.

I don't see any benefit in running Docker to debug Jekyll. Examining the Dockerfile reveals its essence, however: two well-known gems are installed that provide a debug server ruby-debug-ide and debase (GitHub).

.devcontainer Files

You can skip ahead to Container-Free Debugging. I just provide the information in this section for completeness, but it is not necessary for understanding anything relevant to this blog post.

Here are the files included in Jekyll for debugging via Docker, in the .devcontainer directory: devcontainer.json and Dockerfile.

.devcontainer/devcontainer.json
// For format details, see https://aka.ms/vscode-remote/devcontainer.json or this file's README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.101.1/containers/ruby-2
{
	"name": "Ruby 2",
	"dockerFile": "Dockerfile",

	// Set *default* container specific settings.json values on container create.
	"settings": {
		"terminal.integrated.shell.linux": "/bin/bash"
	},

	// Add the IDs of extensions you want installed when the container is created.
	"extensions": [
		"rebornix.Ruby"
	]

	// Use 'forwardPorts' to make a list of ports inside the container available locally.
	// "forwardPorts": [],

	// Use 'postCreateCommand' to run commands after the container is created.
	"postCreateCommand": "bundle install",

	// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
	// "remoteUser": "vscode"

}

The following Dockerfile is specific to Debian Linux and related distros that support apt. Sorry, Mac users!

.devcontainer/Dockerfile
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------

FROM ruby:2

# Avoid warnings by switching to noninteractive
ENV DEBIAN_FRONTEND=noninteractive

# This Dockerfile adds a non-root user with sudo access. Use the "remoteUser"
# property in devcontainer.json to use it. On Linux, the container user's GID/UIDs
# will be updated to match your local UID/GID (when using the dockerFile property).
# See https://aka.ms/vscode-remote/containers/non-root-user for details.
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID

# Configure apt and install packages
RUN apt-get update \
    && apt-get -y install --no-install-recommends apt-utils dialog locales 2>&1 \
    # Verify git, process tools installed
    && apt-get -y install git openssh-client iproute2 procps lsb-release \
    #
    # Install ruby-debug-ide and debase
    && gem install ruby-debug-ide \
    && gem install debase \
    #
    # Install node.js
    && apt-get -y install curl software-properties-common \
    && curl -sL https://deb.nodesource.com/setup_13.x | bash - \
    && apt-get -y install nodejs \
    #
    # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user.
    && groupadd --gid $USER_GID $USERNAME \
    && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \
    # [Optional] Add sudo support for the non-root user
    && apt-get install -y sudo \
    && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\
    && chmod 0440 /etc/sudoers.d/$USERNAME \
    #
    # Clean up
    && apt-get autoremove -y \
    && apt-get clean -y \
    && rm -rf /var/lib/apt/lists/*

# Set the locale
RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
    dpkg-reconfigure --frontend=noninteractive locales && \
    update-locale LANG=en_US.UTF-8

ENV LANG en_US.UTF-8

# Switch back to dialog for any ad-hoc use of apt-get
ENV DEBIAN_FRONTEND=dialog

Container-Free Debugging

These instructions are applicable for all platforms that Jekyll runs on.

Debugging Components

A diagram should help us understand how the different components of a Ruby debugging extension work for Visual Studio Code. I added Ruby components to a diagram provided in Introducing Logpoints and auto-attach from the Visual Studio Code documentation.

You can see three components in the above diagram:

  1. The rebornix.Ruby Visual Studio Code plugin, which provides debugging, lint, Semantic highlighting, and Intellisense support for Ruby.
  2. The ruby-debug-ide gem, which communicates between Visual Studio and Ruby debuggers.
  3. The debase gem, which is the actual Ruby debugger.

Install Debugging Gems

Install the two Ruby gems required for debugging. The debase-ruby_core_source transitive dependency was automatically installed as well.

Shell
$ gem install debase ruby-debug-ide
Fetching debase-0.2.4.1.gem
Fetching debase-ruby_core_source-0.10.14.gem
Successfully installed debase-ruby_core_source-0.10.14
Building native extensions. This could take a while...
Successfully installed debase-0.2.4.1
Parsing documentation for debase-ruby_core_source-0.10.14
Installing ri documentation for debase-ruby_core_source-0.10.14
Parsing documentation for debase-0.2.4.1
Installing ri documentation for debase-0.2.4.1
Done installing documentation for debase-ruby_core_source, debase after 4 seconds
Fetching ruby-debug-ide-0.7.3.gem
Building native extensions. This could take a while...
Successfully installed ruby-debug-ide-0.7.3
Parsing documentation for ruby-debug-ide-0.7.3
Installing ri documentation for ruby-debug-ide-0.7.3
Done installing documentation for ruby-debug-ide after 0 seconds
3 gems installed 

Lets find out the names of the commands provided by the ruby-debug-ide gem:

Shell
$ gem specification ruby-debug-ide executables
---
- rdebug-ide
- gdb_wrapper 

rdebug-ide Command-Line Options

Now lets discover the command-line options for the rdebug-ide command provided by the ruby-debug-ide gem:

Shell
$ bundle exec rdebug-ide
Using ruby-debug-base 0.2.4.1
Usage: rdebug-ide is supposed to be called from RDT, NetBeans, RubyMine, or
       the IntelliJ IDEA Ruby plugin.  The command line interface to
       ruby-debug is rdebug.

Options:
    -h, --host HOST                  Host name used for remote debugging
    -p, --port PORT                  Port used for remote debugging
        --dispatcher-port PORT       Port used for multi-process debugging dispatcher
        --evaluation-timeout TIMEOUT evaluation timeout in seconds (default: 10)
        --evaluation-control         trace to_s evaluation
    -m, --memory-limit LIMIT         evaluation memory limit in mb (default: 10)
    -t, --time-limit LIMIT           evaluation time limit in milliseconds (default: 100)
        --stop                       stop when the script is loaded
    -x, --trace                      turn on line tracing
        --skip_wait_for_start        skip wait for 'start' command
    -l, --load-mode                  load mode (experimental)
    -d, --debug                      Debug self - prints information for debugging ruby-debug itself
        --xml-debug                  Debug self - sends information s for debugging ruby-debug itself
    -I, --include PATH               Add PATH to $LOAD_PATH
        --attach-mode                Tells that rdebug-ide is working in attach mode
        --key-value                  Key/Value presentation of hash items
        --ignore-port                Generate another port
        --keep-frame-binding         Keep frame bindings
        --disable-int-handler        Disables interrupt signal handler
        --rubymine-protocol-extensions
                                     Enable all RubyMine-specific incompatible protocol extensions
        --catchpoint-deleted-event   Enable chatchpointDeleted event
        --value-as-nested-element    Allow to pass variable’s value as nested element instead of attribute
        --socket-path PATH           Listen for debugger on the given UNIX domain socket path

Common options:
    -v, --version                    Show version

Must specify a script to run 

The gdb_wrapper executable provided by ruby-debug-ide gem is meant to be invoked by the IDE:

Shell
$ bundle exec gdb_wrapper
You should specify PID of process you want to attach to 

The debase gem does not provide any executables:

Shell
$ gem specification debase executables
--- [] 

Install Visual Studio Code Ruby Support

Install the rebornix.Ruby Visual Studio Code plugin by Peng Lv. This plugin provides Ruby language support and debugging for Visual Studio Code. When I last checked, it had 2,395,349 installations! Learn about Visual Studio Code debugging for Ruby. You can do the installation via the command line if you like:

Shell
$ code --install-extension rebornix.Ruby@0.28.1

The debase gem is a fast implementation of the standard Ruby debugger debug.rb for Ruby 2.0. The core component provides support that front-ends can build on. It provides breakpoint handling, bindings for stack frames, and more.

Debase relies on debase-ruby_core_source to work with Visual Studio Code, however by default debase pulls in a very old version of debase-ruby_core_source, released in 2017. That old version does not support conditional breakpoints, for example. Add the following to your project Gemfile to get current versions of both gems:

gem 'debase', require: false
gem 'debase-ruby_core_source', '>= 0.10.15', require: false

Now install the new gems declared in Gemfile:

Shell
$ bundle install

Caveat

Looking at the source code for the Ruby plugin it is apparent that functional breakpoints are not implemented. This is not mentioned in the documentation.

Setup Debugging

This is where having your software development projects on an SSD with an M.2 interface, such as NVMe, would really make a difference in your productivity.

Now it is time to launch Jekyll from the rdebug-ide debug server. Debug clients such as Visual Studio Code will be able to control the debug session via port 1234.

At first, I specified all of my normal Jekyll options for local usage, as shown below. They probably impacted the responsiveness of the debug session, but are workable nonetheless.

Shell
$ bundle exec rdebug-ide \
  --host 0.0.0.0 \
  --port 1234 \
  --dispatcher-port 26162 \
  -- \
    /usr/local/bin/jekyll serve \
      --livereload_port 35721 \
      --force_polling \
      --host 0.0.0.0 \
      --port 4001 \
      --future \
      --incremental \
      --livereload \
      --drafts \
      --unpublished
Fast Debugger (ruby-debug-ide 0.7.3, debase 0.2.4.1, file filtering is supported) listens on 0.0.0.0:1234 

Jekyll does not run any slower under the Ruby debugger.

Above you see 2-stage launches expressed as single command lines. The backslashes are line continuation characters, which is why 'one command line' actually spans a dozen or so lines.

  1. Stage 1: debugger parameters
  2. Stage 2: Jekyll parameters

I took a screen shot of the above command line and added a few comments:

You could make the above command line into a script, or a Visual Studio Code task. Here it is as a task:

.vscode/tasks.json
{
  // See https://go.microsoft.com/fwlink/?LinkId=733558
  // for the documentation about the tasks.json format
  "version": "2.0.0",
  "tasks": [
      {
          "label": "start-debug",
          "command": "/usr/local/bin/bundle",
          "args": [
             "exec", "rdebug-ide",
                "--host", "0.0.0.0",
                "--port", "1234",
                "--dispatcher-port", "26162",
                "--",
                "/usr/local/bin/jekyll", "serve",
                    "--livereload_port", "35721",
                    "--force_polling",
                    "--host", "0.0.0.0",
                    "--incremental",
                    "--livereload",
                    "--port", "4001",
                    "--future",
                    "--drafts",
                    "--unpublished",
          ],
          "isBackground": true,
          "presentation": {
              "panel": "new"
          },
          "problemMatcher": {
              "owner": "custom",
              "pattern": {
                  "regexp": "____"
              },
              "background": {
                  "activeOnStart": true,
                  "beginsPattern": "____",
                  "endsPattern": "Fast Debugger"
              }
          }
      }
  ]
}
This is one of those times where I wish JSON had a comment facility.

I made the following run configuration for Visual Studio Code to attach to the process resulting from running the above. Note that this is not the entire contents of .vscode/launch.json, just the one run configuration.

.vscode/launch.json Entry
{
  "cwd": "${workspaceRoot}",
  "name": "Attach rdebug-ide",
  "request": "attach",
  "remoteHost": "localhost",
  "remotePort": "1234",
  "remoteWorkspaceRoot": "/",
  "restart": true,
  "showDebuggerOutput": true,
  "stopOnEntry": true,
  "type": "Ruby",
},

I did not have success when I attempted to run the task automatically when starting the debug session. I am unsure what the problem was. If you are so inclined, you could enable that feature by adding the following to the launch.json entry above. I thoughtfully provided a trailing comma for you to copy:

Extra entry in .vscode/launch.json
"preLaunchTask": "start-debug",

Step By Step

To establish a debugging session:

  1. Launch Jekyll as shown above (bundle exec rdebug-ide …), either on the command line or as a Visual Studio Code task.
  2. Set a breakpoint in Visual Studio Code (I will discuss this next).
  3. Attach the Visual Studio debug client using the run configuration called Attach rdebug-ide.

Breakpoint Types

Visual Studio Code supports several types of breakpoints. This post discusses only two types: location-based breakpoints and function breakpoints. The links in this paragraph explain how breakpoints work in detail; I won't repeat that information here.

However, the rebornix.Ruby Visual Studio plugin does not support function breakpoints.

The overhead of breakpoints can be reduced.

PDB Files

Visual Studio Code uses PDB files to associate source code line numbers and variable names with a program being debugged. However, I am unaware of any mechanism to create a PDB file for Ruby code. Dear reader, if you know how to do this, please contact me.

This means that attaching to a Ruby process, or debugging a gem that was launched, will not automatically cause the relevant source file to open up at the line containing the breakpoint when hit. Instead, you will have to look at the top item in the Call stack pane, where you will see the fully qualified name of the source file and the line number where execution has been paused. You will then have to open that file up in the editor manually.

Execution Break - Ta-Da!

When VS Code hits a breakpoint, the call stack and variables are shown, but the editor does not display the line that the debugger has stopped at. You might not realize that this happened. After all this work (I only publish the happy path), when this just happened as it should for me the first time, I did not recognize it. If a source edit pane opened up with the breakpoint highlighted I would have noticed. Unfortunately, that does not work (yet!).

Now for the ta-da! moment. If you are not a programmer, this will go over with a thud. Since you made it this far, you are either a masochist, or a programmer.

😁

Take a good long look at the variables in the following screenshot. You can see what has been passed from Jekyll to the plugin easily here. This information is really hard to come by any other way.

Context.@environments

context.instance_variable_get('@scopes'), an array of hashes, has only one entry: 'nowMillis': '1645386226'. That was a Liquid variable that I had set in the page that contained a reference to the Bootstrap 5 tabs plugin.

self

The debugger paused at the start of TabsBlock#render. The variable self has type JeklyyBootstrap5Tabs::TabsBlock, and it looks like this:

The Jekyll tag was: {% tabs test pretty %}:

  • self.@markup is a string with value test pretty (with a space at the end).
  • self.@pretty_print is true.
  • self.@tab_name is a string with value test.
  • self.tag_name is a string with value tabs.

About the Author

true

I, Mike Slinn, have been working with Ruby a long time now. Back in 2005 I was the product marketing manager at CodeGear (the company was formerly known as Borland) for their 3rd Rail IDE. 3rd Rail supported Ruby, and Ruby on Rails, at launch.

In 2006, I co-chaired the Silicon Valley Ruby Conference, on behalf of SD Forum in Silicon Valley. As you can see, I have the t-shirt. I was sole chairman of the 2007 Silicon Valley Ruby Conference.

Several court cases have come my way over the years in my capacity as a software expert witness. The court cases featured questions about IP misappropriation for Ruby on Rails programs. You can read about my experience as a software expert if that interests you.

I currently enjoy writing Jekyll plugins in Ruby for this website and others.