185 AnsiString

Description

Quiz description provided by Transfire

Make a subclass of String (or delegate) that tracks “embedded” ANSI codes along with the text. The class should add methods for wrapping the text in ANSI codes. Implement as much of the core String API as possible. So for example:

s1 = AnsiString.new("Hi")
s2 = AnsiString.new("there!)

s1.red    # wrap text in red/escape ANSI codes
s1.blue   # wrap text in blue/escape ANSI codes

s3 = s1 + ' ' + s2  #=> New AnsiString
s3.to_str           #=> "\e[31mHi\e[0m \e[34mthere!\e[0m"

There is an ANSICode module (it’s in Facets) that you are welcome to use for the ANSI backend, if desired. It is easy enough to use; the literal equivalent of the above would be:

ANSICode.red('Hi') + ' ' + ANSICode.blue('there!')

Bonus points for being able to use ANSIStrings in a gsub block:

ansi_string.gsub(pattern){ |s| s.red }

Summary

It would seem that writing Transfire’s desired ANSIString class is more difficult that it appears. (Or, perhaps, y’all are busy preparing for the holidays.) The sole submission for this quiz comes from Robert Dober; it’s not completely to specification nor handles the bonus, but it is a good start. (More appropriately, it might be better to say that the specification isn’t entirely clear, and that Robert’s implementation didn’t match my interpretation of the spec; a proper ANSIString module would need to provide more details on a number of things.)

Robert relies on other libraries to provide the actual ANSI codes; seeing as there are at least three libraries that do, Robert provides a mechanism to choose between them based on user request and/or availability. Let’s take a quick look at this mechanism. (Since this quiz doesn’t use the Module mechanism in Robert’s register_lib routine, I’ve removed the related references for clarity. I suspect those are for a larger set of library management routines.)

@use_lib = 
  ( ARGV.first == '-f' || ARGV.first == '--force'  ) &&
    ARGV[1]

def register_lib lib_path, &blk
  return if @use_lib && lib_path != @use_lib
  require lib_path
  Libraries[ lib_path ] = blk
end

register_lib "facets/ansicode" do | color |
  ANSICode.send color
end

# similar register_lib calls for "highline" and "term/ansicolor"

class ANSIString
  used_lib_name = Libraries.keys[ rand( Libraries.keys.size ) ]
  lib = Libraries[ used_lib_name ]
  case lib
  when Proc
    define_method :__color__, &lib
  else
    raise RuntimeError, "Nooooo I have explained exactly how to register libraries, has I not?"
  end

  # ... rest of ANSIString ...
end

First, we check if the user has requested (via --force) a particular library. This is used in the first line of register_lib, which exits early if we try to register a library other than the one specified. Then register_lib loads the matching library (or all if the user did not specify) via require as is typical. Finally, a reference to the provided code block is kept, indexed by the library name.

This seems, perhaps, part of a larger set of library management routines; its use in this quiz is rather simple, as can be seen in the calls to register_lib immediately following. While registering “facets/ansicode”, a block is provided to call ANSICode.send color. This is then used below in ANSIString, when we choose one of the libraries to use, recall the corresponding code block, and define a new method __color__ that calls that code block.

Altogether, this is a reasonable technique for putting a façade around similar functionality in different libraries and choosing between available libraries, perhaps if one or another is not available. It seems to me that such library management – at least the general mechanisms – might be worthy of its own gem.

Given that we now have a way to access ANSI codes via ANSIString#__color__, let’s now move onto the code related to the task, starting with initialization and conversion to String:

class ANSIString
  ANSIEnd    = "\e[0m"

  def initialize *strings
    @strings = strings.dup
  end

  def to_s
    @strings.map do |s| 
      case s
	  when String
	    s
	  when :end
	    ANSIEnd
	  else
	    __color__ s
	  end
	end.join
  end
end

Internally, ANSIString keeps an array of strings, its initial value set to a copy of the initialization parameters. So we can create ANSI string objects in a couple of ways:

s1 = ANSIString.new "Hello, world!"
s2 = ANSIString.new :green, "Merry ", :red, "Christmas!", :end

When converting with to_s, each member of that array is appropriately converted to a String. It is assumed that members of the array are either already String objects (so are mapped to themselves), the :end symbol (so mapped to constant string ANSIEnd), or appropriate color symbols available in the previously loaded library (mapped to the corresponding ANSI string available through method __color__). Once all items in the array are converted to strings, a simple call to join binds them together into one, final string.

Let’s look at string concatenation:

class ANSIString
  def + other
    other.add_reverse self
  rescue NoMethodError
    self.class::new( *( __end__ << other ) )
  end

  def add_reverse an_ansi_str
    self.class::new( *(
      an_ansi_str.send( :__end__ ) + __end__
    ) )
  end

  private
  def __end__
    @strings.reverse.find{ |x| Symbol === x} == :end ? 
      @strings.dup : @strings.dup << :end
  end
end

Before we get to the concatenation itself, take a quick look at helper method __end__. It looks for the last symbol and compares it against :end. Whether true or false, the @string array is duplicated (and so protects the instance variable from change). Only, __end__ does not append another :end symbol if unnecessary.

I was a little confused, at first, about the implementation of ANSIString concatenation. Perhaps Robert had other plans in mind, but it seemed to me this work could be simplified. Since add_reverse is called nowhere else (and I couldn’t imagine it being called by the user, despite the public interface), I tried inserting add_reverse inline to + (fixing names along the way):

def + other
  other.class::new( *(self.send(:__end__) + other.__end__) )
rescue NoMethodError
  self.class::new( *( __end__ << other ) )
end

And, with further simplification:

def + other
  other.class::new( *( __end__ + other.send(:__end__) ) )
rescue NoMethodError
  self.class::new( *( __end__ << other ) )
end

I believed Robert had a bug, neglecting to call __end__ in the second case, until I realized my mistake: other is not necessarily of the ANSIString class, and so would not have the __end__ method. My attempt to fix my mistake was to rewrite again as this:

def + other
  ANSIString::new( *( __end__ + other.to_s ) )
end

But that has its own problems if other is an ANSIString; it neglects to end the string and converts it to a simple String rather than maintaining its components. Clearly undesirable. Obviously, Robert’s implementation is the right way… or is it? Going back to this version:

def + other
  other.class::new( *( __end__ + other.send(:__end__) ) )
rescue NoMethodError
  self.class::new( *( __end__ << other ) )
end

Ignoring the redundancy, this actually works. My simplification will throw the NoMethodError exception, because String does not define __end__, just as Robert’s version throws that exception if either add_reverse or __end__ is not defined. So, removing redundancy, I believe concatenation can be simplified correctly as:

def + other
	self.class::new( *(
		__end__ + (other.send(:__end__) rescue [other] )
	) )
end

For me, this reduces concatenation to something more quickly understandable.

One last point on concatenation; Robert’s version will create an object of class other.class if that class has both methods add_reverse and __end__, whereas my simplification does not. However, it seems unlikely to me that any class other than ANSIString will have those methods. I recognize that my assumption here may be flawed; Robert will have to provide further details on his reasoning or other uses of the code.

Finally, we deal with adding ANSI codes to the ANSI strings (aside from at initialization):

class ANSIString
  def end
    self.class::new( * __end__ )
  end

  def method_missing name, *args, &blk
    super( name, *args, &blk ) unless args.empty? && blk.nil?
    class << self; self end.module_eval do 
      define_method name do
  		self.class::new( *([name.to_sym] + @strings).flatten )
      end
    end
    send name
  end
end

Method end simply appends the symbol :end to the @strings array by making use of the existing __end__ method. Reusing __end__ (as opposed to just doing @strings << :end) ensures that we don’t have unnecessary :end symbols in the string.

Finally, method_missing catches all other calls, such as bold or red. Any calls with arguments or a code block are passed up first to the superclass, though considering the parent class is Object, any such call is likely to generate a NoMethodError exception (since, if the method was in Object, method_missing would not have been called). Also note that whether “handled” by the superclass or not, all missing methods are also handled by the rest of the code in method_missing. I don’t know if that is intentional or accidental. In general, this seems prone to error, and it would seem a better tactic either to discern the ANSI code methods from the loaded module or to be explicit about such codes.

In any case, calling red on ANSIString the first time actually generates a new method, by way of the define_method call located in method_missing. Further calls to red (and the first call, via the last line send name) will actually use that new method, which prepends red.to_sym (that is, :red) to the string in question.

At this point, ANSIString handles basic initialization, concatenation, ANSI codes and output; it does not handle the rest of the capabilities of String (such as substrings, gsub, and others), so it is not a drop-in replacement for strings. I believe it could be, with time and effort, but that is certainly a greater challenge than is usually attempted on Ruby Quiz.


Wednesday, February 04, 2009