Olag

Olag - Oren’s Library/Application Gem framework

TL;DR

Description

Olag is Oren’s set of utilities for creating a well-behaved gem. This is very opinionated code; it eliminates a lot of the boilerplate, at the cost of making many decisions which may not be suitable for everyone (directory structure, code verification, Codnar for documentation, etc.).

Installation

A simple gem install olag should do the trick, assuming you have Ruby gems set up.

Rakefile

Olag's Rakefile is a good example of how to use Olag's classes to create a full-featured gem Rakefile:

$LOAD_PATH.unshift(File.dirname(__FILE__) + "/lib")

require "olag/rake"

Gem specification

Olag::Rake.new(spec)

The overall Rakefile structure is as follows:

Gem Specification

The gem specification is provided as usual:


spec = Gem::Specification.new do |spec|
  spec.name = "olag"
  spec.version = Olag::VERSION
  spec.author = "Oren Ben-Kiki"
  spec.email = "rubygems-oren@ben-kiki.org"
  spec.homepage = "https://rubygems.org/gems/olag"
  spec.summary = "Olag - Oren's Library/Application Gem framework"
  spec.description = (<<-EOF).gsub(/^\s+/, "").chomp.gsub("\n", " ")
    Olag is Oren's set of utilities for creating a well-behaved gem. This is
    very opinionated software; it eliminates a lot of the boilerplate, at the
    cost of making many decisions which may not be suitable for everyone
    (directory structure, code verification, codnar for documentation, etc.).
  EOF
end

Contained in:

However, the Gem::Specification class is monkey-patched to automatically several of the specification fields, and adding some new ones:


Monkey-patch within the Gem module.

module Gem

  

Enhanced automated gem specification.

  class Specification

    

The title of the gem for documentation (by default, the capitalized name).

    attr_accessor :title

    

The name of the file containing the gem’s version (by default, lib/name/version.rb).

    attr_accessor :version_file

    alias_method :original_initialize, :initialize

    

Create the gem specification. Requires a block to set up the basic gem information (name, version, author, email, description). In addition, the block may override default properties (e.g. title).

    def initialize(&block)
      original_initialize(&block)
      setup_default_members
      add_development_dependencies
      setup_file_members
      setup_rdoc
    end

    

Set the new data members to their default values, unless they were already set by the gem specification block.

    def setup_default_members
      name = self.name
      @title ||= name.capitalize
      @version_file ||= "lib/#{name}/version.rb"
    end

    

Add dependencies required for developing the gem.

    def add_development_dependencies
      add_dependency("olag") unless self.name == "olag"
      %w(Saikuro codnar fakefs flay rake rcov rdoc reek roodi test-spec).each do |gem|
        add_development_dependency(gem)
      end
    end

    

Initialize the standard gem specification file list members.

    def setup_file_members
      

These should cover all the gem’s files, except for the extra rdoc files.

      setup_file_member(:files, "{lib,doc}/**/*")
      self.files << "Rakefile" << "codnar.html"
      setup_file_member(:executables, "bin/*") { |path| path.sub("bin/", "") }
      setup_file_member(:test_files, "test/**/*")
    end

    

Initialize a standard gem specification file list member to the files matching a pattern. If a block is given, it is used to map the file paths. This will append to the file list member if it has already been set by the gem specification block.

    def setup_file_member(member, pattern, &block)
      old_value = instance_variable_get("@#{member}")
      new_value = FileList[pattern].find_all { |path| File.file?(path) }
      new_value.map!(&block) if block
      instance_variable_set("@#{member}", old_value + new_value)
    end

    

Setup RDOC options in the gem specification.

    def setup_rdoc
      self.extra_rdoc_files = [ "README.rdoc", "LICENSE", "ChangeLog" ]
      self.rdoc_options << "--title" << "#{title} #{version}" \
                        << "--main" << "README.rdoc" \
                        << "--line-numbers" \
                        << "--all" \
                        << "--quiet"
    end

  end

end

Rake tasks

The Olag::Rake class sets up the tasks listed above as follows:

require "codnar/rake"
require "olag/change_log"
require "olag/gem_specification"
require "olag/update_version"
require "olag/version"
require "rake/clean"
require "rake/testtask"
require "rcov/rcovtask"
require "rdoc/task"
require "reek/rake/task"
require "roodi"
require "rubygems/package_task"

module Olag

  

Automate Rake task creation for a gem.

  class Rake

    include ::Rake::DSL

    

Define all the Rake tasks.

    def initialize(spec)
      @spec = spec
      @ruby_sources = @spec.files.find_all { |file| file =~ /^Rakefile$|\.rb$/ }
      @weave_configurations = [ :weave_include, :weave_named_chunk_with_containers, :weave_plain_chunk ]
      task(:default => :all)
      define_all_task
      CLOBBER << "saikuro"
    end

  protected

    

Define a task that does “everything”.

    def define_all_task
      define_desc_task("Version, verify, document, package", :all => [ :version, :verify, :doc, :package ])
      define_verify_task
      define_doc_task
      define_commit_task
      

This is a problem. If the version number gets updated, Gem::PackageTask fails. This is better than if it used the old version number, I suppose, but not as nice as if it just used @spec.version everywhere. The solution for this is to do a dry run before doing the final rake commit, which is a good idea in general.

      Gem::PackageTask.new(@spec) { |package| }
    end

    Verify gem functionality

    Analyze the source code

    Generate RDoc documentation

    Generate Codnar documentation

    Automate Git commit process

    Task utilities

  end

