Ruby standard library provides a good solution for parsing command line options passed to the script or application. It’s called OptionParser. It’s powerful and very easy to use. The code you have to write is easy to read and modify. It also supports different kind of options: short and long flags, required or optional arguments, also different types of arguments including dates, lists and so on.

The only thing it cannot do, is parsing commands - that is a word without any flag before it. For example, it will not pick do_something from this command line:

$ my_program do_something -a attr1 --verbose --log=/path/to/file

Soon we will learn how to improve OptionParser, so that it is able to do that. Of course there are many other different libraries that do command line argument parsing, including:

They were created for different purposes, so not all of them support commands. Definitely you could pick one of them to do the job, but I personally do not like to be dependant on 3rd party libraries, that are not included in standard ruby installation. In this case also because as we will see, it is very easy to add needed functionality to the OptionParser.

We are creating a parser, that will allow having different commands in cli, these commands can have their own attributes and there are also some global attributes, that can be provided with any command.

$ my-program [global-options] <command> [command-options]

We want to use the new option parser similar to existing one. At the same time we should be able to have separate command options for every command, like that:

1
2
3
4
5
6
7
8
9
10
11
12
13
@global_options = OptionParser::OptionMap.new
@command_options = OptionParser::OptionMap.new
parser = CommandParser.new 'Usage: my_program [global-options] <command> [command-options]' do |opts|
	opts.separator "Global options (accepted by all commands):"
	opts.on '-h', '--help', 'Show help' do
		@global_options[:help] = true
	end
	opts.command 'my-command', 'It surely does something' do |cmd|
		cmd.on '-a', '--attribute INT', Integer, 'Some integer attribute for the command.' do |opt|
			@command_options[:attribute] = opt
		end
	end
end

We can achieve that by creating our OptionParserWithCommands that inherits from OptionParser and giving it a new method called command, which just should create a OptionParser::Switch and add it to an instance variable commands.

1
2
3
4
5
6
7
8
9
10
11
class OptionParserWithCommands < OptionParser
	def command( key, desc, &block )
		sw = OptionParser::Switch::NoArgument.new( key, nil, [key], nil, nil, [desc],
			Proc.new{ OptionParser.new( &block ) } ), [], [key]
		commands[key.to_s] = sw[0]
	end

	def commands
		@commands ||= {}
	end
end

Still the most important part is missing: parsing. We have to rewrite the parse! method.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def parse!( argv=default_argv )
	# Find, if there is a command in argv
	@command_name = argv.detect{|c| commands.has_key? c }
	if command_name
		#create a temporary parser with option definitions from both - global and particular command -
		# and parse all the options
		OptionParser.new do |parser|
			parser.instance_variable_set(:@stack,
				commands[command_name.to_s].block.call.instance_variable_get(:@stack) + @stack)
		end.parse! argv
	else
		# we do not have any (right) command provided, fallback to just parsing the options.
		super( argv )
	end
end

Perfect! There is just 1 thing missing - help message. Remember, OptionParser generates help automatically based on options’ definitions. We want our extended parser to do that too. But providing all the options for all the commands may be too much. So let’s do it this way. The global options are always listed in help. And then, if a command is not provided, we list all the available commands with descriptions (without their personal options) and, if a command is provided, we list all the personal options for this command together with command’s description.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
attr_reader :command_name

private
def summarize(to = [], width = @summary_width, max = width - 1, indent = @summary_indent, &blk)
	super(to, width, max, indent, &blk) # display global options
	# command is provided
	if command_name and commands.has_key?( command_name )
		to << "Command:\n"
		# display command description
		commands[command_name].summarize( {}, {}, width, max, indent ) do |l|
			to << (l.index($/, -1) ? l : l + $/)
		end
		to << "Command options:\n"
		# display command options
		commands[command_name].block.call.summarize( to, width, max, indent, &blk )
	else
		to << "Commands:\n"
		# display available commands
		commands.each do |name, command|
			command.summarize( {}, {}, width, max, indent ) do |l|
				to << (l.index($/, -1) ? l : l + $/)
			end
		end
	end
	to
end

I have it implemented in a ruby gem jenkins2. There are also some tests, which show the supported options and commands.

So now if you would like to parse commands with OptionParser, all you need is just copy the code from this file to your project.

This approach does not support nested commands, but it should be faily easy to add it, by replacing OptionParser with OptionParserWithCommands inside parse! method and may be adding some checks.

comments powered by Disqus