choosy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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