run_kit 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6dabac9fceff597dbc18cca5e813a37c784bce2ed1148425ad5fd82264a2e3bd
4
- data.tar.gz: f544b871ca35c2da4fd965b017a9e2b11c052f99d755c92b6110e4113ec25a4f
3
+ metadata.gz: 957080dc55365e15529d2077974586f3239ac02d04d8b2ef6f568ca126ed22a5
4
+ data.tar.gz: 92beadf93985edeac07cc957b66fea237a55f29e4c7db97e43eecfd214eda91e
5
5
  SHA512:
6
- metadata.gz: 2e56df9f10755fec5eadc44396f368c0f6ebadd9987b2cf317373129902264c2e40073000198dba0511e142082780c0176a677461cd4fac083cd18d9dd8c2e29
7
- data.tar.gz: b9dbdf57d9ec47c482f1dcdf23b237287564fe6b5c3568b2cdd1185d6ea893aee37b48634bd507e773d6deeb75d1234f54c522f9250e0ed8f348d8e17c9c424f
6
+ metadata.gz: 5ff6d38e709d6412237e499a8df5f3388d133fa56dcd57c66bc89355da4c419273203ae3264acb54dcf569a232d0d8321753821c1b20e599e9480d497d9d0228
7
+ data.tar.gz: 9999afdfe9bda8cf5903cb695a59b26c387401e5d21732e107977100b0873a35c9c2b6bfdb38fa309fcfa3642922050f5ebd809229d57db22f7d5422b75601a5
data/.rubocop.yml CHANGED
@@ -35,7 +35,6 @@ Naming/MemoizedInstanceVariableName: { Enabled: true } # clean memo ivars
35
35
  Naming/MethodName: { Enabled: true } # keep method names conventional
36
36
  Performance/MapCompact: { Enabled: true } # filter_map-ish style
37
37
  Performance/RegexpMatch: { Enabled: false } # local style is fine
38
- Performance/SelectMap: { Enabled: true } # filter_map-ish style
39
38
  Style/BlockDelimiters: { Enabled: true } # do/end vs braces
40
39
  Style/ClassAndModuleChildren: { Enabled: true, EnforcedStyle: nested } # explicit nesting
41
40
  Style/ClassMethodsDefinitions: { Enabled: true } # avoid "class << self"
data/README.md CHANGED
@@ -6,10 +6,10 @@ RunKit is a small toolkit for cli. It provides option parsing, shell and file he
6
6
 
7
7
  ```ruby
8
8
  # install gem
9
- $ gem install run_key
9
+ $ gem install run_kit
10
10
 
11
11
  # or add to your Gemfile
12
- gem "run_key"
12
+ gem "run_kit"
13
13
  ```
14
14
 
15
15
  ## RunKit::Options
@@ -37,7 +37,28 @@ o.bool "--force", env: true # ENV["FORCE"]
37
37
  o.str "--token", env: "API_TOKEN" # ENV["API_TOKEN"]
38
38
  ```
39
39
 
40
- Configured variables appear in `--help`. Command-line values override ENV, which overrides defaults. Boolean ENV values accept `true/1/yes/on` and `false/0/no/off/empty`, ignoring case.
40
+ Custom validation:
41
+
42
+ ```ruby
43
+ o.validate = lambda do |options|
44
+ raise "--count must be positive" if options.count <= 0
45
+ end
46
+ ```
47
+
48
+ Also supports subcommands, `git`-style:
49
+
50
+ ```ruby
51
+ o.bool "-n", "--dry-run"
52
+ o.cmd "build", "Build the project" do |c|
53
+ c.str "--target <target>", default: "release"
54
+ end
55
+ o.cmd "test", "Run tests" do |c|
56
+ c.bool "--verbose"
57
+ end
58
+
59
+ # myapp build --target debug
60
+ # => #<data command="build", dry_run=false, target="debug", _args=[]>
61
+ ```
41
62
 
42
63
  ## RunKit::Shell
43
64
 
@@ -48,7 +69,7 @@ Configured variables appear in `--help`. Command-line values override ENV, which
48
69
  | `csv_read` / `csv_write` | Read/write CSV (add .gz for gzip) |
49
70
  | `file_read` / `file_write` | Atomic read/write files (add .gz for gzip) |
50
71
  | `json_read` / `json_write` | Atomic read/write json (add .gz for gzip) |
51
- | `jsonl_read / `jsonl_write` | Atomic read/write jsonl (add .gz for gzip) |
72
+ | `jsonl_read` / `jsonl_write` | Atomic read/write jsonl (add .gz for gzip) |
52
73
  | |
