cmds 0.2.4 → 0.2.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,19 +7,13 @@ require 'shellwords'
7
7
  require 'nrser'
8
8
 
9
9
  # package
10
+ require 'cmds/util/shell_escape'
10
11
  require 'cmds/util/tokenize_options'
11
12
 
12
13
  class Cmds
13
14
  # class methods
14
15
  # =============
15
16
 
16
- # shortcut for Shellwords.escape
17
- #
18
- # also makes it easier to change or customize or whatever
19
- def self.esc str
20
- Shellwords.escape str
21
- end
22
-
23
17
  # tokenize values for the shell. each values is tokenized individually
24
18
  # and the results are joined with a space.
25
19
  #
@@ -32,15 +26,12 @@ class Cmds
32
26
  def self.tokenize *values, **opts
33
27
  values.map {|value|
34
28
  case value
35
- when nil
36
- # nil is just an empty string, NOT an empty string bash token
37
- ''
38
29
  when Hash
39
30
  tokenize_options value, **opts
40
31
  else
41
- esc value.to_s
32
+ tokenize_value value, **opts
42
33
  end
43
- }.join ' '
34
+ }.flatten.join ' '
44
35
  end # .tokenize
45
36
 
46
37
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Cmds
2
4
  # hash of common default values used in method options.
3
5
  #
@@ -13,39 +15,66 @@ class Cmds
13
15
  # you don't even know about may depend on said behavior.
14
16
  #
15
17
  DEFAULTS = {
18
+ # Alphabetical...
19
+
16
20
  # positional arguments for a command
17
21
  args: [],
18
22
 
19
- # keyword arguments for a command
20
- kwds: {},
21
-
22
- # how to format a command string for execution
23
- format: :squish,
23
+ # what to join array option values with when using `array_mode = :join`
24
+ array_join_string: ',',
24
25
 
25
26
  # what to do with array option values
26
27
  array_mode: :join,
27
28
 
28
- # what to join array option values with when using `array_mode = :join`
29
- array_join_string: ',',
29
+ # Don't asset (raise error if exit code is not 0)
30
+ assert: false,
30
31
 
31
- # what to do with false array values
32
- false_mode: :omit,
32
+ # Don't change directories
33
+ chdir: nil,
34
+
35
+ # No additional environment
36
+ env: {},
33
37
 
34
38
  # Stick ENV var defs inline at beginning of command
35
39
  env_mode: :inline,
36
40
 
37
- # No additional environment
38
- env: {},
41
+ # What to do with `false` *option* values (not `false` values as regular
42
+ # values or inside collections)
43
+ #
44
+ # Just leave them out all-together
45
+ false_mode: :omit,
39
46
 
40
- # Don't change directories
41
- chdir: nil,
47
+ # Flatten nested array values to a single array.
48
+ #
49
+ # Many CLI commands accept arrays in some form or another, but I'm hard
50
+ # pressed to think of one that accepts nested arrays. Flattening can make
51
+ # it simpler to generate values.
52
+ #
53
+ flatten_array_values: true,
42
54
 
43
- # Don't asset (raise error if exit code is not 0)
44
- assert: false,
55
+ # how to format a command string for execution
56
+ format: :squish,
57
+
58
+ hash_mode: :join,
59
+
60
+ # Join hash keys and values with `:`
61
+ hash_join_string: ':',
45
62
 
46
63
  # No input
47
64
  input: nil,
48
65
 
66
+ # keyword arguments for a command
67
+ kwds: {},
68
+
69
+ # What to use to separate "long" opt names (more than one character) from
70
+ # their values. I've commonly seen '=' (`--name=VALUE`)
71
+ # and ' ' (`--name VALUE`).
72
+ long_opt_separator: '=',
73
+
74
+ # What to use to separate "short" opt names (single character) from their
75
+ # values. I've commonly seen ' ' (`-x VALUE`) and '' (`-xVALUE`).
76
+ short_opt_separator: ' ',
77
+
49
78
  }.map { |k, v| [k, v.freeze] }.to_h.freeze
50
79
 
51
80
 
@@ -69,7 +98,7 @@ class Cmds
69
98
  def self.defaults opts, keys = '*', extras = {}
70
99
  if keys == '*'
71
100
  DEFAULTS.dup
