dry-cli-ui 0.6.1 → 0.7.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,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ # The reserved flags: options that mean the same thing in every CLI built on this gem.
9
+ #
10
+ # | Flag | Long | Meaning |
11
+ # | ---- | --------------------- | ---------------------------------------------------------- |
12
+ # | `-n` | `--dry-run` | change nothing, print what would happen |
13
+ # | `-y` | `--yes` | skip every interactive prompt |
14
+ # | `-o` | `--output [FILE]` | write the command's report to FILE, in colour |
15
+ # | `-l` | `--log [FILE]` | log to FILE through SemanticLogger |
16
+ # | `-L` | `--log-level LEVEL` | debug, info, warn, error or fatal (info by default) |
17
+ # | | `--log-format FORMAT` | standard (the default), json, or another SemanticLogger formatter |
18
+ #
19
+ # Extend a command class with it and name the flags it takes. `:log` brings `-L` and
20
+ # `--log-format` with it. The options reach `call` as `dry_run:`, `yes:`, `output:`, `log:`,
21
+ # `log_level:` and `log_format:`; hand them to {Console#with_flags}, or to {Console#output},
22
+ # {Console#logging} and {Console#confirm} one at a time.
23
+ #
24
+ # dry-cli gives every string option a required value, so on its own it rejects a bare `-o`.
25
+ # Loading this module prepends {Hook} onto `Dry::CLI`, which rewrites a bare `-o`,
26
+ # `--output`, `-l` or `--log` into an empty value (see {.arguments}) for the commands that
27
+ # extend this module, and nothing else. An empty value means "the default file".
28
+ #
29
+ # @example
30
+ # class Export < Dry::CLI::Command
31
+ # include Dry::CLI::UI
32
+ # extend Dry::CLI::UI::Flags
33
+ #
34
+ # flags :dry_run, :yes, :output, :log
35
+ # end
36
+ module Flags
37
+ # Every option each reserved flag declares, as arguments to dry-cli's `option`.
38
+ OPTIONS = {
39
+ dry_run: {
40
+ dry_run: { type: :flag, default: false, aliases: ["-n"], desc: "Change nothing; print what would happen" }
41
+ },
42
+ yes: {
43
+ yes: { type: :flag, default: false, aliases: ["-y"], desc: "Answer yes to every prompt" }
44
+ },
45
+ output: {
46
+ output: { aliases: ["-o"], desc: "Write the report to FILE: log/ by default, - for STDOUT" }
47
+ },
48
+ log: {
49
+ log: { aliases: ["-l"], desc: "Log to FILE: log/ by default, - for STDOUT" },
50
+ log_level: { aliases: ["-L"], default: "info", values: %w[debug info warn error fatal],
51
+ desc: "Log level" },
52
+ log_format: { default: "standard", desc: "Log format: standard, json, or another SemanticLogger formatter" }
53
+ }
54
+ }.freeze
55
+
56
+ # The reserved flags that take an optional file.
57
+ OPTIONAL_VALUES = %i[output log].freeze
58
+
59
+ # The switches {.arguments} rewrites unless told otherwise.
60
+ SWITCHES = %w[-o --output -l --log].freeze
61
+
62
+ # Gives a bare switch an empty value, so that dry-cli, which requires one, accepts it.
63
+ #
64
+ # A switch is bare when it is last, or when the next argument starts with `-` and is not
65
+ # exactly `-`. `-o=x`, `--output=x`, `-ox`, `-o x` and `-o -` are left alone, as is
66
+ # everything after `--`.
67
+ #
68
+ # @example
69
+ # Dry::CLI::UI::Flags.arguments(%w[export -o --verbose]) # => ["export", "-o", "", "--verbose"]
70
+ #
71
+ # @param argv [Array<String>] the command line
72
+ # @param switches [Array<String>] the switches that take an optional value
73
+ # @return [Array<String>] a new array; argv is not changed
74
+ def self.arguments(argv, switches: SWITCHES)
75
+ ended = false
76
+ argv.each_with_index.flat_map do |token, index|
77
+ ended ||= token == "--"
78
+ bare = !ended && switches.include?(token) && bare_before?(argv[index + 1])
79
+ bare ? [token, ""] : [token]
80
+ end
81
+ end
82
+
83
+ # Whether a switch followed by this argument has no value.
84
+ #
85
+ # @param following [String, nil] the next argument, nil at the end of the line
86
+ # @return [Boolean]
87
+ def self.bare_before?(following)
88
+ following.nil? || (following.start_with?("-") && following != "-")
89
+ end
90
+ private_class_method :bare_before?
91
+
92
+ # The switches of a command's options that take an optional file, as typed on a command
93
+ # line: `--output` and each of its aliases, and the same for `--log`.
94
+ #
95
+ # @param command [Class] a command class extended with this module
96
+ # @return [Array<String>]
97
+ def self.switches(command)
98
+ command.options.select { OPTIONAL_VALUES.include?(it.name.to_sym) }.flat_map do |option|
99
+ names = [option.name.to_s.tr("_", "-"), *option.aliases.map { it.delete_prefix("-").delete_prefix("-") }]
100
+ names.map { it.size == 1 ? "-#{it}" : "--#{it}" }
101
+ end
102
+ end
103
+
104
+ # Declares reserved flags as options of this command.
105
+ #
106
+ # @param names [Array<Symbol>] any of `:dry_run`, `:yes`, `:output` and `:log`
107
+ # @return [Array<Symbol>] the names
108
+ # @raise [ArgumentError] when a name is not a reserved flag; nothing is declared
109
+ def flags(*names)
110
+ unknown = names - OPTIONS.keys
111
+ raise ArgumentError, "unknown flag #{unknown.map(&:inspect).join(', ')}, expected any of #{OPTIONS.keys}" if unknown.any?
112
+
113
+ names.each { |name| OPTIONS.fetch(name).each { |option_name, settings| option(option_name, settings) } }
114
+ names
115
+ end
116
+
117
+ # Prepended onto `Dry::CLI` when {Flags} loads. It rewrites the arguments of a command that
118
+ # extends {Flags} with {Flags.arguments}, and records on a command that includes {UI} the
119
+ # names it was called by, which name its default report and log files.
120
+ #
121
+ # @api private
122
+ module Hook
123
+ private
124
+
125
+ # @param command [Class, Dry::CLI::Command] the command dry-cli resolved
126
+ # @param arguments [Array<String>] the arguments after the command's names
127
+ # @param names [Array<String>] the names the command was called by
128
+ # @return [Array(Dry::CLI::Command, Hash)] the command to call and its arguments
129
+ def parse(command, arguments, names)
130
+ klass = command.is_a?(Class) ? command : command.class
131
+ arguments = Flags.arguments(arguments, switches: Flags.switches(klass)) if klass.is_a?(Flags)
132
+ result = super
133
+ instance, = result
134
+ instance.instance_variable_set(:@ui_command_names, names) if instance.is_a?(UI)
135
+ result
136
+ end
137
+ end
138
+
139
+ CLI.prepend(Hook)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ # How a command was run, as far as naming its report and log files goes: the program, the
9
+ # command's names, when the process started, and where.
10
+ #
11
+ # @!attribute [r] executable
12
+ # @return [String] the program's basename, e.g. `law-cli`
13
+ # @!attribute [r] action
14
+ # @return [String, nil] the command's names joined with `-`, e.g. `generate-text`; nil for a
15
+ # CLI that is a single command
16
+ # @!attribute [r] name
17
+ # @return [String] what the command's logger is called, usually its class name
18
+ # @!attribute [r] started_at
19
+ # @return [Time] when the process started; stamps the report's file name
20
+ # @!attribute [r] directory
21
+ # @return [String] the directory the command runs in
22
+ Invocation = ::Data.define(:executable, :action, :name, :started_at, :directory) do
23
+ # Describes a command: by the names dry-cli resolved it by, when {Flags::Hook} recorded
24
+ # them, and otherwise by its class name.
25
+ #
26
+ # @param command [Object, nil] the command, or nil for none
27
+ # @param program [String] the program's path
28
+ # @param started_at [Time]
29
+ # @param directory [String]
30
+ # @return [Invocation]
31
+ def self.for(command = nil, program: $PROGRAM_NAME, started_at: UI.started_at, directory: Dir.pwd)
32
+ executable = File.basename(program)
33
+ class_name = command.class.name unless command.nil?
34
+ new(executable: executable, action: action(command, class_name), name: class_name || executable,
35
+ started_at: started_at, directory: directory)
36
+ end
37
+
38
+ # The names the command was called by, else the last part of its class name, dasherized.
39
+ #
40
+ # @param command [Object, nil]
41
+ # @param class_name [String, nil]
42
+ # @return [String, nil]
43
+ def self.action(command, class_name)
44
+ names = command.instance_variable_get(:@ui_command_names)
45
+ return names.join("-") if names
46
+ return if class_name.nil?
47
+
48
+ class_name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
49
+ end
50
+ private_class_method :action
51
+
52
+ # The nearest directory up from `directory` that holds `.git`, else `directory` itself.
53
+ #
54
+ # @param directory [String]
55
+ # @return [String]
56
+ def self.root(directory)
57
+ start = Pathname(directory).expand_path
58
+ (start.ascend.find { it.join(".git").exist? } || start).to_s
59
+ end
60
+
61
+ # The program and the action, joined with `-`: `law-cli-generate-text`.
62
+ #
63
+ # @return [String]
64
+ def basename
65
+ [executable, action].reject { it.nil? || it.empty? }.join("-")
66
+ end
67
+
68
+ # Where `-o` writes. Creates the file's directory when it is missing.
69
+ #
70
+ # @param value [String, nil] what `-o` was given: nil without `-o`, `-` for STDOUT, an
71
+ # empty string for the default name, or a path
72
+ # @return [String, nil] the path, or nil for the command's own output
73
+ def report_path(value)
74
+ stamp = started_at.strftime("%Y-%m-%d.%H%M%S")
75
+ file(value, "#{basename}.#{stamp}.log") unless value.nil? || value == "-"
76
+ end
77
+
78
+ # Where `-l` logs. Creates the file's directory when it is missing.
79
+ #
80
+ # @param value [String, nil] what `-l` was given: nil without `-l`, `-` for STDOUT, an
81
+ # empty string for the default name, or a path
82
+ # @return [String, nil] the path, `-` for STDOUT, or nil for no log
83
+ def log_path(value)
84
+ value.nil? || value == "-" ? value : file(value, "#{basename}.log")
85
+ end
86
+
87
+ private
88
+
89
+ # @param value [String] a path, or empty for the default
90
+ # @param default [String] the default file name, under `log/` at the repository root
91
+ # @return [String]
92
+ def file(value, default)
93
+ path = value.empty? ? File.join(Invocation.root(directory), "log", default) : File.expand_path(value, directory)
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ path
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_logger"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ # SemanticLogger set up from the reserved `-l`, `-L` and `--log-format` flags, for as long as
9
+ # a block runs. See {Console#logging}.
10
+ #
11
+ # At level `debug` it also records, for every exception raised while the block runs, the
12
+ # local variables of each frame on the stack where it was raised. {Console#log_exception}
13
+ # logs them with the exception. Recording them costs time, which is why it happens at
14
+ # `debug` only.
15
+ class Logging
16
+ # The levels `-L` takes.
17
+ LEVELS = %w[debug info warn error fatal].freeze
18
+
19
+ # Formats named differently here than in SemanticLogger.
20
+ FORMATS = { "standard" => :default }.freeze
21
+
22
+ # The most characters of a local variable's `inspect` kept.
23
+ INSPECT_LIMIT = 200
24
+
25
+ # The most frames whose local variables are kept for one exception.
26
+ FRAME_LIMIT = 25
27
+
28
+ # Frames in this gem's own files, which are left out of what is recorded.
29
+ OWN_FILES = "#{File.expand_path(__dir__)}/".freeze
30
+
31
+ # Local variables recorded for each exception, keyed by the exception.
32
+ LOCALS = ::ObjectSpace::WeakMap.new
33
+
34
+ class << self
35
+ # Checks a level name.
36
+ #
37
+ # @param name [String, Symbol]
38
+ # @return [Symbol]
39
+ # @raise [ArgumentError] when it is not one of {LEVELS}
40
+ def level(name)
41
+ return name.to_sym if LEVELS.include?(name.to_s)
42
+
43
+ raise ArgumentError, "unknown log level #{name.to_s.inspect}, expected one of #{LEVELS.join(', ')}"
44
+ end
45
+
46
+ # Builds a SemanticLogger formatter from its name: `standard` for SemanticLogger's default,
47
+ # one line per entry, or the name of any formatter SemanticLogger has, such as `json`,
48
+ # `color` or `logfmt`.
49
+ #
50
+ # @param name [String, Symbol]
51
+ # @return [#call] the formatter
52
+ # @raise [ArgumentError] when SemanticLogger has no formatter of that name
53
+ def formatter(name)
54
+ SemanticLogger::Formatters.factory(FORMATS.fetch(name.to_s) { name.to_s.to_sym })
55
+ rescue ArgumentError
56
+ raise ArgumentError, "unknown log format #{name.to_s.inspect}, expected one of #{formats.join(', ')}", cause: nil
57
+ end
58
+
59
+ # @return [Array<String>] the names {.formatter} takes
60
+ def formats
61
+ names = (SemanticLogger::Formatters.constants - [:Base]).map do |constant|
62
+ constant.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
63
+ end
64
+ (FORMATS.keys + names - FORMATS.values.map(&:to_s)).sort
65
+ end
66
+
67
+ # A SemanticLogger logger.
68
+ #
69
+ # @param name [String]
70
+ # @return [SemanticLogger::Logger]
71
+ def logger(name)
72
+ SemanticLogger[name]
73
+ end
74
+
75
+ # The local variables recorded for an exception raised while logging at `debug`.
76
+ #
77
+ # @param exception [Exception]
78
+ # @return [Array<Hash{Symbol => Object}>, nil] one entry per frame, innermost first, each
79
+ # with its `frame` and `locals`; nil when nothing was recorded
80
+ def locals(exception)
81
+ LOCALS[exception]
82
+ end
83
+
84
+ # Records the local variables of the frames on the stack, for an exception being raised.
85
+ # Keeps what was recorded where the exception was first raised.
86
+ #
87
+ # @param exception [Exception]
88
+ # @return [void]
89
+ def record(exception)
90
+ return if LOCALS.key?(exception) || Thread.current[:dry_cli_ui_recording]
91
+
92
+ Thread.current[:dry_cli_ui_recording] = true
93
+ begin
94
+ LOCALS[exception] = frames(binding.callers)
95
+ ensure
96
+ Thread.current[:dry_cli_ui_recording] = nil
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # @param bindings [Array<Binding>] the stack, innermost first
103
+ # @return [Array<Hash{Symbol => Object}>]
104
+ def frames(bindings)
105
+ bindings.reject { it.source_location.first.start_with?(OWN_FILES) }.filter_map do |frame|
106
+ names = frame.local_variables
107
+ next if names.empty?
108
+
109
+ { frame: frame.source_location.join(":"), locals: names.to_h { [it, describe(frame.local_variable_get(it))] } }
110
+ end.first(FRAME_LIMIT)
111
+ end
112
+
113
+ # @param value [Object]
114
+ # @return [String] its `inspect`, cut to {INSPECT_LIMIT} characters
115
+ def describe(value)
116
+ text = value.inspect
117
+ text.length > INSPECT_LIMIT ? "#{text[0, INSPECT_LIMIT]}..." : text
118
+ rescue StandardError => e
119
+ "#<#{e.class} from inspect>"
120
+ end
121
+ end
122
+
123
+ # @param target [String, nil] a file, `-` for `io`, or nil for no log
124
+ # @param io [IO] where `-` logs
125
+ # @param level [String, Symbol] one of {LEVELS}
126
+ # @param format [String, Symbol] see {.formatter}
127
+ # @raise [ArgumentError] for an unknown level or format
128
+ def initialize(target, io:, level:, format:)
129
+ @target = target
130
+ @io = io
131
+ @level = Logging.level(level)
132
+ @formatter = Logging.formatter(format)
133
+ end
134
+
135
+ # Adds the appender, sets the level, and runs the block. Afterwards flushes and removes the
136
+ # appender and puts the level back.
137
+ #
138
+ # @yield
139
+ # @return [Object] whatever the block returns
140
+ def run(&)
141
+ return yield if target.nil?
142
+
143
+ start
144
+ yield
145
+ ensure
146
+ stop
147
+ end
148
+
149
+ private
150
+
151
+ # @return [String, nil]
152
+ attr_reader :target
153
+
154
+ # @return [IO]
155
+ attr_reader :io
156
+
157
+ # @return [Symbol]
158
+ attr_reader :level
159
+
160
+ # @return [#call]
161
+ attr_reader :formatter
162
+
163
+ # @return [void]
164
+ def start
165
+ destination = target == "-" ? { io: io } : { file_name: target }
166
+ @appender = SemanticLogger.add_appender(**destination, formatter: formatter)
167
+ @previous_level = SemanticLogger.default_level
168
+ SemanticLogger.default_level = level
169
+ trace if level == :debug
170
+ end
171
+
172
+ # @return [void]
173
+ def trace
174
+ require "binding_of_caller"
175
+ @trace = TracePoint.new(:raise) { |point| Logging.record(point.raised_exception) }
176
+ @trace.enable
177
+ end
178
+
179
+ # @return [void]
180
+ def stop
181
+ return unless @appender
182
+
183
+ @trace&.disable
184
+ SemanticLogger.flush
185
+ SemanticLogger.remove_appender(@appender)
186
+ SemanticLogger.default_level = @previous_level
187
+ @appender = nil
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ # A file a command writes its report to, through `-o`. It behaves as the file it wraps, and
9
+ # answers `color?` with true, so a {Terminal} on it writes colour although it is no TTY.
10
+ class Report < ::SimpleDelegator
11
+ # @return [Boolean] always true: a report is written in colour
12
+ def color? = true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ # The part of {Console} behind the reserved `-o`, `-l`, `-L` and `--log-format` flags (see
7
+ # {Flags}): where a command's report goes, and how it logs.
8
+ module Reporting
9
+ # Sends what the block writes to `out` to the file `-o` named. Tables, boxes and statuses at
10
+ # `info` and `success` land there, in colour; spinners, bars, prompts and warnings stay on
11
+ # `err`. The block is given the IO the report goes to, for writing to it directly.
12
+ #
13
+ # The file's last line says when it was closed, and a line on `err` says where it is.
14
+ #
15
+ # @example
16
+ # ui.output(output) do |io|
17
+ # ui.table(rows, header: %w[Rule Count])
18
+ # io.puts "#{rows.size} rules"
19
+ # end
20
+ #
21
+ # @param value [String, nil] what `-o` was given: nil without `-o` and `-` for STDOUT, both
22
+ # meaning the command's own `out`; an empty string for
23
+ # `log/<executable>-<action>.<YYYY-MM-DD>.<HHMMSS>.log` at the repository root, stamped
24
+ # with the time the process started; or a path
25
+ # @yieldparam io [IO] where the report goes
26
+ # @return [Object] whatever the block returns
27
+ # @raise [ArgumentError] without a block
28
+ def output(value = nil, &)
29
+ raise ArgumentError, "output needs a block" unless block_given?
30
+
31
+ path = invocation.report_path(value)
32
+ return yield(out.io) if path.nil?
33
+
34
+ begin
35
+ File.open(path, "w") { |file| write_report(Report.new(file), &) }
36
+ ensure
37
+ notice = "Report written to #{Pathname(path).relative_path_from(invocation.directory)}"
38
+ err.puts(Widgets::Status.line(err, Theme.level(:info), notice))
39
+ end
40
+ end
41
+
42
+ # Logs through SemanticLogger to the file `-l` named while the block runs, then flushes and
43
+ # removes the appender. At level `debug` it records the local variables of every frame an
44
+ # exception is raised through, for {#log_exception}.
45
+ #
46
+ # @example
47
+ # ui.logging(log, level: log_level, format: log_format) do
48
+ # ui.logger.info("Importing", count: rules.size)
49
+ # end
50
+ #
51
+ # @param value [String, nil] what `-l` was given: nil for no log; `-` for the command's own
52
+ # `out`; an empty string for `log/<executable>-<action>.log` at the repository root; or a
53
+ # path
54
+ # @param level [String, Symbol] `debug`, `info`, `warn`, `error` or `fatal`
55
+ # @param format [String, Symbol] `standard`, `json`, or another SemanticLogger formatter
56
+ # @return [Object] whatever the block returns
57
+ # @raise [ArgumentError] without a block, or for an unknown level or format
58
+ def logging(value, level: "info", format: "standard", &)
59
+ raise ArgumentError, "logging needs a block" unless block_given?
60
+
61
+ Logging.new(invocation.log_path(value), io: out.io, level: level, format: format).run(&)
62
+ end
63
+
64
+ # Applies the reserved flags a command was called with: {#logging} around {#output}. Takes
65
+ # the whole options hash `call` receives and ignores what it does not use.
66
+ #
67
+ # @example
68
+ # def call(**options)
69
+ # ui.with_flags(**options) { |io| ui.table(rows) }
70
+ # end
71
+ #
72
+ # @param output [String, nil] see {#output}
73
+ # @param log [String, nil] see {#logging}
74
+ # @param log_level [String, Symbol]
75
+ # @param log_format [String, Symbol]
76
+ # @yieldparam io [IO] where the report goes
77
+ # @return [Object] whatever the block returns
78
+ # @raise [ArgumentError] without a block, or for an unknown level or format
79
+ def with_flags(output: nil, log: nil, log_level: "info", log_format: "standard", **, &)
80
+ raise ArgumentError, "with_flags needs a block" unless block_given?
81
+
82
+ logging(log, level: log_level, format: log_format) { self.output(output, &) }
83
+ end
84
+
85
+ # The command's SemanticLogger logger, named after the command's class. It writes nothing
86
+ # until {#logging} adds an appender.
87
+ #
88
+ # @return [SemanticLogger::Logger]
89
+ def logger
90
+ @logger ||= Logging.logger(invocation.name)
91
+ end
92
+
93
+ # Logs an exception with its backtrace and, when it was raised while logging at `debug`,
94
+ # the local variables of each frame it was raised through, as the payload's `locals`.
95
+ #
96
+ # @example
97
+ # rescue => e
98
+ # ui.log_exception(e, "Import failed")
99
+ #
100
+ # @param exception [Exception]
101
+ # @param message [String, nil] the exception's message by default
102
+ # @param level [String, Symbol]
103
+ # @return [nil]
104
+ # @raise [ArgumentError] for an unknown level
105
+ def log_exception(exception, message = nil, level: :error)
106
+ locals = Logging.locals(exception)
107
+ logger.public_send(Logging.level(level), message || exception.message, locals && { locals: locals }, exception)
108
+ nil
109
+ end
110
+
111
+ private
112
+
113
+ # @return [Invocation] how the command was run; describes no command when none was given
114
+ def invocation
115
+ @invocation ||= Invocation.for
116
+ end
117
+
118
+ # Points `out` at a report while the block runs, then ends the report with the time.
119
+ #
120
+ # @param report [Report]
121
+ # @yieldparam report [Report]
122
+ # @return [Object] whatever the block returns
123
+ def write_report(report)
124
+ previous = @out
125
+ @out = previous.to(report)
126
+ yield report
127
+ ensure
128
+ @out = previous
129
+ report.puts("Closed at #{Time.now.strftime('%Y-%m-%d %H:%M:%S %z')}")
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -89,13 +89,30 @@ module Dry
89
89
  tty? && env["TERM"] != "dumb"