53
74
  | `csv_write_stdout` | Write CSV to stdout |
54
75
  | `gunzip` / `gzip` | (De)compress a string |
@@ -66,7 +87,7 @@ Configured variables appear in `--help`. Command-line values override ENV, which
66
87
  | |
67
88
  | `banner` / `warning` / `fatal` | Pretty banner in green, orange or red (fatal exits) |
68
89
  | `program_name` | Return executable name |
69
- | `prompt?` | Ask use for confirmation |
90
+ | `prompt?` | Ask user for confirmation |
70
91
  | `md5` / `sha256` | Hash strings |
71
92
 
72
93
  ### RunKit CoreExt
@@ -111,6 +132,15 @@ Note: There has been some effort to get the Pathname helpers into Ruby itself, w
111
132
 
112
133
  ### Changelog
113
134
 
135
+ #### 0.1.4 (Sep 2026)
136
+
137
+ - add subcommand support (`o.cmd`)
138
+ - add custom validation (`o.validate`)
139
+
140
+ #### 0.1.3 (Sep 2026)
141
+
142
+ - move PROGBAR constant into RunKit::
143
+
114
144
  #### 0.1.2 (Sep 2026)
115
145
 
116
146
  - allow options to read from ENV
data/demo.rb CHANGED
@@ -3,9 +3,21 @@
3
3
  require_relative "lib/run_kit"
4
4
 
5
5
  options = RunKit.parse do |o|
6
- o.int "-n", "--count <n>", "How many times to run", default: 1
7
- o.str "--mode <mode>", "Run quickly, or not", choices: %w[fast slow]
8
- o.positional "<url>", "url to fetch"
6
+ o.version = "1.0"
7
+ o.bool "--dry-run", "Preview without making changes"
8
+ o.desc = "this is made up"
9
+
10
+ o.cmd "fetch", "Fetch a URL" do |c|
11
+ c.int "-n", "--count <n>", "How many times to run", default: 1
12
+ c.str "--mode <mode>", "Run quickly, or not", choices: %w[fast slow]
13
+ c.positional "<url>", "URL to fetch"
14
+ end
15
+
16
+ o.cmd "build", "Build the project" do |c|
17
+ c.naked = false
18
+ c.str "--target <target>", "Build target", choices: %w[debug release], default: "release"
19
+ c.bool "--force", "Force a rebuild", env: "DEMO_FORCE"
20
+ end
9
21
  end
10
22
 
11
23
  p options
@@ -34,20 +34,15 @@ module RunKit
34
34
  # each.with_progresbar
35
35
  module Enumerator
36
36
  def with_progressbar(options = {}, &block)
37
- defaults = {
38
- format: "%t: %j%% %B #{RunKit::Term.paint_ansi("%c/%u %e", RunKit::Term.ansi256_fg(242))}",
39
- progress_mark: RunKit::Term.paint_ansi("━", RunKit::Term.ansi256_fg(46)),
40
- remainder_mark: RunKit::Term.paint_ansi("━", RunKit::Term.ansi256_fg(237)),
37
+ options = RunKit::PROGRESSBAR.merge(
41
38
  output: $stdout.isatty ? $stdout : $stderr,
42
- total: size,
43
- length: 72,
44
- }
45
- options = defaults.merge(options)
39
+ total: size
40
+ ).merge(options)
46
41
 
