drudge 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +59 -0
- data/LICENSE.txt +22 -0
- data/README.md +107 -0
- data/Rakefile +27 -0
- data/drudge.gemspec +35 -0
- data/features/optional-arguments.feature +64 -0
- data/features/simple-commands.feature +185 -0
- data/features/step_definitions/scripts_steps.rb +19 -0
- data/features/support/env.rb +5 -0
- data/features/variable-length-argument-lists.feature +111 -0
- data/lib/drudge.rb +8 -0
- data/lib/drudge/class_dsl.rb +106 -0
- data/lib/drudge/command.rb +100 -0
- data/lib/drudge/dispatch.rb +41 -0
- data/lib/drudge/errors.rb +30 -0
- data/lib/drudge/ext.rb +17 -0
- data/lib/drudge/kit.rb +45 -0
- data/lib/drudge/parsers.rb +91 -0
- data/lib/drudge/parsers/parse_results.rb +254 -0
- data/lib/drudge/parsers/primitives.rb +278 -0
- data/lib/drudge/parsers/tokenizer.rb +70 -0
- data/lib/drudge/version.rb +3 -0
- data/spec/drudge/class_dsl_spec.rb +125 -0
- data/spec/drudge/command_spec.rb +81 -0
- data/spec/drudge/kit_spec.rb +50 -0
- data/spec/drudge/parsers/parse_results_spec.rb +47 -0
- data/spec/drudge/parsers/primitives_spec.rb +262 -0
- data/spec/drudge/parsers/tokenizer_spec.rb +71 -0
- data/spec/drudge/parsers_spec.rb +149 -0
- data/spec/spec_helper.rb +2 -0
- data/spec/support/capture.rb +16 -0
- data/spec/support/fixtures.rb +13 -0
- data/spec/support/parser_matchers.rb +42 -0
- metadata +219 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
|
3
|
+
Given(/^the file "(.+?)" is executable$/) do |file_name|
|
4
|
+
in_current_dir do
|
5
|
+
FileUtils.chmod "a+x", file_name
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
Given(/^a Ruby script called "(.+?)" with:$/) do |script_name, contents|
|
10
|
+
steps %Q{
|
11
|
+
Given a file named "#{script_name}" with:
|
12
|
+
"""
|
13
|
+
#!/usr/bin/env ruby
|
14
|
+
|
15
|
+
#{contents}
|
16
|
+
"""
|
17
|
+
And the file "#{script_name}" is executable
|
18
|
+
}
|
19
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
Feature: Splatt Arguments
|
2
|
+
Ruby 2.0 supports splatt (*args) arguments.
|
3
|
+
I want a close mapping between splatt arguements and the command line.
|
4
|
+
|
5
|
+
Scenario Outline: Splatt arguments at the end of the command
|
6
|
+
Given a Ruby script called "cli" with:
|
7
|
+
"""
|
8
|
+
require 'drudge'
|
9
|
+
|
10
|
+
class Cli < Drudge
|
11
|
+
|
12
|
+
desc "Greets people"
|
13
|
+
def greet(from, *messages)
|
14
|
+
puts "#{from} says: #{messages.join(', ')}"
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
Cli.dispatch
|
20
|
+
"""
|
21
|
+
When I run `<command>`
|
22
|
+
Then the output should contain "<output>"
|
23
|
+
|
24
|
+
Examples:
|
25
|
+
| command | output |
|
26
|
+
| cli greet | error: expected a value for <from>: |
|
27
|
+
| cli greet Santa Hi | Santa says: Hi |
|
28
|
+
| cli greet Santa Hi Aloha | Santa says: Hi, Aloha |
|
29
|
+
| cli greet Santa Hi Aloha 'Good Morning' | Santa says: Hi, Aloha, Good Morning |
|
30
|
+
|
31
|
+
|
32
|
+
Scenario Outline: Splatt arguments in the middle of the command
|
33
|
+
Given a Ruby script called "cli" with:
|
34
|
+
"""
|
35
|
+
require 'drudge'
|
36
|
+
|
37
|
+
class Cli < Drudge
|
38
|
+
|
39
|
+
desc "Greets people"
|
40
|
+
def greet(from, *messages, to)
|
41
|
+
puts "#{from} says: #{messages.join(', ')} to #{to}"
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
Cli.dispatch
|
46
|
+
"""
|
47
|
+
When I run `<command>`
|
48
|
+
Then the output should contain "<output>"
|
49
|
+
|
50
|
+
Examples:
|
51
|
+
| command | output |
|
52
|
+
| cli greet | error: expected a value for <from> |
|
53
|
+
| cli greet Santa | error: expected a value for <to> |
|
54
|
+
| cli greet Santa Joe | Santa says: to Joe |
|
55
|
+
| cli greet Santa Hi Joe | Santa says: Hi to Joe |
|
56
|
+
| cli greet Santa Hi Aloha Joe | Santa says: Hi, Aloha to Joe |
|
57
|
+
|
58
|
+
Scenario Outline: Splatt arguments at the beginning of the ocmmand
|
59
|
+
Given a Ruby script called "cli" with:
|
60
|
+
"""
|
61
|
+
require 'drudge'
|
62
|
+
|
63
|
+
class Cli < Drudge
|
64
|
+
|
65
|
+
desc "Greets people"
|
66
|
+
def greet(*greeters, message, to)
|
67
|
+
puts "#{greeters.join(', ')} all say: #{message} to #{to}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
Cli.dispatch
|
72
|
+
"""
|
73
|
+
When I run `<command>`
|
74
|
+
Then the output should contain "<output>"
|
75
|
+
|
76
|
+
Examples:
|
77
|
+
| command | output |
|
78
|
+
| cli greet | error: expected a value for <message> |
|
79
|
+
| cli greet Hi | error: expected a value for <to> |
|
80
|
+
| cli greet Hi Joe | all say: Hi to Joe |
|
81
|
+
| cli greet Santa Hi Joe | Santa all say: Hi to Joe |
|
82
|
+
| cli greet Santa Spiderman Hi Joe | Santa, Spiderman all say: Hi to Joe |
|
83
|
+
|
84
|
+
|
85
|
+
Scenario Outline: Splatt arguments combined with optional arguments
|
86
|
+
Given a Ruby script called "cli" with:
|
87
|
+
"""
|
88
|
+
require 'drudge'
|
89
|
+
|
90
|
+
class Cli < Drudge
|
91
|
+
|
92
|
+
desc "Greets people"
|
93
|
+
def greet(from, first_message = 'Hi', *messages, to)
|
94
|
+
puts "#{from} says first #{first_message}, then #{messages.join(', ')} to #{to}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
Cli.dispatch
|
99
|
+
"""
|
100
|
+
When I run `<command>`
|
101
|
+
Then the output should contain "<output>"
|
102
|
+
|
103
|
+
Examples:
|
104
|
+
| command | output |
|
105
|
+
| cli greet | error: expected a value for <from> |
|
106
|
+
| cli greet Santa | error: expected a value for <to> |
|
107
|
+
| cli greet Santa Joe | Santa says first Hi, then to Joe |
|
108
|
+
| cli greet Santa Hello Joe | Santa says first Hello, then to Joe |
|
109
|
+
| cli greet Santa Hello Aloha Joe | Santa says first Hello, then Aloha to Joe |
|
110
|
+
| cli greet Santa Hello Aloha 'Good Morning' Joe | Santa says first Hello, then Aloha, Good Morning to Joe |
|
111
|
+
|
data/lib/drudge.rb
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
require 'drudge/kit'
|
2
|
+
require 'drudge/command'
|
3
|
+
require 'drudge/errors'
|
4
|
+
|
5
|
+
class Drudge
|
6
|
+
|
7
|
+
# A DSL that allows writing of a command line
|
8
|
+
# tool (kit) as a class
|
9
|
+
module ClassDSL
|
10
|
+
|
11
|
+
|
12
|
+
def self.included(cls)
|
13
|
+
cls.singleton_class.send :include, ClassMethods
|
14
|
+
end
|
15
|
+
|
16
|
+
# converts this into a (command) kit,
|
17
|
+
def to_kit(name = $0)
|
18
|
+
Kit.new name, build_commands(self.class.__commands)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def build_commands(commands)
|
24
|
+
commands.map do |c|
|
25
|
+
Command.new(c[:name], c[:params],
|
26
|
+
-> (*args) { self.send c[:name], *args },
|
27
|
+
**c[:meta])
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
module ClassMethods
|
32
|
+
|
33
|
+
# When found before a method definition, it marks the
|
34
|
+
# Provides a short description for the next command
|
35
|
+
def desc(description)
|
36
|
+
@__command_meta ||= {}
|
37
|
+
@__command_meta[:desc] = description
|
38
|
+
end
|
39
|
+
|
40
|
+
def method_added(m)
|
41
|
+
if @__command_meta and @__command_meta[:desc]
|
42
|
+
meth = instance_method(m)
|
43
|
+
|
44
|
+
@__commands ||= []
|
45
|
+
@__commands << { name: meth.name,
|
46
|
+
params: parse_command_parameters(meth.parameters),
|
47
|
+
meta: { desc: @__command_meta[:desc] } }
|
48
|
+
|
49
|
+
@__command_meta = {}
|
50
|
+
end
|
51
|
+
|
52
|
+
super
|
53
|
+
end
|
54
|
+
|
55
|
+
def __commands
|
56
|
+
merged_commands((@__commands || []), (superclass.__commands rescue []))
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def merged_commands(newer, older)
|
62
|
+
# review: this method seems too complex. find a simpler implemetnation
|
63
|
+
case
|
64
|
+
when older.empty? then newer
|
65
|
+
when newer.empty? then older
|
66
|
+
else
|
67
|
+
deep_merger = -> (_, old, new) do
|
68
|
+
if Hash === old
|
69
|
+
old.merge(new, &deep_merger)
|
70
|
+
else
|
71
|
+
new
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
non_overriden = older.reject { |c| newer.any? { |cc| cc[:name] == c[:name] } }
|
76
|
+
newer_and_overriden = newer.map do |cmd|
|
77
|
+
overriden = older.find { |c| c[:name] == cmd[:name] }
|
78
|
+
|
79
|
+
if overriden
|
80
|
+
overriden.merge(cmd, &deep_merger)
|
81
|
+
else
|
82
|
+
cmd
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
non_overriden + newer_and_overriden
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def parse_command_parameters(method_parameters)
|
94
|
+
method_parameters.map do |kind, name|
|
95
|
+
case kind
|
96
|
+
when :req then Param.any(name)
|
97
|
+
when :opt then Param.any(name, optional: true)
|
98
|
+
when :rest then Param.any(name, splatt: true)
|
99
|
+
else raise "Unsupported parameter type"
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'drudge/errors'
|
2
|
+
require 'drudge/parsers'
|
3
|
+
|
4
|
+
class Drudge
|
5
|
+
|
6
|
+
# Describes a command and helps executing it
|
7
|
+
#
|
8
|
+
# The command is defined by a name and a list of arguments (see class Param).
|
9
|
+
# The body of the command is a lambda that accepts exactly the arguments
|
10
|
+
|
11
|
+
class Command
|
12
|
+
include Parsers
|
13
|
+
|
14
|
+
# The name of the command
|
15
|
+
attr_reader :name
|
16
|
+
|
17
|
+
# The list of parameters
|
18
|
+
attr_reader :params
|
19
|
+
|
20
|
+
# The command's body
|
21
|
+
attr_reader :body
|
22
|
+
|
23
|
+
# An optional short desicription of the command
|
24
|
+
attr_reader :desc
|
25
|
+
|
26
|
+
# Initializes a new command
|
27
|
+
def initialize(name, params = [], body, desc: "")
|
28
|
+
@name = name.to_sym
|
29
|
+
@params = params
|
30
|
+
@body = body
|
31
|
+
|
32
|
+
@desc = desc
|
33
|
+
end
|
34
|
+
|
35
|
+
# runs the command
|
36
|
+
def dispatch(*args)
|
37
|
+
@body.call(*args)
|
38
|
+
rescue ArgumentError => e
|
39
|
+
raise CommandArgumentError.new(name), e.message
|
40
|
+
end
|
41
|
+
|
42
|
+
# creates an argument parser for the command
|
43
|
+
def argument_parser
|
44
|
+
end_of_args = eos("extra command line arguments provided")
|
45
|
+
|
46
|
+
parser = params.reverse.reduce(end_of_args) do |rest, param|
|
47
|
+
p = param.argument_parser
|
48
|
+
|
49
|
+
case
|
50
|
+
when param.optional? then ((p > rest) | rest).describe("[#{p}] #{rest}")
|
51
|
+
when param.splatt? then (p.repeats(till: rest) > rest).describe("[#{p} ...] #{rest}")
|
52
|
+
else p > rest
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
# Represents a command parameter
|
60
|
+
class Param
|
61
|
+
include Parsers
|
62
|
+
|
63
|
+
TYPES = %i[any string]
|
64
|
+
|
65
|
+
# the argument's name
|
66
|
+
attr_reader :name
|
67
|
+
|
68
|
+
# the argument's type
|
69
|
+
attr_reader :type
|
70
|
+
|
71
|
+
attr_reader :optional
|
72
|
+
alias_method :optional?, :optional
|
73
|
+
|
74
|
+
attr_reader :splatt
|
75
|
+
alias_method :splatt?, :splatt
|
76
|
+
|
77
|
+
def initialize(name, type, optional: false, splatt: false)
|
78
|
+
@name = name.to_sym
|
79
|
+
@type = type.to_sym
|
80
|
+
@optional = !! optional
|
81
|
+
@splatt = !! splatt
|
82
|
+
end
|
83
|
+
|
84
|
+
# returns a parser that is able to parse arguments
|
85
|
+
# fitting this parameter
|
86
|
+
def argument_parser
|
87
|
+
arg(name, value(/.+/))
|
88
|
+
end
|
89
|
+
|
90
|
+
# factory methods for every type of parameter
|
91
|
+
class << self
|
92
|
+
TYPES.each do |type|
|
93
|
+
define_method type do |name, *rest|
|
94
|
+
new(name, type, *rest)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'drudge/ext'
|
2
|
+
require 'drudge/parsers/tokenizer'
|
3
|
+
|
4
|
+
class Drudge
|
5
|
+
|
6
|
+
module Dispatch
|
7
|
+
|
8
|
+
def self.included(cls)
|
9
|
+
cls.singleton_class.send :include, ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
Tokenizer = Drudge::Parsers::Tokenizer
|
14
|
+
|
15
|
+
# Runs the CLI with the specified arguments
|
16
|
+
def dispatch(command_name = File.basename($0), args = ARGV)
|
17
|
+
cli_kit = self.new.to_kit(command_name)
|
18
|
+
complete_args = command_name, *args
|
19
|
+
|
20
|
+
argument_parser = cli_kit.argument_parser
|
21
|
+
_, *command_arguments = argument_parser.parse!(complete_args)[:args]
|
22
|
+
|
23
|
+
cli_kit.dispatch(*command_arguments)
|
24
|
+
|
25
|
+
rescue CliError => e
|
26
|
+
puts "#{e.command}: #{e.message}"
|
27
|
+
rescue ParseError => pe
|
28
|
+
$stderr.puts <<-EOS.undent
|
29
|
+
error: #{pe.message}:
|
30
|
+
|
31
|
+
#{Tokenizer.untokenize(pe.input)}
|
32
|
+
#{Tokenizer.underline_token(pe.input,
|
33
|
+
pe.remaining_input.first)}
|
34
|
+
EOS
|
35
|
+
|
36
|
+
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class Drudge
|
2
|
+
# a c
|
3
|
+
class CliError < StandardError
|
4
|
+
# the command that produced the error
|
5
|
+
attr_reader :command
|
6
|
+
|
7
|
+
def initialize(command)
|
8
|
+
@command = command.to_s
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# Identifies a parse error
|
13
|
+
class ParseError < StandardError
|
14
|
+
|
15
|
+
attr_reader :remaining_input
|
16
|
+
attr_reader :input
|
17
|
+
|
18
|
+
def initialize(input, remaining_input)
|
19
|
+
@input, @remaining_input = input, remaining_input
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
# Identifies a problem with the arguments
|
25
|
+
class CommandArgumentError < CliError; end
|
26
|
+
|
27
|
+
# The user asked to execute a command that doesn't exist
|
28
|
+
class UnknownCommandError < CliError; end
|
29
|
+
end
|
30
|
+
|
data/lib/drudge/ext.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# Extensions to standard classes
|
2
|
+
|
3
|
+
class String
|
4
|
+
|
5
|
+
# undents the string by removeing as much leading space
|
6
|
+
# as the first line has.
|
7
|
+
#
|
8
|
+
# Useful for cases like:
|
9
|
+
# puts <<-EOS.undent
|
10
|
+
# bla bla bla
|
11
|
+
# bla
|
12
|
+
# EOS
|
13
|
+
#
|
14
|
+
def undent
|
15
|
+
gsub(/^.{#{slice(/^ +/).length}}/, '')
|
16
|
+
end unless method_defined?(:undent)
|
17
|
+
end
|