clin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,195 @@
1
+ require 'clin'
2
+ require 'clin/command_options_mixin'
3
+ require 'clin/argument'
4
+ require 'shellwords'
5
+ require 'clin/common/help_options'
6
+
7
+ # Clin Command
8
+ class Clin::Command < Clin::CommandOptionsMixin
9
+
10
+ class_attribute :args
11
+ class_attribute :description
12
+
13
+
14
+ # Redispatch will be reset to nil when inheriting a dispatcher command
15
+ class_attribute :_redispatch_args
16
+ class_attribute :_abstract
17
+ class_attribute :_exe_name
18
+
19
+ self.args = []
20
+ self.description = ''
21
+ self._abstract = false
22
+
23
+
24
+ # Trigger when a class inherit this class
25
+ # Rest class_attributes that should not be shared with subclass
26
+ # @param subclass [Clin::Command]
27
+ def self.inherited(subclass)
28
+ subclass._redispatch_args = nil
29
+ subclass._abstract = false
30
+ super
31
+ end
32
+
33
+ # Mark the class as abstract
34
+ def self.abstract(value)
35
+ self._abstract = value
36
+ end
37
+
38
+ # Set or get the exe name.
39
+ # Executable name that will be display in the usage.
40
+ # If exe_name is not set in a class or it's parent it will use the global setting Clin.exe_name
41
+ # @param value [String] name of the exe.
42
+ # ```
43
+ # class Git < Clin::Command
44
+ # exe_name 'git'
45
+ # arguments '<command> <args>...'
46
+ # end
47
+ # Git.usage # => git <command> <args>...
48
+ # ```
49
+ def self.exe_name(value=nil)
50
+ self._exe_name = value unless value.nil?
51
+ self._exe_name ||= Clin.exe_name
52
+ end
53
+
54
+ def self.arguments(args)
55
+ self.args = []
56
+ [*args].map(&:split).flatten.each do |arg|
57
+ self.args += [Clin::Argument.new(arg)]
58
+ end
59
+ end
60
+
61
+ def self.usage
62
+ a = [exe_name, args.map(&:original).join(' '), '[Options]']
63
+ a.reject(&:blank?).join(' ')
64
+ end
65
+
66
+ def self.banner
67
+ "Usage: #{usage}"
68
+ end
69
+
70
+ # Parse the command and initialize the command object with the parsed options
71
+ # @param argv [Array|String] command line to parse.
72
+ def self.parse(argv = ARGV, fallback_help: true)
73
+ argv = Shellwords.split(argv) if argv.is_a? String
74
+ argv = argv.clone
75
+ options_map = parse_options(argv)
76
+ error = nil
77
+ begin
78
+ args_map = parse_arguments(argv)
79
+ rescue Clin::MissingArgumentError => e
80
+ error = e
81
+ rescue Clin::FixedArgumentError => e
82
+ raise e unless fallback_help
83
+ error = e
84
+ end
85
+ args_map ||= {}
86
+
87
+ options = options_map.merge(args_map)
88
+ return handle_dispatch(options) unless self._redispatch_args.nil?
89
+ obj = new(options)
90
+ if error
91
+ fail Clin::HelpError, option_parser if fallback_help
92
+ fail error
93
+ end
94
+ obj
95
+ end
96
+
97
+ # Parse the options in the argv.
98
+ # @return [Array] the list of argv that are not options(positional arguments)
99
+ def self.parse_options(argv)
100
+ out = {}
101
+ parser = option_parser(out)
102
+ parser.parse!(argv)
103
+ out
104
+ end
105
+
106
+ # Build the Option Parser object
107
+ # Used to parse the option
108
+ # Useful for regenerating the help as well.
109
+ def self.option_parser(out = {})
110
+ OptionParser.new do |opts|
111
+ opts.banner = banner
112
+ opts.separator ''
113
+ opts.separator 'Options:'
114
+ register_options(opts, out)
115
+ dispatch_doc(opts)
116
+ unless description.blank?
117
+ opts.separator "\nDescription:"
118
+ opts.separator description
119
+ end
120
+ opts.separator ''
121
+ end
122
+ end
123
+
124
+ def self.execute_general_options(options)
125
+ general_options.each do |_cls, gopts|
126
+ gopts.execute(options)
127
+ end
128
+ end
129
+
130
+ # Parse the argument. The options must have been strip out first.
131
+ def self.parse_arguments(argv)
132
+ out = {}
133
+ self.args.each do |arg|
134
+ value, argv = arg.parse(argv)
135
+ out[arg.name.to_sym] = value
136
+ end
137
+ out.delete_if { |_, v| v.nil? }
138
+ end
139
+
140
+ # Redispatch the command to a sub command with the given arguments
141
+ # @param args [Array<String>|String] New argument to parse
142
+ # @param prefix [String] Prefix to add to the beginning of the command
143
+ # @param commands [Array<Clin::Command.class>] Commands that will be tried against
144
+ # If no commands are given it will look for Clin::Command in the class namespace
145
+ # e.g. If those 2 classes are defined.
146
+ # `MyDispatcher < Clin::Command` and `MyDispatcher::ChildCommand < Clin::Command`
147
+ # Will test against ChildCommand
148
+ def self.dispatch(args, prefix: nil, commands: nil)
149
+ self._redispatch_args = [[*args], prefix, commands]
150
+ end
151
+
152
+ # Method called after the argument have been parsed and before creating the command
153
+ # @param params [List<String>] Parsed params from the command line.
154
+ def self.handle_dispatch(params)
155
+ args, prefix, commands = self._redispatch_args
156
+ commands ||= default_commands
157
+ dispatcher = Clin::CommandDispatcher.new(commands)
158
+ args = args.map { |x| params[x] }.flatten
159
+ args = prefix.split + args unless prefix.nil?
160
+ begin
161
+ dispatcher.parse(args)
162
+ rescue Clin::HelpError
163
+ raise Clin::HelpError, option_parser
164
+ end
165
+ end
166
+
167
+ def self.dispatch_doc(opts)
168
+ return if self._redispatch_args.nil?
169
+ opts.separator 'Examples: '
170
+ commands = (self._redispatch_args[2] || default_commands)
171
+ commands.each do |cmd_cls|
172
+ opts.separator "\t#{cmd_cls.usage}"
173
+ end
174
+ end
175
+
176
+ def self.default_commands
177
+ # self.constants.map { |c| self.const_get(c) }.select { |c| c.is_a?(Class) && (c < Clin::Command) }
178
+ self.subcommands
179
+ end
180
+
181
+ # List the subcommands
182
+ # The subcommands are all the Classes inheriting this one that are not set to abstract
183
+ def self.subcommands
184
+ self.subclasses.reject(&:_abstract)
185
+ end
186
+
187
+ general_option 'Clin::HelpOptions'
188
+
189
+ attr_accessor :params
190
+
191
+ def initialize(params)
192
+ @params = params
193
+ self.class.execute_general_options(params)
194
+ end
195
+ end
@@ -0,0 +1,48 @@
1
+ require 'clin'
2
+ require 'clin/command'
3
+
4
+ # Class charge dispatching the CL to the right command
5
+ class Clin::CommandDispatcher
6
+ attr_accessor :commands
7
+
8
+ # Create a new command dispatcher.
9
+ # @param commands [Array<Clin::Command.class>] List of commands that can be dispatched.
10
+ # If commands is nil it will get all the subclass of Clin::Command loaded.
11
+ def initialize(*commands)
12
+ @commands = commands.empty? ? Clin::Command.subcommands : commands.flatten
13
+ end
14
+
15
+ # Parse the command line using the given arguments
16
+ # It will return the newly initialized command with the arguments if there is a match
17
+ # Otherwise will fail and display the help message
18
+ # @param argv [Array<String>] Arguments
19
+ # @return [Clin::Command]
20
+ def parse(argv = ARGV)
21
+ errors = 0
22
+ argv = Shellwords.split(argv) if argv.is_a? String
23
+ @commands.each do |cmd|
24
+ begin
25
+ return cmd.parse(argv, fallback_help: false)
26
+ rescue Clin::ArgumentError
27
+ errors += 1
28
+ end
29
+ end
30
+ fail Clin::HelpError, help_message
31
+ end
32
+
33
+ # Helper method to parse against all the commands
34
+ # @see #parse
35
+ def self.parse(argv=ARGV)
36
+ Clin::CommandDispatcher.new.parse(argv)
37
+ end
38
+
39
+ # Generate the help message for this dispatcher
40
+ # @return [String]
41
+ def help_message
42
+ message = "Usage:\n"
43
+ commands.each do |command|
44
+ message << "\t#{command.usage}\n"
45
+ end
46
+ message
47
+ end
48
+ end
@@ -0,0 +1,88 @@
1
+ require 'clin'
2
+ require 'clin/option'
3
+
4
+ # Template class for reusable options and commands
5
+ # It provide the method to add options to a command
6
+ class Clin::CommandOptionsMixin
7
+ class_attribute :options
8
+ class_attribute :general_options
9
+ self.options = []
10
+ self.general_options = {}
11
+
12
+
13
+ # Add an option
14
+ # @param args list of arguments.
15
+ # * First argument must be the name if no block is given.
16
+ # It will set automatically read the value into the hash with +name+ as key
17
+ # * The remaining arguments are OptionsParser#on arguments
18
+ # ```
19
+ # option :require, '-r', '--require [LIBRARY]', 'Require the library'
20
+ # option '-h', '--helper', 'Show the help' do
21
+ # puts opts
22
+ # exit
23
+ # end
24
+ # ```
25
+ def self.opt_option(*args, &block)
26
+ add_option Clin::Option.new(*args, &block)
27
+ end
28
+
29
+ # Add an option.
30
+ # Helper method that just create a new Clin::Option with the argument then call add_option
31
+ # ```
32
+ # option :show, 'Show some message'
33
+ # # => -s --show SHOW Show some message
34
+ # option :require, 'Require a library', short: false, optional: true, argument: 'LIBRARY'
35
+ # # => --require [LIBRARY] Require a library
36
+ # option :help, 'Show the help', argument: false do
37
+ # puts opts
38
+ # exit
39
+ # end
40
+ # # => -h --help Show the help
41
+ # ```
42
+ def self.option(name, description, **config, &block)
43
+ add_option Clin::Option.new(name, description, **config, &block)
44
+ end
45
+
46
+ # For an option that does not have an argument
47
+ # Same as .option except it will default argument to false
48
+ # ```
49
+ # option :verbose, 'Use verbose' #=> -v --verbose will be added to the option of this command
50
+ # ```
51
+ def self.flag_option(name, description, **config, &block)
52
+ add_option Clin::Option.new(name, description, **config.merge(argument: false), &block)
53
+ end
54
+
55
+ def self.add_option(option)
56
+ # Need to use += instead of << otherwise the parent class will also be changed
57
+ self.options += [option]
58
+ end
59
+
60
+ # Add a general option
61
+ # @param option_cls [Class<GeneralOption>] Class inherited from GeneralOption
62
+ # @param config [Hash] General option config. Check the general option config.
63
+ def self.general_option(option_cls, config = {})
64
+ option_cls = option_cls.constantize if option_cls.is_a? String
65
+ self.general_options = self.general_options.merge(option_cls => option_cls.new(config))
66
+ end
67
+
68
+ # Remove a general option
69
+ # Might be useful if a parent added the option but is not needed in this child.
70
+ def self.remove_general_option(option_cls)
71
+ option_cls = option_cls.constantize if option_cls.is_a? String
72
+ self.general_options = self.general_options.except(option_cls)
73
+ end
74
+
75
+ # To be called inside OptionParser block
76
+ # Extract the option in the command line using the OptionParser and map it to the out map.
77
+ # @param opts [OptionParser]
78
+ # @param out [Hash] Where the options shall be extracted
79
+ def self.register_options(opts, out)
80
+ options.each do |option|
81
+ option.register(opts, out)
82
+ end
83
+
84
+ general_options.each do |_cls, option|
85
+ option.class.register_options(opts, out)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,25 @@
1
+ require 'clin'
2
+ require 'clin/general_option'
3
+ # Help option class
4
+ # Add the help option to you command
5
+ # ```
6
+ # class MyCommand < Clin::Command
7
+ # general_option Clin::HelpOptions
8
+ # end
9
+ # ```
10
+ # Then running you command with -h or --help will show the help menu
11
+ class Clin::HelpOptions < Clin::GeneralOption
12
+ flag_option :help, 'Show the help.' do |opts, out, _|
13
+ out[:help] = opts
14
+ end
15
+
16
+ def initialize(raise: true)
17
+ @raise = raise
18
+ end
19
+
20
+
21
+ def execute(options)
22
+ return unless @raise
23
+ fail Clin::HelpError, options[:help] if options[:help]
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # Contains the errors for Clin
2
+ module Clin
3
+ # Parent error class for all Clin errors
4
+ Error = Class.new(RuntimeError)
5
+
6
+ # Error cause by the user input(when parsing command)
7
+ CommandLineError = Class.new(Error)
8
+
9
+ # Error when the help needs to be shown
10
+ HelpError = Class.new(CommandLineError)
11
+
12
+ # Error when an positional argument is wrong
13
+ ArgumentError = Class.new(CommandLineError)
14
+
15
+ # Error when a fixed argument is not matched
16
+ class FixedArgumentError < ArgumentError
17
+ def initialize(argument = '', got = '')
18
+ super("Expecting '#{argument}' but got '#{got}'")
19
+ end
20
+ end
21
+
22
+ # Error when a command is missing an argument
23
+ class MissingArgumentError < ArgumentError
24
+ def initialize(message = '')
25
+ super("Missing argument #{message}")
26
+ end
27
+ end
28
+
29
+ # Error when a option is wrong
30
+ OptionError = Class.new(CommandLineError)
31
+ end
@@ -0,0 +1,15 @@
1
+ require 'clin'
2
+
3
+ class Clin::GeneralOption < Clin::CommandOptionsMixin
4
+
5
+ def initialize(config = {})
6
+
7
+ end
8
+
9
+ # It get the params the general options needs and do whatever the option is suppose to do with it.
10
+ # Method called in the initialize of the command. This allow general options to be extracted when parsing a command line
11
+ # as well as calling the command directly in the code
12
+ # @param _params [Hash] Params got in the command
13
+ def execute(_params)
14
+ end
15
+ end
@@ -0,0 +1,102 @@
1
+ require 'clin'
2
+
3
+ # Option container.
4
+ class Clin::Option
5
+ attr_accessor :name, :description, :optional_argument, :block
6
+ attr_reader :short, :long, :argument
7
+
8
+ def initialize(name, description, short: nil, long: nil, argument: nil, optional_argument: false, &block)
9
+ @name = name
10
+ @description = description
11
+ @short = short
12
+ @long = long
13
+ @optional_argument = optional_argument
14
+ @argument = argument
15
+ @block = block
16
+ end
17
+
18
+ # Register the option to the Option Parser
19
+ # @param opts [OptionParser]
20
+ # @param out [Hash] Out options mapping
21
+ def register(opts, out)
22
+ if @block.nil?
23
+ opts.on(*option_parser_arguments) do |value|
24
+ on(value, out)
25
+ end
26
+ else
27
+ opts.on(*option_parser_arguments) do |value|
28
+ block.call(opts, out, value)
29
+ end
30
+ end
31
+ end
32
+
33
+ def default_short
34
+ "-#{name[0].downcase}"
35
+ end
36
+
37
+ def default_long
38
+ "--#{name.downcase}"
39
+ end
40
+
41
+ def default_argument
42
+ name.to_s.upcase
43
+ end
44
+
45
+ # Get the short option
46
+ # If @short is nil it will use #default_short
47
+ # If @short is false it will return nil
48
+ # @return [String]
49
+ def short
50
+ return nil if @short === false
51
+ @short ||= default_short
52
+ end
53
+
54
+ # Get the long option
55
+ # If @long is nil it will use #default_long
56
+ # If @long is false it will return nil
57
+ # @return [String]
58
+ def long
59
+ return nil if @long === false
60
+ @long ||= default_long
61
+ end
62
+
63
+ # Get the argument option
64
+ # If @argument is nil it will use #default_argument
65
+ # If @argument is false it will return nil
66
+ # @return [String]
67
+ def argument
68
+ return nil if @argument === false
69
+ @argument ||= default_argument
70
+ end
71
+
72
+ def option_parser_arguments
73
+ args = [short, long_argument, description]
74
+ args.compact
75
+ end
76
+
77
+ def on(value, out)
78
+ out[@name] = value
79
+ end
80
+
81
+ def ==(other)
82
+ return false unless other.is_a? Clin::Option
83
+ @name == other.name &&
84
+ @description == other.description &&
85
+ short == other.short &&
86
+ long == other.long &&
87
+ argument == other.argument &&
88
+ @optional_argument == other.optional_argument &&
89
+ @block == other.block
90
+ end
91
+
92
+ protected
93
+ def long_argument
94
+ return nil unless long
95
+ out = long
96
+ if argument
97
+ arg = @optional_argument ? "[#{argument}]" : argument
98
+ out += " #{arg}"
99
+ end
100
+ out
101
+ end
102
+ end
@@ -0,0 +1,4 @@
1
+ # Clin version
2
+ module Clin
3
+ VERSION = '0.1.0'
4
+ end
data/lib/clin.rb ADDED
@@ -0,0 +1,29 @@
1
+ require 'active_support'
2
+ require 'active_support/core_ext'
3
+ require 'optparse'
4
+ require 'clin/version'
5
+
6
+ # Clin Global module. All classes and clin modules should be inside this module
7
+ module Clin
8
+ def self.default_exe_name
9
+ 'command'
10
+ end
11
+
12
+ # Global exe_name
13
+ # If this is not override it will be 'command'
14
+ def self.exe_name
15
+ @exe_name ||= Clin.default_exe_name
16
+ end
17
+
18
+ # Set the global exe name
19
+ def self.exe_name=(value)
20
+ @exe_name=value
21
+ end
22
+ end
23
+
24
+ require 'clin/command'
25
+ require 'clin/command_options_mixin'
26
+ require 'clin/general_option'
27
+ require 'clin/command_dispatcher'
28
+ require 'clin/common/help_options'
29
+ require 'clin/errors'
@@ -0,0 +1,117 @@
1
+ require 'spec_helper'
2
+ require 'clin/argument'
3
+
4
+ RSpec.describe Clin::Argument do
5
+
6
+ describe '#check_optional' do
7
+ subject { Clin::Argument.new('') }
8
+ it { expect(subject.check_optional('not_optional')).to eq('not_optional') }
9
+ it { expect(subject.check_optional('<not_optional>')).to eq('<not_optional>') }
10
+ it { expect(subject.check_optional('[optional]')).to eq('optional') }
11
+ it { expect(subject.check_optional('[<optional>]')).to eq('<optional>') }
12
+ it { expect(subject.check_optional('[<optional>...]')).to eq('<optional>...') }
13
+ it { expect(subject.check_optional('[optional...]')).to eq('optional...') }
14
+ it { expect { subject.check_optional('[optional') }.to raise_error(Clin::Error) }
15
+ it { expect { subject.check_optional('[optional]...') }.to raise_error(Clin::Error) }
16
+
17
+ it 'expect optional to set instance variable' do
18
+ subject.check_optional('[optional]')
19
+ expect(subject.optional).to be true
20
+ end
21
+
22
+ it 'expect not optional to keep instance variable' do
23
+ subject.check_optional('not_optional')
24
+ expect(subject.optional).to be false
25
+ end
26
+ end
27
+
28
+ describe '#check_multiple' do
29
+ subject { Clin::Argument.new('') }
30
+ it { expect(subject.check_multiple('multiple...')).to eq('multiple') }
31
+ it { expect(subject.check_multiple('<multiple>...')).to eq('<multiple>') }
32
+ it { expect(subject.check_multiple('not_multiple')).to eq('not_multiple') }
33
+ it { expect(subject.check_multiple('<not_multiple>')).to eq('<not_multiple>') }
34
+
35
+ it 'expect to set instance variable' do
36
+ subject.check_multiple('multiple...')
37
+ expect(subject.multiple).to be true
38
+ end
39
+
40
+ it 'expect not to set instance variable' do
41
+ subject.check_multiple('not_multiple')
42
+ expect(subject.multiple).to be false
43
+ end
44
+ end
45
+
46
+ describe '#check_variable' do
47
+ subject { Clin::Argument.new('') }
48
+ it { expect(subject.check_variable('not_variable')).to eq('not_variable') }
49
+ it { expect(subject.check_variable('<variable>')).to eq('variable') }
50
+
51
+ it { expect { subject.check_variable('<variable') }.to raise_error(Clin::Error) }
52
+ it { expect { subject.check_variable('<variable...') }.to raise_error(Clin::Error) }
53
+ it 'expect optional to set instance variable' do
54
+ subject.check_variable('<variable>')
55
+ expect(subject.variable).to be true
56
+ end
57
+
58
+ it 'expect not optional to keep instance variable' do
59
+ subject.check_variable('not_variable')
60
+ expect(subject.variable).to be false
61
+ end
62
+ end
63
+
64
+ describe '#initialize' do
65
+ subject { Clin::Argument.new('[<optional_var_mul>...]') }
66
+ it { expect(subject.name).to eq('optional_var_mul') }
67
+ it { expect(subject.optional).to be true }
68
+ it { expect(subject.multiple).to be true }
69
+ it { expect(subject.variable).to be true }
70
+ end
71
+
72
+ describe 'parse' do
73
+ context 'when argument is optional' do
74
+ subject { Clin::Argument.new('[<optional>]') }
75
+
76
+ it 'get the argument' do
77
+ value, rem = subject.parse(%w(value1 value2))
78
+ expect(value).to eq('value1')
79
+ expect(rem).to eq(['value2'])
80
+ end
81
+
82
+ it 'work when there is no argument' do
83
+ value, rem = subject.parse([])
84
+ expect(value).to be nil
85
+ expect(rem).to eq([])
86
+ end
87
+ end
88
+
89
+ context 'when multiple arguments are allowed' do
90
+ subject { Clin::Argument.new('<multiple>...') }
91
+
92
+ it 'get the argument' do
93
+ value, rem = subject.parse(%w(value1 value2))
94
+ expect(value).to eq(%w(value1 value2))
95
+ expect(rem).to eq([])
96
+ end
97
+
98
+ it { expect { subject.parse([]).to raise_error(Clin::CommandLineError) } }
99
+ end
100
+
101
+ context 'when argument must match exactly' do
102
+ subject { Clin::Argument.new('argument') }
103
+
104
+ it 'get the argument' do
105
+ value, rem = subject.parse(['argument'])
106
+ expect(value).to eq('argument')
107
+ expect(rem).to eq([])
108
+ end
109
+ it { expect { subject.parse(['other_value']).to raise_error(Clin::CommandLineError) } }
110
+ end
111
+
112
+ context 'when multiple argument must match exactly' do
113
+ subject { Clin::Argument.new('argument...') }
114
+ it { expect { subject.parse(%w(argument other_value)).to raise_error(Clin::CommandLineError) } }
115
+ end
116
+ end
117
+ end