Mike Slinn

Gem ‘Subclassing’ and Introspection

Published 2023-04-06. Last modified 2023-07-21.
Time to read: 4 minutes.

This page is part of the ruby collection.

Ruby gems provide a handy way of packaging reusable Ruby code for use in Ruby programming projects. Bundler simplifies and automates the process of downloading, installing and maintaining compatible versions of Ruby gem dependencies for a project. Ruby gems are also easy to publish.

My jekyll_plugin_support gem contains classes called JekyllBlock, JekyllTag, JekyllBlockNoArgParsing, and JekyllTagNoArgParsing. These classes are meant to be subclassed. New Jekyll plugins are created around these subclasses, and these plugins are normally packaged as gems as well.

To clarify, while gems cannot be subclassed, when the jekyll_plugin_support gem is added to a project as a dependency, at least one of the four classes that I mentioned will need to be subclassed. The subclasses are used to define a Jekyll plugin.

Context

Recently, I wanted to add optional functionality into jekyll_plugin_support that would be available to JekyllBlock and JekyllTag subclasses.

The new jekyll_plugin_support functionality required it to recognize when it was being invoked by a gem and to obtain the information stored in the invoking gem’s Gem::Specification.

This required a deep dive into how Jekyll loads plugins, how Liquid dispatches tags, and the inner workings of Ruby gems. This article is not going to drag you through all the details; instead, just the most important details will be discussed.

Ruby Gem Definitions

Ruby gems are defined by a Gem::Specification, normally saved in a .gemspec file. If you have ever looked at a Ruby gem’s source code, this is familiar to you. For example, here is the gem specification for jekyll_plugin_support:

require_relative 'lib/jekyll_plugin_support/version'

Gem::Specification.new do |spec|
  github = 'https://github.com/mslinn/jekyll_plugin_support'

  spec.bindir = 'exe'
  spec.authors = ['Mike Slinn']
  spec.email = ['mslinn@mslinn.com']
  spec.files = Dir['.rubocop.yml', 'LICENSE.*', 'Rakefile', '{lib,spec}/**/*', '*.gemspec', '*.md']
  spec.homepage = 'https://www.mslinn.com/jekyll_plugins/jekyll_plugin_support.html'
  spec.license = 'MIT'
  spec.metadata = {
    'allowed_push_host' => 'https://rubygems.org',
    'bug_tracker_uri'   => "#{github}/issues",
    'changelog_uri'     => "#{github}/CHANGELOG.md",
    'homepage_uri'      => spec.homepage,
    'source_code_uri'   => github,
  }
  spec.name = 'jekyll_plugin_support'
  spec.post_install_message = <<~END_MESSAGE

    Thanks for installing #{spec.name}!

  END_MESSAGE
  spec.require_paths = ['lib']
  spec.required_ruby_version = '>= 2.6.0'
  spec.summary = 'Provides a framework for writing and testing Jekyll plugins'
  spec.test_files = spec.files.grep %r{^(test|spec|features)/}
  spec.version = JekyllPluginSupportVersion::VERSION

  spec.add_dependency 'facets'
  spec.add_dependency 'jekyll', '>= 3.5.0'
  spec.add_dependency 'jekyll_plugin_logger'
  spec.add_dependency 'key-value-parser'
  spec.add_dependency 'pry'
end

Gem Self-Discovery

A key feature of Ruby gems, essential to the code presented in this article, is that gems are not stored in a compressed format. Instead, they are stored as uncompressed files in a directory tree.

In contrast, most package management formats for other computer languages and OSes use a compressed format to store dependencies. For example: Python wheel, Java jars (also used by Maven), and Debian deb (also used by Ubuntu apt).

The following Ruby code was developed on StackOverflow. The method returns the Gem::Specification for a gem when pointed to a file within any directory within the gem.

# @param file must be a fully qualified directory name pointing to an installed gem, or within it,
#             or a file name within an installed gem
# @return Gem::Specification of gem that file points into,
# or nil if no gem exists at the given file
def current_spec(file)
  return nil unless File.exist? file

  searcher = if Gem::Specification.respond_to?(:find)
               Gem::Specification
             elsif Gem.respond_to?(:searcher)
               Gem.searcher.init_gemspecs
             end

  searcher&.find do |spec|
    spec.full_gem_path.start_with? file
  end
end

The current_spec method in the above code uses the safe navigation operator.

&. is called the “safe navigation operator” because it suppresses method calls when the receiver is nil. It returns nil and does not evaluate method arguments if the call is skipped.

