freyia 0.5.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.
@@ -0,0 +1,400 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "column_printer"
4
+ require_relative "table_printer"
5
+ require_relative "wrapped_printer"
6
+
7
+ module Freyia
8
+ module Shell
9
+ class Basic
10
+ attr_accessor :base
11
+ attr_reader :padding
12
+
13
+ # Initialize base, mute and padding to nil.
14
+ #
15
+ def initialize(base:)
16
+ @base = base
17
+ @mute = false
18
+ @padding = 0
19
+ @always_force = false
20
+ end
21
+
22
+ # Mute everything that's inside given block
23
+ #
24
+ def mute
25
+ @mute = true
26
+ yield
27
+ ensure
28
+ @mute = false
29
+ end
30
+
31
+ # Check if base is muted
32
+ #
33
+ def mute?
34
+ @mute
35
+ end
36
+
37
+ # Sets the output padding, not allowing less than zero values.
38
+ #
39
+ def padding=(value)
40
+ @padding = [0, value].max
41
+ end
42
+
43
+ # Sets the output padding while executing a block and resets it.
44
+ #
45
+ def indent(count = 1)
46
+ orig_padding = padding
47
+ self.padding = padding + count
48
+ yield
49
+ self.padding = orig_padding
50
+ end
51
+
52
+ # Asks something to the user and receives a response.
53
+ #
54
+ # If a default value is specified it will be presented to the user
55
+ # and allows them to select that value with an empty response. This
56
+ # option is ignored when limited answers are supplied.
57
+ #
58
+ # If asked to limit the correct responses, you can pass in an
59
+ # array of acceptable answers. If one of those is not supplied,
60
+ # they will be shown a message stating that one of those answers
61
+ # must be given and re-asked the question.
62
+ #
63
+ # If asking for sensitive information, the :echo option can be set
64
+ # to false to mask user input from $stdin.
65
+ #
66
+ # If the required input is a path, then set the path option to
67
+ # true. This will enable tab completion for file paths relative
68
+ # to the current working directory on systems that support
69
+ # Readline.
70
+ #
71
+ # ==== Example
72
+ # ask "What is your name?"
73
+ #
74
+ # ask "What is the planet furthest from the sun?", default: "Neptune"
75
+ #
76
+ # ask "What is your favorite Neopolitan flavor?",
77
+ # limited_to: ["strawberry", "chocolate", "vanilla"]
78
+ #
79
+ # ask "What is your password?", echo: false
80
+ #
81
+ # ask "Where should the file be saved?", path: true
82
+ #
83
+ def ask(statement, *args)
84
+ options = args.last.is_a?(Hash) ? args.pop : {}
85
+ color = args.first
86
+
87
+ if options[:limited_to]
88
+ ask_filtered(statement, color, options)
89
+ else
90
+ ask_simply(statement, color, options)
91
+ end
92
+ end
93
+
94
+ # Say (print) something to the user. If the sentence ends with a whitespace
95
+ # or tab character, a new line is not appended (print + flush). Otherwise
96
+ # are passed straight to puts (behavior got from Highline).
97
+ #
98
+ # ==== Example
99
+ # say("I know you knew that.")
100
+ #
101
+ def say(message = "", color = nil, force_new_line = (message.to_s !~ %r{( |\t)\Z}))
102
+ return if quiet?
103
+
104
+ buffer = prepare_message(message, *color)
105
+ buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
106
+
107
+ stdout.print(buffer)
108
+ stdout.flush
109
+ end
110
+
111
+ # Say (print) an error to the user. If the sentence ends with a whitespace
112
+ # or tab character, a new line is not appended (print + flush). Otherwise
113
+ # are passed straight to puts (behavior got from Highline).
114
+ #
115
+ # ==== Example
116
+ # say_error("error: something went wrong")
117
+ #
118
+ def say_error(message = "", color = nil, force_new_line = (message.to_s !~ %r{( |\t)\Z}))
119
+ return if quiet?
120
+
121
+ buffer = prepare_message(message, *color)
122
+ buffer << "\n" if force_new_line && !message.to_s.end_with?("\n")
123
+
124
+ stderr.print(buffer)
125
+ stderr.flush
126
+ end
127
+
128
+ # Say a status with the given color and appends the message. Since this
129
+ # method is used frequently by automations, it allows nil or false to be given
130
+ # in log_status, avoiding the message from being shown. If a Symbol is
131
+ # given in log_status, it's used as the color.
132
+ #
133
+ def say_status(status, message, log_status = true) # rubocop:disable Style/OptionalBooleanParameter
134
+ return if quiet? || log_status == false
135
+
136
+ spaces = " " * (padding + 1)
137
+ status = status.to_s.rjust(12)
138
+ margin = (" " * status.length) + spaces
139
+
140
+ color = log_status.is_a?(Symbol) ? log_status : :green
141
+ status = set_color status, color, true if color
142
+
143
+ message = message.to_s.chomp.gsub(%r{(?<!\A)^}, margin)
144
+ buffer = "#{status}#{spaces}#{message}\n"
145
+
146
+ stdout.print(buffer)
147
+ stdout.flush
148
+ end
149
+
150
+ # Asks the user a question and returns true if the user replies "y" or
151
+ # "yes".
152
+ #
153
+ def yes?(statement, color = nil)
154
+ !!(ask(statement, color, add_to_history: false) =~ is?(:yes))
155
+ end
156
+
157
+ # Asks the user a question and returns true if the user replies "n" or
158
+ # "no".
159
+ #
160
+ def no?(statement, color = nil)
161
+ !!(ask(statement, color, add_to_history: false) =~ is?(:no))
162
+ end
163
+
164
+ # Prints values in columns
165
+ #
166
+ # ==== Parameters
167
+ # Array[String, String, ...]
168
+ #
169
+ def print_in_columns(array)
170
+ printer = ColumnPrinter.new(stdout)
171
+ printer.print(array)
172
+ end
173
+
174
+ # Prints a table.
175
+ #
176
+ # ==== Parameters
177
+ # Array[Array[String, String, ...]]
178
+ #
179
+ # ==== Options
180
+ # indent<Integer>:: Indent the first column by indent value.
181
+ # colwidth<Integer>:: Force the first column to colwidth spaces wide.
182
+ # borders<Boolean>:: Adds ascii borders.
183
+ #
184
+ def print_table(array, options = {})
185
+ printer = TablePrinter.new(stdout, options)
186
+ printer.print(array)
187
+ end
188
+
189
+ # Prints a long string, word-wrapping the text to the current width of the
190
+ # terminal display. Ideal for printing heredocs.
191
+ #
192
+ # ==== Parameters
193
+ # String
194
+ #
195
+ # ==== Options
196
+ # indent<Integer>:: Indent each line of the printed paragraph by indent value.
197
+ #
198
+ def print_wrapped(message, options = {})
199
+ printer = WrappedPrinter.new(stdout, options)
200
+ printer.print(message)
201
+ end
202
+
203
+ # Deals with file collision and returns true if the file should be
204
+ # overwritten and false otherwise. If a block is given, it uses the block
205
+ # response as the content for the diff.
206
+ #
207
+ # ==== Parameters
208
+ # destination<String>:: the destination file to solve conflicts
209
+ # block<Proc>:: an optional block that returns the value to be used in diff and merge
210
+ #
211
+ def file_collision(destination) # rubocop:todo Metrics
212
+ return true if @always_force
213
+
214
+ options = block_given? ? "[Ynaqdhm]" : "[Ynaqh]"
215
+
216
+ loop do # rubocop:todo Metrics/BlockLength
217
+ answer = ask(
218
+ %[Overwrite #{destination}? (enter "h" for help) #{options}],
219
+ add_to_history: false
220
+ )
221
+
222
+ case answer
223
+ when nil
224
+ say ""
225
+ return true
226
+ when is?(:yes), is?(:force), ""
227
+ return true
228
+ when is?(:no), is?(:skip)
229
+ return false
230
+ when is?(:always)
231
+ return @always_force = true
232
+ when is?(:quit)
233
+ say "Aborting..."
234
+ raise SystemExit
235
+ when is?(:diff)
236
+ show_diff(destination, yield) if block_given?
237
+ say "Retrying..."
238
+ when is?(:merge)
239
+ if block_given? && !merge_tool.empty?
240
+ merge(destination, yield)
241
+ return nil
242
+ end
243
+
244
+ say "Please specify merge tool to `FREYIA_MERGE` env."
245
+ else
246
+ say file_collision_help(block_given?)
247
+ end
248
+ end
249
+ end
250
+
251
+ # Called if something goes wrong during the execution. This is used by Freyia
252
+ # internally and should not be used inside your scripts. If something went
253
+ # wrong, you can always raise an exception. If you raise a Freyia::Error, it
254
+ # will be rescued and wrapped in the method below.
255
+ #
256
+ def error(statement)
257
+ stderr.puts statement
258
+ end
259
+
260
+ # Apply color to the given string with optional bold. Disabled in the
261
+ # Freyia::Shell::Basic class.
262
+ #
263
+ def set_color(string, *) #:nodoc:
264
+ string
265
+ end
266
+
267
+ protected
268
+
269
+ def prepare_message(message, *color)
270
+ spaces = " " * padding
271
+ spaces + set_color(message.to_s, *color)
272
+ end
273
+
274
+ def can_display_colors?
275
+ false
276
+ end
277
+
278
+ def lookup_color(color)
279
+ return color unless color.is_a?(Symbol)
280
+
281
+ self.class.const_get(color.to_s.upcase)
282
+ end
283
+
284
+ def stdout
285
+ $stdout
286
+ end
287
+
288
+ def stderr
289
+ $stderr
290
+ end
291
+
292
+ def is?(value) #:nodoc: # rubocop:disable Naming/PredicateMethod
293
+ value = value.to_s
294
+
295
+ if value.size == 1
296
+ %r{\A#{value}\z}i
297
+ else
298
+ %r{\A(#{value}|#{value[0, 1]})\z}i
299
+ end
300
+ end
301
+
302
+ def file_collision_help(block_given) #:nodoc:
303
+ help = <<-HELP
304
+ Y - yes, overwrite
305
+ n - no, do not overwrite
306
+ a - all, overwrite this and all others
307
+ q - quit, abort
308
+ h - help, show this help
309
+ HELP
310
+ if block_given
311
+ help << <<-HELP
312
+ d - diff, show the differences between the old and the new
313
+ m - merge, run merge tool
314
+ HELP
315
+ end
316
+ help
317
+ end
318
+
319
+ def show_diff(destination, content) #:nodoc:
320
+ require "tempfile"
321
+ Tempfile.open(File.basename(destination), File.dirname(destination),
322
+ binmode: true) do |temp|
323
+ temp.write content
324
+ temp.rewind
325
+ system(*diff_tool, destination, temp.path)
326
+ end
327
+ end
328
+
329
+ def quiet? #:nodoc:
330
+ mute? || (base && base.options[:quiet])
331
+ end
332
+
333
+ def unix?
334
+ Terminal.unix?
335
+ end
336
+
337
+ def ask_simply(statement, color, options)
338
+ default = options[:default]
339
+ message = [statement, ("(#{default})" if default), nil].uniq.join(" ")
340
+ message = prepare_message(message, *color)
341
+ result = Freyia::LineEditor.readline(message, options)
342
+
343
+ return unless result
344
+
345
+ result = result.strip
346
+
347
+ if default && result == ""
348
+ default
349
+ else
350
+ result
351
+ end
352
+ end
353
+
354
+ def ask_filtered(statement, color, options)
355
+ answer_set = options[:limited_to]
356
+ case_insensitive = options.fetch(:case_insensitive, false)
357
+ correct_answer = nil
358
+ until correct_answer
359
+ answers = answer_set.join(", ")
360
+ answer = ask_simply("#{statement} [#{answers}]", color, options)
361
+ correct_answer = answer_match(answer_set, answer, case_insensitive)
362
+ say("Your response must be one of: [#{answers}]. Please try again.") unless correct_answer
363
+ end
364
+ correct_answer
365
+ end
366
+
367
+ def answer_match(possibilities, answer, case_insensitive)
368
+ if case_insensitive
369
+ possibilities.detect { |possibility| possibility.downcase == answer.downcase }
370
+ else
371
+ possibilities.detect { |possibility| possibility == answer }
372
+ end
373
+ end
374
+
375
+ def merge(destination, content) #:nodoc:
376
+ require "tempfile"
377
+ Tempfile.open([File.basename(destination), File.extname(destination)],
378
+ File.dirname(destination)) do |temp|
379
+ temp.write content
380
+ temp.rewind
381
+ system(*merge_tool, temp.path, destination)
382
+ end
383
+ end
384
+
385
+ def merge_tool #:nodoc:
386
+ @merge_tool ||= begin
387
+ require "shellwords"
388
+ Shellwords.split(ENV["FREYIA_MERGE"] || "git difftool --no-index")
389
+ end
390
+ end
391
+
392
+ def diff_tool #:nodoc:
393
+ @diff_cmd ||= begin
394
+ require "shellwords"
395
+ Shellwords.split(ENV["FREYIA_DIFF"] || ENV["RAILS_DIFF"] || "diff -u")
396
+ end
397
+ end
398
+ end
399
+ end
400
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "basic"
4
+ require_relative "lcs_diff"
5
+
6
+ module Freyia
7
+ module Shell
8
+ # Inherit from Freyia::Shell::Basic and add set_color behavior. Check
9
+ # Freyia::Shell::Basic to see all available methods.
10
+ #
11
+ class Color < Basic
12
+ include LCSDiff
13
+
14
+ # Embed in a String to clear all previous ANSI sequences.
15
+ CLEAR = "\e[0m"
16
+ # The start of an ANSI bold sequence.
17
+ BOLD = "\e[1m"
18
+
19
+ # Set the terminal's foreground ANSI color to black.
20
+ BLACK = "\e[30m"
21
+ # Set the terminal's foreground ANSI color to red.
22
+ RED = "\e[31m"
23
+ # Set the terminal's foreground ANSI color to green.
24
+ GREEN = "\e[32m"
25
+ # Set the terminal's foreground ANSI color to yellow.
26
+ YELLOW = "\e[33m"
27
+ # Set the terminal's foreground ANSI color to blue.
28
+ BLUE = "\e[34m"
29
+ # Set the terminal's foreground ANSI color to magenta.
30
+ MAGENTA = "\e[35m"
31
+ # Set the terminal's foreground ANSI color to cyan.
32
+ CYAN = "\e[36m"
33
+ # Set the terminal's foreground ANSI color to white.
34
+ WHITE = "\e[37m"
35
+
36
+ # Set the terminal's background ANSI color to black.
37
+ ON_BLACK = "\e[40m"
38
+ # Set the terminal's background ANSI color to red.
39
+ ON_RED = "\e[41m"
40
+ # Set the terminal's background ANSI color to green.
41
+ ON_GREEN = "\e[42m"
42
+ # Set the terminal's background ANSI color to yellow.
43
+ ON_YELLOW = "\e[43m"
44
+ # Set the terminal's background ANSI color to blue.
45
+ ON_BLUE = "\e[44m"
46
+ # Set the terminal's background ANSI color to magenta.
47
+ ON_MAGENTA = "\e[45m"
48
+ # Set the terminal's background ANSI color to cyan.
49
+ ON_CYAN = "\e[46m"
50
+ # Set the terminal's background ANSI color to white.
51
+ ON_WHITE = "\e[47m"
52
+
53
+ # Set color by using a string or one of the defined constants. If a third
54
+ # option is set to true, it also adds bold to the string. This is based
55
+ # on Highline implementation and it automatically appends CLEAR to the end
56
+ # of the returned String.
57
+ #
58
+ # Pass foreground, background and bold options to this method as
59
+ # symbols.
60
+ #
61
+ # Example:
62
+ #
63
+ # set_color "Hi!", :red, :on_white, :bold
64
+ #
65
+ # The available colors are:
66
+ #
67
+ # :bold
68
+ # :black
69
+ # :red
70
+ # :green
71
+ # :yellow
72
+ # :blue
73
+ # :magenta
74
+ # :cyan
75
+ # :white
76
+ # :on_black
77
+ # :on_red
78
+ # :on_green
79
+ # :on_yellow
80
+ # :on_blue
81
+ # :on_magenta
82
+ # :on_cyan
83
+ # :on_white
84
+ def set_color(string, *colors) # rubocop:todo Metrics
85
+ if colors.compact.empty? || !can_display_colors?
86
+ string
87
+ elsif colors.all? { |color| color.is_a?(Symbol) || color.is_a?(String) }
88
+ ansi_colors = colors.map { |color| lookup_color(color) }
89
+ "#{ansi_colors.join}#{string}#{CLEAR}"
90
+ else
91
+ # The old API was `set_color(color, bold=boolean)`. We
92
+ # continue to support the old API because you should never
93
+ # break old APIs unnecessarily :P
94
+ foreground, bold = colors
95
+ foreground = self.class.const_get(foreground.to_s.upcase) if foreground.is_a?(Symbol)
96
+
97
+ bold = bold ? BOLD : ""
98
+ "#{bold}#{foreground}#{string}#{CLEAR}"
99
+ end
100
+ end
101
+
102
+ protected
103
+
104
+ def can_display_colors?
105
+ are_colors_supported? && !are_colors_disabled?
106
+ end
107
+
108
+ def are_colors_supported?
109
+ stdout.tty? && ENV["TERM"] != "dumb"
110
+ end
111
+
112
+ def are_colors_disabled?
113
+ !ENV["NO_COLOR"].nil? && !ENV["NO_COLOR"].empty?
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terminal"
4
+
5
+ module Freyia
6
+ module Shell
7
+ class ColumnPrinter
8
+ attr_reader :stdout, :options
9
+
10
+ def initialize(stdout, options = {})
11
+ @stdout = stdout
12
+ @options = options
13
+ @indent = options[:indent].to_i
14
+ end
15
+
16
+ def print(array) # rubocop:todo Metrics
17
+ return if array.empty?
18
+
19
+ colwidth = (array.map { |el| el.to_s.size }.max || 0) + 2
20
+ array.each_with_index do |value, index|
21
+ # Don't output trailing spaces when printing the last column
22
+ if (((index + 1) % (Terminal.terminal_width / colwidth)).zero? && !index.zero?) ||
23
+ index + 1 == array.length
24
+ stdout.puts value
25
+ else
26
+ stdout.printf("%-#{colwidth}s", value)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LCSDiff
4
+ protected
5
+
6
+ # Overwrite show_diff to show diff with colors if Diff::LCS is
7
+ # available.
8
+ def show_diff(destination, content) #:nodoc:
9
+ if diff_lcs_loaded? && ENV["FREYIA_DIFF"].nil? && ENV["RAILS_DIFF"].nil?
10
+ actual = File.binread(destination).to_s.split("\n")
11
+ content = content.to_s.split("\n")
12
+
13
+ Diff::LCS.sdiff(actual, content).each do |diff|
14
+ output_diff_line(diff)
15
+ end
16
+ else
17
+ super
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def output_diff_line(diff) #:nodoc:
24
+ case diff.action
25
+ when "-"
26
+ say "- #{diff.old_element.chomp}", :red, true
27
+ when "+"
28
+ say "+ #{diff.new_element.chomp}", :green, true
29
+ when "!"
30
+ say "- #{diff.old_element.chomp}", :red, true
31
+ say "+ #{diff.new_element.chomp}", :green, true
32
+ else
33
+ say " #{diff.old_element.chomp}", nil, true
34
+ end
35
+ end
36
+
37
+ # Check if Diff::LCS is loaded. If it is, use it to create pretty output
38
+ # for diff.
39
+ def diff_lcs_loaded? #:nodoc:
40
+ return true if defined?(Diff::LCS)
41
+ return @diff_lcs_loaded unless @diff_lcs_loaded.nil?
42
+
43
+ @diff_lcs_loaded = begin
44
+ require "diff/lcs"
45
+ true
46
+ rescue LoadError
47
+ false
48
+ end
49
+ end
50
+ end