72
- else
101
+ else
73
102
  keys.
74
103
  map {|key|
75
104
  [key, DEFAULTS.fetch(key)]
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Cmds
4
+ # @!group Shell Quoting and Escaping Methods
5
+
6
+ # Constants
7
+ # ======================================================================
8
+
9
+ # Quote "name" keys `:single` and `:double` mapped to their character.
10
+ #
11
+ # @return [Hash<Symbol, String>]
12
+ #
13
+ QUOTE_TYPES = {
14
+ single: %{'},
15
+ double: %{"},
16
+ }.freeze
17
+
18
+
19
+ # List containing just `'` and `"`.
20
+ #
21
+ # @return [Array<String>]
22
+ #
23
+ QUOTE_VALUES = QUOTE_TYPES.values.freeze
24
+
25
+
26
+ # Class Methods
27
+ # ======================================================================
28
+
29
+ # Shortcut for Shellwords.escape
30
+ #
31
+ # Also makes it easier to change or customize or whatever.
32
+ #
33
+ # @see http://ruby-doc.org/stdlib/libdoc/shellwords/rdoc/Shellwords.html#method-c-escape
34
+ #
35
+ # @param [#to_s] str
36
+ # @return [String]
37
+ #
38
+ def self.esc str
39
+ Shellwords.escape str
40
+ end
41
+
42
+
43
+ # Format a string to be a shell token by wrapping it in either single or
44
+ # double quotes and replacing instances of that quote with what I'm calling
45
+ # a "quote dance":
46
+ #
47
+ # 1. Closing the type of quote in use
48
+ # 2. Quoting the type of quote in use with the *other* type of quote
49
+ # 3. Then opening up the type in use again and keeping going.
50
+ #
51
+ # @example Single quoting string containing single quotes
52
+ #
53
+ # Cmds.quote_dance %{you're}, :single
54
+ # # => %{'you'"'"'re'}
55
+ #
56
+ # @example Double quoting string containing double quotes
57
+ #
58
+ # Cmds.quote_dance %{hey "ho" let's go}, :double
59
+ # # => %{"hey "'"'"ho"'"'" let's go"}
60
+ #
61
+ # **_WARNING:
62
+ # Does NOT escape anything except the quotes! So if you double-quote a
63
+ # string with shell-expansion terms in it and pass it to the shell
64
+ # THEY WILL BE EVALUATED_**
65
+ #
66
+ # @param [String] string
67
+ # String to quote.
68
+ #
69
+ # @return [return_type]
70
+ # @todo Document return value.
71
+ #
72
+ def self.quote_dance string, quote_type
73
+ outside = QUOTE_TYPES.fetch quote_type
74
+ inside = QUOTE_VALUES[QUOTE_VALUES[0] == outside ? 1 : 0]
75
+
76
+ outside +
77
+ string.gsub(
78
+ outside,
79
+ outside + inside + outside + inside + outside
80
+ ) +
81
+ outside
82
+ end # .quote_dance
83
+
84
+
85
+ # Single quote a string for use in the shell.
86
+ #
87
+ # @param [String] string
88
+ # String to quote.
89
+ #
90
+ # @return [String]
91
+ # Single-quoted string.
92
+ #
93
+ def self.single_quote string
94
+ quote_dance string, :single
95
+ end # .single_quote
96
+
97
+ # Set {.single_quote} up as our quote method.
98
+ singleton_class.send :alias_method, :quote, :single_quote
99
+
100
+
101
+ end # class Cmds
@@ -1,43 +1,50 @@
1
1
  require 'json'
2
- require 'nrser/refinements'
2
+ require 'nrser'
3
3
 
4
4
  require_relative "defaults"
5
+ require_relative "tokenize_value"
5
6
 
6
7
  class Cmds
7
- TOKENIZE_OPT_KEYS = [:array_mode, :array_join_string, :false_mode]
8
8
 
9
- # turn an option name and value into an array of shell-escaped string
10
- # token suitable for use in a command.
9
+ # Turn an option name and value into an array of shell-escaped string
10
+ # tokens suitable for use in a command.
11
11
  #
12
12
  # @param [String] name
13
- # string name (one or more characters).
13
+ # String name (one or more characters).
14
14
  #
15
15
  # @param [*] value
16
- # value of the option.
16
+ # Value of the option.
17
17
  #
18
18
  # @param [Hash] **opts
19
- # @option [Symbol] :array_mode (:multiple)
19
+ #
20
+ # @option [Symbol] :array_mode (:join)
20
21
  # one of:
22
+ #
23
+ # 1. `:join` (default) -- join values in one token.
24
+ #
25
+ # tokenize_option 'blah', [1, 2, 3], array_mode: :join
26
+ # => ['--blah=1,2,3']
21
27
  #
22
- # 1. `:multiple` (default) provide one token for each value.
28
+ # 2. `:repeat` repeat the option for each value.
23
29
  #
24
- # expand_option 'blah', [1, 2, 3]
30
+ # tokenize_option 'blah', [1, 2, 3], array_mode: :repeat
25
31
  # => ['--blah=1', '--blah=2', '--blah=3']
26
- #
27
- # 2. `:join` -- join values in one token.
28
- #
29
- # expand_option 'blah', [1, 2, 3], array_mode: :join
30
- # => ['--blah=1,2,3']
31
32
  #
32
33
  # @option [String] :array_join_string (',')
33
- # string to join array values with when `:array_mode` is `:join`.
34
+ # String to join array values with when `:array_mode` is `:join`.
34
35
  #
35
36
  # @return [Array<String>]
36
- # string tokens.
37
+ # List of individual shell token strings, escaped for use.
38
+ #
39
+ # @raise [ArgumentError]
40
+ # 1. If `name` is the wrong type or empty.
41
+ # 2. If any options have bad values.
37
42
  #
38
43
  def self.tokenize_option name, value, **opts
44
+ # Set defaults for any options not passed
39
45
  opts = defaults opts, TOKENIZE_OPT_KEYS
40
46
 
47
+ # Validate `name`
41
48
  unless name.is_a?(String) && name.length > 0
42
49
  raise ArgumentError.new NRSER.squish <<-END
43
50
  `name` must be a String of length greater than zero,
@@ -45,74 +52,138 @@ class Cmds
45
52
  END
46
53
  end
47
54
 
48
- prefix, separator = if name.length == 1
49
- # -b <value> style
50
- ['-', ' ']
55
+ # Set type (`:short` or `:long`) prefix and name/value separator depending
56
+ # on if name is "short" (single character) or "long" (anything else)
57
+ #
58
+ type, prefix, separator = if name.length == 1
59
+ # -b <value> style (short)
60
+ [ :short, '-', opts[:short_opt_separator] ]
51
61
  else
52
- # --blah=<value> style
53
- ['--', '=']
62
+ # --blah=<value> style (long)
63
+ [ :long, '--', opts[:long_opt_separator] ]
54
64
  end
55
-
65
+
56
66
  case value
57
- when nil
58
- []
59
-
60
- when Array
61
- # the PITA one
62
- case opts[:array_mode]
63
- when :repeat
64
- # `-b 1 -b 2 -b 3` / `--blah=1 --blah=2 --blah=3` style
65
- value.flatten.map {|v|
66
- prefix + esc(name) + separator + esc(v)
67
- }
68
-
69
- when :join
70
- # `-b 1,2,3` / `--blah=1,2,3` style
71
- [ prefix +
72
- esc(name) +
73
- separator +
74
- esc(value.join opts[:array_join_string]) ]
75
-
76
- when :json
77
- [prefix + esc(name) + separator + "'" + JSON.dump(value).gsub(%{'}, %{'"'"'}) + "'"]
78
-
79
- else
80
- # SOL
81
- raise ArgumentError.new NRSER.squish <<-END
82
- bad array_mode option: #{ opts[:array_mode] },
83
- should be :repeat, :join or :json
84
- END
85
-
86
- end
87
-
67
+
68
+ # Special cases (booleans), where we may want to emit an option name but
69
+ # no value (depending on options)
70
+ #
88
71
  when true
89
- # `-b` or `--blah`
72
+ # `-b` or `--blah` style token
90
73
  [prefix + esc(name)]
91
74
 
92
75
  when false
93
76
  case opts[:false_mode]
94
- when :omit
95
- # don't emit any token for a false boolean
77
+ when :omit, :ignore
78
+ # Don't emit any token for a false boolean
96
79
  []
97
- when :no
98
- # `--no-blah` style
99
- #
100
- # but there's not really a great way to handle short names...
101
- # we use `--no-b`
80
+
81
+ when :negate, :no
82
+ # Emit `--no-blah` style token
102
83
  #
103
- ["--no-#{ esc(name) }"]
84
+ if type == :long
85
+ # Easy one
86
+ ["--no-#{ esc(name) }"]
104
87
 
88
+ else
89
+ # Short option... there seems to be little general consensus on how
90
+ # to handle these guys; I feel like the most common is to invert the
91
+ # case, which only makes sense for languages that have lower and
92
+ # upper case :/
93
+ case opts[:false_short_opt_mode]
94
+
95
+ when :capitalize, :cap, :upper, :upcase
96
+ # Capitalize the name
97
+ #
98
+ # {x: false} => ["-X"]
99
+ #
100
+ # This only really makes sense for lower case a-z, so raise if it's
101
+ # not in there
102
+ unless "a" <= name <= "z"
103
+ raise ArgumentError.new binding.erb <<-END
104
+ Can't negate CLI option `<%= name %>` by capitalizing name.
105
+
106
+ Trying to tokenize option `<%= name %>` with `false` value and:
107
+
108
+ 1. `:false_mode` is set to `<%= opts[:false_mode] %>`, which
109
+ tells {Cmds.tokenize_option} to emit a "negating" name with
110
+ no value like
111
+
112
+ {update: false} => --no-update
113
+
114
+ 2. `:false_short_opt_mode` is set to `<%= opts[:false_short_opt_mode] %>`,
115
+ which means negate through capitalizing the name character,
116
+ like:
117
+
118
+ {u: false} => -U
119
+
120
+ 3. But this is only implemented for names in `a-z`
121
+
122
+ Either change the {Cmds} instance configuration or provide a
123
+ different CLI option name or value.
124
+ END
125
+ end
126
+
127
+ # Emit {x: false} => ['-X'] style
128
+ ["-#{ name.upcase }"]
129
+
130
+ when :long
131
+ # Treat it the same as a long option,
132
+ # emit {x: false} => ['--no-x'] style
133
+ #
134
+ # Yeah, I've never seen it anywhere else, but it seems reasonable I
135
+ # guess..?
136
+ #
137
+ ["--no-#{ esc(name) }"]
138
+
139
+ when :string
140
+ # Use the string 'false' as a value
141
+ [prefix + esc( name ) + separator + 'false']
142
+
143
+ when String
144
+ # It's some custom string to use
145
+ [prefix + esc( name ) + separator + esc( string )]
146
+
147
+ else
148
+ raise ArgumentError.new binding.erb <<-END
149
+ Bad `:false_short_opt_mode` value:
150
+
151
+ <%= opts[:false_short_opt_mode].pretty_inspect %>
152
+
153
+ Should be
154
+
155
+ 1. :capitalize (or :cap, :upper, :upcase)
156
+ 2. :long
157
+ 3. :string
158
+ 4. any String
159
+
160
+ END
161
+
162
+ end # case opts[:false_short_opt_mode]
163
+ end # if :long else
105
164
  else
106
165
  raise ArgumentError.new NRSER.squish <<-END
107
- bad :false_mode option: #{ opts[:false_mode] },
166
+ bad :false_mode option: #{ opts[:false_mode] },
108
167
  should be :omit or :no
109
168
  END
110
169
  end
111
-
170
+
171
+ # General case
112
172
  else
113
- # we let .esc handle it
114
- [prefix + esc(name) + separator + esc(value)]
115
-
116
- end
117
- end
118
- end
173
+ # Tokenize the value, which may
174
+ #
175
+ # 1. Result in more than one token, like when `:array_mode` is `:repeat`
176
+ # (in which case we want to emit multiple option tokens)
177
+ #
178
+ # 2. Result in zero tokens, like when `value` is `nil`
179
+ # (in which case we want to emit no option tokens)
180
+ #
181
+ # and map the resulting tokens into option tokens
182
+ #
183
+ tokenize_value( value, **opts ).map { |token|
184
+ prefix + esc(name) + separator + token
185
+ }
186
+
187
+ end # case value
188
+ end # .tokenize_option
189
+ end # class Cmds