end

Task utilities

The following utilities are used to create the different tasks. It would have be nicer if Rake had treated the task description as just another task property.



Define a new task with a description.

def define_desc_task(description, *parameters)
  desc(description)
  task(*parameters) do
    yield(task) if block_given?
  end
end


Define a new task using some class.

def define_desc_class(description, klass, *parameters)
  desc(description)
  klass.new(*parameters) do |task|
    yield(task) if block_given?
  end
end

Contained in:

Verify the gem

The following tasks verify that the gem is correct. Testing for 100% code coverage seems excessive but in reality it isn't that hard to do, and is really only a modest form of test coverage verification.



Define a task to verify everything is OK.

def define_verify_task
  define_desc_task("Test, coverage, analyze code", :verify => [ :coverage, :analyze ])
  define_desc_class("Test code covarage with RCov", Rcov::RcovTask, "coverage") { |task| Rake.configure_coverage_task(task) }
  define_desc_class("Test code without coverage", ::Rake::TestTask, "test") { |task| Rake.configure_test_task(task) }
  define_analyze_task
end


Configure a task to run all tests and verify 100% coverage. This is cheating a bit, since it will not complain about files that are not reached at all from any of the tests.

def self.configure_coverage_task(task)
  task.test_files = FileList["test/*.rb"]
  task.libs << "lib" << "test/lib"
  task.rcov_opts << "--failure-threshold" << "100" \
                 << "--exclude" << "^/"
  

Strangely, on some occasions, RCov crazily tries to compute coverage for files inside Ruby’s gem repository. Excluding all absolute paths prevents this.

end


Configure a task to just run the tests without verifying coverage.

def self.configure_test_task(task)
  task.test_files = FileList["test/*.rb"]
  task.libs << "lib" << "test/lib"
end

Contained in:

The following tasks verify that the code is squeacky-clean. While passing the code through all these verifiers seems excessive, it isn't that hard to achieve in practice. There were several times I did refactorings "just to satisfy reek (or flay)" and ended up with an unexpected code improvement. Anyway, if you aren't a youch OCD about this sort of thing, Olag is probably not for you :-)



Define a task to verify the source code is clean.

def define_analyze_task
  define_desc_task("Analyze source code", :analyze => [ :reek, :roodi, :flay, :saikuro ])
  define_desc_class("Check for smelly code with Reek", Reek::Rake::Task) { |task| configure_reek_task(task) }
  define_desc_task("Check for smelly code with Roodi", :roodi) { run_roodi_task }
  define_desc_task("Check for duplicated code with Flay", :flay) { run_flay_task }
  define_desc_task("Check for complex code with Saikuro", :saikuro) { run_saikuro_task }
end


Configure a task to ensure there are no code smells using Reek.

def configure_reek_task(task)
  task.reek_opts << "--quiet"
  task.source_files = @ruby_sources
end


Run Roodi to ensure there are no code smells.

def run_roodi_task
  runner = Roodi::Core::Runner.new
  runner.config = "roodi.config" if File.exist?("roodi.config")
  @ruby_sources.each { |ruby_source| runner.check_file(ruby_source) }
  (errors = runner.errors).each { |error| puts(error) }
  raise "Roodi found #{errors.size} errors." unless errors.empty?
end


Run Flay to ensure there are no duplicated code fragments.

def run_flay_task
  dirs = %w(bin lib test/lib).find_all { |dir| File.exist?(dir) }
  result = IO.popen("flay " + dirs.join(' '), "r").read.chomp
  return if result == "Total score (lower is better) = 0\n"
  puts(result)
  raise "Flay found code duplication."
end


Run Saikuro to ensure there are no overly complex functions.

def run_saikuro_task
  dirs = %w(bin lib test).find_all { |dir| File.exist?(dir) }
  system("saikuro -c -t -y 0 -e 10 -o saikuro/ -i #{dirs.join(' -i ')} > /dev/null")
  result = File.read("saikuro/index_cyclo.html")
  raise "Saikuro found complicated code." if result.include?("Errors and Warnings")
end

Contained in:

Generate Documentation

The following tasks generate the usual RDoc documentation, required to make the gem behave well in the Ruby tool ecosystem:



Define a task to build all the documentation.

def define_doc_task
  desc "Generate all documentation"
  task :doc => [ :rdoc, :codnar ]
  ::Rake::RDocTask.new { |task| configure_rdoc_task(task) }
  define_codnar_task
end


Configure a task to build the RDoc documentation.

def configure_rdoc_task(task)
  task.rdoc_files += @ruby_sources.reject { |file| file =~ /^test|Rakefile/ } + [ "LICENSE", "README.rdoc" ]
  task.main = "README.rdoc"
  task.rdoc_dir = "rdoc"
  task.options = @spec.rdoc_options
end

Contained in:

The following tasks generate the Codnar documentation (e.g., the document you are reading now), which goes beyond RDoc to provide an end-to-end linear narrative describing the gem:



A set of file Regexp patterns and their matching Codnar configurations. All the gem files are matched agains these patterns, in order, and a Codnar::SplitTask is created for the first matching one. If the matching configuration list is empty, the file is not split. However, the file must match at least one of the patterns. The gem is expected to modify this array, if needed, before creating the Rake object.

