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.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.rspec +3 -0
- data/.travis.yml +27 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/tty-file.rb +1 -0
- data/lib/tty/file.rb +497 -0
- data/lib/tty/file/create_file.rb +104 -0
- data/lib/tty/file/differ.rb +78 -0
- data/lib/tty/file/download_file.rb +56 -0
- data/lib/tty/file/version.rb +7 -0
- data/tasks/console.rake +10 -0
- data/tasks/coverage.rake +11 -0
- data/tasks/spec.rake +29 -0
- data/tty-file.gemspec +29 -0
- metadata +155 -0
data/Rakefile
ADDED
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
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
|