47
42
  return enum_for(__method__) if !block
48
43
 
49
- if !options[:hide]
50
- bar = ProgressBar.create(options)
44
+ bar = if !options[:hide]
45
+ ProgressBar.create(options)
51
46
  end
52
47
  RunKit::Term.with_hidden_cursor(options[:output]) do
53
48
  each do
@@ -6,15 +6,17 @@
6
6
  module RunKit
7
7
  module Options
8
8
  class Config
9
- attr_accessor :app_name, :banner, :color, :exit, :help, :naked, :version
10
- attr_reader :flags, :help_flag, :lookup, :positionals, :separators, :version_flag
9
+ attr_accessor :banner, :color, :desc, :exit, :help, :naked, :root, :validate, :version
10
+ attr_reader :help_flag, :name, :version_flag
11
11
  alias_method :naked?, :naked
12
12
 
13
- def initialize
14
- @app_name = File.basename($PROGRAM_NAME)
13
+ def initialize(name: nil)
15
14
  @naked = true
16
- @lookup = {}
17
- @flags, @positionals, @separators = [], [], []
15
+ self.name = name || Shell.program_name
16
+ end
17
+
18
+ def name=(name)
19
+ @name = name.to_s
18
20
  end
19
21
 
20
22
  # Add a positional param declared as `<url>`.
@@ -26,6 +28,18 @@ module RunKit
26
28
  end
27
29
  end
28
30
 
31
+ # Add a subcommand with its own nested Config, eg `myapp build`.
32
+ def cmd(name, desc = nil)
33
+ name = name.to_s
34
+ raise ArgumentError, "duplicate command #{name}" if commands.key?(name)
35
+ Config.new(name:).tap do
36
+ _1.desc, _1.root = desc, self
37
+ yield _1 if block_given?
38
+ raise ArgumentError, "nested commands are not supported" if _1.commands.any?
39
+ commands[name] = _1
40
+ end
41
+ end
42
+
29
43
  # Add separator text at the current point in generated help.
30
44
  def sep(text = "")
31
45
  [flags.length, text].tap do
@@ -62,7 +76,9 @@ module RunKit
62
76
  end
63
77
 
64
78
  # long-form aliases
79
+ alias_method :app_name=, :name=
65
80
  alias_method :boolean, :bool
81
+ alias_method :command, :cmd
66
82
  alias_method :integer, :int
67
83
  alias_method :pathname, :path
68
84
  alias_method :positional, :pos
@@ -81,27 +97,38 @@ module RunKit
81
97
  # one-liners
82
98
  def flag(switch) = lookup[switch]
83
99
  def flag?(switch) = lookup.key?(switch)
100
+ def full_name = root ? "#{root.full_name} #{name}" : name
84
101
  def key?(key) = lookup.key?(key)
85
102
  def required = flags.select(&:required?)
86
103
 
104
+ # memoized accessors
105
+ def commands = @commands ||= {}
106
+ def flags = @flags ||= []
107
+ def lookup = @lookup ||= {}
108
+ def positionals = @positionals ||= []
109
+ def separators = @separators ||= []
110
+
87
111
  # Complete one-time setup after the caller has declared overrides.
88
112
  def prepare!
89
113
  return if @prepared
90
114
  @prepared = true
115
+
116
+ # Children inherit shared settings before adding builtins.
117
+ if root
118
+ self.color, self.exit, self.version = root.color, root.exit, root.version
119
+ end
120
+
121
+ # now defaults
91
122
  @exit ||= lambda { |status| Kernel.exit(status) }
92
- @help_flag = add_builtin(["-h", "--help"], "Show this message")
93
- @version_flag = add_builtin(["-v", "--version"], "Show version") if version
123
+ @help_flag = bool("-h", "--help", "Show this message")
124
+ @version_flag = bool("-v", "--version", "Show version") if version
125
+
126
+ # setup subcommands
127
+ commands.each_value(&:prepare!)
94
128
  end
95
129
 
96
130
  protected
97
131
 
