cmds 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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