Fixed color support in OS X
[ruby_koans.git] / src / edgecase.rb
1 #!/usr/bin/env ruby
2 # -*- ruby -*-
3
4 require 'test/unit/assertions'
5 begin
6   require 'win32console'
7 rescue LoadError
8 end
9
10 # --------------------------------------------------------------------
11 # Support code for the Ruby Koans.
12 # --------------------------------------------------------------------
13
14 class FillMeInError < StandardError
15 end
16
17 def ruby_version?(version)
18   RUBY_VERSION =~ /^#{version}/ ||
19     (version == 'jruby' && defined?(JRUBY_VERSION)) ||
20     (version == 'mri' && ! defined?(JRUBY_VERSION))
21 end
22
23 def in_ruby_version(*versions)
24   yield if versions.any? { |v| ruby_version?(v) }
25 end
26
27 in_ruby_version("1.8") do
28   class KeyError < StandardError
29   end
30 end
31
32 # Standard, generic replacement value.
33 # If value19 is given, it is used in place of value for Ruby 1.9.
34 def __(value="FILL ME IN", value19=:mu)
35   if RUBY_VERSION < "1.9"
36     value
37   else
38     (value19 == :mu) ? value : value19
39   end
40 end
41
42 # Numeric replacement value.
43 def _n_(value=999999, value19=:mu)
44   if RUBY_VERSION < "1.9"
45     value
46   else
47     (value19 == :mu) ? value : value19
48   end
49 end
50
51 # Error object replacement value.
52 def ___(value=FillMeInError, value19=:mu)
53   if RUBY_VERSION < "1.9"
54     value
55   else
56     (value19 == :mu) ? value : value19
57   end
58 end
59
60 # Method name replacement.
61 class Object
62   def ____(method=nil)
63     if method
64       self.send(method)
65     end
66   end
67
68   in_ruby_version("1.9") do
69     public :method_missing
70   end
71 end
72
73 class String
74   def side_padding(width)
75     extra = width - self.size
76     if width < 0
77       self
78     else
79       left_padding = extra / 2
80       right_padding = (extra+1)/2
81       (" " * left_padding) + self + (" " *right_padding)
82     end
83   end
84 end
85
86 module EdgeCase
87   class << self
88     def simple_output
89       ENV['SIMPLE_KOAN_OUTPUT'] == 'true'
90     end
91   end
92
93   module Color
94     #shamelessly stolen (and modified) from redgreen
95     COLORS = {
96       :clear   => 0,  :black   => 30, :red   => 31,
97       :green   => 32, :yellow  => 33, :blue  => 34,
98       :magenta => 35, :cyan    => 36,
99     }
100
101     module_function
102
103     COLORS.each do |color, value|
104       module_eval "def #{color}(string); colorize(string, #{value}); end"
105       module_function color
106     end
107
108     def colorize(string, color_value)
109       if use_colors?
110         color(color_value) + string + color(COLORS[:clear])
111       else
112         string
113       end
114     end
115
116     def color(color_value)
117       "\e[#{color_value}m"
118     end
119
120     def use_colors?
121       return false if ENV['NO_COLOR']
122       if ENV['ANSI_COLOR'].nil?
123         if using_windows?
124           using_win32console
125         else
126           return true
127         end
128       else
129         ENV['ANSI_COLOR'] =~ /^(t|y)/i
130       end
131     end
132
133     def using_windows?
134       File::ALT_SEPARATOR
135     end
136
137     def using_win32console
138       defined? Win32::Console
139     end
140   end
141
142   class Sensei
143     attr_reader :failure, :failed_test, :pass_count
144
145     in_ruby_version("1.8") do
146       AssertionError = Test::Unit::AssertionFailedError
147     end
148
149     in_ruby_version("1.9") do
150       if defined?(MiniTest)
151         AssertionError = MiniTest::Assertion
152       else
153         AssertionError = Test::Unit::AssertionFailedError
154       end
155     end
156
157     def initialize
158       @pass_count = 0
159       @failure = nil
160       @failed_test = nil
161       @observations = []
162     end
163
164     PROGRESS_FILE_NAME = '.path_progress'
165
166     def add_progress(prog)
167       @_contents = nil
168       exists = File.exists?(PROGRESS_FILE_NAME)
169       File.open(PROGRESS_FILE_NAME,'a+') do |f|
170         f.print "#{',' if exists}#{prog}"
171       end
172     end
173
174     def progress
175       if @_contents.nil?
176         if File.exists?(PROGRESS_FILE_NAME)
177           File.open(PROGRESS_FILE_NAME,'r') do |f|
178             @_contents = f.read.to_s.gsub(/\s/,'').split(',')
179           end
180         else
181           @_contents = []
182         end
183       end
184       @_contents
185     end
186
187     def observe(step)
188       if step.passed?
189         @pass_count += 1
190         if @pass_count > progress.last.to_i
191           @observations << Color.green("#{step.koan_file}##{step.name} has expanded your awareness.")
192         end
193       else
194         @failed_test = step
195         @failure = step.failure
196         add_progress(@pass_count)
197         @observations << Color.red("#{step.koan_file}##{step.name} has damaged your karma.")
198         throw :edgecase_exit
199       end
200     end
201
202     def failed?
203       ! @failure.nil?
204     end
205
206     def assert_failed?
207       failure.is_a?(AssertionError)
208     end
209
210     def instruct
211       if failed?
212         @observations.each{|c| puts c }
213         encourage
214         guide_through_error
215         a_zenlike_statement
216         show_progress
217       else
218         end_screen
219       end
220     end
221
222     def show_progress
223       bar_width = 50
224       total_tests = EdgeCase::Koan.total_tests
225       scale = bar_width.to_f/total_tests
226       print Color.green("your path thus far [")
227       happy_steps = (pass_count*scale).to_i
228       happy_steps = 1 if happy_steps == 0 && pass_count > 0
229       print Color.green('.'*happy_steps)
230       if failed?
231         print Color.red('X')
232         print Color.cyan('_'*(bar_width-1-happy_steps))
233       end
234       print Color.green(']')
235       print " #{pass_count}/#{total_tests}"
236       puts
237     end
238
239     def end_screen
240       if EdgeCase.simple_output
241         boring_end_screen
242       else
243         artistic_end_screen
244       end
245     end
246
247     def boring_end_screen
248       puts "Mountains are again merely mountains"
249     end
250
251     def artistic_end_screen
252       "JRuby 1.9.x Koans"
253       ruby_version = "(in #{'J' if defined?(JRUBY_VERSION)}Ruby #{defined?(JRUBY_VERSION) ? JRUBY_VERSION : RUBY_VERSION})"
254       ruby_version = ruby_version.side_padding(54)
255         completed = <<-ENDTEXT
256                                   ,,   ,  ,,
257                                 :      ::::,    :::,
258                    ,        ,,: :::::::::::::,,  ::::   :  ,
259                  ,       ,,,   ,:::::::::::::::::::,  ,:  ,: ,,
260             :,        ::,  , , :, ,::::::::::::::::::, :::  ,::::
261            :   :    ::,                          ,:::::::: ::, ,::::
262           ,     ,:::::                                  :,:::::::,::::,
263       ,:     , ,:,,:                                       :::::::::::::
264      ::,:   ,,:::,                                           ,::::::::::::,
265     ,:::, :,,:::                                               ::::::::::::,
266    ,::: :::::::,       Mountains are again merely mountains     ,::::::::::::
267    :::,,,::::::                                                   ::::::::::::
268  ,:::::::::::,                                                    ::::::::::::,
269  :::::::::::,                                                     ,::::::::::::
270 :::::::::::::                                                     ,::::::::::::
271 ::::::::::::                      Ruby Koans                       ::::::::::::,
272 ::::::::::::#{                  ruby_version                     },::::::::::::,
273 :::::::::::,                                                      , ::::::::::::
274 ,:::::::::::::,                brought to you by                 ,,::::::::::::,
275 ::::::::::::::                                                    ,::::::::::::
276  ::::::::::::::,                                                 ,:::::::::::::
277  ::::::::::::,             EdgeCase Software Artisans           , ::::::::::::
278   :,::::::::: ::::                                               :::::::::::::
279    ,:::::::::::  ,:                                          ,,:::::::::::::,
280      ::::::::::::                                           ,::::::::::::::,
281       :::::::::::::::::,                                  ::::::::::::::::
282        :::::::::::::::::::,                             ::::::::::::::::
283         ::::::::::::::::::::::,                     ,::::,:, , ::::,:::
284           :::::::::::::::::::::::,               ::,: ::,::, ,,: ::::
285               ,::::::::::::::::::::              ::,,  , ,,  ,::::
286                  ,::::::::::::::::              ::,, ,   ,:::,
287                       ,::::                         , ,,
288                                                   ,,,
289 ENDTEXT
290         puts completed
291     end
292
293     def encourage
294       puts
295       puts "The Master says:"
296       puts Color.cyan("  You have not yet reached enlightenment.")
297       if ((recents = progress.last(5)) && recents.size == 5 && recents.uniq.size == 1)
298         puts Color.cyan("  I sense frustration. Do not be afraid to ask for help.")
299       elsif progress.last(2).size == 2 && progress.last(2).uniq.size == 1
300         puts Color.cyan("  Do not lose hope.")
301       elsif progress.last.to_i > 0
302         puts Color.cyan("  You are progressing. Excellent. #{progress.last} completed.")
303       end
304     end
305
306     def guide_through_error
307       puts
308       puts "The answers you seek..."
309       puts Color.red(indent(failure.message).join)
310       puts
311       puts "Please meditate on the following code:"
312       if assert_failed?
313         puts embolden_first_line_only(indent(find_interesting_lines(failure.backtrace)))
314       else
315         puts embolden_first_line_only(indent(failure.backtrace))
316       end
317       puts
318     end
319
320     def embolden_first_line_only(text)
321       first_line = true
322       text.collect { |t|
323         if first_line
324           first_line = false
325           Color.red(t)
326         else
327           Color.cyan(t)
328         end
329       }
330     end
331
332     def indent(text)
333       text = text.split(/\n/) if text.is_a?(String)
334       text.collect{|t| "  #{t}"}
335     end
336
337     def find_interesting_lines(backtrace)
338       backtrace.reject { |line|
339         line =~ /test\/unit\/|edgecase\.rb|minitest/
340       }
341     end
342
343     # Hat's tip to Ara T. Howard for the zen statements from his
344     # metakoans Ruby Quiz (http://rubyquiz.com/quiz67.html)
345     def a_zenlike_statement
346       if !failed?
347         zen_statement =  "Mountains are again merely mountains"
348       else
349         zen_statement = case (@pass_count % 10)
350         when 0
351           "mountains are merely mountains"
352         when 1, 2
353           "learn the rules so you know how to break them properly"
354         when 3, 4
355           "remember that silence is sometimes the best answer"
356         when 5, 6
357           "sleep is the best meditation"
358         when 7, 8
359           "when you lose, don't lose the lesson"
360         else
361           "things are not what they appear to be: nor are they otherwise"
362         end
363       end
364       puts Color.green(zen_statement)
365     end
366   end
367
368   class Koan
369     include Test::Unit::Assertions
370
371     attr_reader :name, :failure, :koan_count, :step_count, :koan_file
372
373     def initialize(name, koan_file=nil, koan_count=0, step_count=0)
374       @name = name
375       @failure = nil
376       @koan_count = koan_count
377       @step_count = step_count
378       @koan_file = koan_file
379     end
380
381     def passed?
382       @failure.nil?
383     end
384
385     def failed(failure)
386       @failure = failure
387     end
388
389     def setup
390     end
391
392     def teardown
393     end
394
395     def meditate
396       setup
397       begin
398         send(name)
399       rescue StandardError, EdgeCase::Sensei::AssertionError => ex
400         failed(ex)
401       ensure
402         begin
403           teardown
404         rescue StandardError, EdgeCase::Sensei::AssertionError => ex
405           failed(ex) if passed?
406         end
407       end
408       self
409     end
410
411     # Class methods for the EdgeCase test suite.
412     class << self
413       def inherited(subclass)
414         subclasses << subclass
415       end
416
417       def method_added(name)
418         testmethods << name if !tests_disabled? && Koan.test_pattern =~ name.to_s
419       end
420
421       def end_of_enlightenment
422         @tests_disabled = true
423       end
424
425       def command_line(args)
426         args.each do |arg|
427           case arg
428           when /^-n\/(.*)\/$/
429             @test_pattern = Regexp.new($1)
430           when /^-n(.*)$/
431             @test_pattern = Regexp.new(Regexp.quote($1))
432           else
433             if File.exist?(arg)
434               load(arg)
435             else
436               fail "Unknown command line argument '#{arg}'"
437             end
438           end
439         end
440       end
441
442       # Lazy initialize list of subclasses
443       def subclasses
444         @subclasses ||= []
445       end
446
447        # Lazy initialize list of test methods.
448       def testmethods
449         @test_methods ||= []
450       end
451
452       def tests_disabled?
453         @tests_disabled ||= false
454       end
455
456       def test_pattern
457         @test_pattern ||= /^test_/
458       end
459
460       def total_tests
461         self.subclasses.inject(0){|total, k| total + k.testmethods.size }
462       end
463     end
464   end
465
466   class ThePath
467     def walk
468       sensei = EdgeCase::Sensei.new
469       each_step do |step|
470         sensei.observe(step.meditate)
471       end
472       sensei.instruct
473     end
474
475     def each_step
476       catch(:edgecase_exit) {
477         step_count = 0
478         EdgeCase::Koan.subclasses.each_with_index do |koan,koan_index|
479           koan.testmethods.each do |method_name|
480             step = koan.new(method_name, koan.to_s, koan_index+1, step_count+=1)
481             yield step
482           end
483         end
484       }
485     end
486   end
487 end
488
489 END {
490   EdgeCase::Koan.command_line(ARGV)
491   EdgeCase::ThePath.new.walk
492 }