[QUIZ] AnsiString (#185)

M

Matthew Moss

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

The three rules of Ruby Quiz 2:

1. Please do not post any solutions or spoiler discussion for this
quiz until 48 hours have passed from the time on this message.

2. Support Ruby Quiz 2 by submitting ideas as often as you can!
Visit <http://splatbang.com/rubyquiz/>.

3. Enjoy!

Suggestion: A [QUIZ] in the subject of emails about the problem
helps everyone on Ruby Talk follow the discussion. Please reply to
the original quiz message, if you can.

-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-

## AnsiString (#185)


_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"

I have an [ANSICode][1] module (it's in [Facets][2]) that you are
welcome to provide 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 }


[1]: http://facets.rubyforge.org/doc/api/more/classes/ANSICode.html
[2]: http://facets.rubyforge.org/
 
J

James Gray

Since the Facets ANSICode was mentioned, I just thought that I'd
also make everyone aware of the term-ansicolor gem by Florian Frank (e-mail address removed)
which does this type of thing for colors.

HighLine also does this:
I want to be red!
=> nil

You can use color() without printing with code like:

red_str = HighLine.new.color("I want to be red!", :red)

And if you use that you can drop the /import in the require.

James Edward Gray II
 
T

Trans

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

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

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

Make that 's2.blue'.
=A0 =A0 s3 =3D s1 + ' ' + s2 =A0#=3D> New AnsiString
=A0 =A0 s3.to_str =A0 =A0 =A0 =A0 =A0 #=3D> "\e[31mHi\e[0m \e[34mthere!\e=
[0m"

I've found it pretty challenging to keep the ANSI codes in sync with
the text while still being able to manipulate the text like a normal
string. I'm curious to see how other people approach it.

T.
 
M

Matthew Moss

It would seem that writing Transfire's desired `ANSIString` class is =20
more difficult that it appears. (Or, perhaps, y'all are busy preparing =20=

for the holidays.) The sole submission for this quiz comes from =20
_Robert Dober_; it's not completely to specification nor handles the =20
bonus, but it is a good start. (More appropriately, it might be better =20=

to say that the specification isn't entirely clear, and that Robert's =20=

implementation didn't match *my* interpretation of the spec; a proper =20=

`ANSIString` module would need to provide more details on a number of =20=

things.)

Robert relies on other libraries to provide the actual ANSI codes; =20
seeing as there are at least three libraries that do, Robert provides =20=

a mechanism to choose between them based on user request and/or =20
availability. Let's take a quick look at this mechanism. (Since this =20
quiz doesn't use the Module mechanism in Robert's `register_lib` =20
routine, I've removed the related references for clarity. I suspect =20
those are for a larger set of library management routines.)

@use_lib =3D
( ARGV.first =3D=3D '-f' || ARGV.first =3D=3D '--force' ) &&
ARGV[1]
=09
def register_lib lib_path, &blk
return if @use_lib && lib_path !=3D @use_lib
require lib_path
Libraries[ lib_path ] =3D 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 =3D Libraries.keys[ rand( Libraries.keys.size ) =
]
lib =3D Libraries[ used_lib_name ]
case lib
when Proc
define_method :__color__, &lib
else
raise RuntimeError, "Nooooo I have explained exactly how to =20=

register libraries, has I not?"
end

# ... rest of ANSIString ...
end
=09
First, we check if the user has requested (via `--force`) a particular =20=

library. This is used in the first line of `register_lib`, which exits =20=

early if we try to register a library other than the one specified. =20
Then `register_lib` loads the matching library (or all if the user did =20=

not specify) via `require` as is typical. Finally, a reference to the =20=

provided code block is kept, indexed by the library name.

This seems, perhaps, part of a larger set of library management =20
routines; its use in this quiz is rather simple, as can be seen in the =20=

calls to `register_lib` immediately following. While registering =20
"facets/ansicode", a block is provided to call `ANSICode.send color`. =20=

This is then used below in `ANSIString`, when we choose one of the =20
libraries to use, recall the corresponding code block, and define a =20
new method `__color__` that calls that code block.

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

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

class ANSIString
ANSIEnd =3D "\e[0m"

def initialize *strings
@strings =3D 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
=09
Internally, `ANSIString` keeps an array of strings, its initial value =20=

set to a copy of the initialization parameters. So we can create ANSI =20=

string objects in a couple of ways:

s1 =3D ANSIString.new "Hello, world!"
s2 =3D ANSIString.new :green, "Merry ", :red, "Christmas!", :end
=09
When converting with `to_s`, each member of that array is =20
appropriately converted to a `String`. It is assumed that members of =20
the array are either already `String` objects (so are mapped to =20
themselves), the `:end` symbol (so mapped to constant string =20
`ANSIEnd`), or appropriate color symbols available in the previously =20
loaded library (mapped to the corresponding ANSI string available =20
through method `__color__`). Once all items in the array are converted =20=

to strings, a simple call to `join` binds them together into one, =20
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 =3D=3D=3D x} =3D=3D :end ?
@strings.dup : @strings.dup << :end
end
end

