cmds 0.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.
data/lib/cmds.rb ADDED
@@ -0,0 +1,322 @@
1
+ # stdlib
2
+ require 'shellwords'
3
+ require 'open3'
4
+ require 'erubis'
5
+
6
+ # deps
7
+ require 'nrser'
8
+
9
+ # project
10
+ require "cmds/version"
11
+
12
+ class Cmds
13
+ class Result
14
+ attr_reader :cmd, :status, :out, :err
15
+
16
+ def initialize cmd, status, out, err
17
+ @cmd = cmd
18
+ @status = status
19
+ @out = out
20
+ @err = err
21
+ end
22
+
23
+ def ok?
24
+ @status == 0
25
+ end
26
+
27
+ def error?
28
+ ! ok?
29
+ end
30
+ end
31
+
32
+ # extension of Erubis' EscapedEruby (which auto-escapes `<%= %>` and
33
+ # leaves `<%== %>` raw) that calls `Cmds.expand_sub` on the value
34
+ class ShellEruby < Erubis::EscapedEruby
35
+ def escaped_expr code
36
+ "::Cmds.expand_sub(#{code.strip})"
37
+ end
38
+ end
39
+
40
+ class ERBContext < BasicObject
41
+ def initialize args, kwds
42
+ @args = args
43
+ @kwds = kwds
44
+ @arg_index = 0
45
+ end
46
+
47
+ def method_missing sym, *args, &block
48
+ if args.empty? && block.nil?
49
+ if sym.to_s[-1] == '?'
50
+ key = sym.to_s[0...-1].to_sym
51
+ @kwds[key]
52
+ else
53
+ @kwds.fetch sym
54
+ end
55
+ else
56
+ super
57
+ end
58
+ end
59
+
60
+ def get_binding
61
+ ::Kernel.send :binding
62
+ end
63
+
64
+ def arg
65
+ @args.fetch(@arg_index).tap {@arg_index += 1}
66
+ end
67
+ end
68
+
69
+ # shortcut for Shellwords.escape
70
+ #
71
+ # also makes it easier to change or customize or whatever
72
+ def self.esc str
73
+ Shellwords.escape str
74
+ end
75
+
76
+ # escape option hash.
77
+ #
78
+ # this is only useful for the two common option styles:
79
+ #
80
+ # - single character keys become `-<char> <value>`
81
+ #
82
+ # {x: 1} => "-x 1"
83
+ #
84
+ # - longer keys become `--<key>=<value>` options
85
+ #
86
+ # {blah: 2} => "--blah=2"
87
+ #
88
+ # if you have something else, you're going to have to just put it in
89
+ # the cmd itself, like:
90
+ #
91
+ # Cmds "blah -assholeOptionOn:%{s}", "ok"
92
+ #
93
+ # or whatever similar shit said command requires.
94
+ #
95
+ # however, if the value is an Array, it will repeat the option for each
96
+ # value:
97
+ #
98
+ # {x: [1, 2, 3]} => "-x 1 -x 2 -x 3"
99
+ # {blah: [1, 2, 3]} => "--blah=1 --blah=2 --blah=3"
100
+ #
101
+ # i can't think of any right now, but i swear i've seen commands that take
102
+ # opts that way.
103
+ #
104
+ def self.expand_option_hash hash
105
+ hash.map {|key, values|
106
+ # keys need to be strings
107
+ key = key.to_s unless key.is_a? String
108
+
109
+ [key, values]
110
+
111
+ }.sort {|(key_a, values_a), (key_b, values_b)|
112
+ # sort by the (now string) keys
113
+ key_a <=> key_b
114
+
115
+ }.map {|key, values|
116
+ # for simplicity's sake, treat all values like an array
117
+ values = [values] unless values.is_a? Array
118
+
119
+ # keys of length 1 expand to `-x v` form
120
+ expanded = if key.length == 1
121
+ values.map {|value|
122
+ if value.nil?
123
+ "-#{ esc key }"
124
+ else
125
+ "-#{ esc key } #{ esc value}"
126
+ end
127
+ }
128
+
129
+ # longer keys expand to `--key=value` form
130
+ else
131
+ values.map {|value|
132
+ if value.nil?
133
+ "--#{ esc key }"
134
+ else
135
+ "--#{ esc key }=#{ esc value }"
136
+ end
137
+ }
138
+ end
139
+ }.flatten.join ' '
140
+ end # ::expand_option_hash
141
+
142
+ # expand one of the substitutions
143
+ def self.expand_sub sub
144
+ case sub
145
+ when nil
146
+ # nil is just an empty string, NOT an empty string bash token
147
+ ''
148
+ when Hash
149
+ expand_option_hash sub
150
+ else
151
+ esc sub.to_s
152
+ end
153
+ end # ::expand_sub
154
+
155
+ # substitute values into a command, escaping them for the shell and
156
+ # offering convenient expansions for some structures.
157
+ #
158
+ # `cmd` is a string that can be substituted via ruby's `%` operator, like
159
+ #
160
+ # "git diff %s"
161
+ #
162
+ # for positional substitution, or
163
+ #
164
+ # "git diff %{path}"
165
+ #
166
+ # for keyword substitution.
167
+ #
168
+ # `subs` is either:
169
+ #
170
+ # - an Array when `cmd` has positional placeholders
171
+ # - a Hash when `cmd` has keyword placeholders.
172
+ #
173
+ # the elements of the `subs` array or values of the `subs` hash are:
174
+ #
175
+ # - strings that are substituted into `cmd` after being escaped:
176
+ #
177
+ # sub "git diff %{path}", path: "some path/to somewhere"
178
+ # # => 'git diff some\ path/to\ somewhere'
179
+ #
180
+ # - hashes that are expanded into options:
181
+ #
182
+ # sub "psql %{opts} %{database} < %{filepath}",
183
+ # database: "blah",
184
+ # filepath: "/where ever/it/is.psql",
185
+ # opts: {
186
+ # username: "bingo bob",
187
+ # host: "localhost",
188
+ # port: 12345,
189
+ # }
190
+ # # => 'psql --host=localhost --port=12345 --username=bingo\ bob blah < /where\ ever/it/is.psql'
191
+ #
192
+ def self.sub cmd, args = [], kwds = {}
193
+ raise TypeError.new("args must be an Array") unless args.is_a? Array
194
+ raise TypeError.new("kwds must be an Hash") unless kwds.is_a? Hash
195
+
196
+ context = ERBContext.new(args, kwds)
197
+ erb = ShellEruby.new(replace_shortcuts cmd)
198
+
199
+ NRSER.squish erb.result(context.get_binding)
200
+ end # ::sub
201
+
202
+ def self.subs_to_args_and_kwds subs
203
+ args = []
204
+ kwds = {}
205
+
206
+ case subs.length
207
+ when 0
208
+ # pass
209
+ when 1
210
+ case subs[0]
211
+ when Hash
212
+ kwds = subs[0]
213
+
214
+ when Array
215
+ args = subs[0]
216
+
217
+ else
218
+ raise TypeError.new NRSER.squish <<-BLOCK
219
+ first *subs arg must be Array or Hash, not #{ subs[0].inspect }
220
+ BLOCK
221
+ end
222
+
223
+ when 2
224
+ unless subs[0].is_a? Array
225
+ raise TypeError.new NRSER.squish <<-BLOCK
226
+ first *subs arg needs to be an array, not #{ subs[0].inspect }
227
+ BLOCK
228
+ end
229
+
230
+ unless subs[1].is_a? Hash
231
+ raise TypeError.new NRSER.squish <<-BLOCK
232
+ third *subs arg needs to be a Hash, not #{ subs[1].inspect }
233
+ BLOCK
234
+ end
235
+
236
+ args, kwds = subs
237
+ else
238
+ raise ArgumentError.new NRSER.squish <<-BLOCK
239
+ must provide one or two *subs arguments, received #{ 1 + subs.length }
240
+ BLOCK
241
+ end
242
+
243
+ [args, kwds]
244
+ end
245
+
246
+ # create a new Cmd from template and subs and call it
247
+ def self.run template, *subs
248
+ self.new(template, *subs).call
249
+ end
250
+
251
+ def self.replace_shortcuts template
252
+ template
253
+ .gsub(
254
+ # %s => <%= arg %>
255
+ /(\A|[[:space:]])\%s(\Z|[[:space:]])/,
256
+ '\1<%= arg %>\2'
257
+ )
258
+ .gsub(
259
+ # %%s => %s (escpaing)
260
+ /(\A|[[:space:]])(\%+)\%s(\Z|[[:space:]])/,
261
+ '\1\2s\3'
262
+ )
263
+ .gsub(
264
+ # %{key} => <%= key %>, %{key?} => <%= key? %>
265
+ /(\A|[[:space:]])\%\{([a-zA-Z_]+\??)\}(\Z|[[:space:]])/,
266
+ '\1<%= \2 %>\3'
267
+ )
268
+ .gsub(
269
+ # %%{key} => %{key}, %%{key?} => %{key?} (escpaing)
270
+ /(\A|[[:space:]])(\%+)\%\{([a-zA-Z_]+\??)\}(\Z|[[:space:]])/,
271
+ '\1\2{\3}\4'
272
+ )
273
+ .gsub(
274
+ # %<key>s => <%= key %>, %<key?>s => <%= key? %>
275
+ /(\A|[[:space:]])\%\<([a-zA-Z_]+\??)\>s(\Z|[[:space:]])/,
276
+ '\1<%= \2 %>\3'
277
+ )
278
+ .gsub(
279
+ # %%<key>s => %<key>s, %%<key?>s => %<key?>s (escaping)
280
+ /(\A|[[:space:]])(\%+)\%\<([a-zA-Z_]+\??)\>s(\Z|[[:space:]])/,
281
+ '\1\2<\3>s\4'
282
+ )
283
+ end
284
+
285
+ attr_reader :tempalte, :args, :kwds
286
+
287
+ def initialize template, *subs
288
+ @template = template
289
+ @args, @kwds = Cmds.subs_to_args_and_kwds subs
290
+ end #initialize
291
+
292
+ def call *subs
293
+ args, kwds = merge_subs subs
294
+
295
+ cmd = Cmds.sub @template, args, kwds
296
+
297
+ out, err, status = Open3.capture3 cmd
298
+
299
+ Cmds::Result.new cmd, status, out, err
300
+ end #call
301
+
302
+ # returns a new `Cmds` with the subs merged in
303
+ def curry *subs
304
+ self.class.new @template, *merge_subs(subs)
305
+ end
306
+
307
+ private
308
+
309
+ def merge_subs subs
310
+ # break `subs` into `args` and `kwds`
311
+ args, kwds = Cmds.subs_to_args_and_kwds subs
312
+
313
+ [@args + args, @kwds.merge(kwds)]
314
+ end #merge_subs
315
+
316
+ # end private
317
+ end # Cmds
318
+
319
+ # convenience for Cmds::run
320
+ def Cmds *args
321
+ Cmds.run *args
322
+ end
data/scratch/blah.rb ADDED
@@ -0,0 +1,6 @@
1
+ def f *args
2
+ p args
3
+ p kwargs
4
+ end
5
+
6
+ f 1, 2, 3
data/scratch/erb.rb ADDED
@@ -0,0 +1,53 @@
1
+ require 'shellwords'
2
+ require 'erubis'
3
+ require 'nrser'
4
+
5
+ class ShellEruby < Erubis::EscapedEruby
6
+ def escaped_expr code
7
+ "Shellwords.escape((#{code.strip}).to_s)"
8
+ end
9
+ end
10
+
11
+ class ERBContext
12
+ def initialize args, kwargs
13
+ @args = args
14
+ @kwargs = kwargs
15
+ @arg_index = 0
16
+ end
17
+
18
+ def method_missing sym, *args, &block
19
+ if args.empty? && block.nil?
20
+ if sym.to_s[-1] == '?'
21
+ key = sym.to_s[0...-1].to_sym
22
+ @kwargs[key]
23
+ else
24
+ @kwargs.fetch(sym)
25
+ end
26
+ else
27
+ super
28
+ end
29
+ end
30
+
31
+ def get_binding
32
+ binding
33
+ end
34
+
35
+ def arg
36
+ @args.fetch(@arg_index).tap {@arg_index += 1}
37
+ end
38
+ end
39
+
40
+ tpl = <<-BLOCK
41
+ defaults write <%= domain %> <%= key %> -dict
42
+ <% values.each do |key, value| %>
43
+ <%= key %> <%= value %>
44
+ <% end %>
45
+ BLOCK
46
+
47
+ ctx = ERBContext.new [], domain: "com.nrser.blah",
48
+ key: "don't do it",
49
+ values: {x: '<ex>', y: 'why'}
50
+
51
+ s = NRSER.squish ShellEruby.new(tpl).result(ctx.get_binding)
52
+
53
+ puts s
@@ -0,0 +1,16 @@
1
+ require 'json'
2
+
3
+ require 'spec_helper'
4
+
5
+ describe "Cmds::call" do
6
+ it "is reusable" do
7
+ args_cmd = Cmds.new "./test/echo_cmd.rb <%= arg %>"
8
+ kwds_cmd = Cmds.new "./test/echo_cmd.rb <%= s %>"
9
+
10
+ ["arg one", "arg two", "arg three"].each do |arg|
11
+ [args_cmd.call([arg]), kwds_cmd.call(s: arg)].each do |result|
12
+ expect_argv( result ).to eq [arg]
13
+ end
14
+ end
15
+ end # is reusable
16
+ end # Cmds::run
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Cmds::curry" do
4
+ it "currys" do
5
+ base = Cmds.new "./test/echo_cmd.rb <%= x %> <%= y %>"
6
+
7
+ x1 = base.curry x: 1
8
+ x2 = base.curry x: 2
9
+
10
+ expect_argv( x1.call y: 'why' ).to eq ['1', 'why']
11
+ expect_argv( x2.call y: 'who' ).to eq ['2', 'who']
12
+ expect_argv( base.call x: 3, y: 4 ).to eq ['3', '4']
13
+ end # it currys
14
+ end # Cmds::run
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+
3
+ describe Cmds::ERBContext do
4
+ let(:tpl) {
5
+ <<-BLOCK
6
+ defaults
7
+ <% if current_host? %>
8
+ -currentHost <%= current_host %>
9
+ <% end %>
10
+ export <%= domain %> <%= filepath %>
11
+ BLOCK
12
+ }
13
+
14
+ def get_result tpl, bnd
15
+ NRSER.squish ERB.new(tpl).result(bnd.get_binding)
16
+ end
17
+
18
+ it "should work" do
19
+ bnd = Cmds::ERBContext.new [], current_host: 'xyz', domain: 'com.nrser.blah', filepath: '/tmp/export.plist'
20
+
21
+ expect(get_result tpl, bnd).to eq "defaults -currentHost xyz export com.nrser.blah /tmp/export.plist"
22
+ end
23
+ end
@@ -0,0 +1,61 @@
1
+ require 'spec_helper'
2
+
3
+ describe "Cmds::expand_option_hash_spec" do
4
+
5
+ context "one single char key" do
6
+ it "handles nil value" do
7
+ expect(Cmds.expand_option_hash x: nil).to eq "-x"
8
+ end
9
+
10
+ it "handles simple value" do
11
+ expect(Cmds.expand_option_hash x: 1).to eq "-x 1"
12
+ end
13
+
14
+ it "handles array value" do
15
+ expect(Cmds.expand_option_hash x: [1, 2, 3]).to eq "-x 1 -x 2 -x 3"
16
+ end
17
+ end # single char key
18
+
19
+ context "multiple single char keys" do
20
+ it "order expansion by key" do
21
+ expect(Cmds.expand_option_hash b: 2, a: 1, c: 3).to eq "-a 1 -b 2 -c 3"
22
+ end
23
+ end # multiple single char keys
24
+
25
+ context "one longer key" do
26
+ it "handles nil value" do
27
+ expect(Cmds.expand_option_hash blah: nil).to eq "--blah"
28
+ end
29
+
30
+ it "handles a simple value" do
31
+ expect(Cmds.expand_option_hash blah: 1).to eq "--blah=1"
32
+ end
33
+
34
+ it "handles an array value" do
35
+ expect(Cmds.expand_option_hash blah: [1, 2, 3]).to eq "--blah=1 --blah=2 --blah=3"
36
+ end
37
+ end # one longer key
38
+
39
+ context "multiple longer keys" do
40
+ it "order expansion by key" do
41
+ expect(Cmds.expand_option_hash bob: 2, al: 1, cat: 3).to eq "--al=1 --bob=2 --cat=3"
42
+ end
43
+ end # multiple longer keys
44
+
45
+ it "handles a mess of stuff" do
46
+ expect(
47
+ Cmds.expand_option_hash d: 1,
48
+ blah: "blow",
49
+ cat: nil,
50
+ x: ['m', 'e',]
51
+ ).to eq "--blah=blow --cat -d 1 -x m -x e"
52
+ end
53
+
54
+ it "escapes paths" do
55
+ expect(
56
+ Cmds.expand_option_hash path: "/some folder/some where",
57
+ p: "maybe ov/er here..."
58
+ ).to eq '-p maybe\ ov/er\ here... --path=/some\ folder/some\ where'
59
+ end
60
+
61
+ end # ::expand_option_hash_spec
@@ -0,0 +1,78 @@
1
+ require 'spec_helper'
2
+
3
+ def expect_to_replace input, output
4
+ [
5
+ "#{ input }",
6
+ "blah #{ input }",
7
+ "#{ input } blah",
8
+ "blah\n#{ input }\nblah",
9
+ ].each do |str|
10
+ expect( Cmds.replace_shortcuts input ).to eq output
11
+ end
12
+ end
13
+
14
+ describe 'Cmds::replace_shortcuts' do
15
+ it "should replace %s with <%= arg %>" do
16
+ expect_to_replace "%s", "<%= arg %>"
17
+ end
18
+
19
+ it "should replace %%s with %s (escaping)" do
20
+ expect_to_replace "%%s", "%s"
21
+ end
22
+
23
+ it "should replace %%%s with %%s (escaping)" do
24
+ expect_to_replace "%%%s", "%%s"
25
+ end
26
+
27
+ it "should replace %{key} with <%= key %>" do
28
+ expect_to_replace "%{key}", "<%= key %>"
29
+ end
30
+
31
+ it "should replace %%{key} with %{key} (escaping)" do
32
+ expect_to_replace '%%{key}', '%{key}'
33
+ end
34
+
35
+ it "should replace %%%{key} with %%{key} (escaping)" do
36
+ expect_to_replace '%%%{key}', '%%{key}'
37
+ end
38
+
39
+ it "should replace %{key?} with <%= key? %>" do
40
+ expect_to_replace "%{key?}", "<%= key? %>"
41
+ end
42
+
43
+ it "should replace %%{key?} with %{key?} (escaping)" do
44
+ expect_to_replace '%%{key?}', '%{key?}'
45
+ end
46
+
47
+ it "should replace %%%{key?} with %%{key?} (escaping)" do
48
+ expect_to_replace '%%%{key?}', '%%{key?}'
49
+ end
50
+
51
+ it "should replace %<key>s with <%= key %>" do
52
+ expect_to_replace "%<key>s", "<%= key %>"
53
+ end
54
+
55
+ it "should replace %%<key>s with %<key>s (escaping)" do
56
+ expect_to_replace "%%<key>s", "%<key>s"
57
+ end
58
+
59
+ it "should replace %%%<key>s with %%<key>s (escaping)" do
60
+ expect_to_replace "%%%<key>s", "%%<key>s"
61
+ end
62
+
63
+ it "should replace %<key?>s with <%= key? %>" do
64
+ expect_to_replace '%<key?>s', '<%= key? %>'
65
+ end
66
+
67
+ it "should replace %%<key?>s with %<key?>s (escaping)" do
68
+ expect_to_replace '%%<key?>s', '%<key?>s'
69
+ end
70
+
71
+ it "should replace %%%<key?>s with %%<key?>s (escaping)" do
72
+ expect_to_replace '%%%<key?>s', '%%<key?>s'
73
+ end
74
+
75
+ it "should not touch % that don't fit the shortcut sytax" do
76
+ expect( Cmds.replace_shortcuts "50%" ).to eq "50%"
77
+ end
78
+ end
@@ -0,0 +1,49 @@
1
+ require 'json'
2
+
3
+ require 'spec_helper'
4
+
5
+ describe "Cmds::run" do
6
+ context "echo_cmd.rb 'hello world!'" do
7
+
8
+ shared_examples "executes correctly" do
9
+ it_behaves_like "ok"
10
+
11
+ it "should have 'hello world!' as ARGV[0]" do
12
+ expect( JSON.load(result.out)['ARGV'][0] ).to eq "hello world!"
13
+ end
14
+ end # executes correctly
15
+
16
+ context "positional args" do
17
+ let(:result) {
18
+ Cmds "./test/echo_cmd.rb <%= arg %>", ["hello world!"]
19
+ }
20
+
21
+ it_behaves_like "executes correctly"
22
+ end
23
+
24
+ context "keyword args" do
25
+ let(:result) {
26
+ Cmds "./test/echo_cmd.rb <%= s %>", s: "hello world!"
27
+ }
28
+
29
+ it_behaves_like "executes correctly"
30
+ end
31
+
32
+ end # context echo_cmd.rb 'hello world!'
33
+
34
+ # context "feeding kwargs to args cmd" do
35
+ # let(:result) {
36
+ # Cmds "./test/echo_cmd.rb %s", s: "sup y'all"
37
+ # }
38
+
39
+ # it "" do
40
+ # expect( result.cmd ).to eq nil
41
+ # end
42
+ # end
43
+
44
+ it "should error when second (subs) arg is not a hash or array" do
45
+ expect {
46
+ Cmds "./test/echo_cmd.rb <%= arg %>", "hello world!"
47
+ }.to raise_error TypeError
48
+ end
49
+ end # Cmds::run