90
90
  end
91
91
 
92
- # Whether to emit ANSI colour codes.
92
+ # Whether to emit ANSI colour codes: on an animated terminal, or on a stream that asks for
93
+ # colour by answering `color?` with true, such as a {Report}; never under `NO_COLOR`.
93
94
  #
94
95
  # @return [Boolean]
95
96
  def color?
96
97
  return color unless color.nil?
97
98
 
98
- animated? && env["NO_COLOR"].to_s.empty?
99
+ (animated? || (io.respond_to?(:color?) && io.color?)) && env["NO_COLOR"].to_s.empty?
100
+ end
101
+
102
+ # A terminal like this one, writing to another stream, and never animated: what a
103
+ # command's report goes to.
104
+ #
105
+ # @param io [IO]
106
+ # @return [Terminal]
107
+ def to(io)
108
+ Terminal.new(io, env: env, color: color, animate: false, width: @width)
109
+ end
110
+
111
+ # Leaves out the environment, which can hold secrets, from what a debug log records.
112
+ #
113
+ # @return [String]
114
+ def inspect
115
+ "#<#{self.class} io=#{io.inspect}>"
99
116
  end
100
117
 
101
118
  # @return [Integer] columns available
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.6.1"
14
+ VERSION = "0.7.0"
15
15
  end