Before we get to the concatenation itself, take a quick look at helper =20=

method `__end__`. It looks for the last symbol and compares it against =20=

`:end`. Whether true or false, the `@string` array is duplicated (and =20=

so protects the instance variable from change). Only, `__end__` does =20
not append another `:end` symbol if unnecessary.

I was a little confused, at first, about the implementation of =20
`ANSIString` concatenation. Perhaps Robert had other plans in mind, =20
but it seemed to me this work could be simplified. Since `add_reverse` =20=

is called nowhere else (and I couldn't imagine it being called by the =20=

user, despite the public interface), I tried inserting `add_reverse` =20
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 =20
second case, until I realized my mistake: `other` is not necessarily =20
of the `ANSIString` class, and so would not have the `__end__` method. =20=

My attempt to fix my mistake was to rewrite again as this:

def + other
ANSIString::new( *( __end__ + other.to_s ) )
end
=09
But that has its own problems if `other` *is* an `ANSIString`; it =20
neglects to end the string and converts it to a simple `String` rather =20=

than maintaining its components. Clearly undesirable. Obviously, =20
Robert's implementation is the right way... or is it? Going back to =20
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 =20
throw the `NoMethodError` exception, because `String` does not define =20=

`__end__`, just as Robert's version throws that exception if either =20
`add_reverse` or `__end__` is not defined. So, removing redundancy, I =20=

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 =20
understandable.

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

reasoning or other uses of the code.

