diff --git a/.quiet_quality.yml b/.quiet_quality.yml index 336c18e..c66dce2 100644 --- a/.quiet_quality.yml +++ b/.quiet_quality.yml @@ -6,3 +6,4 @@ changed_files: false filter_messages: false logging: light colorize: true +message_format: "%lcyan10tool| [%myellow40rule] %bred60loc %e-90body" diff --git a/README.md b/README.md index 7c0652f..f4615b7 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ The configuration file supports the following _global_ options (top-level keys): * `colorize`: by default, `bin/qq` will include color codes in its output, to make failing tools easier to spot, and messages easier to read. But you can supply `colorize: false` to tell it not to do that if you don't want them. +* `message_format`: you can specify a format string with which to render the + messages, which interpolates values with various formatting flags. Details + given in the "Message Formatting" section below. And then each tool can have an entry, within which `changed_files` and `filter_messages` can be specified - the tool-specific settings override the @@ -181,6 +184,40 @@ generated file like `db/schema.rb`, and that file doesn't meet your rubocop (or standardrb) rules, you'll get _told_ unless you exclude it at the quiet-quality level as well. +### Message Formatting + +You can supply a message-format string on the cli or in your config file, which +will override the default formatting for message output on the CLI. These format +strings are intended to be a single line containing "substitution tokens", which +each look like `%[lr]?[bem]?color?(Size)(Source)`. + +* The first (optional) flag can be an "l", and "r", or be left off (which is the + same as "l"). This flag indicates the 'justification' - left or right. +* The second (optional) flag can be a "b", an "e", or an "m", defaulting to "e"; + these stand for "beginning", "ending", and "middle", and represent what part + of the string should be truncated if it needs to be shortened. +* The third (optional) part is a color name, and can be any of "yellow", "red", + "green", "blue", "cyan", or "none" (leaving it off is the same as specifing + "none"). This is the color to use for the token in the output - note that any + color supplied here is used regardless of the '--colorize' flag. +* The fourth part of the token is required, and is the _size_ of the token. If a + positive integer is supplied, then the token will take up that much space, and + will be padded on the appropriate side if necessary; if a negative integer is + supplied, then the token will not be padded out, but will still get truncated + if it is too long. The value '0' is special, and indicates that the token + should be neither padded nor truncated. +* The last part of the token is a string indicating the _source_ data to + represent, and must be one of these values: "tool", "loc", "level", "path", + "lines", "rule", "body". Each of these represents one piece of data out of the + message object that can be rendered into the message line. + +Some example message formats: + +```text +%lcyan8tool | %lmyellow30rule | %0loc +%le6tool [%mblue20rule] %b45loc %cyan-100body +``` + ### CLI Options To specify which _tools_ to run (and if any are specified, the `default_tools` diff --git a/lib/quiet_quality/cli/arg_parser.rb b/lib/quiet_quality/cli/arg_parser.rb index 1500416..060d698 100644 --- a/lib/quiet_quality/cli/arg_parser.rb +++ b/lib/quiet_quality/cli/arg_parser.rb @@ -67,6 +67,7 @@ def parser setup_filter_messages_options(parser) setup_colorization_options(parser) setup_logging_options(parser) + setup_message_formatting_options(parser) setup_verbosity_options(parser) end end @@ -168,6 +169,12 @@ def setup_logging_options(parser) end end + def setup_message_formatting_options(parser) + parser.on("-F", "--message-format FMT", "A format string with which to print messages") do |fmt| + set_global_option(:message_format, fmt) + end + end + def setup_verbosity_options(parser) parser.on("-v", "--verbose", "Log more verbosely - multiple times is more verbose") do QuietQuality.logger.increase_level! diff --git a/lib/quiet_quality/cli/message_formatter.rb b/lib/quiet_quality/cli/message_formatter.rb new file mode 100644 index 0000000..1906381 --- /dev/null +++ b/lib/quiet_quality/cli/message_formatter.rb @@ -0,0 +1,190 @@ +module QuietQuality + module Cli + class MessageFormatter + TOKEN_MATCHING_REGEX = %r{%[a-z]*-?\d+(?:tool|loc|level|path|lines|rule|body)} + + def initialize(message_format:) + @message_format = message_format + end + + def format(message) + formatted_tokens = parsed_tokens.map { |pt| FormattedToken.new(parsed_token: pt, message: message) } + formatted_tokens.reduce(message_format) do |interpolating, ftok| + interpolating.gsub(ftok.token, ftok.formatted_token) + end + end + + private + + attr_reader :message_format + + def tokens + @_tokens ||= message_format.scan(TOKEN_MATCHING_REGEX) + end + + def parsed_tokens + @_parsed_tokens ||= tokens.map { |tok| ParsedToken.new(tok) } + end + + class ParsedToken + TOKEN_PARSING_REGEX = %r{ + % # start the interplation token + (?[lr])? # specify the justification + (?[bem])? # where to truncate from + (?yellow|red|green|blue|cyan|none)? # what color + (?-?\d+) # string size (may be negative) + (?tool|loc|level|path|lines|rule|body) # data source name + }x + + COLORS = { + "yellow" => :yellow, + "red" => :red, + "green" => :green, + "blue" => :light_blue, + "cyan" => :light_cyan, + "none" => nil + }.freeze + + JUSTIFICATIONS = {"l" => :left, "r" => :right}.freeze + TRUNCATIONS = {"b" => :beginning, "m" => :middle, "e" => :ending}.freeze + + def initialize(token) + @token = token + end + + attr_reader :token + + def justification + JUSTIFICATIONS.fetch(token_pieces[:just]&.downcase, :left) + end + + def truncation + TRUNCATIONS.fetch(token_pieces[:trunc]&.downcase, :ending) + end + + def color + COLORS.fetch(token_pieces[:color]&.downcase, nil) + end + + def size + raw_size.abs + end + + def source + token_pieces[:source] + end + + def allow_pad? + raw_size.positive? + end + + def allow_truncate? + !raw_size.zero? + end + + private + + def token_pieces + @_token_pieces ||= token.match(TOKEN_PARSING_REGEX) + end + + def raw_size + @_raw_size ||= token_pieces[:size].to_i + end + end + private_constant :ParsedToken + + class FormattedToken + def initialize(parsed_token:, message:) + @parsed_token = parsed_token + @message = message + end + + def formatted_token + colorized(padded(truncated(base_string))) + end + + def token + parsed_token.token + end + + private + + attr_reader :parsed_token, :message + + def line_range + if message.start_line == message.stop_line + message.start_line.to_s + else + "#{message.start_line}-#{message.stop_line}" + end + end + + def base_string + case parsed_token.source + when "tool" then message.tool_name + when "loc" then location_string + when "level" then message.level + when "path" then message.path + when "lines" then line_range + when "rule" then message.rule + when "body" then flattened_body + end + end + + def location_string + "#{message.path}:#{line_range}" + end + + def flattened_body + message.body.gsub(/ *\n */, "\\n") + end + + def truncated(s) + return s unless parsed_token.allow_truncate? + return s if s.length <= parsed_token.size + size = parsed_token.size + + case parsed_token.truncation + when :beginning then truncate_beginning(s, size) + when :middle then truncate_middle(s, size) + when :ending then truncate_ending(s, size) + end + end + + def truncate_beginning(s, size) + "…" + s.slice(1 - size, size - 1) + end + + def truncate_middle(s, size) + front_len = (size / 2.0).floor + back_len = (size / 2.0).ceil - 1 + s.slice(0, front_len) + "…" + s.slice(-back_len, back_len) + end + + def truncate_ending(s, size) + s.slice(0, size - 1) + "…" + end + + def padded(s) + return s unless parsed_token.allow_pad? + return s if s.length >= parsed_token.size + + case parsed_token.justification + when :left then s.ljust(parsed_token.size) + when :right then s.rjust(parsed_token.size) + end + end + + def colorized(s) + if parsed_token.color.nil? + s + else + Colorize.colorize(s, color: parsed_token.color) + end + end + end + private_constant :FormattedToken + end + end +end diff --git a/lib/quiet_quality/cli/presenter.rb b/lib/quiet_quality/cli/presenter.rb index 8317841..65169e6 100644 --- a/lib/quiet_quality/cli/presenter.rb +++ b/lib/quiet_quality/cli/presenter.rb @@ -78,12 +78,28 @@ def reduce_text(s, length) s.gsub(/ *\n */, "\\n").slice(0, length) end - def log_message(msg) + def locally_formatted_message(msg) tool = colorize(:yellow, msg.tool_name) line_range = line_range_for(msg) rule_string = msg.rule ? " [#{colorize(:yellow, msg.rule)}]" : "" truncated_body = reduce_text(msg.body, 120) - stream.puts "#{tool} #{msg.path}:#{line_range}#{rule_string} #{truncated_body}" + "#{tool} #{msg.path}:#{line_range}#{rule_string} #{truncated_body}" + end + + def loggable_message(msg) + if options.message_format + message_formatter.format(msg) + else + stream.puts locally_formatted_message(msg) + end + end + + def log_message(msg) + stream.puts loggable_message(msg) + end + + def message_formatter + @_message_formatter ||= MessageFormatter.new(message_format: options.message_format) end end end diff --git a/lib/quiet_quality/config/builder.rb b/lib/quiet_quality/config/builder.rb index 60570ef..7ee2def 100644 --- a/lib/quiet_quality/config/builder.rb +++ b/lib/quiet_quality/config/builder.rb @@ -120,6 +120,7 @@ def update_comparison_branch def update_logging set_unless_nil(options, :logging, apply.global_option(:logging)) set_unless_nil(options, :colorize, apply.global_option(:colorize)) + set_unless_nil(options, :message_format, apply.global_option(:message_format)) end # ---- update the tool options (apply global forms first) ------- diff --git a/lib/quiet_quality/config/options.rb b/lib/quiet_quality/config/options.rb index 28dbac1..c19510d 100644 --- a/lib/quiet_quality/config/options.rb +++ b/lib/quiet_quality/config/options.rb @@ -11,9 +11,10 @@ def initialize @comparison_branch = nil @colorize = true @logging = :normal + @message_format = nil end - attr_accessor :tools, :comparison_branch, :annotator, :executor, :exec_tool + attr_accessor :tools, :comparison_branch, :annotator, :executor, :exec_tool, :message_format attr_reader :logging attr_writer :colorize @@ -42,6 +43,7 @@ def to_h comparison_branch: comparison_branch, colorize: colorize?, logging: logging, + message_format: message_format, tools: tool_hashes_by_name } end diff --git a/lib/quiet_quality/config/parsed_options.rb b/lib/quiet_quality/config/parsed_options.rb index 97e1140..dfe3d17 100644 --- a/lib/quiet_quality/config/parsed_options.rb +++ b/lib/quiet_quality/config/parsed_options.rb @@ -12,6 +12,7 @@ class ParsedOptions :comparison_branch, :colorize, :logging, + :message_format, :limit_targets, :filter_messages, :file_filter diff --git a/lib/quiet_quality/config/parser.rb b/lib/quiet_quality/config/parser.rb index 8ecb0e5..e9c5334 100644 --- a/lib/quiet_quality/config/parser.rb +++ b/lib/quiet_quality/config/parser.rb @@ -48,6 +48,7 @@ def store_global_options(opts) read_global_option(opts, :unfiltered, :filter_messages, as: :reversed_boolean) read_global_option(opts, :colorize, :colorize, as: :boolean) read_global_option(opts, :logging, :logging, as: :symbol, validate_from: Options::LOGGING_LEVELS) + read_global_option(opts, :message_format, :message_format, as: :string) end def store_tool_options(opts) diff --git a/spec/quiet_quality/cli/arg_parser_spec.rb b/spec/quiet_quality/cli/arg_parser_spec.rb index dd45baf..db2b446 100644 --- a/spec/quiet_quality/cli/arg_parser_spec.rb +++ b/spec/quiet_quality/cli/arg_parser_spec.rb @@ -58,6 +58,7 @@ def self.expect_usage_error(desc, arguments, matcher) -l, --light Print aggregated results only -q, --quiet Don't print results, only return a status code -L, --logging LEVEL Specify logging mode (from normal/light/quiet) + -F, --message-format FMT A format string with which to print messages -v, --verbose Log more verbosely - multiple times is more verbose HELP_OUTPUT end @@ -166,6 +167,10 @@ def self.expect_usage_error(desc, arguments, matcher) expect_options("--logging normal", ["--logging", "normal"], global: {logging: :normal}) expect_options("-Lnormal", ["-Lnormal"], global: {logging: :normal}) expect_usage_error("-Lshenanigans", ["-Lshenanigans"], /Unrecognized logging level/i) + + expect_options("without message-format", [], global: {message_format: nil}) + expect_options("-F '%lmcyan20rule %lbred40tool'", ["-F", "%lmcyan20rule %lbred40tool"], global: {message_format: "%lmcyan20rule %lbred40tool"}) + expect_options("--message-format '%lmcyan20rule %lbred40tool'", ["--message-format", "%lmcyan20rule %lbred40tool"], global: {message_format: "%lmcyan20rule %lbred40tool"}) end describe "logging color options" do diff --git a/spec/quiet_quality/cli/message_formatter_spec.rb b/spec/quiet_quality/cli/message_formatter_spec.rb new file mode 100644 index 0000000..5bed264 --- /dev/null +++ b/spec/quiet_quality/cli/message_formatter_spec.rb @@ -0,0 +1,47 @@ +RSpec.describe QuietQuality::Cli::MessageFormatter do + let(:message_format) { "foo" } + subject(:message_formatter) { described_class.new(message_format: message_format) } + + describe "#format" do + let(:message) do + generate_message( + tool_name: "fake_tool", + path: "path/to/the/file.rb", + start_line: 5, + stop_line: stop_line, + level: "Moderate", + rule: "FakeRule", + body: "This is a message" + ) + end + let(:stop_line) { 7 } + + subject(:formatted) { message_formatter.format(message) } + + def self.it_formats_with(fmt, as:) + context "given a format string of '#{fmt}'" do + let(:message_format) { fmt } + if as.is_a?(Regexp) + it { is_expected.to match(as) } + else + it { is_expected.to eq(as) } + end + end + end + + it_formats_with "foo", as: "foo" + it_formats_with "%lered15tool", as: "\e[31mfake_tool \e[0m" + it_formats_with "%blue12loc", as: "\e[94mpath/to/the…\e[0m" + it_formats_with "%rbgreen5level", as: "\e[32m…rate\e[0m" + it_formats_with "%mblue8path", as: "\e[94mpath….rb\e[0m" + it_formats_with "%cyan10lines", as: "\e[96m5-7 \e[0m" + it_formats_with "%none-14rule", as: "FakeRule" + it_formats_with "%0body", as: "This is a message" + it_formats_with "%r15level", as: " Moderate" + + context "when the start_line matches the stop_line" do + let(:stop_line) { 5 } + it_formats_with "%0level | %-4lines", as: "Moderate | 5" + end + end +end diff --git a/spec/quiet_quality/cli/presenter_spec.rb b/spec/quiet_quality/cli/presenter_spec.rb index 7a85f8c..e15088c 100644 --- a/spec/quiet_quality/cli/presenter_spec.rb +++ b/spec/quiet_quality/cli/presenter_spec.rb @@ -3,7 +3,8 @@ let(:level) { nil } let(:colorize) { false } - let(:options) { build_options(colorize: colorize, logging: level) } + let(:message_format) { nil } + let(:options) { build_options(colorize: colorize, logging: level, message_format: message_format) } let(:rspec_outcome) { build_success(:rspec, "rspec output", "rspec logging") } let(:haml_lint_outcome) { build_failure(:haml_lint, "haml_lint output", "haml_lint logging") } @@ -79,6 +80,19 @@ expect(stream).to have_received(:puts).with("\e[33msudo_make_me_a_sandwhich\e[0m bar.rb:8-14 [\e[33mbarule\e[0m] barbody" + "x" * 113).ordered end end + + context "with a message-format supplied" do + let(:message_format) { "%5tool|%5path|%r7lines|%mcyan5rule" } + + it "writes standard output with formatted messages" do + log_results + expect(stream).to have_received(:puts).with("--- Passed: rspec").ordered + expect(stream).to have_received(:puts).with("--- Failed: haml_lint").ordered + expect(stream).to have_received(:puts).with("\n\n2 messages:").ordered + expect(stream).to have_received(:puts).with("ai_f…|foo.…| 55|\e[96mfo…le\e[0m") + expect(stream).to have_received(:puts).with("sudo…|bar.…| 8-14|\e[96mba…le\e[0m") + end + end end end end diff --git a/spec/quiet_quality/config/builder_spec.rb b/spec/quiet_quality/config/builder_spec.rb index 1640887..cb9ac86 100644 --- a/spec/quiet_quality/config/builder_spec.rb +++ b/spec/quiet_quality/config/builder_spec.rb @@ -195,6 +195,38 @@ end end + describe "#message_format" do + subject(:message_format) { options.message_format } + + context "when global_options[:message_format] is unset" do + let(:global_options) { {} } + it { is_expected.to be_nil } + end + + context "when global_options[:message_format] is specified" do + let(:global_options) { {message_format: "foobar"} } + it { is_expected.to eq("foobar") } + end + + context "when a config file is passed" do + let(:global_options) { {config_path: "/fake.yml", message_format: cli_message_format}.compact } + + context "when the config file sets message_format" do + let(:cfg_global_options) { {message_format: "barbaz"} } + + context "and the cli does not" do + let(:cli_message_format) { nil } + it { is_expected.to eq("barbaz") } + end + + context "and the cli sets it differently" do + let(:cli_message_format) { "foobaz" } + it { is_expected.to eq("foobaz") } + end + end + end + end + describe "#tools" do subject(:tools) { options.tools } diff --git a/spec/quiet_quality/config/options_spec.rb b/spec/quiet_quality/config/options_spec.rb index e687414..2571dfc 100644 --- a/spec/quiet_quality/config/options_spec.rb +++ b/spec/quiet_quality/config/options_spec.rb @@ -9,6 +9,7 @@ expect(options.comparison_branch).to be_nil expect(options.colorize?).to be(true) expect(options.logging).to eq(:normal) + expect(options.message_format).to be_nil end it { is_expected.to respond_to(:tools=) } @@ -17,6 +18,7 @@ it { is_expected.to respond_to(:exec_tool=) } it { is_expected.to respond_to(:comparison_branch=) } it { is_expected.to respond_to(:colorize=) } + it { is_expected.to respond_to(:message_format=) } describe "#logging=" do it "updates the logging value" do @@ -90,6 +92,7 @@ executor: "QuietQuality::Executors::ConcurrentExecutor", exec_tool: nil, logging: :normal, + message_format: nil, tools: { rspec: { tool_name: :rspec, diff --git a/spec/quiet_quality/config/parser_spec.rb b/spec/quiet_quality/config/parser_spec.rb index 8648d87..98aec43 100644 --- a/spec/quiet_quality/config/parser_spec.rb +++ b/spec/quiet_quality/config/parser_spec.rb @@ -147,6 +147,8 @@ def self.expect_config(description, config, defaults: nil, globals: nil, tools: expect_invalid "an invalid logging option", %({logging: shecklackity}), /option logging must be one of the allowed values/ expect_config "colorization enabled", %({colorize: true}), globals: {colorize: true} expect_config "colorization disabled", %({colorize: false}), globals: {colorize: false} + expect_config "message_format unset", %({}), globals: {message_format: nil} + expect_config "message_format set", %({message_format: "foo"}), globals: {message_format: "foo"} end describe "file_filter parsing" do diff --git a/spec/support/option_setup.rb b/spec/support/option_setup.rb index 0d4f46d..f43953b 100644 --- a/spec/support/option_setup.rb +++ b/spec/support/option_setup.rb @@ -17,6 +17,7 @@ def build_options(**attrs) opts = QuietQuality::Config::Options.new maybe_set_option(opts, attrs, :comparison_branch) maybe_set_option(opts, attrs, :logging) + maybe_set_option(opts, attrs, :message_format) maybe_set_option(opts, attrs, :colorize) maybe_set_option(opts, attrs, :annotator, :annotator_from) maybe_set_option(opts, attrs, :executor, :executor_from)