tty-file 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # encoding: utf-8
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ FileList['tasks/**/*.rake'].each(&method(:import))
6
+
7
+ task default: :spec
8
+
9
+ desc 'Run all specs'
10
+ task ci: %w[ spec ]
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "tty/file"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/tty-file.rb ADDED
@@ -0,0 +1 @@
1
+ require 'tty/file'
data/lib/tty/file.rb ADDED
@@ -0,0 +1,497 @@
1
+ # encoding: utf-8
2
+
3
+ require 'pastel'
4
+ require 'tty-prompt'
5
+ require 'erb'
6
+ require 'tempfile'
7
+
8
+ require 'tty/file/create_file'
9
+ require 'tty/file/download_file'
10
+ require 'tty/file/differ'
11
+ require 'tty/file/version'
12
+
13
+ module TTY
14
+ module File
15
+ def self.private_module_function(method)
16
+ module_function(method)
17
+ private_class_method(method)
18
+ end
19
+
20
+ # File permissions
21
+ U_R = 0400
22
+ U_W = 0200
23
+ U_X = 0100
24
+ G_R = 0040
25
+ G_W = 0020
26
+ G_X = 0010
27
+ O_R = 0004
28
+ O_W = 0002
29
+ O_X = 0001
30
+ A_R = 0444
31
+ A_W = 0222
32
+ A_X = 0111
33
+
34
+ # Check if file is binary
35
+ #
36
+ # @param [String] relative_path
37
+ # the path to file to check
38
+ #
39
+ # @example
40
+ # binary?('Gemfile') # => false
41
+ #
42
+ # @example
43
+ # binary?('image.jpg') # => true
44
+ #
45
+ # @return [Boolean]
46
+ # Returns `true` if the file is binary
47
+ #
48
+ # @api public
49
+ def binary?(relative_path)
50
+ bytes = ::File.stat(relative_path).blksize
51
+ bytes = 4096 if bytes > 4096
52
+ buffer = ::File.read(relative_path) || ''
53
+ begin
54
+ return buffer !~ /\A[\s[[:print:]]]*\z/m
55
+ rescue ArgumentError => error
56
+ return true if error.message =~ /invalid byte sequence/
57
+ raise
58
+ end
59
+ end
60
+ module_function :binary?
61
+
62
+ # Change file permissions
63
+ #
64
+ # @param [String] relative_path
65
+ # @param [Integer,String] permisssions
66
+ # @param [Hash[Symbol]] options
67
+ #
68
+ # @example
69
+ # chmod('Gemfile', 0755)
70
+ #
71
+ # @example
72
+ # chmod('Gemilfe', TTY::File::U_R | TTY::File::U_W)
73
+ #
74
+ # @example
75
+ # chmod('Gemfile', 'u+x,g+x')
76
+ #
77
+ # @api public
78
+ def chmod(relative_path, permissions, options = {})
79
+ mode = ::File.lstat(relative_path).mode
80
+ if permissions.to_s =~ /\d+/
81
+ mode = permissions
82
+ else
83
+ permissions.scan(/[ugoa][+-=][rwx]+/) do |setting|
84
+ who, action = setting[0], setting[1]
85
+ setting[2..setting.size].each_byte do |perm|
86
+ mask = const_get("#{who.upcase}_#{perm.chr.upcase}")
87
+ (action == '+') ? mode |= mask : mode ^= mask
88
+ end
89
+ end
90
+ end
91
+ log_status(:chmod, relative_path, options.fetch(:verbose, true), :green)
92
+ ::FileUtils.chmod_R(mode, relative_path) unless options[:noop]
93
+ end
94
+ module_function :chmod
95
+
96
+ # Create new file if doesn't exist
97
+ #
98
+ # @param [String] relative_path
99
+ # @param [String|nil] content
100
+ # the content to add to file
101
+ # @param [Hash] options
102
+ # @option options [Symbol] :force
103
+ # forces ovewrite if conflict present
104
+ #
105
+ # @example
106
+ # create_file('doc/README.md', '# Title header')
107
+ #
108
+ # @example
109
+ # create_file 'doc/README.md' do
110
+ # '# Title Header'
111
+ # end
112
+ #
113
+ # @api public
114
+ def create_file(relative_path, *args, &block)
115
+ options = args.last.is_a?(Hash) ? args.pop : {}
116
+
117
+ content = block_given? ? block[] : args.join
118
+
119
+ CreateFile.new(relative_path, content, options).call
120
+ end
121
+ module_function :create_file
122
+
123
+ # Copy file from the relative source to the relative
124
+ # destination running it through ERB.
125
+ #
126
+ # @example
127
+ # copy_file 'templates/test.rb', 'app/test.rb'
128
+ #
129
+ # @param [Hash] options
130
+ # @option options [Symbol] :context
131
+ # the binding to use for the template
132
+ # @option options [Symbol] :preserve
133
+ # If true, the owner, group, permissions and modified time
134
+ # are preserved on the copied file, defaults to false.
135
+ # @option options [Symbol] :noop
136
+ # If true do not execute the action.
137
+ # @option options [Symbol] :verbose
138
+ # If true log the action status to stdout
139
+ #
140
+ # @api public
141
+ def copy_file(source_path, *args, &block)
142
+ options = args.last.is_a?(Hash) ? args.pop : {}
143
+ dest_path = args.first || source_path.sub(/\.erb$/, '')
144
+
145
+ if ::File.directory?(dest_path)
146
+ dest_path = ::File.join(dest_path, ::File.basename(source_path))
147
+ end
148
+
149
+ ctx = if (vars = options[:context])
150
+ vars.instance_eval('binding')
151
+ else
152
+ instance_eval('binding')
153
+ end
154
+
155
+ options[:context] ||= self
156
+ create_file(dest_path, options) do
157
+ template = ERB.new(::File.binread(source_path), nil, "-", "@output_buffer")
158
+ content = template.result(ctx)
159
+ content = block[content] if block
160
+ content
161
+ end
162
+ return unless options[:preserve]
163
+ copy_metadata(source_path, dest_path, options)
164
+ end
165
+ module_function :copy_file
166
+
167
+ # Copy file metadata
168
+ #
169
+ # @api public
170
+ def copy_metadata(src_path, dest_path, options = {})
171
+ stats = ::File.lstat(src_path)
172
+ ::File.utime(stats.atime, stats.mtime, dest_path)
173
+ chmod(dest_path, stats.mode, options)
174
+ end
175
+ module_function :copy_metadata
176
+
177
+ # Diff files line by line
178
+ #
179
+ # @param [String] path_a
180
+ # @param [String] path_b
181
+ # @param [Hash[Symbol]] options
182
+ # @option options [Symbol] :format
183
+ # the diffining output format
184
+ # @option options [Symbol] :context_lines
185
+ # the number of extra lines for the context
186
+ # @option options [Symbol] :threshold
187
+ # maximum file size in bytes
188
+ #
189
+ # @example
190
+ # diff(file_a, file_b, format: :old)
191
+ #
192
+ # @api public
193
+ def diff(path_a, path_b, options = {})
194
+ threshold = options[:threshold] || 10_000_000
195
+ output = ''
196
+
197
+ open_tempfile_if_missing(path_a) do |file_a|
198
+ if ::File.size(file_a) > threshold
199
+ raise ArgumentError, "(file size of #{file_a.path} exceeds #{threshold} bytes, diff output suppressed)"
200
+ end
201
+ if binary?(file_a)
202
+ raise ArgumentError, "(#{file_a.path} is binary, diff output suppressed)"
203
+ end
204
+ open_tempfile_if_missing(path_b) do |file_b|
205
+ if binary?(file_b)
206
+ raise ArgumentError, "(#{file_a.path} is binary, diff output suppressed)"
207
+ end
208
+ if ::File.size(file_b) > threshold
209
+ return "(file size of #{file_b.path} exceeds #{threshold} bytes, diff output suppressed)"
210
+ end
211
+
212
+ log_status(:diff, "#{file_a.path} - #{file_b.path}",
213
+ options.fetch(:verbose, true), :green)
214
+ return output if options[:noop]
215
+
216
+ block_size = file_a.lstat.blksize
217
+ while !file_a.eof? && !file_b.eof?
218
+ output << Differ.new(file_a.read(block_size),
219
+ file_b.read(block_size),
220
+ options).call
221
+ end
222
+ end
223
+ end
224
+ output
225
+ end
226
+ module_function :diff
227
+ alias diff_files diff
228
+
229
+ # Download the content from a given address and
230
+ # save at the given relative destination. If block
231
+ # is provided in place of destination, the content of
232
+ # of the uri is yielded.
233
+ #
234
+ # @param [String] uri
235
+ # the URI address
236
+ # @param [String] dest
237
+ # the relative path to save
238
+ # @param [Hash[Symbol]] options
239
+ # @param options [Symbol] :limit
240
+ # the limit of redirects
241
+ #
242
+ # @example
243
+ # download_file("https://gist.github.com/4701967",
244
+ # "doc/benchmarks")
245
+ #
246
+ # @example
247
+ # download_file("https://gist.github.com/4701967") do |content|
248
+ # content.gsub("\n", " ")
249
+ # end
250
+ #
251
+ # @api public
252
+ def download_file(uri, *args, &block)
253
+ options = args.last.is_a?(Hash) ? args.pop : {}
254
+ dest_path = args.first || ::File.basename(uri)
255
+
256
+ unless uri =~ %r{^https?\://}
257
+ copy_file(uri, dest_path, options)
258
+ return
259
+ end
260
+
261
+ content = DownloadFile.new(uri, dest_path, options).call
262
+
263
+ if block_given?
264
+ content = (block.arity == 1 ? block[content] : block[])
265
+ end
266
+
267
+ create_file(dest_path, content, options)
268
+ end
269
+ module_function :download_file
270
+
271
+ # Prepend to a file
272
+ #
273
+ # @param [String] relative_path
274
+ # @param [Array[String]] content
275
+ # the content to preped to file
276
+ #
277
+ # @example
278
+ # prepend_to_file('Gemfile', "gem 'tty'")
279
+ #
280
+ # @example
281
+ # prepend_to_file('Gemfile') do
282
+ # "gem 'tty'"
283
+ # end
284
+ #
285
+ # @api public
286
+ def prepend_to_file(relative_path, *args, &block)
287
+ options = args.last.is_a?(Hash) ? args.pop : {}
288
+ log_status(:prepend, relative_path, options.fetch(:verbose, true), :green)
289
+ options.merge!(before: /\A/, verbose: false)
290
+ inject_into_file(relative_path, *(args << options), &block)
291
+ end
292
+ module_function :prepend_to_file
293
+
294
+ # Append to a file
295
+ #
296
+ # @param [String] relative_path
297
+ # @param [Array[String]] content
298
+ # the content to append to file
299
+ #
300
+ # @example
301
+ # append_to_file('Gemfile', "gem 'tty'")
302
+ #
303
+ # @example
304
+ # append_to_file('Gemfile') do
305
+ # "gem 'tty'"
306
+ # end
307
+ #
308
+ # @api public
309
+ def append_to_file(relative_path, *args, &block)
310
+ options = args.last.is_a?(Hash) ? args.pop : {}
311
+ log_status(:append, relative_path, options.fetch(:verbose, true), :green)
312
+ options.merge!(after: /\z/, verbose: false)
313
+ inject_into_file(relative_path, *(args << options), &block)
314
+ end
315
+ module_function :append_to_file
316
+ alias add_to_file append_to_file
317
+
318
+ # Inject content into file at a given location
319
+ #
320
+ # @param [String] relative_path
321
+ #
322
+ # @param [Hash] options
323
+ # @option options [Symbol] :before
324
+ # the matching line to insert content before
325
+ # @option options [Symbol] :after
326
+ # the matching line to insert content after
327
+ # @option options [Symbol] :force
328
+ # insert content more than once
329
+ # @option options [Symbol] :verbose
330
+ # log status
331
+ #
332
+ # @example
333
+ # inject_into_file('Gemfile', "gem 'tty'", after: "gem 'rack'\n")
334
+ #
335
+ # @example
336
+ # inject_into_file('Gemfile', "gem 'tty'\n", "gem 'loaf'", after: "gem 'rack'\n")
337
+ #
338
+ # @example
339
+ # inject_into_file('Gemfile', after: "gem 'rack'\n") do
340
+ # "gem 'tty'\n"
341
+ # end
342
+ #
343
+ # @api public
344
+ def inject_into_file(relative_path, *args, &block)
345
+ options = args.last.is_a?(Hash) ? args.pop : {}
346
+
347
+ replacement = block_given? ? block[] : args.join
348
+
349
+ flag, match = if options.key?(:after)
350
+ [:after, options.delete(:after)]
351
+ else
352
+ [:before, options.delete(:before)]
353
+ end
354
+
355
+ match = match.is_a?(Regexp) ? match : Regexp.escape(match)
356
+ content = if flag == :after
357
+ '\0' + replacement
358
+ else
359
+ replacement + '\0'
360
+ end
361
+
362
+ replace_in_file(relative_path, /#{match}/, content, options.merge(verbose: false))
363
+
364
+ log_status(:inject, relative_path, options.fetch(:verbose, true), :green)
365
+ end
366
+ module_function :inject_into_file
367
+ alias insert_into_file inject_into_file
368
+
369
+ # Replace content of a file matching string
370
+ #
371
+ # @options [Hash[String]] options
372
+ # @option options [Symbol] :force
373
+ # replace content even if present
374
+ # @option options [Symbol] :verbose
375
+ # log status
376
+ #
377
+ # @example
378
+ # replace_in_file('Gemfile', /gem 'rails'/, "gem 'hanami'")
379
+ #
380
+ # @example
381
+ # replace_in_file('Gemfile', /gem 'rails'/) do |match|
382
+ # match = "gem 'hanami'"
383
+ # end
384
+ #
385
+ # @api public
386
+ def replace_in_file(relative_path, *args, &block)
387
+ check_path(relative_path)
388
+ options = args.last.is_a?(Hash) ? args.pop : {}
389
+
390
+ contents = IO.read(relative_path)
391
+
392
+ replacement = (block ? block[] : args[1..-1].join).gsub('\0', '')
393
+
394
+ log_status(:replace, relative_path, options.fetch(:verbose, true), :green)
395
+
396
+ return if options[:noop]
397
+
398
+ if options[:force] || !contents.include?(replacement)
399
+ if !contents.gsub!(*args, &block)
400
+ find = args[0]
401
+ raise "#{find.inspect} not found in #{relative_path}"
402
+ end
403
+ ::File.open(relative_path, 'w') do |file|
404
+ file.write(contents)
405
+ end
406
+ end
407
+ end
408
+ module_function :replace_in_file
409
+ alias gsub_file replace_in_file
410
+
411
+ # Remove a file or a directory at specified relative path.
412
+ #
413
+ # @param [Hash[:Symbol]] options
414
+ # @option options [Symbol] :noop
415
+ # pretend removing file
416
+ # @option options [Symbol] :force
417
+ # remove file ignoring errors
418
+ # @option options [Symbol] :verbose
419
+ # log status
420
+ #
421
+ # @example
422
+ # remove_file 'doc/README.md'
423
+ #
424
+ # @api public
425
+ def remove_file(relative_path, *args)
426
+ options = args.last.is_a?(Hash) ? args.pop : {}
427
+
428
+ log_status(:remove, relative_path, options.fetch(:verbose, true), :red)
429
+
430
+ return if options[:noop]
431
+
432
+ ::FileUtils.rm_r(relative_path, force: options[:force], secure: true)
433
+ end
434
+ module_function :remove_file
435
+
436
+ # Check if path exists
437
+ #
438
+ # @raise [ArgumentError]
439
+ #
440
+ # @api private
441
+ def check_path(path)
442
+ return if ::File.exist?(path)
443
+ raise ArgumentError, "File path #{path} does not exist."
444
+ end
445
+ private_module_function :check_path
446
+
447
+ @output = $stdout
448
+ @pastel = Pastel.new(enabled: true)
449
+
450
+ def decorate(message, color)
451
+ @pastel.send(color, message)
452
+ end
453
+ private_module_function :decorate
454
+
455
+ # Log file operation
456
+ #
457
+ # @api private
458
+ def log_status(cmd, message, verbose, color = false)
459
+ return unless verbose
460
+
461
+ cmd = cmd.to_s.rjust(12)
462
+ cmd = decorate(cmd, color) if color
463
+
464
+ message = "#{cmd} #{message}"
465
+ message += "\n" unless message.end_with?("\n")
466
+
467
+ @output.print(message)
468
+ @output.flush
469
+ end
470
+ module_function :log_status
471
+
472
+ # If content is not a path to a file, create a
473
+ # tempfile and open it instead.
474
+ #
475
+ # @param [String] object
476
+ # a path to file or content
477
+ #
478
+ # @api private
479
+ def open_tempfile_if_missing(object, &block)
480
+ if ::FileTest.file?(object)
481
+ ::File.open(object, &block)
482
+ else
483
+ tempfile = Tempfile.new('tty-file-diff')
484
+ tempfile << object
485
+ tempfile.rewind
486
+
487
+ block[tempfile]
488
+
489
+ unless tempfile.nil?
490
+ tempfile.close
491
+ tempfile.unlink
492
+ end
493
+ end
494
+ end
495
+ private_module_function :open_tempfile_if_missing
496
+ end # File
497
+ end # TTY