choosy 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. data/Gemfile +7 -0
  2. data/Gemfile.lock +25 -0
  3. data/LICENSE +23 -0
  4. data/README.markdown +393 -0
  5. data/Rakefile +57 -0
  6. data/lib/VERSION +1 -0
  7. data/lib/choosy/base_command.rb +59 -0
  8. data/lib/choosy/command.rb +32 -0
  9. data/lib/choosy/converter.rb +115 -0
  10. data/lib/choosy/dsl/base_command_builder.rb +172 -0
  11. data/lib/choosy/dsl/command_builder.rb +43 -0
  12. data/lib/choosy/dsl/option_builder.rb +155 -0
  13. data/lib/choosy/dsl/super_command_builder.rb +41 -0
  14. data/lib/choosy/errors.rb +11 -0
  15. data/lib/choosy/option.rb +22 -0
  16. data/lib/choosy/parse_result.rb +64 -0
  17. data/lib/choosy/parser.rb +184 -0
  18. data/lib/choosy/printing/color.rb +101 -0
  19. data/lib/choosy/printing/erb_printer.rb +23 -0
  20. data/lib/choosy/printing/help_printer.rb +174 -0
  21. data/lib/choosy/super_command.rb +77 -0
  22. data/lib/choosy/super_parser.rb +81 -0
  23. data/lib/choosy/verifier.rb +62 -0
  24. data/lib/choosy/version.rb +12 -0
  25. data/lib/choosy.rb +16 -0
  26. data/spec/choosy/base_command_spec.rb +11 -0
  27. data/spec/choosy/command_spec.rb +51 -0
  28. data/spec/choosy/converter_spec.rb +145 -0
  29. data/spec/choosy/dsl/base_command_builder_spec.rb +328 -0
  30. data/spec/choosy/dsl/commmand_builder_spec.rb +80 -0
  31. data/spec/choosy/dsl/option_builder_spec.rb +386 -0
  32. data/spec/choosy/dsl/super_command_builder_spec.rb +83 -0
  33. data/spec/choosy/parser_spec.rb +275 -0
  34. data/spec/choosy/printing/color_spec.rb +74 -0
  35. data/spec/choosy/printing/help_printer_spec.rb +117 -0
  36. data/spec/choosy/super_command_spec.rb +80 -0
  37. data/spec/choosy/super_parser_spec.rb +106 -0
  38. data/spec/choosy/verifier_spec.rb +180 -0
  39. data/spec/integration/command-A_spec.rb +37 -0
  40. data/spec/integration/supercommand-A_spec.rb +61 -0
  41. data/spec/spec_helpers.rb +30 -0
  42. metadata +150 -0