98
- # Add help/version flags, but only for switches the user did not override.
99
- def add_builtin(switches, help_text)
100
- unused = switches.select { !flag?(_1) }
101
- return if unused.empty?
102
- add_flag(Flag.new(:bool, unused + [help_text]))
103
- end
104
-
105
132
  def add_flag(flag)
106
133
  # dup check
107
134
  raise ArgumentError, "reserved flag key: _args" if flag.key == :_args
@@ -18,65 +18,88 @@ module RunKit
18
18
  # Render generated help, unless the caller supplied complete help text.
19
19
  def to_s
20
20
  return config.help if config.help
21
+ help = [].tap do
22
+ _1 << desc if config.desc
23
+ _1 << banner
24
+ _1 << commands_text if config.commands.any?
25
+ _1 << flags_text(config, "Options:", builtins: !config.root)
26
+ _1 << flags_text(config.root, "Other options:") if config.root
27
+ end.compact.join("\n\n")
28
+ "#{help}\n"
29
+ end
21
30
 
22
- # usage: xyz (banner)
23
- buf = StringIO.new
24
- buf << banner
25
- buf << "\n"
26
- buf << "\n" if config.separators.any? { _1.first.zero? }
31
+ # Render a heading and aligned flags, preserving explicit separator lines.
32
+ def flags_text(source, title, builtins: true)
33
+ # gather flags
34
+ flags = source.flags
35
+ flags -= [source.help_flag, source.version_flag] unless builtins
36
+ return if flags.empty? && source.separators.empty?
27
37
 
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
38
+ [].tap do |lines|
39
+ lines << color.blue(title)
40
+ label_width = flags.map { Term.width(flag_label(_1)) }.max
41
+ flags.each.with_index do |flag, idx|
42
+ lines.concat(separator_lines(source, idx))
43
+ buf = StringIO.new
32
44
 
33
- # left
34
- label = flag_label(flag)
35
- buf << " " * INDENT
36
- buf << label
45
+ # left
46
+ label = flag_label(flag)
47
+ buf << " " * INDENT
48
+ buf << label
37
49
 
38
- # right
39
- help = flag.help
40
- if flag.env
41
- env_help = "[env: #{flag.env}]"
42
- help = help ? "#{help} #{env_help}" : env_help
43
- end
44
- if help
45
- buf << " " * (label_width - Term.width(label) + 2)
46
- indent = INDENT + label_width + 2
47
- buf << Term.wrap(help, width - indent).gsub("\n", "\n#{" " * indent}")
50
+ # right
51
+ help = flag.help
52
+ if flag.env
53
+ env_help = "[env: #{flag.env}]"
54
+ help = help ? "#{help} #{env_help}" : env_help
55
+ end
56
+ if help
57
+ buf << " " * (label_width - Term.width(label) + 2)
58
+ indent = INDENT + label_width + 2
59
+ buf << Term.wrap(help, width - indent).gsub("\n", "\n#{" " * indent}")
60
+ end
61
+ lines << buf.string
48
62
  end
49
- buf << "\n"
50
- end
51
- buf << separator_text(config.flags.length)
52
-
53
- buf.string
63
+ lines.concat(separator_lines(source, flags.length))
64
+ end.join("\n")
54
65
  end
55
66
 
56
- # Build the usage line from the configured app name and positionals.
67
+ # Build the usage line from the command's full name and positionals.
57
68
  def banner
58
69
  text = config.banner
59
- text ||= [color.blue("Usage:"), color.green(config.app_name), "[options]"].tap do
70
+ text ||= [color.blue("Usage:"), color.green(config.full_name), "[options]"].tap do
60
71
  _1.push(*config.positionals.map(&:meta))
72
+ _1.push(color.yellow("<command>")) if config.commands.any?
61
73
  end.join(" ")
62
74
  Term.wrap(text, width)
63
75
  end
64
76
 