CODNAR_CONFIGURATIONS = [
  [
    

Exclude the ChangeLog and generated codnar.html files from the generated documentation.

    "ChangeLog|codnar\.html",
  ], [
    

Configurations for splitting Ruby files. Using Sunlight makes for fast splitting but slow viewing. Using GVim is the reverse.

    "Rakefile|.*\.rb|bin/.*",
    "classify_source_code:ruby",
    "format_code_gvim_css:ruby",
    "classify_shell_comments",
    "format_rdoc_comments",
    "chunk_by_vim_regions",
  ], [
    

Configurations for splitting Haskell files. Not that ruby gems tend to contain Haskell files, but it allows more easily adding a Rakefile for generating codnar documentation into a Haskell project.

    ".*\.hs",
    "classify_source_code:haskell",
    "format_code_gvim_css:haskell",
    "classify_haddock_comments",
    "format_haddock_comments",
    "chunk_by_vim_regions",
  ], [
    

Configurations for GraphViz diagram files.

    ".*\.dot",
    "split_graphviz_documentation",
  ], [
    

Configurations for HTML documentation files.

    ".*\.html",
    "split_html_documentation",
  ], [
    

Configurations for Markdown documentation files.

    ".*\.markdown|.*\.md",
    "split_markdown_documentation",
  ], [
    

Configurations for RDOC documentation files.

    "LICENSE|.*\.rdoc",
    "split_rdoc_documentation",
  ],
]


Define a task to build the Codnar documentation.

def define_codnar_task
  @spec.files.each do |file|
    configurations = Rake.split_configurations(file)
    Codnar::Rake::SplitTask.new([ file ], configurations) unless configurations == []
  end
  Codnar::Rake::WeaveTask.new("doc/root.html", @weave_configurations)
end


Find the Codnar split configurations for a file.

def self.split_configurations(file)
  CODNAR_CONFIGURATIONS.each do |configurations|
    regexp = configurations[0] = convert_to_regexp(configurations[0])
    return configurations[1..-1] if regexp.match(file)
  end
  abort("No Codnar configuration for file: #{file}")
end


Convert a string configuration pattern to a real Regexp.

def self.convert_to_regexp(regexp)
  return regexp if Regexp == regexp
  begin
    return Regexp.new("^(#{regexp})$")
  rescue
    abort("Invalid pattern regexp: ^(#{regexp})$ error: #{$!}")
  end
end

Contained in:

Codnar is very configurable, and the above provides a reasonable default configuration for pure Ruby gems. You can modify the CODNAR_CONFIGURATIONS array before creating the Rake object, by unshifting additional/overriding patterns into it. For example, you may choose to use GVim for syntax highlighting. This will cause splitting to become much slower, but the generated HTML will already include the highlighting markup so it will display instantly. Or, you may have additional source file types (Javascript, CSS, HTML, C, etc.) to be highlighted.

Automate Git commit process

In an ideal world, committing to Git would be a simple matter of typing git commit -m "...". In our case, things get a bit complicated.

There is some information that we need to extract out of Git and inject into our files (to be committed). Since Git pre-commit hooks do not allow us to modify any source files, this turns commit into a two-phase process: we do an initial commit, update some files, then git commit --amend to merge them with the first commit.



Define a task that commit changes to Git.

def define_commit_task
  define_desc_task("Git commit process", :commit => [ :all, :first_commit, :changelog, :second_commit ])
  define_desc_task("Update version file from Git", :version) { update_version_file }
  define_desc_task("Perform the 1st (main) Git commit", :first_commit) { run_git_first_commit }
  define_desc_task("Perform the 2nd (amend) Git commit", :second_commit) { run_git_second_commit }
  define_desc_task("Update ChangeLog from Git", :changelog) { Olag::ChangeLog.new("ChangeLog") }
end


Update the content of the version file to contain the correct Git-derived build number.

def update_version_file
  version_file = @spec.version_file
  updated_version = Olag::Version::update(version_file)
  abort("Updated gem version; re-run rake") if @spec.version.to_s != updated_version
end


Run the first Git commit. The user will be given an editor to review the commit and enter a commit message.

def run_git_first_commit
  raise "Git 1st commit failed" unless system("set +x; git commit")
end


Run the second Git commit. This amends the first commit with the updated ChangeLog.

def run_git_second_commit
  raise "Git 2nd commit failed" unless system("set +x; EDITOR=true git commit --amend ChangeLog")
end

Contained in:

The first piece of information we need to extract from Git is the current build number, which needs to be injected into the gem's version number:


This module contains all the Olag code.

module Olag

  

This version number. The third number is automatically updated to track the number of Git commits by running rake version.

  VERSION = "0.1.22"

end

Documentation generation will depend on the content (and therefore modification time) of this file. Luckily, we can update this number before the first commit, and we can ensure it only touches the file if there is a real change, to avoid unnecessary documentation regeneration:

module Olag

  module Version

    

Update the file containing the gem’s version. The file is expected to contain a line in the format: VERSION = "major.minor.commits". The third number is updated according to the number of Git commits. This works well as long as we are working in the master branch.

    def self.update(path)
      current_file_contents, current_version, correct_version = current_status(path)
      if current_version != correct_version
        correct_file_contents = current_file_contents.sub(current_version, correct_version)
        File.open(path, "w") { |file| file.write(correct_file_contents) }
      end
      return correct_version
    end

  protected

    

