run_kit 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,135 @@
1
+ #
2
+ # One configured flag, including all switches that invoke it.
3
+ #
4
+
5
+ module RunKit
6
+ module Options
7
+ class Flag
8
+ # --foo or -f
9
+ SWITCH_RE = /\A-(\w|-\w[\w-]*)\z/
10
+ # --foo=bar
11
+ INLINE_RE = /\A-(\w|-\w[\w-]*)=(.*)\z/m
12
+ # --no-foo
13
+ NEGATE_RE = /\A--no-(\w[\w-]*)\z/
14
+
15
+ KINDS = %i[bool float int path str sym]
16
+
17
+ attr_reader :choices, :default, :help, :kind, :meta, :required, :switches
18
+
19
+ # ctor
20
+ def initialize(kind, opts, default: nil, required: false, choices: nil)
21
+ @choices, @kind, @required = choices, kind, required
22
+
23
+ # extract @help from last string
24
+ @switches = opts.dup
25
+ @help = if switch && !switch.start_with?("-")
26
+ switches.pop
27
+ end
28
+
29
+ # Extract meta from the final switch, if written as `--port <int>`.
30
+ @meta = build_meta
31
+
32
+ # default, with some special handling for bool
33
+ @default = default
34
+ if bool? && default.nil? && !required?
35
+ @default = false
36
+ end
37
+
38
+ validate
39
+ end
40
+
41
+ #
42
+ # parsing
43
+ #
44
+
45
+ # Parse a single cli param
46
+ def parse(switch, param)
47
+ if bool?
48
+ raise Error, "option '#{switch}=#{param}' does not take a value" if param
49
+ return true
50
+ end
51
+
52
+ raise Error, "option '#{switch}' requires a value" if !param
53
+ parsed = begin
54
+ case kind
55
+ when :float then Float(param)
56
+ when :int then Integer(param, 10)
57
+ when :path then Pathname.new(param)
58
+ when :str then param
59
+ when :sym then param.to_sym
60
+ end
61
+ rescue ArgumentError
62
+ raise Error, "invalid value '#{param}' for option '#{switch}'"
63
+ end
64
+
65
+ if choices && !choices.include?(parsed)
66
+ raise Error, "invalid value '#{parsed}' for option '#{switch}', must be one of #{choices.join(", ")}"
67
+ end
68
+ parsed
69
+ end
70
+
71
+ # one-liners
72
+ def bool? = kind == :bool
73
+ def key = @key ||= switch.sub(/^-+/, "").tr("-", "_").to_sym
74
+ def switch = switches.last
75
+ def takes_param? = !bool?
76
+ alias_method :required?, :required
77
+
78
+ protected
79
+
80
+ #
81
+ # validation
82
+ #
83
+
84
+ def validate
85
+ # switches
86
+ raise ArgumentError, "at least one switch is required" if switches.empty?
87
+ switches.each do
88
+ raise ArgumentError, "invalid switch: #{_1}" unless _1.is_a?(String)
89
+ raise ArgumentError, "invalid switch: #{_1}" unless _1.match?(SWITCH_RE)
90
+ end
91
+ raise ArgumentError, "duplicate switch" unless switches.uniq.length == switches.length
92
+
93
+ # params
94
+ raise ArgumentError, "invalid flag kind: #{kind}" unless KINDS.include?(kind)
95
+ raise ArgumentError, "boolean flags do not accept meta" if bool? && meta
96
+ raise ArgumentError, "required must be true or false" unless required == true || required == false
97
+ raise ArgumentError, "required flags cannot have defaults" if required && default != nil
98
+ raise ArgumentError, "invalid default #{default.inspect} for #{kind}" if default != nil && !allowed?(default)
99
+
100
+ # choices
101
+ if choices
102
+ raise ArgumentError, "choices must be an array" unless choices.is_a?(Array)
103
+ raise ArgumentError, "choices cannot be empty" if choices.empty?
104
+ choices.each do
105
+ raise ArgumentError, "invalid choice #{_1.inspect} for #{kind}" unless allowed?(_1)
106
+ end
107
+ end
108
+ end
109
+
110
+ #
111
+ # helpers
112
+ #
113
+
114
+ def build_meta
115
+ # Only the final spelling may carry an inferred `<meta>`.
116
+ if (m = /\A(\S+) <([^>]+)>\z/.match(switch))
117
+ switches[switches.length - 1] = m[1]
118
+ return m[2]
119
+ end
120
+ kind.to_s unless bool?
121
+ end
122
+
123
+ def allowed?(candidate)
124
+ case kind
125
+ when :bool then candidate == true || candidate == false
126
+ when :float then candidate.is_a?(Float)
127
+ when :int then candidate.is_a?(Integer)
128
+ when :path then candidate.is_a?(Pathname)
129
+ when :str then candidate.is_a?(String)
130
+ when :sym then candidate.is_a?(Symbol)
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,85 @@
1
+ #
2
+ # Renders the generated help text. Most of the fiddly work here is keeping
3
+ # columns aligned while ANSI color is present.
4
+ #
5
+
6
+ module RunKit
7
+ module Options
8
+ class Help
9
+ INDENT = 2
10
+
11
+ attr_reader :config, :width
12
+
13
+ def initialize(config, width = nil)
14
+ @config = config
15
+ @width = (width || Term.winsize[1]).clamp(60, 100)
16
+ end
17
+
18
+ # Render generated help, unless the caller supplied complete help text.
19
+ def to_s
20
+ return config.help if config.help
21
+
22
+ # usage: xyz (banner)
23
+ buf = StringIO.new
24
+ buf << banner
25
+ buf << "\n"
26
+ buf << "\n" if config.separators.any? { _1.first.zero? }
27
+
28
+ # Render each flag with aligned switch labels and wrapped help text.
29
+ label_width = widest_label
30
+ config.flags.each.with_index do |flag, idx|
31
+ buf << separator_text(idx) # sep
32
+
33
+ # left
34
+ label = flag_label(flag)
35
+ buf << " " * INDENT
36
+ buf << label
37
+
38
+ # right
39
+ if flag.help
40
+ buf << " " * (label_width - Term.width(label) + 2)
41
+ indent = INDENT + label_width + 2
42
+ buf << Term.wrap(flag.help, width - indent).gsub("\n", "\n#{" " * indent}")
43
+ end
44
+ buf << "\n"
45
+ end
46
+ buf << separator_text(config.flags.length)
47
+
48
+ buf.string
49
+ end
50
+
51
+ # Build the usage line from the configured app name and positionals.
52
+ def banner
53
+ text = config.banner
54
+ text ||= [color.blue("Usage:"), color.green(config.app_name), "[options]"].tap do
55
+ _1.push(*config.positionals.map(&:meta))
56
+ end.join(" ")
57
+ Term.wrap(text, width)
58
+ end
59
+
60
+ # Render separator text at its recorded position between flags.
61
+ def separator_text(position)
62
+ StringIO.new.tap do |buf|
63
+ config.separators.each do |(pos, str)|
64
+ if pos == position
65
+ buf << color.blue(str)
66
+ buf << "\n"
67
+ end
68
+ end
69
+ end.string
70
+ end
71
+
72
+ # one-liners
73
+ def color = @color ||= Color.new(config.color)
74
+ def widest_label = config.flags.map { Term.width(flag_label(_1)) }.max
75
+
76
+ protected
77
+
78
+ def flag_label(flag)
79
+ label = flag.switches.map { color.green(_1) }.join(", ")
80
+ return label unless flag.takes_param?
81
+ "#{label} #{color.yellow("<#{flag.meta}>")}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,82 @@
1
+ #
2
+ # Main entry point and parsing
3
+ #
4
+ #
5
+ # Glossary
6
+ #
7
+ # | term | meaning | example |
8
+ # |------------|------------------------------------|---------------------------|
9
+ # | flag | configured cli flag | o.int "-p", "--port" |
10
+ # | switch | dashed string invoking a flag | -p or --port |
11
+ # | key | result field derived from a switch | --http-port => :http_port |
12
+ # | param | raw input consumed by a flag | 8080 from --port=8080 |
13
+ # | - | - | - |
14
+ # | app_name | program name used in output | "curl" |
15
+ # | banner | usage text at top of help | Usage: curl [options] |
16
+ # | meta | placeholder shown for a param | <xxx> from `--port <xxx>` |
17
+ # | positional | configured required param slot | o.positional "<url>" |
18
+ # | separator | help section heading | "Network:" |
19
+ #
20
+
21
+ module RunKit
22
+ module Options
23
+ class Main
24
+ attr_reader :config
25
+
26
+ def initialize = @config = Config.new
27
+ def app_name = config.app_name
28
+
29
+ # Parse argv and turn internal parser outcomes into CLI behavior.
30
+ def parse(argv)
31
+ config.prepare!
32
+
33
+ begin
34
+ options = Parser.new(config).parse(argv)
35
+ klass = Data.define(*options.keys)
36
+ klass.new(**options)
37
+ rescue Error => ex
38
+ warn "#{app_name}: #{ex.message}"
39
+ warn "#{app_name}: try '#{app_name} --help' for more information"
40
+ exit_fn(1, error: ex.message)
41
+ rescue HelpRequested, NakedRequested, VersionRequested => ex
42
+ early_exit(ex)
43
+ exit_fn(0)
44
+ end
45
+ end
46
+
47
+ protected
48
+
49
+ def early_exit(ex)
50
+ case ex
51
+ when HelpRequested
52
+ puts Help.new(config)
53
+ when NakedRequested
54
+ puts "#{app_name}: try '#{app_name} --help' for more information"
55
+ when VersionRequested
56
+ puts "#{app_name} #{config.version}"
57
+ end
58
+ end
59
+
60
+ def exit_fn(status, error: nil)
61
+ args = [].tap do
62
+ _1 << status
63
+ _1 << error if config.exit.arity == 2
64
+ end
65
+ config.exit.call(*args)
66
+ nil
67
+ end
68
+ end
69
+
70
+ class Error < StandardError; end
71
+
72
+ # early exits
73
+ class HelpRequested < Exception; end # rubocop:disable Lint/InheritException
74
+ class NakedRequested < Exception; end # rubocop:disable Lint/InheritException
75
+ class VersionRequested < Exception; end # rubocop:disable Lint/InheritException
76
+
77
+ # main entry point
78
+ def self.parse(argv = ARGV)
79
+ Main.new.tap { yield _1.config if block_given? }.parse(argv)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,142 @@
1
+ #
2
+ # Turns argv into a typed result. It knows the CLI grammar but stays away from
3
+ # printing and exiting.
4
+ #
5
+
6
+ module RunKit
7
+ module Options
8
+ class Parser
9
+ attr_reader :config, :options, :queue
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ # Reset transient state, parse argv, and assemble the result.
16
+ def parse(argv)
17
+ @options, @queue = config.defaults, argv.dup
18
+ parse_queue
19
+ validate!
20
+ options
21
+ end
22
+
23
+ protected
24
+
25
+ #
26
+ # main parser
27
+ #
28
+
29
+ def parse_queue
30
+ raise NakedRequested if config.naked? && queue.empty?
31
+
32
+ # any non-flags we find below
33
+ operands = []
34
+
35
+ # process argv as queue
36
+ while (item = queue.shift)
37
+ case item
38
+ when Flag::SWITCH_RE, Flag::INLINE_RE then parse_switch(item, Regexp.last_match)
39
+ when /\A-[^-]/ then parse_smashed(item)
40
+ when "", /\A[^-]/ then operands << item
41
+ when "--" then break operands.concat(queue)
42
+ else; raise Error, "unexpected argument '#{item}' found"
43
+ end
44
+ end
45
+
46
+ # add pred? for bools
47
+ config.flags.filter_map { _1.key if _1.bool? }.each do
48
+ options[:"#{_1}?"] = options[_1] if options.key?(_1)
49
+ end
50
+
51
+ # positionals
52
+ config.positionals.each do
53
+ options[_1.key] = operands.shift
54
+ end
55
+
56
+ # _args
57
+ options[:_args] = operands
58
+ end
59
+
60
+ #
61
+ # -x or -x=123 or --xyz or --xyz=123 or --no-xyz
62
+ #
63
+
64
+ def parse_switch(item, match)
65
+ switch = "-#{match[1]}"
66
+ param = match[2]
67
+ separator = param ? "=" : ""
68
+
69
+ # -x or --xyz?
70
+ if (flag = config.flag(switch))
71
+ builtin!(flag)
72
+ param = queue.shift if flag.takes_param? && separator.empty?
73
+ options[flag.key] = flag.parse(switch, param)
74
+ return
75
+ end
76
+
77
+ # --no-xyz?
78
+ if (neg = find_negated_flag(switch))
79
+ raise Error, "option '#{item}' does not take a value" if separator == "="
80
+ options[neg.key] = false
81
+ return
82
+ end
83
+
84
+ raise Error, "unexpected argument '#{item}' found"
85
+ end
86
+
87
+ #
88
+ # smashed flags
89
+ #
90
+
91
+ # Expand short-switch groups such as `-qv`. A parameter-taking switch ends
92
+ # the group and consumes either its attached suffix or the next queue item.
93
+ def parse_smashed(group)
94
+ (1...group.length).each do |idx|
95
+ switch = "-#{group[idx]}"
96
+ flag = config.flag(switch)
97
+ raise Error, "unexpected argument '#{group}' found" unless flag
98
+ builtin!(flag)
99
+
100
+ # For `-qnLee`, `Lee` belongs to `-n`; for `-qn Lee`, shift the queue.
101
+ if flag.takes_param?
102
+ param = if idx + 1 < group.length
103
+ group[idx + 1...group.length]
104
+ else
105
+ queue.shift
106
+ end
107
+ options[flag.key] = flag.parse(switch, param)
108
+ return
109
+ end
110
+
111
+ # bool
112
+ options[flag.key] = true
113
+ end
114
+ end
115
+
116
+ #
117
+ # helpers
118
+ #
119
+
120
+ def builtin!(flag)
121
+ raise HelpRequested if flag == config.help_flag
122
+ raise VersionRequested if flag == config.version_flag
123
+ end
124
+
125
+ def find_negated_flag(switch)
126
+ if (m = Flag::NEGATE_RE.match(switch))
127
+ flag = config.flag("--#{m[1]}")
128
+ flag if flag&.bool?
129
+ end
130
+ end
131
+
132
+ def validate!
133
+ config.required.each do
134
+ raise Error, "required option '#{_1.switch}' is missing" if !options.key?(_1.key)
135
+ end
136
+ config.positionals.each do
137
+ raise Error, "required argument '#{_1.meta}' is missing" if !options[_1.key]
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,23 @@
1
+ #
2
+ # A required positional param like `<url>`.
3
+ #
4
+
5
+ module RunKit
6
+ module Options
7
+ class Positional
8
+ POSITIONAL_RE = /\A<([A-Z]\w*)>\z/i
9
+
10
+ attr_reader :help, :meta
11
+
12
+ def initialize(meta:, help:)
13
+ @help, @meta = help, meta
14
+ raise ArgumentError, "positional help must be a string" unless help.is_a?(String)
15
+ raise ArgumentError, "positional meta must be a string" unless meta.is_a?(String)
16
+ raise ArgumentError, "positional must use <meta>" unless POSITIONAL_RE.match?(meta)
17
+ end
18
+
19
+ # one-liners
20
+ def key = @key ||= POSITIONAL_RE.match(meta)[1].to_sym
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "options/color"
2
+ require_relative "options/config"
3
+ require_relative "options/flag"
4
+ require_relative "options/help"
5
+ require_relative "options/main"
6
+ require_relative "options/parser"
7
+ require_relative "options/positional"