65
- # Render separator text at its recorded position between flags.
66
- def separator_text(position)
67
- StringIO.new.tap do |buf|
68
- config.separators.each do |(pos, str)|
69
- if pos == position
70
- buf << color.blue(str)
71
- buf << "\n"
72
- end
77
+ # Render the list of subcommands, aligned like the flag list above.
78
+ def commands_text
79
+ [].tap do |lines|
80
+ lines << color.blue("Commands:")
81
+ label_width = config.commands.keys.map { Term.width(_1) }.max
82
+ config.commands.each do |name, child|
83
+ lines << StringIO.new.tap do |buf|
84
+ buf << " " * INDENT << color.green(name)
85
+ if child.desc
86
+ buf << " " * (label_width - Term.width(name) + 2)
87
+ indent = INDENT + label_width + 2
88
+ buf << Term.wrap(child.desc, width - indent).gsub("\n", "\n#{" " * indent}")
89
+ end
90
+ end.string
73
91
  end
74
- end.string
92
+ end.join("\n")
93
+ end
94
+
95
+ # Keep blank and multiline separators exactly as supplied.
96
+ def separator_lines(source, position)
97
+ source.separators.filter_map { |pos, str| color.blue(str) if pos == position }
75
98
  end
76
99
 
77
100
  # one-liners
78
101
  def color = @color ||= Color.new(config.color)
79
- def widest_label = config.flags.map { Term.width(flag_label(_1)) }.max
102
+ def desc = Term.wrap(config.desc, width)
80
103
 
81
104
  protected
82
105
 
@@ -16,67 +16,125 @@
16
16
  # | meta | placeholder shown for a param | <xxx> from `--port <xxx>` |
17
17
  # | positional | configured required param slot | o.positional "<url>" |
18
18
  # | separator | help section heading | "Network:" |
19
+ # | command | subcommand with its own Config | o.cmd "build" { ... } |
19
20
  #
20
21
 
21
22
  module RunKit
22
23
  module Options
23
24
  class Main
24
- attr_reader :config
25
+ # root is the top-level config; ctx is the active parser or validator's config.
26
+ attr_reader :ctx, :root
25
27
 
26
- def initialize = @config = Config.new
27
- def app_name = config.app_name
28
+ def initialize = @root = Config.new
28
29
 
29
30
  # Parse argv and turn internal parser outcomes into CLI behavior.
30
31
  def parse(argv)
31
- config.prepare!
32
+ @ctx = root
33
+ root.prepare!
34
+
35
+ # handle --help and --version
36
+ return exit_fn(0) if early_exit?(argv)
32
37
 
33
38
  begin
34
- options = Parser.new(config).parse(argv)
39
+ options = root.commands.empty? ? parse_with_ctx(root, argv) : subcommand(argv)
35
40
  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)
41
+ klass.new(**options).tap { validate(_1) }
42
+ rescue Error, NakedRequested => ex
43
+ handle_error(ex)
44
44
  end
45
45
  end
46
46
 
47
47
  protected
48
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}"
49
+ # Handle --help or --version
50
+ def early_exit?(argv)
51
+ if argv.include?("--help") || argv.include?("-h")
52
+ selected = root.commands[argv.first] || root
53
+ puts Help.new(selected)
54
+ return true
55
+ end
56
+ if root.version && (argv.include?("--version") || argv.include?("-v"))
57
+ puts "#{root.name} #{root.version}"
58
+ return true
59
+ end
60
+ end
61
+
62
+ # Peek at the first bare argument to pick a subcommand, then parse the
63
+ # rest with its own Config and merge the two option hashes together.
64
+ def subcommand(argv)
65
+ # Parse root options before the subcommand.
66
+ root_options = parse_with_ctx(root, argv, passthru: true)
67
+ name, *rest = root_options[:_args]
68
+ raise NakedRequested if !name
69
+
70
+ # Find and parse the child.
71
+ child = root.commands[name]
72
+ raise Error, "unknown command '#{name}'" if !child
73
+ child_options = parse_with_ctx(child, rest)
74
+
75
+ # merge
76
+ root_options.merge(child_options).merge(command: name)
77
+ end
78
+
79
+ # Validate the final options, root first.
80
+ def validate(options)
81
+ [ctx.root, ctx].compact.each do
82
+ validate_with_ctx(_1, options)
83
+ rescue RuntimeError => ex
84
+ raise Error, ex.message
57
85
  end
