JACOP 1.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cc06d2415d5d58cd68dae44da601be5e14a7bff1afed2fb29dd3720506ba704e
4
+ data.tar.gz: 205de8de785654fe05959899cfc4be4f14ab0c6fedb851db7fc5a6dcd1dac5d8
5
+ SHA512:
6
+ metadata.gz: 15370f5420a1eb3edc5b4ff7e7fadf8b245683088cad3f6cd936f3a394eaa45f5945c2543d3cde2b6f0d4c7f1ebd95666b19da27b0a19f269f2b1b73277ac4b7
7
+ data.tar.gz: 1b5fc247e1be74fe3e3d37854c7d3af836011fe2bfabb87c810be8f3d969fcd9ee82f328c0cf504cedb81188f5254349934cb1e678678d66a99a251c97ab4107
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JACOP
4
+ class Exception < ::StandardError; end
5
+ class DefinitionError < Exception; end
6
+ class ParserError < Exception; end
7
+ end
data/lib/jacop/node.rb ADDED
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+ require_relative 'option'
5
+ require_relative 'exception'
6
+
7
+ module JACOP
8
+ class Node
9
+ attr_reader :options
10
+ attr_reader :name
11
+ attr_reader :nodes
12
+ attr_reader :hidden
13
+
14
+ def initialize(name:, parent: nil, params:)
15
+ @name = name
16
+ @parent = parent
17
+ @banner = params[:banner]
18
+ @notes = params[:notes]
19
+ @version = params[:version]
20
+ @desc = params[:desc]
21
+ @call = params[:call]
22
+ raise DefinitionError, "#{@call} is not call-able" if @call and !@call.respond_to?(:call)
23
+
24
+ @hidden = params[:hidden]
25
+ @options = []
26
+ @nodes = []
27
+ params[:options]&.each {|h| @options << Option.new(command: self, option: h[:name], params: h) } || []
28
+ @nodes = params[:nodes]&.map { |h| Node.new(name: h[:name] , parent: self, params: h)} || []
29
+
30
+ # Default 'help' and 'version' options get added automatically
31
+ @options << Option.new(option: 'help', command: self, params: {short: 'h', desc: "Show help screen", type: :switch})
32
+ @options << Option.new(option: 'version', command: self, params: {short: 'v', desc: "Show version", type: :switch}) if @version
33
+ end
34
+
35
+ def parse(cmdline)
36
+ case cmdline
37
+ when String then
38
+ cmdline = cmdline.shellsplit
39
+ when NilClass then
40
+ cmdline = []
41
+ end
42
+
43
+ usage if cmdline.empty? and @call.nil?
44
+
45
+ begin
46
+ token = cmdline.shift
47
+ if token && !token.empty? && token !~ /^-{1,2}/ then
48
+ node = @nodes.find {|n| n.name == token}
49
+ raise ParserError, "Unknown command '#{token}'" unless node
50
+
51
+ node.parse(cmdline)
52
+ else
53
+ while token
54
+ is_short = token.match(/^-[[:alnum:]]/) != nil
55
+ t = token.sub(/^-{1,2}/, '')
56
+ opt = @options.find { |o| is_short ? o.short == t : o.option == t }
57
+ raise ParserError, "Unknown option '#{token}'" unless opt
58
+ opt.parse(cmdline, @options.select {|o| !o.value.nil?})
59
+ token = cmdline.shift
60
+ end
61
+
62
+ usage if @options.find {|o| o.option == 'help'}&.value
63
+ version if @options.find {|o| o.option == 'version'}&.value
64
+ @options.select {|o| o.required }.each {|o| o.validate_required }
65
+
66
+ execute if cmdline.empty?
67
+ end
68
+ rescue ParserError => e
69
+ usage(1, e.message)
70
+ end
71
+ end
72
+
73
+ def request_short(option, short)
74
+ noauto = !short.nil?
75
+ shorts = @options.map {|o| o.short}
76
+
77
+ c = option.downcase.scan(/[a-z]/).each
78
+ short = c.peek unless noauto
79
+ loop do
80
+ if shorts.member?(short) then
81
+ if noauto then
82
+ short = nil
83
+ break
84
+ else
85
+ begin
86
+ short = /[a-z]/.match?(short) ? short.upcase : c.next
87
+ rescue StopIteration
88
+ short = nil
89
+ break
90
+ end
91
+ end
92
+ else
93
+ break
94
+ end
95
+ end
96
+
97
+ return short
98
+ end
99
+
100
+ def usage_snippet(width)
101
+ " %-#{width}.#{width}s%s%s" % [@name + (@nodes.select {|n| !n.hidden}.empty? ? '' : '>'), " " * 8, @desc]
102
+ end
103
+
104
+ def to_s
105
+ "%s%s%s" % [@parent, @parent ? ' ' : nil, @name]
106
+ end
107
+
108
+ def banner
109
+ @banner || @parent.banner
110
+ end
111
+
112
+ private
113
+
114
+ def execute
115
+ return unless @call
116
+
117
+ options = @options.select {|o| !o.value.nil?}.map { |o| {o.option => o.value} }.reduce(&:merge) || {}
118
+
119
+ @call.call(options)
120
+ end
121
+
122
+ def version
123
+ puts "Version #{@version}"
124
+ exit(0)
125
+ end
126
+
127
+ def usage(exitcode = 0, misusage = nil)
128
+ puts misusage if misusage
129
+
130
+ if banner then
131
+ puts banner
132
+ puts
133
+ end
134
+
135
+ puts @desc if @desc and !@desc.empty?
136
+ nodes = @nodes.select {|n| !n.hidden }
137
+
138
+ usage_str = +'General usage: '
139
+ usage_str << File.basename($PROGRAM_NAME)
140
+ usage_str << "#{self}"
141
+ usage_str << ' <command>' unless nodes.empty?
142
+ usage_str << ' %s<options>%s' % (@options.any? {|o| o.required } ? [nil, nil] : ['[', ']']) unless @options.empty?
143
+
144
+ puts usage_str
145
+ puts
146
+
147
+ width = (nodes.map {|n| n.name.size + 1 } + @options.map {|o| o.usage_str.size}).max
148
+
149
+ if !nodes.empty?
150
+ puts 'Commands:'
151
+ nodes.each { |m| puts m.usage_snippet(width) }
152
+ puts
153
+ end
154
+
155
+ options = @options.select {|o| !o.hidden }
156
+ if !options.empty?
157
+ puts 'Options:'
158
+ options.each { |o| puts o.usage_snippet(width) }
159
+ puts
160
+ end
161
+
162
+ if @notes then
163
+ puts "Notes:"
164
+ @notes.each_line {|line| puts " #{line}"}
165
+ puts
166
+ end
167
+
168
+ exit(exitcode)
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'option_builder'
4
+ require_relative 'option'
5
+ require_relative 'exception'
6
+
7
+ module JACOP
8
+ ##
9
+ # Internal class, not to be used directly.
10
+ # The methods of this class are the DSL statements for defining a command.
11
+ # They become available in a `configure` or `command` block after including the `JACOP` mixin.
12
+ class NodeBuilder
13
+ attr_reader :params # :nodoc:
14
+
15
+ def initialize(name=nil) # :nodoc:
16
+ @params = {
17
+ name: name,
18
+ nodes: [],
19
+ options: []
20
+ }
21
+ end
22
+
23
+ ##
24
+ # Define a new custom data-type for options.
25
+ # The data-type will be available globally, not just in the blocks where it was defined.
26
+ def add_type(type:, klass:, &block)
27
+ raise DefinitionError, "add_type needs a block" unless block_given?
28
+
29
+ Option.add_type(type: type, klass: klass, &block)
30
+ end
31
+
32
+ ##
33
+ # Set the banner in the help/usage screen
34
+ def banner(banner)
35
+ @params[:banner] = banner
36
+ end
37
+
38
+ ##
39
+ # Set notes shown in the help/usage screen
40
+ def notes(notes)
41
+ @params[:notes] = notes
42
+ end
43
+
44
+ ##
45
+ # Set the version. If set, the `--version` option will be created automatically.
46
+ def version(version)
47
+ @params[:version] = version
48
+ end
49
+
50
+ ##
51
+ # Set the description for the command in the help/usage screen
52
+ def desc(desc)
53
+ @params[:desc] = desc
54
+ end
55
+ alias_method :description, :desc
56
+
57
+ ##
58
+ # Define which method or block should be called when this command is invoked.
59
+ # The given argument will usually be either a `Proc` or `Method` object, or a block.
60
+ # In any case, it must respond to the `#call` method. The `callable` will receive a
61
+ # single argument containing a hash of all supplied command-line arguments.
62
+ def call(callable = nil, &block)
63
+ @params[:call] = block_given? ? block : callable
64
+ end
65
+
66
+ ##
67
+ # Set the command to be hidden. It will not be shown in the help/usage screen.
68
+ def hidden(hidden = true)
69
+ @params[:hidden] = hidden
70
+ end
71
+
72
+ ##
73
+ # Define a new (sub-) command
74
+ def command(name, &block)
75
+ builder = NodeBuilder.new(name)
76
+ builder.instance_eval(&block)
77
+ @params[:nodes] << builder.params
78
+ end
79
+
80
+ ##
81
+ # Define a new option for this command
82
+ def option(name, &block)
83
+ builder = OptionBuilder.new(name)
84
+ builder.instance_eval(&block)
85
+ @params[:options] << builder.params
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative 'exception'
5
+
6
+ module JACOP
7
+ class Option
8
+
9
+ @@valid_types = {
10
+ switch: { klass: [NilClass, TrueClass, FalseClass], converter: Proc.new {|val| !!val } },
11
+ string: { klass: String, converter: Proc.new {|val| val.to_s } },
12
+ int: { klass: Integer, converter: Proc.new {|val| val.to_i } },
13
+ percent: { klass: Float, converter: Proc.new {|val| val.to_f / 100 } },
14
+ date: { klass: Date, converter: Proc.new {|val| Date.parse(val) } }
15
+ }
16
+
17
+ def self.valid_types; @@valid_types; end
18
+
19
+ def self.add_type(type:, klass:, &block)
20
+ @@valid_types[type] = { klass: klass, converter: block }
21
+ end
22
+
23
+ attr_reader :option
24
+ attr_reader :value
25
+ attr_reader :short
26
+ attr_reader :required
27
+ attr_reader :hidden
28
+
29
+ def initialize(command:, option:, params:)
30
+ @option = option
31
+ @desc = params[:desc]
32
+ @short = command.request_short(@option, params[:short])
33
+ @required = params[:required]
34
+ @conflicts = @required ? [] : params[:conflicts] || []
35
+ @hidden = params[:hidden]
36
+ @type = validate_type(params[:type])
37
+ @multi = params[:multi]
38
+ @default = validate_value(params[:default], multi: @multi, allow_nil: true)
39
+ @value = @default
40
+
41
+ validate
42
+ end
43
+
44
+ def to_s
45
+ @option
46
+ end
47
+
48
+ def usage_str
49
+ s = +"--#{@option}"
50
+ s << "|-#{@short}" if !(@short.nil? or short.empty?)
51
+ s << " <%s>%s" % [@type.upcase, @multi ? "[, ...]" : nil] if @type != :switch
52
+
53
+ return s
54
+ end
55
+
56
+ def usage_snippet(width)
57
+ " %-#{width}.#{width}s%s%s%s" % [usage_str,
58
+ " " * 8,
59
+ @desc,
60
+ @default.nil? ?
61
+ nil :
62
+ " (Default: #{@default})"
63
+ ]
64
+ end
65
+
66
+ def conflicts(other)
67
+ return false if self == other
68
+
69
+ @conflicts.member?(other.option)
70
+ end
71
+
72
+ def parse(cmdline, options)
73
+ if options.any? { |o| conflicts(o) || o.conflicts(self)} then
74
+ raise ParserError, "Option '#{option}' conflicts with another option"
75
+ end
76
+
77
+ val = []
78
+ while !cmdline.empty? && cmdline[0] !~ /^-{1,2}/ do
79
+ raise ParserError, "Option '#{option}' expects a single argument" unless val.empty? or @multi
80
+ val << (@multi ? cmdline.shift.split(/,/) : cmdline.shift)
81
+ val.flatten!
82
+ end
83
+
84
+ if @type == :switch then
85
+ @value = true
86
+ elsif @multi then
87
+ @value = val.map { |x| parse_value(x.strip) }
88
+ else
89
+ @value = parse_value(val.first)
90
+ end
91
+ end
92
+
93
+ def validate_required
94
+ raise ParserError, "Required option '#{@option}' is missing" if @required and !@value
95
+ end
96
+
97
+ private
98
+
99
+ def parse_value(value)
100
+ begin
101
+ value = @@valid_types.dig(@type, :converter).call(value)
102
+ rescue => e
103
+ raise ParserError, "Error while coercing value '#{value}' into type '#{@type}': #{e.message}"
104
+ end
105
+
106
+ return value
107
+ end
108
+
109
+ def validate_type(type)
110
+ if type then
111
+ t = type.to_sym
112
+ return t if @@valid_types.keys.member?(t)
113
+
114
+ raise DefinitionError, "Unknown type '#{t}'. Valid types are: #{@@valid_types.keys.join(', ')}"
115
+ end
116
+
117
+ return t
118
+ end
119
+
120
+ def validate
121
+ raise ParserError, "'short' options are expected to be single characters" if @short and @short.size > 1
122
+ raise DefinitionError, "Settings 'required' and 'conflicts' for option '#{@option} are mutually exclusive" if @required and !@conflicts.empty?
123
+
124
+ if @conflicts && !@conflicts.is_a?(Array) then
125
+ @conflicts = [@conflicts]
126
+ end
127
+ end
128
+
129
+ def validate_value(value, multi: @multi, allow_nil: false)
130
+ return if !value and allow_nil
131
+
132
+ if multi then
133
+ if value.is_a? Array then
134
+ value.each { |x| validate_value(x, multi: false) }
135
+ else
136
+ raise ParserError, "Array expected for option '#{@option}'"
137
+ end
138
+ else
139
+ type_mismatch = value.class != @@valid_types.dig(@type, :klass)
140
+ raise ParserError, "Value '#{value}' for option '#{@option}' has wrong type, expected '#{@type}'" if type_mismatch
141
+ end
142
+
143
+ return value
144
+ end
145
+
146
+ end
147
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JACOP
4
+ ##
5
+ # Internal class, not to be used directly.
6
+ # The methods of this class are the DSL statements for defining an option for a command.
7
+ # They become available in an `option` block after including the `JACOP` mixin.
8
+ class OptionBuilder
9
+ attr_reader :params # :nodoc:
10
+
11
+ def initialize(name) # :nodoc:
12
+ @params = {
13
+ name: name
14
+ }
15
+ end
16
+
17
+ ##
18
+ # Set a description for this option in the help/usage screen.
19
+ def desc(desc)
20
+ @params[:desc] = desc
21
+ end
22
+ alias_method :description, :desc
23
+
24
+ ##
25
+ # Set the alternative single-character option for this option. If not given, it will be auto-generated.
26
+ # Auto-generation of the short option can be disabled by setting this to an empty string.
27
+ def short(short)
28
+ @params[:short] = short
29
+ end
30
+
31
+ ##
32
+ # Define this option as being required.
33
+ # `required` and `conflicts` cannot both be used for the same option.
34
+ def required(required = true)
35
+ @params[:required] = required
36
+ end
37
+
38
+ ##
39
+ # Define other options with which this option is mutually exclusive.
40
+ def conflicts(conflicts)
41
+ @params[:conflicts] = conflicts
42
+ end
43
+
44
+ ##
45
+ # Set this option to be hidden. It will not be shown in the help/usage screen.
46
+ def hidden(hidden = true)
47
+ @params[:hidden] = hidden
48
+ end
49
+
50
+ ##
51
+ # Set the data-type of this option's argument. This is a required setting.
52
+ def type(type)
53
+ @params[:type] = type
54
+ end
55
+
56
+ ##
57
+ # Set a default value for this option's argument.
58
+ def default(default)
59
+ @params[:default] = default
60
+ end
61
+
62
+ ##
63
+ # Define that this option can take multiple arguments, separated by commas.
64
+ def multi(multi = true)
65
+ @params[:multi] = multi
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JACOP
4
+ ##
5
+ # Version of the `JACOP` gem
6
+ VERSION = '1.0.1'
7
+ end
data/lib/jacop.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './jacop/node_builder'
4
+ require_relative './jacop/node'
5
+ require_relative './jacop/exception'
6
+
7
+ ##
8
+ # JACOP is a DSL for building and parsing command-line commands and options.
9
+ # Include this mixin to make use of the DSL.
10
+ module JACOP
11
+
12
+ ##
13
+ # Configure the top-level command level
14
+ def configure(&block)
15
+ builder = NodeBuilder.new
16
+ builder.instance_eval(&block)
17
+ self.root_node = builder.params
18
+ end
19
+
20
+ ##
21
+ # Parse the provided command-line
22
+ def parse(cmdline)
23
+ raise DefinitionError, "#{self.class} has not been configured" if root_node.empty?
24
+
25
+ Node.new(name: root_node[:name], params: root_node).parse(cmdline)
26
+ end
27
+
28
+ private
29
+ def root_node
30
+ @root_node ||= {}
31
+ end
32
+
33
+ def root_node=(val)
34
+ @root_node = merge_nodes(root_node, val)
35
+ end
36
+
37
+ def merge_nodes(node1, node2)
38
+ node1.merge(node2) do |key, this_val, other_val|
39
+ if this_val.is_a?(Hash) && other_val.is_a?(Hash)
40
+ merge_nodes(this_val, other_val)
41
+ elsif this_val.is_a?(Array) && other_val.is_a?(Array)
42
+ this_val + other_val
43
+ else
44
+ this_val
45
+ end
46
+ end
47
+ end
48
+
49
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: JACOP
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Oliver Brakmann
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: JACOP is a DSL and command-line parser allowing for regular and Git-style
13
+ command syntax, featuring type-aware option parsing and automatic help screen generation.
14
+ email:
15
+ - oliver.brakmann@posteo.de
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/jacop.rb
21
+ - lib/jacop/exception.rb
22
+ - lib/jacop/node.rb
23
+ - lib/jacop/node_builder.rb
24
+ - lib/jacop/option.rb
25
+ - lib/jacop/option_builder.rb
26
+ - lib/jacop/version.rb
27
+ homepage: https://codeberg.org/obrakmann/jacop
28
+ licenses:
29
+ - EUPL-1.2
30
+ metadata: {}
31
+ rdoc_options: []
32
+ require_paths:
33
+ - lib
34
+ required_ruby_version: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.5.0
39
+ required_rubygems_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ requirements: []
45
+ rubygems_version: 3.6.7
46
+ specification_version: 4
47
+ summary: Just Another COmmand-line Parser
48
+ test_files: []