Return the current version file contents, the current version, and the correct version.

    def self.current_status(path)
      prefix, current_suffix = extract_version(path, current_file_contents = File.read(path))
      correct_suffix = count_git_commits.to_s
      current_version = prefix + current_suffix
      correct_version = prefix + correct_suffix
      return current_file_contents, current_version, correct_version
    end

    

Extract the version number from the contents of the version file. This is an array of two strings - the prefix containing the major and minor numbers, and the suffix containing the commits number.

    def self.extract_version(path, file_contents)
      abort("#{path}: Does not contain a valid VERSION line.") unless file_contents =~ /VERSION\s+=\s+["'](\d+\.\d+\.)(\d+)["']/
      return [ $1, $2 ]
    end

    

Return the total number of Git commits that apply to the current state of the working directory. This means we add one to the actual number of commits if there are uncommitted changes; this way the version number does not change after doing a commit - it only changes after we make changes following a commit.

    def self.count_git_commits
      git_commits = IO.popen("git rev-list --all | wc -l").read.chomp.to_i
      git_status = IO.popen("git status").read
      git_commits += 1 unless git_status.include?("working directory clean")
      return git_commits
    end

  end

end

The second information we extract from Git is the ChangeLog file. Here, obviously, the ChangeLog needs to include the first commit's message, so we are forced to regenerate the file and amend Git's history with a second commit:

module Olag

  

Create ChangeLog files based on the Git revision history.

  class ChangeLog

    

Write a changelog based on the Git log.

    def initialize(path)
      @subjects_by_id = {}
      @sorted_ids = []
      read_log_lines
      File.open(path, "w") do |file|
        @log_file = file
        write_log_file
      end
    end

  protected

    

Read all the log lines from Git’s revision history.

    def read_log_lines
      IO.popen("git log --pretty='format:%ci::%an <%ae>::%s'", "r").each_line do |log_line|
        load_log_line(log_line)
      end
    end

    

Load a single Git log line into memory.

    def load_log_line(log_line)
      id, subject = ChangeLog.parse_log_line(log_line)
      @sorted_ids << id
      @subjects_by_id[id] ||= []
      @subjects_by_id[id] << subject
    end

    

Extract the information we need (ChangeLog entry id and subject) from a Git log line.

    def self.parse_log_line(log_line)
      date, author, subject = log_line.chomp.split("::")
      date, time, zone = date.split(" ")
      id = "#{date}\t#{author}"
      return id, subject
    end

    

Write a ChangeLog file based on the read Git log lines.

    def write_log_file
      @sorted_ids.uniq.each do |id|
        write_log_entry(id, @subjects_by_id[id])
      end
    end

    

Write a single ChaneLog entry.

    def write_log_entry(id, subjects)
      @log_file.puts "#{id}\n\n"
      @log_file.puts subjects.map { |subject| "\t* #{subject}" }.join("\n")
      @log_file.puts "\n"
    end

  end

end

Utility classes

Olag provides a set of utility classes that are useful in implementing well-behaved gems.

Unindeting text

When using "here documents" (<<EOF data), it is nice to be able to indent the data to match the surrounding code. There are other cases where it is useful to "unindent" multi-line text. The following tests demonstrates using the unindent function:

require "olag/string_unindent"
require "test/spec"


Test unindenting a multi-line text.

class TestUnindentText < ::Test::Unit::TestCase

  def test_automatic_unindent
    <<-EOF.unindent.should == "a\n  b\n"
      a
        b
    EOF
  end

  def test_invalid_unindent
    "    a\n  b\n".unindent.should == "a\n  b\n"
  end

  def test_integer_unindent
    "  a\n    b\n".unindent(1).should == " a\n   b\n"
  end

  def test_string_unindent
    "  a\n    b\n".unindent(" ").should == " a\n   b\n"
  end

end

And here is the implementation extending the built-in String class:


Extend the core String class.

class String

  

Strip away common indentation from the beginning of each line in this String. By default, detects the indentation from the first line. This can be overriden to the exact (String) indentation to strip, or to the (Fixnum) number of spaces the first line is further-indented from the rest of the text.

  def unindent(unindentation = 0)
    unindentation = " " * (indentation.length - unindentation) if Fixnum === unindentation
    return gsub(/^#{unindentation}/, "")
  end

  

Extract the indentation from the beginning of this String.

  def indentation
    return sub(/[^ ].*$/m, "")
  end

end

Accessing gem data files

Sometimes it is useful to package some files inside a gem, to be read by user code. This is of course trivial for Ruby code files (just use require) but not trivial if you want, say, to include some CSS files in your gem for everyone to use. Olag provides a way to resolve the path of any file in any gem (basically replicating what require does). Here is a simple test of using this functionality:

require "olag/data_files"
require "test/spec"


Test accessing data files packages with the gem.

class TestAccessDataFiles < Test::Unit::TestCase

  def test_access_data_file
    File.exist?(Olag::DataFiles.expand_path("olag/data_files.rb")).should == true
  end

  def test_access_missing_file
    Olag::DataFiles.expand_path("no-such-file").should == "no-such-file"
  end

end

And here is the implementation:

module Olag

  

Provide access to data files packaged with the gem.

  module DataFiles

    

Given the name of a data file packaged in some gem, return the absolute disk path for accessing that data file. This is similar to what require does internally, but here we just want the path for reading data, rather than load the Ruby code in the file.

    def self.expand_path(relative_path)
      $LOAD_PATH.each do |load_directory|
        absolute_path = File.expand_path(load_directory + "/" + relative_path)
        return absolute_path if File.exist?(absolute_path)
      end
      return relative_path # This will cause "file not found error" down the line.
    end

  end

end

Simulating objects with Hash tables

Javascript has an interesting convention where hash["key"] and hash.key mean the same thing. This is very useful in cutting down boilerplate code, and it also makes your data serialize to very clean YAML. Unlike OpenStruct, you do not need to define all the members in advance, and you can alternate between the .key and ["key"] forms as convenient for any particular piece of code. The down side is that you lose any semblance of type checking - misspelled member names and other errors are silently ignored. Well, that's what we have unit tests for, right? :-)

Olag provides an extension to the Hash class that provides the above, for these who have chosen to follow the dark side of the force. Here is a simple test demonstrating using this ability:

require "olag/hash_struct"
require "test/spec"


Test accessing missing keys as members.

class TestMissingKeys < ::Test::Unit::TestCase

  def test_read_missing_key
    {}.missing.should == nil
  end

  def test_set_missing_key
    hash = {}
    hash.missing = "value"
    hash.missing.should == "value"
  end

end

And here is the implementation:


Extend the core Hash class.

class Hash

  

Provide OpenStruct/JavaScript-like implicit .key and .key= methods.

  def method_missing(method, *arguments)
    method = method.to_s
    key = method.chomp("=")
    return method == key ? self[key] : self[key] = arguments[0]
  end

end

Sorting Hash tables YAML keys

In order to get deterministic test results, and for general hukman-friendlyness, it is often desirable to sort hash table keys when dumping it to YAML. In Ruby 1.8.x, this isn't the default. It is possible to introduce this behavior using the following hack:

require "yaml"

if RUBY_VERSION.include?("1.8")

  

Modify the hash class to emit YAML sorted keys. This is an ugly hack, specifically for Ruby 1.8.*.

  class Hash

    

Provide access to the old, unsorted implementation.

    alias :unsorted_to_yaml :to_yaml

    

Return the hash in YAML format with sorted keys.

    def to_yaml(opts = {})
      YAML::quick_emit(self, opts) do |out|
        out.map(taguri, to_yaml_style) do |map|
          to_yaml_sorted_keys.each do |key|
            map.add(key, fetch(key))
          end
        end
      end
    end

    

Return the hash keys, sorted for emitting into YAML.

    def to_yaml_sorted_keys
      begin
        return keys.sort
      rescue
        return to_yaml_lexicographically_sorted_keys
      end
    end

    

Return the hash keys, sorted lexicographically for emitting into YAML.

    def to_yaml_lexicographically_sorted_keys
      begin
        return keys.sort_by {|key| key.to_s}
      rescue
        return keys
      end
    end

  end

end

Here is a simple test demonstrating using it:

require "olag/hash_sorted_yaml"
require "test/spec"


An uncomparable class. Keys of this class will cause the Hash to be emitted in string sort order.

class Uncomparable

  def initialize(value)
    @value = value
  end

  def to_yaml(opts = {})
    return @value.to_yaml(opts)
  end

  def to_s
    return @value.to_s
  end

  %w(<=> < <= >= >).each do |operator|
    define_method(operator) { raise "Prevent operator: #{operator}" }
  end

end


An uncomparable class that can’t be converted to a string. Keys of this class will cause the Hash to be emitted in unsorted order.

class Unsortable < Uncomparable

  def to_s
    raise "Prevent conversion to string as well."
  end

end


Test sorting keys of YAML generated from Hash tables.

class TestSortedKeys < ::Test::Unit::TestCase

  SORTED_NUMERICALLY = <<-EOF.unindent
    ---
    2: 2
    4: 4
    11: 1
    33: 3
  EOF

  SORTED_LEXICOGRAPHICALLY = <<-EOF.unindent
    ---
    11: 1
    2: 2
    33: 3
    4: 4
  EOF

  def test_sortable_keys
    { 2 => 2, 4 => 4, 11 => 1, 33 => 3 }.to_yaml.gsub(/ +$/, "").should == SORTED_NUMERICALLY
  end

  def test_uncomparable_keys
    { Uncomparable.new(11) => 1, 2 => 2, 33 => 3, 4 => 4 }.to_yaml.gsub(/ +$/, "").should == SORTED_LEXICOGRAPHICALLY
  end

  def test_unsortable_keys
    yaml_should_not = { Unsortable.new(11) => 1, 2 => 2, 33 => 3, 4 => 4 }.to_yaml.gsub(/ +$/, "").should.not
    yaml_should_not == SORTED_NUMERICALLY
    yaml_should_not == SORTED_LEXICOGRAPHICALLY
  end

end

Collecting errors

In library code, it is bad practice to terminate the program on an error. Raising an exception is preferrable, but that forces you to abort the processing. In some cases, it is preferrable to collect the error, skip a bit of processing, and continue (if only for detecting additional errors). For example, one would expect a compiler to emit more than just the first syntax error message.

Olag provides an error collection class that also automatically formats the error to indicate its location. Here is a simple test that demonstrates collecting errors:

require "olag/errors"
require "olag/test"
require "test/spec"


Test collecting errors.

class TestCollectErrors < Test::Unit::TestCase

  include Test::WithErrors
  include Test::WithFakeFS

  def test_one_error
    @errors << "Oops"
    @errors.should == [ "#{$0}: Oops" ]
  end

  def test_path_error
    @errors.in_path("foo") do
      @errors << "Oops"
      "result"
    end.should == "result"
    @errors.should == [ "#{$0}: Oops in file: foo" ]
  end

  def test_line_error
    @errors.in_path("foo") do
      @errors.at_line(1)
      @errors << "Oops"
    end
    @errors.should == [ "#{$0}: Oops in file: foo at line: 1" ]
  end

  def test_file_error
    write_fake_file("foo", "bar\n")
    @errors.in_file("foo") do
      @errors << "Oops"
      "result"
    end.should == "result"
    @errors.should == [ "#{$0}: Oops in file: foo" ]
  end

  def test_file_lines_error
    write_fake_file("foo", "bar\nbaz\n")
    @errors.in_file_lines("foo") do |line|
      @errors << "Oops" if line == "baz\n"
    end
    @errors.should == [ "#{$0}: Oops in file: foo at line: 2" ]
  end


end

Which uses a mix-in that helps writing tests that use errors:

require "olag/errors"

module Test

  

Mix-in for tests that collect Errors.

  module WithErrors

    

Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class.

    def self.included(base)
      base.class_eval do

        alias_method :errors_original_setup, :setup

        

Automatically create an fresh +@errors+ data member for each test.

        def setup
          errors_original_setup
          @errors = Olag::Errors.new
        end

      end
    end

  end

end

Here is the actual implementation:

module Olag

  

Collect a list of errors.

  class Errors < Array

    

The current path we are reporting errors for, if any.

    attr_reader :path

    

The current line number we are reporting errors for, if any.

    attr_reader :line_number

    

Create an empty errors collection.

    def initialize
      @path = nil
      @line_number = nil
    end

    

Associate all errors collected by a block with a specific disk file.

    def in_path(path, &block)
      prev_path, prev_line_number = @path, @line_number
      @path, @line_number = path, nil
      result = block.call(path)
      @path, @line_number = prev_path, prev_line_number
      return result
    end

    

Associate all errors collected by a block with a disk file that is opened and passed to the block.

    def in_file(path, mode = "r", &block)
      return in_path(path) { File.open(path, mode, &block) }
    end

    

Associate all errors collected by a block with a line read from a disk file that is opened and passed to the block.

    def in_file_lines(path, &block)
      in_file(path) do |file|
        @line_number = 0
        file.each_line do |line|
          @line_number += 1
          block.call(line)
        end
      end
    end

    

Set the line number for any errors collected from here on.

    def at_line(line_number)
      @line_number = line_number
    end

    

Add a single error to the collection, with automatic context annotation (current disk file and line). Other methods (push, += etc.) do not automatically add the context annotation.

    def <<(message)
      push(annotate_error_message(message))
    end

  protected

    

Annotate an error message with the context (current file and line).

    def annotate_error_message(message)
      return "#{$0}: #{message}" unless @path
      return "#{$0}: #{message} in file: #{@path}" unless @line_number
      return "#{$0}: #{message} in file: #{@path} at line: #{@line_number}"
    end

  end

end

Testing with a fake file system

Sometimes tests need to muck around with disk files. One way to go about it is to create a temporary disk directory, work in there, and clean it up when done. Another, simpler way is to use the FakeFS file system, which captures all(most) of Ruby's file operations and redirect them to an in-memory fake file system. Here is a mix-in that helps writing tests using FakeFS (we will use it below when running applications inside unit tests):

require "fileutils"
require "fakefs/safe"

module Test

  

Mix-in for tests that use the FakeFS fake file system.

  module WithFakeFS

    

Create and write into a file on the fake file system.

    def write_fake_file(path, content = nil, &block)
      directory = File.dirname(path)
      FileUtils.mkdir_p(directory) unless File.exists?(directory)
      File.open(path, "w") do |file|
        file.write(content) unless content.nil?
        block.call(file) unless block.nil?
      end
    end

    

Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class.

    def self.included(base)
      base.class_eval do

        alias_method :fakefs_original_setup, :setup

        

Automatically create an fresh fake file system for each test.

        def setup
          fakefs_original_setup
          FakeFS.activate!
          FakeFS::FileSystem.clear
        end

        alias_method :fakefs_original_teardown, :teardown

        

Automatically clean up the fake file system at the end of each test.

        def teardown
          fakefs_original_teardown
          FakeFS.deactivate!
        end

      end

    end

  end

end

Testing with a temporary file

When running external programs, actually generating a temporary disk file is sometimes inevitable. Of course, such files need to be cleaned up when the test is done. If we need more than just one such file, it is easier to create a whole temporary directory which is easily cleaned up in one operation.

Here is a mix-in that helps writing tests using temporary files and folders:

require "fileutils"
require "tempfile"

module Test

  

Mix-in for tests that write a temporary disk file.

  module WithTempfile

    

Create a temporary file on the disk. The file will be automatically removed when the test is done.

    def write_tempfile(path, content, directory = ".")
      file = Tempfile.open(path, directory)
      file.write(content)
      file.close(false)
      (@tempfiles ||= []) << file
      return file.path
    end

    

Create a temporary directory on the disk. The directory will be automatically removed when the test is done. This is very useful for complex file tests that can’t use FakeFS.

    def create_tempdir(directory = ".")
      file = Tempfile.open("dir", directory)
      (@tempfiles ||= []) << file
      File.delete(path = file.path)
      Dir.mkdir(path)
      return path
    end

    

Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class.

    def self.included(base)
      base.class_eval do

        alias_method :tempfile_original_teardown, :teardown

        

Automatically clean up the temporary files when the test is done.

        def teardown
          tempfile_original_teardown
          (@tempfiles || []).each do |tempfile|
            path = tempfile.path
            FileUtils.rm_rf(path) if File.exist?(path)
          end
        end

      end
    end

  end

end

Testing with a temporary directory

When running external programs, or sometimes even Ruby programs, it is useful to have a temporary directory to stuff everything into, which is automatically removed when the test is done. The WithTempfile module does provide the create_tempdir function, which we can use; but it is easier to also include the WithTempdir module, which does this automatically in the setup function for us, and precomputes up the standard I/O file names as well.

require "olag/test/with_tempfile"

module Test

  

Mix-in for tests that run applications in a temporary directory. This assumes that the test class has already mixed-in the WithTempfile mix-in.

  module WithTempdir

    

Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class.

    def self.included(base)
      base.class_eval do

        alias_method :tempdir_original_setup, :setup

        

Create a temporary directory for the run and percompute the standard I/O file names in it.

        def setup
          tempdir_original_setup
          @tempdir = create_tempdir
          @stdout = @tempdir + "/stdout"
          @stdin = @tempdir + "/stdin"
          @stderr = @tempdir + "/stderr"
        end

      end
    end

  end

end

Testing Rake tasks

Testing Rake tasks is tricky because tests may be run in the context of Rake. Therefore, the best practice is to create a new Rake application and restore the original when the test is done:

module Test

  

Mix-in for tests that use Rake.

  module WithRake

    

Aliasing methods needs to be deferred to when the module is included and be executed in the context of the class.

    def self.included(base)
      base.class_eval do

        alias_method :rake_original_setup, :setup

        

Automatically create a fresh Rake application.

        def setup
          rake_original_setup
          @original_rake = Rake.application
          @rake = Rake::Application.new
          Rake.application = @rake
        end

        alias_method :rake_original_teardown, :teardown

        

Automatically restore the original Rake application.

        def teardown
          rake_original_teardown
          Rake.application = @original_rake
        end

      end
    end

  end

end

Testing in general

Rather than requiring each of the above test mix-in modules on its own, it is convenient to just require "olag/test" and be done:

require "olag/test/with_errors"
require "olag/test/with_fakefs"
require "olag/test/with_rake"
require "olag/test/with_tempfile"


Enhance the global test module with additional mix-in modules.

module Test
end

Applications

Writing an application requires a lot of boilerplate. Olag provides an Application base class that handles standard command line flags, execution from within tests, and errors collection.

Here is a simple test for running such an application from unit tests:

require "olag/application"
require "olag/test"
require "test/spec"


An application that emits an error when run.

class ErrorApplication < Olag::Application

  

Run the error application.

  def run
    super { @errors << "Oops!" }
  end

  

Test minimal number of arguments.

  def parse_arguments
    expect_at_least(2, "fake arguments")
    expect_at_most(3, "fake arguments")
  end

end


Test running a Olag Application.

class TestRunApplication < Test::Unit::TestCase

  include Test::WithFakeFS

  def test_do_nothing
    Olag::Application.with_argv([]) { Olag::Application.new(true).run }.should == 0
  end

  def test_inexact_arguments
    Olag::Application.with_argv(%w(-e stderr foo)) { Olag::Application.new(true).run }.should == 1
    File.read("stderr").should.include?("Expects no arguments")
  end

  def test_missing_arguments
    Olag::Application.with_argv(%w(-e stderr foo)) { ErrorApplication.new(true).run }.should == 1
    File.read("stderr").should.include?("Expects at least 2 fake arguments")
  end

  def test_extra_arguments
    Olag::Application.with_argv(%w(-e stderr foo bar baz bad)) { ErrorApplication.new(true).run }.should == 1
    File.read("stderr").should.include?("Expects at most 3 fake arguments")
  end

  def test_print_version
    Olag::Application.with_argv(%w(-o nested/stdout -v -h)) { Olag::Application.new(true).run }.should == 0
    File.read("nested/stdout").should == "#{$0}: Version: #{Olag::VERSION}\n"
  end

  def test_print_help
    Olag::Application.with_argv(%w(-o stdout -h -v)) { Olag::Application.new(true).run }.should == 0
    File.read("stdout").should.include?("DESCRIPTION:")
  end

  def test_print_errors
    Olag::Application.with_argv(%w(-e stderr foo bar)) { ErrorApplication.new(true).run }.should == 1
    File.read("stderr").should.include?("Oops!")
  end

end

And here is the implementation:

require "fileutils"
require "olag/errors"
require "olag/globals"
require "olag/string_unindent.rb"
require "olag/version"
require "optparse"

module Olag

  

Base class for Olag applications.

  class Application

    

Create a Olag application.

    def initialize(is_test = nil)
      @errors = Errors.new
      @is_test = !!is_test
    end

    

Run the Olag application, returning its status.

    def run(*arguments, &block)
      parse_options
      yield(*arguments) if block_given?
      return print_errors
    rescue ExitException => exception
      return exception.status
    end

    

Execute a block with an overriden ARGV, typically for running an application.

    def self.with_argv(argv)
      return Globals.without_changes do
        ARGV.replace(argv)
        yield
      end
    end

  protected

    

Parse the command line options of the program.

    def parse_options
      parser = OptionParser.new do |options|
        (@options = options).banner = banner + "\n\nOPTIONS:\n\n"
        define_flags
      end
      parser.parse!
      parse_arguments
    end

    

Expect a limited number of remaining arguments (verified by the block).

    def expect_limited_arguments(message_prefix, arguments_limit, argument_type, &block)
      if !yield(ARGV.size)
        $stderr.puts("#{$0}: #{message_prefix} #{arguments_limit} #{argument_type}.")
        exit(1)
      end
    end

    

Ensure we have got at least a certain number of command line arguments.

    def expect_at_least(minimal_arguments, argument_type)
      expect_limited_arguments("Expects at least", minimal_arguments, argument_type) { |remaining_arguments| remaining_arguments >= minimal_arguments }
    end

    

Ensure we have got at least a certain number of command line arguments.

    def expect_at_most(maximal_arguments, argument_type)
      expect_limited_arguments("Expects at most", maximal_arguments, argument_type) { |remaining_arguments| remaining_arguments <= maximal_arguments }
    end

    

Ensure we have got an exact number of command line arguments.

    def expect_exactly(exact_arguments, argument_type)
      arguments_limit = exact_arguments == 0 ? 'no' : exact_arguments
      expect_limited_arguments("Expects", arguments_limit, argument_type) { |remaining_arguments| remaining_arguments == exact_arguments }
    end

    

Parse remaining command-line file arguments. This is expected to be overriden by the concrete application sub-class. By default assumes there are no such arguments.

    def parse_arguments
      expect_exactly(0, "arguments")
    end

    

Define application flags. This is expected to be overriden by the concrete application sub-class.

    def define_flags
      define_help_flag
      define_version_flag
      define_redirect_flag("$stdout", "output", "w")
      define_redirect_flag("$stderr", "error", "w")
      #! Most scripts do not use this, but they can add it.
      #! define_redirect_flag("$stdin", "input", "r")
    end

    

Define the standard help flag.

    def define_help_flag
      @options.on("-h", "--help", "Print this help message and exit.") do
        puts(@options)
        print_additional_help
        exit(0)
      end
    end

    

Print additional help message. This includes both the command line file arguments, if any, and a short description of the program.

    def print_additional_help
      arguments_name, arguments_description = arguments
      puts(format("    %-33s%s", arguments_name, arguments_description)) if arguments_name
      print("\nDESCRIPTION:\n\n")
      print(description)
    end

    

Return the banner line of the help message. This is expected to be overriden by the concrete application sub-class. By default returns the path name of thje executed program.

    def banner
      return $0
    end

    

Return the name and description of any final command-line file arguments, if any. This is expected to be overriden by the concrete application sub-class. By default, assume there are no final command-line file arguments (however, `parse_options` does not enforce this by default).

    def arguments
      return nil, nil
    end

    

Return a short description of the program. This is expected to be overriden by the concrete application sub-class. By default, provide

    def description
      return "Sample description\n"
    end

    

Define the standard version flag.

    def define_version_flag
      version_number = version
      @options.on("-v", "--version", "Print the version number (#{version_number}) and exit.") do
        puts("#{$0}: Version: #{version_number}")
        exit(0)
      end
    end

    

Define a flag redirecting one of the standard IO files.

    def define_redirect_flag(variable, name, mode)
      @options.on("-#{name[0,1]}", "--#{name} FILE", String, "Redirect standard #{name} to a file.") do |file|
        eval("#{variable} = Application::redirect_file(#{variable}, file, mode)")
      end
    end

    

Redirect a standard file.

    def self.redirect_file(default, file, mode)
      return default if file.nil? || file == "-"
      FileUtils.mkdir_p(File.dirname(File.expand_path(file))) if mode == "w"
      return File.open(file, mode)
    end

    

Return the application’s version. This is expected to be overriden by the concrete application sub-class. In the base class, we just return Olag’s version which only useful for Olag’s tests.

    def version
      return Olag::VERSION
    end

    

Print all the collected errors.

    def print_errors
      @errors.each do |error|
        $stderr.puts(error)
      end
      return @errors.size
    end

    

Exit the application, unless we are running inside a test.

    def exit(status)
      Kernel.exit(status) unless @is_test
      raise ExitException.new(status)
    end

  end

  

Exception used to exit when running inside tests.

  class ExitException < Exception

    

The exit status.

    attr_reader :status

    

Create a new exception to indicate exiting the program with some status.

    def initialize(status)
      @status = status
    end

  end

end

It makes use of the following utility class, for saving and restoring the global state when running an application in a test:

module Olag

  

Save and restore the global variables when running an application inside a test.

  class Globals

    

Run some code without affecting the global state.

    def self.without_changes(&block)
      state = Globals.new
      begin
        return block.call
      ensure
        state.restore
      end
    end

    

Restore the relevant global variables.

    def restore
      $stdin = Globals.restore_file($stdin, @original_stdin)
      $stdout = Globals.restore_file($stdout, @original_stdout)
      $stderr = Globals.restore_file($stderr, @original_stderr)
      ARGV.replace(@original_argv)
    end

  protected

    

Take a snapshot of the relevant global variables.

    def initialize
      @original_stdin = $stdin
      @original_stdout = $stdout
      @original_stderr = $stderr
      @original_argv = ARGV.dup
    end

    

Restore a specific global file variable to its original state.

    def self.restore_file(current, original)
      current.close unless current == original
      return original
    end

  end

end

License

Olag is published under the MIT license:

Copyright © 2010-2011 Oren Ben-Kiki

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.