58
86
  end
59
87
 
88
+ # Render the outcome using the active context.
89
+ def handle_error(ex)
90
+ if ex.is_a?(Error)
91
+ warn "#{ctx.full_name}: #{ex.message}"
92
+ warn "#{ctx.full_name}: try '#{ctx.full_name} --help' for more information"
93
+ return exit_fn(1, error: ex.message)
94
+ end
95
+
96
+ puts Help.new(ctx)
97
+ exit_fn(0)
98
+ end
99
+
60
100
  def exit_fn(status, error: nil)
61
101
  args = [].tap do
62
102
  _1 << status
63
- _1 << error if config.exit.arity == 2
103
+ _1 << error if root.exit.arity == 2
64
104
  end
65
- config.exit.call(*args)
105
+ root.exit.call(*args)
66
106
  nil
67
107
  end
108
+
109
+ #
110
+ # Keep the active config after a failure so the outer rescue can use it.
111
+ # This is error context, not a push/pop command stack.
112
+ #
113
+
114
+ def with_ctx(ctx)
115
+ @ctx = ctx
116
+ yield
117
+ end
118
+
119
+ def parse_with_ctx(ctx, argv, passthru: false)
120
+ with_ctx(ctx) do
121
+ Parser.new(ctx).parse(argv, passthru:)
122
+ end
123
+ end
124
+
125
+ def validate_with_ctx(ctx, options)
126
+ with_ctx(ctx) do
127
+ ctx.validate&.call(options)
128
+ end
129
+ end
68
130
  end
69
131
 
70
132
  class Error < StandardError; end
71
-
72
- # early exits
73
- class HelpRequested < Exception; end # rubocop:disable Lint/InheritException
74
133
  class NakedRequested < Exception; end # rubocop:disable Lint/InheritException
75
- class VersionRequested < Exception; end # rubocop:disable Lint/InheritException
76
134
 
77
135
  # main entry point
78
136
  def self.parse(argv = ARGV)
79
- Main.new.tap { yield _1.config if block_given? }.parse(argv)
137
+ Main.new.tap { yield _1.root if block_given? }.parse(argv)
80
138
  end
81
139
  end
82
140
  end
@@ -6,20 +6,32 @@
6
6
  module RunKit
7
7
  module Options
8
8
  class Parser
9
- attr_reader :config, :options, :queue
9
+ attr_reader :config
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
13
13
  end
14
14
 
15
- # Reset transient state, parse argv, and assemble the result.
16
- def parse(argv)
17
- env = build_env
18
- raise NakedRequested if config.naked? && argv.empty? && env.empty?
15
+ # Reset transient state, parse argv, and assemble the result. When
16
+ # passthru is set, scanning halts at the first bare arg (for subcommands).
17
+ def parse(argv, passthru: false)
18
+ # 1. naked?
19
+ raise NakedRequested if config.naked? && argv.empty?
20
+
21
+ # 2. Parse argv.
22
+ argv_options = parse_argv(argv, passthru:)
23
+
24
+ # 3. defaults => ENV => ARGV
25
+ options = {}.merge(config.defaults, parse_env, argv_options)
26
+
27
+ # 4. validate final options
28
+ validate!(options)
29
+
30
+ # success! add predicate? keys
31
+ config.flags.select(&:bool?).map(&:key).each do
32
+ options[:"#{_1}?"] = options[_1] if options.key?(_1)
33
+ end
19
34
 
20
- @options, @queue = config.defaults.merge(env), argv.dup
21
- parse_queue
22
- validate!
23
35
  options
24
36
  end
25
37
 
@@ -29,57 +41,55 @@ module RunKit
29
41
  # main parser
30
42
  #