If I paste the code that defines searcher into irb, the result is a Gem::Specification, which is confusing because it is actually a Gem::Specification that contains a collection of Gem::Specifications.

Shell
$ irb
irb(main):001:1* searcher = if Gem::Specification.respond_to?(:find)
irb(main):002:1*              Gem::Specification
irb(main):003:1*            elsif Gem.respond_to?(:searcher)
irb(main):004:1*              Gem.searcher.init_gemspecs
irb(main):005:0>            end
=> Gem::Specification 

Let’s look at the first element contained within searcher:

irb (continued)
irb(main):006:0> searcher.first
=>
Gem::Specification.new do |s|
  s.name = "error_highlight"
  s.version = Gem::Version.new("0.3.0")
  s.installed_by_version = Gem::Version.new("0")
  s.authors = ["Yusuke Endoh"]
  s.date = Time.utc(2023, 1, 24)
  s.description = "The gem enhances Exception#message by adding a short explanation where the exception is raised"
  s.email = ["mame@ruby-lang.org"]
  s.files = ["lib/error_highlight.rb",
   "lib/error_highlight/base.rb",
   "lib/error_highlight/core_ext.rb",
   "lib/error_highlight/formatter.rb",
   "lib/error_highlight/version.rb"]
  s.homepage = "https://github.com/ruby/error_highlight"
  s.licenses = ["MIT"]
  s.require_paths = ["lib"]
  s.required_ruby_version = Gem::Requirement.new([">= 3.1.0.dev"])
  s.rubygems_version = "3.3.3"
  s.specification_version = 4
  s.summary = "Shows a one-line code snippet with an underline in the error backtrace"
  end 

Let’s search the list for the Gem::Specification for the jekyll_plugin_support v0.6.0 gem into spec.

irb (continued)
irb(main):007:0> spec = searcher.find_by_name('jekyll_plugin_support', '0.6.0')
=>
Gem::Specification.new do |s|
... 

The directory that contains an installed Gem::Specification can be obtained by calling full_gem_path:

irb (continued)
irb(main):008:0> spec.full_gem_path
=> "/home/mslinn/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/jekyll_plugin_support-0.6.0" 

As most Ruby programmers know, a Ruby source file can discover its own location by examining __FILE__. If this is done by a file within a Ruby gem, then the location of the file is obtained, and one of the parent directories of the location will be the location of the entire gem.

irb (continued)
irb(main):008:0> file = __FILE__
=> "(irb)" 

Unfortunately, __FILE__ does not return anything useful from irb, as you can see above. Let’s cheat a little and set file to a valid value for the gem we are looking at. We’ll point at lib/jekyll_plugin_support.rb within the gem:

irb (continued)
irb(main):009:0> file = File.join(spec.full_gem_path, 'lib/jekyll_plugin_support.rb')
=> "/home/mslinn/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/jekyll_plugin_support-0.6.0/lib/jekyll_plugin_suppo..." 

irb(main):010:0> File.exist? file
=> true 

Here is an quick test to see if the file is within the gem directory tree:

irb (continued)
irb(main):008:0> file.start_with? spec.full_gem_path
=> true 

If a gem needs to know what its Gem::Specification is, it can iterate through each of the items within searcher, and compare the value from full_gem_path against the value returned by __FILE__.

irb (continued)
irb(main):008:0> searcher&.find do |spec|
  file.start_with? spec.full_gem_path
end
=> Gem::Specification.new do |s|
  ... 

Now that we know how the current_spec method works, we can invoke it from a Ruby gem like this:

Ruby gem source
spec = current_spec __FILE__

The gem has obtained its own Gem::Specification through digital navel-gazing (omphaloskepsis).

The gem can now retrieve its own properties from spec.

Ruby gem source (continued)
@name           = spec.name
@authors        = spec.authors # Array
@homepage       = spec.homepage
@published_date = spec.date.to_date.to_s
@version        = spec.version.to_s
😁

... and that is the essence of how the jekyll_plugin_support subclass feature works.

About the Author

I, Mike Slinn, have been working with Ruby for 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 the SD Forum in Silicon Valley. As you can see, I have the t-shirt. I was the 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, as well as Ruby utilities.

* indicates a required field.

Please select the following to receive Mike Slinn’s newsletter:

You can unsubscribe at any time by clicking the link in the footer of emails.

Mike Slinn uses Mailchimp as his marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp’s privacy practices.