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