31
43
 
32
- def parse_queue
33
- # any non-flags we find below
34
- operands = []
35
-
36
- # process argv as queue
37
- while (item = queue.shift)
38
- case item
39
- when Flag::SWITCH_RE, Flag::INLINE_RE then parse_switch(item, Regexp.last_match)
40
- when /\A-[^-]/ then parse_smashed(item)
41
- when "", /\A[^-]/ then operands << item
42
- when "--" then break operands.concat(queue)
43
- else; raise Error, "unexpected argument '#{item}' found"
44
+ def parse_argv(argv, passthru: false)
45
+ {}.tap do |result|
46
+ # any non-flags we find below
47
+ operands = []
48
+
49
+ # process argv as queue
50
+ queue = argv.dup
51
+ while (item = queue.shift)
52
+ case item
53
+ when Flag::SWITCH_RE, Flag::INLINE_RE then result.merge!(parse_switch(item, Regexp.last_match, queue))
54
+ when /\A-[^-]/ then result.merge!(parse_smashed(item, queue))
55
+ when "", /\A[^-]/
56
+ operands << item
57
+ if passthru
58
+ operands.concat(queue)
59
+ break
60
+ end
61
+ when "--" then break operands.concat(queue)
62
+ else; raise Error, "unexpected argument '#{item}' found"
63
+ end
44
64
  end
45
- end
46
65
 
47
- # add pred? for bools
48
- config.flags.filter_map { _1.key if _1.bool? }.each do
49
- options[:"#{_1}?"] = options[_1] if options.key?(_1)
50
- end
66
+ # positionals
67
+ config.positionals.each { result[_1.key] = operands.shift }
51
68
 
52
- # positionals
53
- config.positionals.each do
54
- options[_1.key] = operands.shift
69
+ # _args
70
+ result[:_args] = operands
55
71
  end
56
-
57
- # _args
58
- options[:_args] = operands
59
72
  end
60
73
 
61
74
  #
62
75
  # -x or -x=123 or --xyz or --xyz=123 or --no-xyz
63
76
  #
64
77
 
65
- def parse_switch(item, match)
78
+ def parse_switch(item, match, queue)
66
79
  switch = "-#{match[1]}"
67
80
  param = match[2]
68
81
  separator = param ? "=" : ""
69
82
 
70
83
  # -x or --xyz?
71
84
  if (flag = config.flag(switch))
72
- builtin!(flag)
73
85
  param = queue.shift if flag.takes_param? && separator.empty?
74
- options[flag.key] = flag.parse(switch, param)
75
- return
86
+ return {flag.key => flag.parse(switch, param)}
76
87
  end
77
88
 
78
89
  # --no-xyz?
79
90
  if (neg = find_negated_flag(switch))
80
91
  raise Error, "option '#{item}' does not take a value" if separator == "="
81
- options[neg.key] = false
82
- return
92
+ return {neg.key => false}
83
93
  end
84
94
 
85
95
  raise Error, "unexpected argument '#{item}' found"
@@ -91,12 +101,12 @@ module RunKit
91
101
 
92
102
  # Expand short-switch groups such as `-qv`. A parameter-taking switch ends
93
103
  # the group and consumes either its attached suffix or the next queue item.
94
- def parse_smashed(group)
104
+ def parse_smashed(group, queue)
105
+ result = {}
95
106
  (1...group.length).each do |idx|
96
107
  switch = "-#{group[idx]}"
97
108
  flag = config.flag(switch)
98
109
  raise Error, "unexpected argument '#{group}' found" unless flag
99
- builtin!(flag)
100
110
 
101
111
  # For `-qnLee`, `Lee` belongs to `-n`; for `-qn Lee`, shift the queue.
102
112
  if flag.takes_param?
@@ -105,30 +115,29 @@ module RunKit
105
115
  else
106
116
  queue.shift
107
117
  end
108
- options[flag.key] = flag.parse(switch, param)
109
- return
118
+ result[flag.key] = flag.parse(switch, param)
119
+ return result
110
120
  end
