AnsiString
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.