Published 2023-04-06.
Last modified 2023-07-21.
Time to read: 4 minutes.
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::Specification
s.
$ 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(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(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(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(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(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(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(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:
spec = current_spec __FILE__
The gem has obtained its own Gem::
through digital navel-gazing (omphaloskepsis).
The gem can now retrieve its own properties from spec
.
@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.