111
121
 
112
122
  # bool
113
- options[flag.key] = true
123
+ result[flag.key] = true
114
124
  end
125
+ result
115
126
  end
116
127
 
117
128
  #
118
- # helpers
129
+ # env
119
130
  #
120
131
 
121
- def build_env
122
- config.flags.filter_map do
123
- next unless _1.env && ENV.key?(_1.env)
124
- [_1.key, _1.parse_env(ENV.fetch(_1.env))]
132
+ def parse_env
133
+ config.flags.select { _1.env && ENV.key?(_1.env) }.map do |flag|
134
+ [flag.key, flag.parse_env(ENV[flag.env])]
125
135
  end.to_h
126
136
  end
127
137
 
128
- def builtin!(flag)
129
- raise HelpRequested if flag == config.help_flag
130
- raise VersionRequested if flag == config.version_flag
131
- end
138
+ #
139
+ # helpers
140
+ #
132
141
 
133
142
  def find_negated_flag(switch)
134
143
  if (m = Flag::NEGATE_RE.match(switch))
@@ -137,7 +146,7 @@ module RunKit
137
146
  end
138
147
  end
139
148
 
140
- def validate!
149
+ def validate!(options)
141
150
  config.required.each do
142
151
  raise Error, "required option '#{_1.switch}' is missing" if !options.key?(_1.key)
143
152
  end
data/lib/run_kit/shell.rb CHANGED
@@ -29,9 +29,9 @@ module RunKit
29
29
  # json file read/write, including gz
30
30
  #
31
31
 
32
- def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), symbolize_names:)
32
+ def json_read(path, symbolize_names: true) = JSON.parse(file_read(path), allow_comments: true, symbolize_names:)
33
33
  def json_write(path, json) = file_write(path, JSON.pretty_generate(json))
34
- def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, symbolize_names:) }
34
+ def jsonl_read(path, symbolize_names: true) = file_read(path).split("\n").map { JSON.parse(_1, allow_comments: true, symbolize_names:) }
35
35
  def jsonl_write(path, json) = file_write(path, json.map { JSON.generate(_1) }.join("\n"))
36
36
 
37
37
  #
@@ -262,8 +262,8 @@ module RunKit
262
262
  data = gunzip(data) if compress
263
263
  case format
264
264
  when :bin then data.force_encoding("ascii-8bit")
265
- when :json then JSON.parse(data)
266
- when :jsonl then data.split("\n").map { JSON.parse(_1) }
265
+ when :json then JSON.parse(data, allow_comments: true)
266
+ when :jsonl then data.split("\n").map { JSON.parse(_1, allow_comments: true) }
267
267
  when :marshal then Marshal.load(data)
268
268
  when :str, :string then data.force_encoding("utf-8")
269
269
  else; raise "unknown format #{format.inspect}"
data/lib/run_kit.rb CHANGED
@@ -16,7 +16,14 @@ require_relative "run_kit/term"
16
16
  require_relative "run_kit/options"
17
17
  require_relative "run_kit/shell"
18
18
 
19
- # handy entry point for RunKit::Options
20
19
  module RunKit
20
+ PROGRESSBAR = {
21
+ format: "%t: %j%% %B #{Term.paint_ansi("%c/%u %e", Term.ansi256_fg(242))}",
22
+ progress_mark: Term.paint_ansi("━", Term.ansi256_fg(46)),
23
+ remainder_mark: Term.paint_ansi("━", Term.ansi256_fg(237)),
24
+ length: 72,
25
+ }
26
+
27
+ # handy entry point for RunKit::Options
21
28
  def self.parse(...) = Options.parse(...)
22
29
  end
data/run_kit.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = "run_kit"
3
- s.version = "0.1.2"
3
+ s.version = "0.1.4"
4
4
  s.authors = ["Adam Doppelt"]
5
5
  s.email = "amd@gurge.com"
6
6
  s.summary = "Run kit."
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: run_kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Doppelt