drudge 0.4.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.
- 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
|