16
16
  end
17
17
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ module Widgets
7
+ # The line that says what each colour of a progress bar means:
8
+ #
9
+ # Color Mapping: [ red: errors and invalid files | yellow: relevant but auxiliary | green: forms ]
10
+ #
11
+ # With colour, each label is drawn on its colour's background instead
12
+ # of naming it. The colours are the configured ones:
13
+ # {Configuration#bar_failed_color}, {Configuration#bar_aux_color} and
14
+ # {Configuration#bar_color}.
15
+ module Legend
16
+ # Each label's keyword, and the setting its colour comes from, in
17
+ # the order a bar draws them.
18
+ SETTINGS = { failed: :bar_failed_color, aux: :bar_aux_color, ok: :bar_color }.freeze
19
+
20
+ # @param terminal [Terminal]
21
+ # @param config [Configuration]
22
+ # @param labels [Hash{Symbol => #to_s, nil}] keyed as {SETTINGS};
23
+ # a nil label is left out
24
+ # @return [String] the line, without a newline
25
+ # @raise [ArgumentError] when every label is nil
26
+ def self.line(terminal, config, labels)
27
+ entries = SETTINGS.filter_map { |key, setting| [labels[key].to_s, config.public_send(setting)] unless labels[key].nil? }
28
+ raise ArgumentError, "legend needs at least one of #{SETTINGS.keys.map { "#{it}:" }.join(', ')}" if entries.empty?
29
+
30
+ shown = entries.map { |label, style| terminal.color? ? swatch(terminal.pastel, label, style) : "#{style || 'plain'}: #{label}" }
31
+ "Color Mapping: [ #{shown.join(' | ')} ]"
32
+ end
33
+
34
+ # @param pastel [Pastel::Delegator]
35
+ # @param label [String]
36
+ # @param style [Symbol, nil]
37
+ # @return [String] the label, padded, in black on the style's
38
+ # background; on the style itself when it has no background form
39
+ def self.swatch(pastel, label, style)
40
+ background = :"on_#{style}"
41
+ styles = Configuration::STYLES.include?(background) ? [:black, background] : [style].compact
42
+ pastel.decorate(" #{label} ", *styles)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end