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.