@@ -0,0 +1,184 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/parse_result'
3
+ require 'choosy/dsl/option_builder'
4
+
5
+ module Choosy
6
+ class Parser
7
+ attr_reader :flags, :terminals, :command
8
+
9
+ def initialize(command, lazy=nil, terminals=nil)
10
+ @command = command
11
+ @lazy = lazy || false
12
+ @terminals = terminals || []
13
+
14
+ @flags = {}
15
+ return if command.options.nil?
16
+ command.options.each do |o|
17
+ verify_option(o)
18
+ end
19
+ end
20
+
21
+ def parse!(argv, result=nil)
22
+ index = 0
23
+ result ||= ParseResult.new(@command)
24
+
25
+ while index < argv.length
26
+ case argv[index]
27
+ when '-'
28
+ if lazy?
29
+ result.unparsed << '-'
30
+ index += 1
31
+ else
32
+ raise Choosy::ParseError.new("Unfinished option '-'")
33
+ end
34
+ when '--'
35
+ result.unparsed << '--' if lazy?
36
+ index = parse_rest(argv, index, result)
37
+ when /^-/
38
+ index = parse_option(argv, index, result)
39
+ else
40
+ index = parse_arg(argv, index, result)
41
+ end
42
+ end
43
+
44
+ result
45
+ end
46
+
47
+ def lazy?
48
+ @lazy
49
+ end
50
+
51
+ private
52
+ def verify_option(option)
53
+ verify_flag(option, option.short_flag)
54
+ verify_flag(option, option.long_flag)
55
+ end
56
+
57
+ def verify_flag(option, flag)
58
+ return nil if flag.nil?
59
+ if @flags[flag]
60
+ raise Choosy::ConfigurationError.new("Duplicate option: '#{flag}'")
61
+ end
62
+ @flags[flag] = option
63
+ end
64
+
65
+ def parse_option(argv, index, result)
66
+ current = argv[index]
67
+ flag, arg = current.split("=", 2)
68
+ option = @flags[flag]
69
+
70
+ if option.nil?
71
+ if lazy?
72
+ result.unparsed << current
73
+ return index + 1
74
+ else
75
+ raise Choosy::ParseError.new("Unrecognized option: '#{flag}'")
76
+ end
77
+ end
78
+
79
+ if option.arity == Choosy::DSL::OptionBuilder::ZERO_ARITY
80
+ parse_boolean_option(result, option, index, arg, current)
81
+ elsif option.arity == Choosy::DSL::OptionBuilder::ONE_ARITY
82
+ parse_single_option(result, option, index, argv, flag, arg)
83
+ else # Vararg
84
+ parse_multiple_option(result, option, index, argv, flag, arg)
85
+ end
86
+ end
87
+
88
+ def parse_boolean_option(result, option, index, arg, current)
89
+ raise Choosy::ParseError.new("Argument given to boolean flag: '#{current}'") if arg
90
+ result.options[option.name] = !option.default_value
91
+ index + 1
92
+ end
93
+
94
+ def parse_single_option(result, option, index, argv, flag, arg)
95
+ if arg
96
+ result.options[option.name] = arg
97
+ index += 1
98
+ else
99
+ current, index = read_arg(argv, index + 1, result)
100
+ if current.nil?
101
+ raise Choosy::ParseError.new("Argument missing for option: '#{flag}'")
102
+ else
103
+ result.options[option.name] = current
104
+ end
105
+ end
106
+
107
+ index
108
+ end
109
+
110
+ def parse_multiple_option(result, option, index, argv, flag, arg)
111
+ if arg
112
+ if option.arity.min > 1
113
+ raise Choosy::ParseError.new("The '#{flag}' flag requires at least #{option.arity.min} arguments")
114
+ end
115
+ result.options[option.name] = arg
116
+ return index + 1
117
+ end
118
+
119
+ index += 1
120
+ min = index + option.arity.min
121
+ max = index + option.arity.max
122
+ args = []
123
+
124
+ while index < min
125
+ current, index = read_arg(argv, index, result)
126
+ if current.nil?
127
+ raise Choosy::ParseError.new("The '#{flag}' flag requires at least #{option.arity.min} arguments")
128
+ end
129
+ args << current
130
+ end
131
+
132
+ while index < max && index < argv.length
133
+ current, index = read_arg(argv, index, result)
134
+ break if current.nil?
135
+ args << current
136
+ end
137
+
138
+ if index < argv.length && argv[index] == '-'
139
+ index += 1
140
+ end
141
+
142
+ result.options[option.name] = args
143
+ index
144
+ end
145
+
146
+ def parse_arg(argv, index, result)
147
+ while index < argv.length
148
+ current, index = read_arg(argv, index, result)
149
+ break if current.nil?
150
+ if lazy?
151
+ result.unparsed << current
152
+ else
153
+ result.args << current
154
+ end
155
+ end
156
+ index
157
+ end
158
+
159
+ def read_arg(argv, index, result)
160
+ return [nil, index] if index >= argv.length
161
+
162
+ current = argv[index]
163
+ return [nil, index] if current[0] == '-'
164
+ if @terminals.include? current
165
+ result.unparsed.push(*argv[index, argv.length])
166
+ return [nil, argv.length]
167
+ end
168
+ [current, index + 1]
169
+ end
170
+
171
+ def parse_rest(argv, index, result)
172
+ index += 1
173
+ while index < argv.length
174
+ if lazy?
175
+ result.unparsed << argv[index]
176
+ else
177
+ result.args << argv[index]
178
+ end
179
+ index += 1
180
+ end
181
+ index
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,101 @@
1
+ require 'choosy/errors'
2
+
3
+ module Choosy::Printing
4
+ class Color
5
+ # Extrapolated from:
6
+ # http://kpumuk.info/ruby-on-rails/colorizing-console-ruby-script-output/
7
+ COLORS = {
8
+ :black => 0,
9
+ :red => 1,
10
+ :green => 2,
11
+ :yellow => 3,
12
+ :blue => 4,
13
+ :magenta => 5,
14
+ :cyan => 6,
15
+ :white => 7,
16
+ }
17
+
18
+ EFFECTS = {
19
+ :reset => 0,
20
+ :bright => 1,
21
+ :underline => 4,
22
+ :blink => 5,
23
+ :exchange => 7,
24
+ :hide => 8
25
+ }
26
+
27
+ FOREGROUND = 30
28
+ BACKGROUND = 40
29
+
30
+ def initialize
31
+ begin
32
+ require 'Win32/Console/ANSI' if RUBY_PLATFORM =~ /win32/
33
+ @disabled = false
34
+ rescue LoadError
35
+ # STDERR.puts "You must gem install win32console to use color on Windows"
36
+ disable!
37
+ end
38
+ end
39
+
40
+ def disabled?
41
+ @disabled
42
+ end
43
+
44
+ def disable!
45
+ @disabled = true
46
+ end
47
+
48
+ def color?(color)
49
+ COLORS.has_key?(color.to_sym)
50
+ end
51
+
52
+ def effect?(effect)
53
+ EFFECTS.has_key?(effect.to_sym)
54
+ end
55
+
56
+ def respond_to?(method)
57
+ color?(method) || effect?(method)
58
+ end
59
+
60
+ # Dynamically handle colors and effects
61
+ def method_missing(method, *args, &block)
62
+ str, offset = unpack_args(method, args)
63
+ return str || "" if disabled?
64
+
65
+ if color?(method)
66
+ bedazzle(COLORS[method] + offset, str)
67
+ elsif effect?(method)
68
+ bedazzle(EFFECTS[method], str)
69
+ else
70
+ raise NoMethodError.new("undefined method '#{method}' for Color")
71
+ end
72
+ end
73
+
74
+ private
75
+ def unpack_args(method, args)
76
+ case args.length
77
+ when 0
78
+ [nil, FOREGROUND]
79
+ when 1
80
+ [args[0], FOREGROUND]
81
+ when 2
82
+ case args[1]
83
+ when :foreground then [args[0], FOREGROUND]
84
+ when :background then [args[0], BACKGROUND]
85
+ else raise ArgumentError.new("unrecognized state for Color##{method}, :foreground or :background only")
86
+ end
87
+ else
88
+ raise ArgumentError.new("too many arguments to Color##{method} (max 2)")
89
+ end
90
+ end
91
+
92
+ def bedazzle(number, str)
93
+ prefix = "e#{number}[m"
94
+ if str.nil?
95
+ prefix
96
+ else
97
+ "#{prefix}#{str}e0[m"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,23 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/printing/help_printer'
3
+ require 'erb'
4
+
5
+ module Choosy::Printing
6
+ class ERBPrinter < HelpPrinter
7
+ attr_reader :command
8
+ attr_accessor :template
9
+
10
+ def print!(command)
11
+ @command = command
12
+ contents = nil
13
+ File.open(template, 'r') {|f| contents = f.read }
14
+ erb = ERB.new contents
15
+
16
+ erb.run(self)
17
+ end
18
+
19
+ def erb_binding
20
+ binding
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,174 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/printing/color'
3
+
4
+ module Choosy::Printing
5
+ class HelpPrinter
6
+ DEFAULT_LINE_COUNT = 25
7
+ DEFAULT_COLUMN_COUNT = 80
8
+
9
+ attr_reader :color
10
+
11
+ def initialize
12
+ @color = Color.new
13
+ end
14
+
15
+ def lines
16
+ @lines ||= find_terminal_size('LINES', 'lines', 0) || DEFAULT_LINE_COUNT
17
+ end
18
+
19
+ def lines=(value)
20
+ @lines = value
21
+ end
22
+
23
+ def columns
24
+ @columns ||= find_terminal_size('COLUMNS', 'cols', 1) || DEFAULT_COLUMN_COUNT
25
+ end
26
+
27
+ def columns=(value)
28
+ @columns = value
29
+ end
30
+
31
+ def colored=(val)
32
+ @color.disable! unless val
33
+ end
34
+
35
+ def print!(command)
36
+ print_usage(command)
37
+ print_summary(command.summary) if command.summary
38
+ print_description(command.description) if command.description
39
+ command.listing.each do |l|
40
+ if l.is_a?(String)
41
+ print_separator(l)
42
+ elsif l.is_a?(Choosy::Option)
43
+ print_option(l)
44
+ else
45
+ print_command(l)
46
+ end
47
+ end
48
+ end
49
+
50
+ # FIXME: hideously ugly
51
+ def print_usage(command)
52
+ args = if command.respond_to?(:argument_validation) && command.argument_validation
53
+ " [ARGS]"
54
+ else
55
+ ""
56
+ end
57
+ cmds = if command.respond_to?(:commands)
58
+ " [COMMANDS]"
59
+ else
60
+ ""
61
+ end
62
+ options = if command.option_builders.length == 0
63
+ ""
64
+ else
65
+ " [OPTIONS]"
66
+ end
67
+ $stdout << "USAGE: #{command.name}#{cmds}#{options}#{args}\n"
68
+ end
69
+
70
+ def print_summary(summary)
71
+ write_lines(summary, ' ')
72
+ end
73
+
74
+ def print_description(desc)
75
+ print_separator("DESCRIPTION")
76
+ write_lines(desc, " ")
77
+ end
78
+
79
+ def print_separator(sep)
80
+ $stdout << "\n#{sep}\n"
81
+ end
82
+
83
+ def print_option(option)
84
+ $stdout << " "
85
+ if option.short_flag
86
+ $stdout << option.short_flag
87
+ if option.long_flag
88
+ $stdout << ", "
89
+ end
90
+ end
91
+
92
+ if option.long_flag
93
+ $stdout << option.long_flag
94
+ end
95
+
96
+ if option.flag_parameter
97
+ $stdout << " "
98
+ $stdout << option.flag_parameter
99
+ end
100
+
101
+ $stdout << "\n"
102
+ write_lines(option.description, " ")
103
+ end
104
+
105
+ def print_command(command)
106
+ write_lines("#{command.name}\t#{command.summary}", " ")
107
+ end
108
+
109
+ protected
110
+ def write_lines(str, prefix)
111
+ str.split("\n").each do |line|
112
+ if line.length == 0
113
+ $stdout << "\n"
114
+ else
115
+ wrap_long_lines(line, prefix)
116
+ end
117
+ end
118
+ end
119
+
120
+ # FIXME: not exactly pretty, but it works, mostly
121
+ MAX_BACKTRACK = 25
122
+ def wrap_long_lines(line, prefix)
123
+ index = 0
124
+ while index < line.length
125
+ segment_size = line.length - index
126
+ if segment_size >= columns
127
+ i = columns + index - prefix.length
128
+ while i > columns - MAX_BACKTRACK
129
+ if line[i] == ' '
130
+ indent_line(line[index, i - index], prefix)
131
+ index = i + 1
132
+ break
133
+ else
134
+ i -= 1
135
+ end
136
+ end
137
+ else
138
+ indent_line(line[index, line.length], prefix)
139
+ index += segment_size
140
+ end
141
+ end
142
+ end
143
+
144
+ def indent_line(line, prefix)
145
+ $stdout << prefix
146
+ $stdout << line
147
+ $stdout << "\n"
148
+ end
149
+
150
+ private
151
+ # https://github.com/cldwalker/hirb
152
+ # modified from hirb
153
+ def find_terminal_size(env_name, tput_name, stty_index)
154
+ begin
155
+ if ENV[env_name] =~ /^\d$/
156
+ ENV[env_name].to_i
157
+ elsif (RUBY_PLATFORM =~ /java/ || (!STDIN.tty? && ENV['TERM'])) && command_exists?('tput')
158
+ `tput #{tput_name}`.to_i
159
+ elsif STDIN.tty? && command_exists?('stty')
160
+ `stty size`.scan(/\d+/).map { |s| s.to_i }[stty_index]
161
+ else
162
+ nil
163
+ end
164
+ rescue
165
+ nil
166
+ end
167
+ end
168
+
169
+ # directly from hirb
170
+ def command_exists?(command)
171
+ ENV['PATH'].split(File::PATH_SEPARATOR).any? {|d| File.exists? File.join(d, command) }
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,77 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/parser'
3
+ require 'choosy/base_command'
4
+ require 'choosy/super_parser'
5
+ require 'choosy/dsl/super_command_builder'
6
+
7
+ module Choosy
8
+ class SuperCommand < BaseCommand
9
+ attr_reader :command_builders
10
+
11
+ def command_builders
12
+ @command_builders ||= {}
13
+ end
14
+
15
+ def commands
16
+ @command_builders.values.map {|b| b.command }
17
+ end
18
+
19
+ def parsimonious=(value)
20
+ @parsimonious = value
21
+ end
22
+
23
+ def parsimonious?
24
+ @parsimonous ||= false
25
+ end
26
+
27
+ def execute!(args)
28
+ super_result = parse!(args)
29
+ super_result.subresults.each do |result|
30
+ cmd = result.command
31
+ if cmd.executor.nil?
32
+ raise Choosy::ConfigurationError.new("No executor given for: #{cmd.name}")
33
+ end
34
+ end
35
+
36
+ super_result.subresults.each do |result|
37
+ cmd = result.command
38
+ cmd.executor.call(result.options, result.args)
39
+ end
40
+ end
41
+
42
+ protected
43
+ def create_builder
44
+ Choosy::DSL::SuperCommandBuilder.new(self)
45
+ end
46
+
47
+ def parse(args)
48
+ parser = SuperParser.new(self)
49
+ parser.parse!(args)
50
+ end
51
+
52
+ def handle_help(hc)
53
+ command_name = hc.message
54
+
55
+ if command_name.to_s == @name.to_s
56
+ printer.print!(self)
57
+ else
58
+ builder = command_builders[command_name]
59
+ if builder
60
+ printer.print!(builder.command)
61
+ else
62
+ $stdout << "#{@name}: #{format_help(command_name)}\n"
63
+ exit 1
64
+ end
65
+ end
66
+ end
67
+
68
+ def format_help(command)
69
+ help = if command_builders[:help]
70
+ "See '#{@name} help'."
71
+ else
72
+ ""
73
+ end
74
+ "'#{command}' is not a standard command. #{help}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/parser'
3
+ require 'choosy/parse_result'
4
+
5
+ module Choosy
6
+ class SuperParser
7
+ attr_reader :terminals
8
+
9
+ def initialize(super_command, parsimonious=nil)
10
+ @super_command = super_command
11
+ @parsimonious = parsimonious || false
12
+ generate_terminals
13
+ end
14
+
15
+ def parsimonious?
16
+ @parsimonious
17
+ end
18
+
19
+ def parse!(args)
20
+ result = parse_globals(args)
21
+ unparsed = result.unparsed
22
+
23
+ while unparsed.length > 0
24
+ command_result = parse_command(unparsed, terminals)
25
+ command_result.options.merge!(result.options)
26
+ result.subresults << command_result
27
+
28
+ unparsed = command_result.unparsed
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ private
35
+ def generate_terminals
36
+ @terminals = []
37
+ if parsimonious?
38
+ @super_command.commands.each do |c|
39
+ @terminals << c.name.to_s
40
+ end
41
+ end
42
+ end
43
+
44
+ def parse_globals(args)
45
+ result = SuperParseResult.new(@super_command)
46
+ parser = Parser.new(@super_command, true)
47
+ parser.parse!(args, result)
48
+ result.verify!
49
+
50
+ # if we found a global action, we should have hit it by now...
51
+ if result.unparsed.length == 0
52
+ if @super_command.command_builders[:help]
53
+ raise Choosy::HelpCalled.new(@super_command.name)
54
+ else
55
+ raise Choosy::SuperParseError.new("requires a command")
56
+ end
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ def parse_command(args, terminals)
63
+ command_name = args.shift
64
+ command_builder = @super_command.command_builders[command_name.to_sym]
65
+ if command_builder.nil?
66
+ if command_name =~ /^-/
67
+ raise Choosy::SuperParseError.new("unrecognized option: '#{command_name}'")
68
+ else
69
+ raise Choosy::SuperParseError.new("unrecognized command: '#{command_name}'")
70
+ end
71
+ end
72
+
73
+ command = command_builder.command
74
+ parser = Parser.new(command, false, terminals)
75
+ command_result = parser.parse!(args)
76
+
77
+ command_result.verify!
78
+ end
79
+ end
80
+ end
81
+
@@ -0,0 +1,62 @@
1
+ require 'choosy/errors'
2
+ require 'choosy/dsl/option_builder'
3
+
4
+ module Choosy
5
+ class Verifier
6
+ def verify_options!(result)
7
+ result.command.options.each do |option|
8
+ required?(option, result)
9
+ populate!(option, result)
10
+ convert!(option, result)
11
+ validate!(option, result)
12
+ end
13
+ end
14
+
15
+ def verify_arguments!(result)
16
+ if result.command.respond_to?(:argument_validation) && result.command.argument_validation
17
+ result.command.argument_validation.call(result.args)
18
+ end
19
+ end
20
+
21
+ def required?(option, result)
22
+ if option.required? && result[option.name].nil?
23
+ raise ValidationError.new("Required option '#{option.long_flag}' missing.")
24
+ end
25
+ end
26
+
27
+ def populate!(option, result)
28
+ return if option.name == Choosy::DSL::OptionBuilder::HELP || option.name == Choosy::DSL::OptionBuilder::VERSION
29
+
30
+ if !result.options.has_key?(option.name) # Not already set
31
+ if !option.default_value.nil? # Has default?
32
+ result[option.name] = option.default_value
33
+ elsif option.cast_to == :boolean
34
+ result[option.name] = false
35
+ elsif option.arity.max > 1
36
+ result[option.name] = []
37
+ else
38
+ result[option.name] = nil
39
+ end
40
+ end
41
+ end
42
+
43
+ def convert!(option, result)
44
+ value = result[option.name]
45
+ if exists? value
46
+ result[option.name] = Converter.convert(option.cast_to, value)
47
+ end
48
+ end
49
+
50
+ def validate!(option, result)
51
+ value = result[option.name]
52
+ if option.validation_step && exists?(value)
53
+ option.validation_step.call(value)
54
+ end
55
+ end
56
+
57
+ private
58
+ def exists?(value)
59
+ value && value != []
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,12 @@
1
+ require 'scanf'
2
+
3
+ module Choosy
4
+ class Version
5
+ CURRENT = File.read(File.join(File.dirname(__FILE__), '..', 'VERSION'))
6
+ MAJOR, MINOR, TINY = CURRENT.scanf('%d.%d.%d')
7
+
8
+ def self.to_s
9
+ CURRENT
10
+ end
11
+ end
12
+ end