clin 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.
@@ -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