Mike Slinn
Mike Slinn

Gem ‘Subclassing’ and Introspection

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

This page is part of the ruby collection, categorized under gem.

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 gems dependencies for a project. Ruby gems are also very 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 also.

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 are meant 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 at any file in any directory within the gem.

Shell
# @param file must be a fully qualified file name
# @return Gem::Specification of gem that file points into,
# or nil if not called from a gem
def current_spec(file)
  return nil unless file.exist?

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

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

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

&., called “safe navigation operator”, allows to skip method call when receiver is nil. It returns nil and doesn’t evaluate method’s arguments if the call is skipped.  – From Ruby-Doc.org

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 

Lets look at the first element contained within searcher:

Shell
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 

Lets search the list for the Gem::Specification for the jekyll_plugin_support v0.6.0 gem into spec.

Shell
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:

Shell
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 location by examining __FILE__. If this is done by a file within Ruby gem, then the location of the file is obtained, and one of the parent directories of the location will be location of the gem.

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

Unfortunately, __FILE__ does not return anything useful from irb, as you can see. 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:

Shell
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 easily test to see if the file is within the gem directory tree:

Shell
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__.

Shell
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
@homepage       = spec.homepage
@published_date = spec.date.to_date.to_s
@version        = spec.version
😁

... 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 a long time now. Back in 2005 I was the product marketing manager at CodeGear (the company was formerly known as Borland) for their https://www.infoq.com/news/2007/05/codegear-ror-ide/ 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, and Ruby utilities.