Permalink
Browse files

First pass of Thor::Runner

  • Loading branch information...
1 parent 82ff27a commit b522f05ef166d142e80e30d264ae0dfba09ac4f8 @wycats wycats committed May 8, 2008
Showing with 335 additions and 75 deletions.
  1. +7 −0 .autotest
  2. +4 −1 Rakefile
  3. +131 −0 bin/thor
  4. +47 −38 lib/thor.rb
  5. +29 −0 spec/spec_helper.rb
  6. +83 −0 spec/thor_runner_spec.rb
  7. +34 −36 spec/thor_spec.rb
  8. 0 task.thor
View
@@ -0,0 +1,7 @@
+Autotest.add_hook :initialize do |at|
+ at.clear_mappings
+ at.add_exception(/\.git/)
+ at.add_mapping(%r{^spec/.*_spec}) {|filename,_| at.files_matching %r{#{filename}}}
+ at.add_mapping(%r{^bin/thor}) {|_,_| at.files_matching %r{spec/thor_runner_spec}}
+ at.add_mapping(%r{}) {|_,_| at.files_matching %r{spec/.*_spec}}
+end
View
@@ -28,7 +28,9 @@ spec = Gem::Specification.new do |s|
s.require_path = 'lib'
s.autorequire = GEM
- s.files = %w(LICENSE README.markdown Rakefile) + Dir.glob("{lib,specs}/**/*")
+ s.bindir = "bin"
+ s.executables = %w( thor )
+ s.files = %w(LICENSE README.markdown Rakefile) + Dir.glob("{bin,lib,specs}/**/*")
end
Rake::GemPackageTask.new(spec) do |pkg|
@@ -43,6 +45,7 @@ Spec::Rake::SpecTask.new do |t|
t.spec_opts << "-fs --color"
end
+task :specs => :spec
desc "install the gem locally"
task :install => [:package] do
View
@@ -0,0 +1,131 @@
+require "thor"
+
+class Thor::Util
+ # @public
+ def self.constant_to_thor_path(str)
+ snake_case(str).squeeze(":")
+ end
+
+ # @public
+ def self.constant_from_thor_path(str)
+ make_constant(to_constant(str))
+ end
+
+ private
+ # @private
+ def self.to_constant(str)
+ str.gsub(/:(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
+ end
+
+ # @private
+ def self.make_constant(str)
+ list = str.split("::")
+ obj = Object
+ list.each {|x| obj = obj.const_get(x) }
+ obj
+ end
+
+ # @private
+ def self.snake_case(str)
+ return str.downcase if str =~ /^[A-Z]+$/
+ str.gsub(/([A-Z]+)(?=[A-Z][a-z]?)|\B[A-Z]/, '_\&') =~ /_*(.*)/
+ return $+.downcase
+ end
+
+end
+
+class Thor::Runner < Thor
+
+ def self.globs_for(path)
+ ["#{path}/Thorfile", "#{path}/*.thor", "#{path}/tasks/*.thor", "#{path}/lib/tasks/*.thor"]
+ end
+
+ map "-T" => :list
+
+ desc "list [SEARCH]", "list the available thor tasks"
+ method_options :force => :boolean
+ def list(search = "")
+ search = /.*#{search}.*/
+ thorfiles.each {|f| load f unless Thor.subclass_files.keys.include?(File.expand_path(f))}
+
+ # Calculate the largest base class name
+ max_base = Thor.subclasses.max do |x,y|
+ Thor::Util.constant_to_thor_path(x.name).size <=> Thor::Util.constant_to_thor_path(y.name).size
+ end.name.size
+
+ # Calculate the size of the largest option description
+ max_left_item = Thor.subclasses.max do |x,y|
+ (x.help_list && x.help_list.max.usage + x.help_list.max.opt).to_i <=>
+ (y.help_list && y.help_list.max.usage + y.help_list.max.opt).to_i
+ end
+
+ max_left = max_left_item.help_list.max.usage + max_left_item.help_list.max.opt
+
+ Thor.subclasses.map {|k| k.help_list}.compact.each do |item|
+ display_tasks(item, max_base, max_left)
+ end
+ end
+
+ def method_missing(meth, *args)
+ meth = meth.to_s
+ unless meth =~ /:/
+ puts "Thor tasks must contain a :"
+ return
+ end
+
+ klass = meth.split(":")[0...-1].join(":")
+ to_call = meth.split(":").last
+ begin
+ klass = Thor::Util.constant_from_thor_path(klass)
+ rescue NameError
+ puts "There was no available namespace `#{klass}'."
+ return
+ end
+
+ ARGV.replace [to_call, ARGV[1..-1]].compact
+ klass.start
+ end
+
+ private
+ def display_tasks(item, max_base, max_left)
+ base = Thor::Util.constant_to_thor_path(item.klass.name)
+ item.usages.each do |name, usage|
+ format_string = "%-#{max_left + max_base + 5}s"
+ print format_string %
+ "#{base}:#{item.usages.assoc(name).last} #{display_opts(item.opts.assoc(name) && item.opts.assoc(name).last)}"
+ puts item.descriptions.assoc(name).last
+ end
+ end
+
+ def display_opts(opts)
+ return "" unless opts
+ opts.map do |opt, val|
+ if val == true || val == "BOOLEAN"
+ "[#{opt}]"
+ elsif val == "REQUIRED"
+ opt + "=" + opt.gsub(/\-/, "").upcase
+ elsif val == "OPTIONAL"
+ "[" + opt + "=" + opt.gsub(/\-/, "").upcase + "]"
+ end
+ end.join(" ")
+ end
+
+ def thorfiles
+ path = Dir.pwd
+ system_thorfiles = Dir["#{ENV["HOME"]}/.thor/**/*.thor"]
+ thorfiles = []
+
+ # Look for Thorfile or *.thor in the current directory or a parent directory, until the root
+ while thorfiles.empty?
+ thorfiles = Dir[*Thor::Runner.globs_for(path)]
+ path = File.dirname(path)
+ break if path == "/"
+ end
+ thorfiles
+ end
+
+end
+
+unless defined?(Spec)
+ Thor::Runner.start
+end
View
@@ -1,6 +1,19 @@
require "#{File.dirname(__FILE__)}/getopt"
class Thor
+ def self.inherited(klass)
+ subclass_files[File.expand_path(caller[0].split(":")[0])] << klass
+ subclasses << klass
+ end
+
+ def self.subclass_files
+ @subclass_files ||= Hash.new {|h,k| h[k] = []}
+ end
+
+ def self.subclasses
+ @subclasses ||= []
+ end
+
def self.method_added(meth)
return if !public_instance_methods.include?(meth.to_s) || !@usage
@descriptions ||= []
@@ -26,6 +39,31 @@ def self.method_options(opts)
end
end
+ def self.help_list
+ return nil unless @usages
+ @help_list ||= begin
+ max_usage = @usages.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size
+ max_opts = @opts.empty? ? 0 : format_opts(@opts.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last).size
+ max_desc = @descriptions.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size
+ Struct.new(:klass, :usages, :opts, :descriptions, :max).new(
+ self, @usages, @opts, @descriptions, Struct.new(:usage, :opt, :desc).new(max_usage, max_opts, max_desc)
+ )
+ end
+ end
+
+ def self.format_opts(opts)
+ return "" unless opts
+ opts.map do |opt, val|
+ if val == true || val == "BOOLEAN"
+ "[#{opt}]"
+ elsif val == "REQUIRED"
+ opt + "=" + opt.gsub(/\-/, "").upcase
+ elsif val == "OPTIONAL"
+ "[" + opt + "=" + opt.gsub(/\-/, "").upcase + "]"
+ end
+ end.join(" ")
+ end
+
def self.start
meth = ARGV.shift
params = []
@@ -42,51 +80,22 @@ def self.start
params << options
end
new(meth, params).instance_variable_get("@results")
- end
-
- def thor_usages
- self.class.instance_variable_get("@usages")
- end
-
- def thor_descriptions
- self.class.instance_variable_get("@descriptions")
- end
-
- def thor_opts
- self.class.instance_variable_get("@opts")
end
-
def initialize(op, params)
- @results = send(op.to_sym, *params) if public_methods.include?(op)
+ @results = send(op.to_sym, *params) if public_methods.include?(op) || !methods.include?(op)
end
-
- private
- def format_opts(opts)
- return "" unless opts
- opts.map do |opt, val|
- if val == true || val == "BOOLEAN"
- opt
- elsif val == "REQUIRED"
- opt + "=" + opt.gsub(/\-/, "").upcase
- elsif val == "OPTIONAL"
- "[" + opt + "=" + opt.gsub(/\-/, "").upcase + "]"
- end
- end.join(" ")
- end
-
- public
+
desc "help", "show this screen"
def help
+ list = self.class.help_list
puts "Options"
puts "-------"
- max_usage = thor_usages.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size
- max_opts = thor_opts.empty? ? 0 : format_opts(thor_opts.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last).size
- max_desc = thor_descriptions.max {|x,y| x.last.to_s.size <=> y.last.to_s.size}.last.size
- thor_usages.each do |meth, usage|
- format = "%-" + (max_usage + max_opts + 4).to_s + "s"
- print format % (thor_usages.assoc(meth)[1] + (thor_opts.assoc(meth) ? " " + format_opts(thor_opts.assoc(meth)[1]) : ""))
- puts thor_descriptions.assoc(meth)[1]
+ list.usages.each do |meth, usage|
+ format = "%-" + (list.max.usage + list.max.opt + 4).to_s + "s"
+ print format % (list.usages.assoc(meth)[1] + (list.opts.assoc(meth) ? " " + self.class.format_opts(list.opts.assoc(meth)[1]) : ""))
+ puts list.descriptions.assoc(meth)[1]
end
- end
+ end
+
end
View
@@ -1,2 +1,31 @@
$TESTING=true
$:.push File.join(File.dirname(__FILE__), '..', 'lib')
+
+module Spec::Expectations::ObjectExpectations
+ alias_method :must, :should
+ alias_method :must_not, :should_not
+end
+
+class StdOutCapturer
+ attr_reader :output
+
+ def initialize
+ @output = ""
+ end
+
+ def self.call_func
+ begin
+ old_out = $stdout
+ output = new
+ $stdout = output
+ yield
+ ensure
+ $stdout = old_out
+ end
+ output.output
+ end
+
+ def write(s)
+ @output += s
+ end
+end
@@ -0,0 +1,83 @@
+require File.dirname(__FILE__) + '/spec_helper'
+require "thor"
+
+load File.join("#{File.dirname(__FILE__)}", "..", "bin", "thor")
+
+class StdOutCapturer
+ attr_reader :output
+
+ def initialize
+ @output = ""
+ end
+
+ def self.call_func
+ begin
+ old_out = $stdout
+ output = new
+ $stdout = output
+ yield
+ ensure
+ $stdout = old_out
+ end
+ output.output
+ end
+
+ def write(s)
+ @output += s
+ end
+end
+
+module MyTasks
+ class ThorTask < Thor
+ desc "foo", "bar"
+ def foo
+ "foo"
+ end
+ end
+end
+
+class ThorTask2 < Thor
+end
+
+describe Thor::Util do
+ it "knows how to convert class names into thor names" do
+ Thor::Util.constant_to_thor_path("FooBar::BarBaz::BazBat").must == "foo_bar:bar_baz:baz_bat"
+ end
+
+ it "knows how to convert a thor name to a constant" do
+ Thor::Util.constant_from_thor_path("my_tasks:thor_task").must == MyTasks::ThorTask
+ end
+end
+
+describe Thor do
+ it "tracks its subclasses, grouped by the files they come from" do
+ Thor.subclass_files[File.expand_path(__FILE__)].must == [MyTasks::ThorTask, ThorTask2]
+ end
+
+ it "tracks its subclasses in an Array" do
+ Thor.subclasses.must include(MyTasks::ThorTask)
+ Thor.subclasses.must include(ThorTask2)
+ end
+end
+
+describe Thor::Runner do
+ it "can give a list of the available tasks" do
+ ARGV.replace ["list"]
+ StdOutCapturer.call_func { Thor::Runner.start }.must =~ /my_tasks:thor_task:foo +bar/
+ end
+
+ it "runs tasks from other Thor files" do
+ ARGV.replace ["my_tasks:thor_task:foo"]
+ Thor::Runner.start.should == "foo"
+ end
+
+ it "prints an error if a thor task is not namespaced" do
+ ARGV.replace ["hello"]
+ StdOutCapturer.call_func { Thor::Runner.start }.must =~ /Thor tasks must contain a :/
+ end
+
+ it "prints an error if the namespace could not be found" do
+ ARGV.replace ["hello:goodbye"]
+ StdOutCapturer.call_func { Thor::Runner.start }.must =~ /There was no available namespace `hello'/
+ end
+end
Oops, something went wrong.

0 comments on commit b522f05

Please sign in to comment.