tty-file 0.1.0

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/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