Finally, we deal with adding ANSI codes to the ANSI strings (aside =20
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
=09
Method `end` simply appends the symbol `:end` to the `@strings` array =20=

by making use of the existing `__end__` method. Reusing `__end__` (as =20=

opposed to just doing `@strings << :end`) ensures that we don't have =20
unnecessary `:end` symbols in the string.

Finally, `method_missing` catches all other calls, such as `bold` or =20
`red`. Any calls with arguments or a code block are passed up first to =20=

the superclass, though considering the parent class is `Object`, any =20
such call is likely to generate a `NoMethodError` exception (since, if =20=

the method was in `Object`, `method_missing` would not have been =20
called). Also note that whether "handled" by the superclass or not, =20
all missing methods are *also* handled by the rest of the code in =20
`method_missing`. I don't know if that is intentional or accidental. =20
In general, this seems prone to error, and it would seem a better =20
tactic either to discern the ANSI code methods from the loaded module =20=

or to be explicit about such codes.

In any case, calling `red` on `ANSIString` the first time actually =20
generates a new method, by way of the `define_method` call located in =20=

`method_missing`. Further calls to `red` (and the first call, via the =20=

last line `send name`) will actually use that new method, which =20
prepends `red.to_sym` (that is, `:red`) to the string in question.

At this point, `ANSIString` handles basic initialization, =20
concatenation, ANSI codes and output; it does not handle the rest of =20
the capabilities of `String` (such as substrings, `gsub`, and others), =20=

so it is not a drop-in replacement for strings. I believe it could be, =20=

with time and effort, but that is certainly a greater challenge than =20
is usually attempted on Ruby Quiz.
 
R

Robert Dober

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.)
Exactly, it was the multi library approach which interested me more
than the ANSIString implementation, hence
the sloppy implementation :(.
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.
Not really I was quite sloppy, it took me same time to re-understand
my code, always a bad sign.
Sorry for giving you so much work :(.<snip>
Cheers
R.
 
T

Trans

It would seem that writing Transfire's desired `ANSIString` class is =A0
more difficult that it appears. (Or, perhaps, y'all are busy preparing = =A0
for the holidays.)

Yep. That's too bad. The holidays have had me occupied as well, and it
is a tricky problem. I was hoping someone brighter than I would come
up with a really clever way of doing it.

Robert's implementation using an array is string and symbol is much
like my first stab at it. In some ways I think maybe it's the better
way to go, although more limited in scope, one can at least wrap ones
head around the implementation without too much trouble. Thanks for
taking a stab at the quiz Robert!

My implementation on the other hand tries to go the full nine-yards
toward drop-in compatibility with the core String class. It's not
complete by any means, but the foundation is in place for doing so,
ie. the #shift_marks method. The downside though is that the algorithm
is somewhat complex and, worse still, time consuming, not to mention
imperfect -- hence my hope someone else might have a brighter idea for
going about this.


require 'facets/ansicode'
require 'facets/string'

# ANSIStrings stores a regular string (@text) and
# a Hash mapping character index to ansicodes (@marks).
# For example is we has the string:
#
# "Big Apple"
#
# And applied the color red to it, the marks hash would be:
#
# { 0=3D>[:red] , 9=3D>[:clear] }
#
class ANSIString

CLR =3D ANSICode.clear

attr :text
attr :marks

def initialize(text=3Dnil, marks=3Dnil)
@text =3D (text || '').to_s
@marks =3D marks || []
yield(self) if block_given?
end

def to_s
s =3D text.dup
m =3D marks.sort do |(a,b)|
v =3D b[0] <=3D> a[0]
if v =3D=3D 0
(b[1] =3D=3D :clear or b[1] =3D=3D :reset) ? -1 : 1
else
v
end
end
m.each do |(index, code)|
s.insert(index, ANSICode.__send__(code))
end
#s << CLR unless s =3D~ /#{Regexp.escape(CLR)}$/ # always end
with a clear
s
end

#
alias_method :to_str, :to_s

def size ; text.size ; end

def upcase ; self.class.new(text.upcase, marks) ; end
def upcase! ; text.upcase! ; end

def downcase ; self.class.new(text.upcase, marks) ; end
def downcase! ; text.upcase! ; end

def +(other)
case other
when String
ntext =3D text + other.text
nmarks =3D marks.dup
omarks =3D shift_marks(0, text.size, other.marks)
omarks.each{ |(i, c)| nmarks << [i,c] }
else
ntext =3D text + other.to_s
nmarks =3D marks.dup
end
self.class.new(ntext, nmarks)
end

def slice(*args)
if args.size =3D=3D 2
index, len =3D *args
endex =3D index+len
new_text =3D text[index, len]
new_marks =3D []
marks.each do |(i, v)|
new_marks << [i, v] if i >=3D index && i < endex
end
self.class.new(new_text, new_marks)
elsif args.size =3D=3D 1
rng =3D args.first
case rng
when Range
index, endex =3D rng.begin, rng.end
new_text =3D text[rng]
new_marks =3D []
marks.each do |(i, v)|
new_marks << [i, v] if i >=3D index && i < endex
end
self.class.new(new_text, new_marks)
else
nm =3D marks.select do |(i,c)|
marks[0] =3D=3D rng or ( marks[0] =3D=3D rng + 1 &&
[:clear, :reset].include?(marks[1]) )
end
self.class.new(text[rng,1], nm)
end
else
raise ArgumentError
end
end

alias_method :[], :slice

# This is more limited than the normal String method.
# It does not yet support a block, and +replacement+
# won't substitute for \1, \2, etc.
#
# TODO: block support.
def sub!(pattern, replacement=3Dnil, &block)
mark_changes =3D []
text =3D @text.sub(pattern) do |s|
index =3D $~.begin(0)
replacement =3D block.call(s) if block_given?
delta =3D (replacement.size - s.size)
mark_changes << [index, delta]
replacement
end
marks =3D @marks
mark_changes.each do |index, delta|
marks =3D shift_marks(index, delta, marks)
end
@text =3D text
@marks =3D marks
self
end

#
def sub(pattern,replacement=3Dnil, &block)
dup.sub!(pattern, replacement, &block)
end

#
def gsub!(pattern, replacement=3Dnil, &block)
mark_changes =3D []
mark_additions =3D []
text =3D @text.gsub(pattern) do |s|
index =3D $~.begin(0)
replacement =3D block.call(self.class.new(s)) if block_given?
if self.class=3D=3D=3Dreplacement
adj_marks =3D replacement.marks.map{ |(i,c)| [i+index,c] }
mark_additions.concat(adj_marks)
replacement =3D replacement.text
end
delta =3D (replacement.size - s.size)
mark_changes << [index, delta]
replacement
end
marks =3D @marks
mark_changes.each do |(index, delta)|
marks =3D shift_marks(index, delta, marks)
end
marks.concat(mark_additions)
@text =3D text
@marks =3D marks
self
end

#
def gsub(pattern, replacement=3Dnil, &block)
dup.gsub!(pattern, replacement, &block)
end

#
def ansi(code)
m =3D marks.dup
m.unshift([0, code])
m.push([size, :clear])
self.class.new(text, m)
end
alias_method :color, :ansi

#
def ansi!(code)
marks.unshift([0, ansicolor])
marks.push([size, :clear])
end
alias_method :color!, :ansi!

def red ; color:)red) ; end
def green ; color:)green) ; end
def blue ; color:)blue) ; end
def black ; color:)black) ; end
def magenta ; color:)magenta) ; end
def yellow ; color:)yellow) ; end
def cyan ; color:)cyan) ; end

def bold ; ansi:)bold) ; end
def underline ; ansi:)underline) ; end

def red! ; color!:)red) ; end
def green! ; color!:)green) ; end
def blue! ; color!:)blue) ; end
def black! ; color!:)black) ; end
def magenta! ; color!:)magenta) ; end
def yellow! ; color!:)yellow) ; end
def cyan! ; color!:)cyan) ; end

def bold! ; ansi!:)bold) ; end
def underline! ; ansi!:)underline) ; end

private

#
def shift_marks(index, delta, marks=3Dnil)
new_marks =3D []
(marks || @marks).each do |(i, c)|
case i <=3D> index
when -1
new_marks << [i, c]
when 0, 1
new_marks << [i+delta, c]
end
end
new_marks
end

#
def shift_marks!(index, delta)
@marks.replace(shift_marks(index, delta))
end

end


Sorry for my late post. I'm only now starting to get settled back into
the routine of things.
 

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Members online

Forum statistics

Threads
474,262
Messages
2,571,048
Members
48,769
Latest member
Clifft

Latest Threads

Top