diff --git a/changelog/new_add_markdown_formatter.md b/changelog/new_add_markdown_formatter.md new file mode 100644 index 000000000000..a622c840a07a --- /dev/null +++ b/changelog/new_add_markdown_formatter.md @@ -0,0 +1 @@ +* [#10542](https://github.com/rubocop/rubocop/pull/10542): Add markdown formatter. ([@joe-sharp][]) diff --git a/docs/modules/ROOT/pages/formatters.adoc b/docs/modules/ROOT/pages/formatters.adoc index 381984eb8c37..e3e18951ef0d 100644 --- a/docs/modules/ROOT/pages/formatters.adoc +++ b/docs/modules/ROOT/pages/formatters.adoc @@ -343,6 +343,15 @@ Useful for CI environments. It will create an HTML report like http://f.cl.ly/it $ rubocop --format html -o rubocop.html ---- +== Markdown Formatter + +Useful for CI environments, especially if posting comments back to pull requests. It will create a markdown report like https://github.com/rubocop/rubocop/blob/master/spec/fixtures/markdown_formatter/expected.md[this]. + +[source,sh] +---- +$ rubocop --format markdown -o rubocop.md +---- + == TAP Formatter *Machine-parsable* diff --git a/lib/rubocop.rb b/lib/rubocop.rb index 8fbd9596a276..7c00cc993c91 100644 --- a/lib/rubocop.rb +++ b/lib/rubocop.rb @@ -674,6 +674,7 @@ require_relative 'rubocop/formatter/html_formatter' require_relative 'rubocop/formatter/json_formatter' require_relative 'rubocop/formatter/junit_formatter' +require_relative 'rubocop/formatter/markdown_formatter' require_relative 'rubocop/formatter/offense_count_formatter' require_relative 'rubocop/formatter/progress_formatter' require_relative 'rubocop/formatter/quiet_formatter' diff --git a/lib/rubocop/formatter/formatter_set.rb b/lib/rubocop/formatter/formatter_set.rb index 0f7e517b282f..ecdb930a68ec 100644 --- a/lib/rubocop/formatter/formatter_set.rb +++ b/lib/rubocop/formatter/formatter_set.rb @@ -18,6 +18,7 @@ class FormatterSet < Array '[h]tml' => HTMLFormatter, '[j]son' => JSONFormatter, '[ju]nit' => JUnitFormatter, + '[m]arkdown' => MarkdownFormatter, '[o]ffenses' => OffenseCountFormatter, '[pa]cman' => PacmanFormatter, '[p]rogress' => ProgressFormatter, diff --git a/lib/rubocop/formatter/markdown_formatter.rb b/lib/rubocop/formatter/markdown_formatter.rb new file mode 100644 index 000000000000..7051c2c0e849 --- /dev/null +++ b/lib/rubocop/formatter/markdown_formatter.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module RuboCop + module Formatter + # This formatter displays the report data in markdown + class MarkdownFormatter < BaseFormatter + include TextUtil + include PathUtil + attr_reader :files, :summary + + def initialize(output, options = {}) + super + @files = [] + @summary = Struct.new(:offense_count, :inspected_files, :target_files).new(0) + end + + def started(target_files) + summary.target_files = target_files + end + + def file_finished(file, offenses) + files << Struct.new(:path, :offenses).new(file, offenses) + summary.offense_count += offenses.count + end + + def finished(inspected_files) + summary.inspected_files = inspected_files + render_markdown + end + + private + + def render_markdown + n_files = pluralize(summary.inspected_files.count, 'file') + n_offenses = pluralize(summary.offense_count, 'offense', no_for_zero: true) + + output.write "# RuboCop Inspection Report\n\n" + output.write "#{n_files} inspected, #{n_offenses} detected:\n\n" + write_file_messages + end + + def write_file_messages + files.each do |file| + write_heading(file) + file.offenses.each do |offense| + write_context(offense) + write_code(offense) + end + end + end + + def write_heading(file) + filename = relative_path(file.path) + n_offenses = pluralize(file.offenses.count, 'offense') + + output.write "### #{filename} - (#{n_offenses})\n" + end + + def write_context(offense) + output.write( + " * **Line # #{offense.location.line} - #{offense.severity}:** #{offense.message}\n\n" + ) + end + + def write_code(offense) + code = offense.location.source_line + possible_ellipses(offense.location) + + output.write " ```rb\n #{code}\n ```\n\n" unless code.blank? + end + + def possible_ellipses(location) + location.first_line == location.last_line ? '' : ' ...' + end + end + end +end diff --git a/spec/fixtures/markdown_formatter/expected.md b/spec/fixtures/markdown_formatter/expected.md new file mode 100644 index 000000000000..a9c9785d9958 --- /dev/null +++ b/spec/fixtures/markdown_formatter/expected.md @@ -0,0 +1,139 @@ +# RuboCop Inspection Report + +3 files inspected, 22 offenses detected: + +### app/controllers/application_controller.rb - (1 offense) + * **Line # 1 - convention:** Style/FrozenStringLiteralComment: Missing frozen string literal comment. + + ```rb + class ApplicationController < ActionController::Base + ``` + +### app/controllers/books_controller.rb - (14 offenses) + * **Line # 1 - convention:** Style/Documentation: Missing top-level documentation comment for `class BooksController`. + + ```rb + class BooksController < ApplicationController + ``` + + * **Line # 1 - convention:** Style/FrozenStringLiteralComment: Missing frozen string literal comment. + + ```rb + class BooksController < ApplicationController + ``` + + * **Line # 2 - convention:** Style/SymbolArray: Use `%i` or `%I` for an array of symbols. + + ```rb + before_action :set_book, only: [:show, :edit, :update, :destroy] + ``` + + * **Line # 12 - convention:** Style/EmptyMethod: Put empty method definitions on a single line. + + ```rb + def show ... + ``` + + * **Line # 21 - convention:** Style/EmptyMethod: Put empty method definitions on a single line. + + ```rb + def edit ... + ``` + + * **Line # 31 - convention:** Layout/LineLength: Line is too long. [121/120] + + ```rb + format.html { redirect_to @book, notice: 'Book was successfully created.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ``` + + * **Line # 45 - convention:** Layout/LineLength: Line is too long. [121/120] + + ```rb + format.html { redirect_to @book, notice: 'Book was successfully updated.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ``` + + * **Line # 59 - convention:** Layout/LineLength: Line is too long. [121/120] + + ```rb + format.html { redirect_to books_url, notice: 'Book was successfully destroyed.' } # aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ``` + + * **Line # 64 - convention:** Layout/EmptyLinesAroundAccessModifier: Keep a blank line before and after `private`. + + ```rb + private + ``` + + * **Line # 66 - convention:** Layout/IndentationWidth: Use 2 (not 4) spaces for indentation. + + ```rb + def set_book + ``` + + * **Line # 66 - convention:** Layout/IndentationConsistency: Inconsistent indentation detected. + + ```rb + def set_book ... + ``` + + * **Line # 70 - convention:** Layout/LineLength: Line is too long. [121/120] + + ```rb + # Never trust parameters from the scary internet, only allow the allow list through. aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ``` + + * **Line # 71 - convention:** Layout/IndentationWidth: Use 2 (not 4) spaces for indentation. + + ```rb + def book_params + ``` + + * **Line # 71 - convention:** Layout/IndentationConsistency: Inconsistent indentation detected. + + ```rb + def book_params ... + ``` + +### app/models/book.rb - (7 offenses) + * **Line # 1 - convention:** Style/Documentation: Missing top-level documentation comment for `class Book`. + + ```rb + class Book < ActiveRecord::Base + ``` + + * **Line # 1 - convention:** Style/FrozenStringLiteralComment: Missing frozen string literal comment. + + ```rb + class Book < ActiveRecord::Base + ``` + + * **Line # 2 - convention:** Naming/MethodName: Use snake_case for method names. + + ```rb + def someMethod + ``` + + * **Line # 3 - warning:** Lint/UselessAssignment: Useless assignment to variable - `foo`. + + ```rb + foo = bar = baz + ``` + + * **Line # 3 - warning:** Lint/UselessAssignment: Useless assignment to variable - `bar`. Did you mean `baz`? + + ```rb + foo = bar = baz + ``` + + * **Line # 4 - convention:** Style/RescueModifier: Avoid using `rescue` in its modifier form. + + ```rb + Regexp.new(/\A
(.*)<\/p>\Z/m).match(full_document)[1] rescue full_document + ``` + + * **Line # 4 - convention:** Style/RegexpLiteral: Use `%r` around regular expression. + + ```rb + Regexp.new(/\A
(.*)<\/p>\Z/m).match(full_document)[1] rescue full_document + ``` + diff --git a/spec/fixtures/markdown_formatter/project b/spec/fixtures/markdown_formatter/project new file mode 120000 index 000000000000..e705edc617bf --- /dev/null +++ b/spec/fixtures/markdown_formatter/project @@ -0,0 +1 @@ +../html_formatter/project \ No newline at end of file diff --git a/spec/rubocop/formatter/markdown_formatter_spec.rb b/spec/rubocop/formatter/markdown_formatter_spec.rb new file mode 100644 index 000000000000..f9e85674e485 --- /dev/null +++ b/spec/rubocop/formatter/markdown_formatter_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Formatter::MarkdownFormatter, :isolated_environment do + spec_root = File.expand_path('../..', __dir__) + + around do |example| + project_path = File.join(spec_root, 'fixtures/markdown_formatter/project') + FileUtils.cp_r(project_path, '.') + + Dir.chdir(File.basename(project_path)) { example.run } + end + + # Run without Style/EndOfLine as it gives different results on + # different platforms. + # Metrics/AbcSize is very strict, exclude it too + let(:options) { %w[--except Layout/EndOfLine,Metrics/AbcSize --format markdown --out] } + + let(:actual_markdown_path) do + path = File.expand_path('result.md') + RuboCop::CLI.new.run([*options, path]) + path + end + + let(:actual_markdown_path_cached) do + path = File.expand_path('result_cached.md') + 2.times { RuboCop::CLI.new.run([*options, path]) } + path + end + + let(:actual_markdown) { File.read(actual_markdown_path, encoding: Encoding::UTF_8) } + + let(:actual_markdown_cached) { File.read(actual_markdown_path_cached, encoding: Encoding::UTF_8) } + + let(:expected_markdown_path) { File.join(spec_root, 'fixtures/markdown_formatter/expected.md') } + + let(:expected_markdown) do + markdown = File.read(expected_markdown_path, encoding: Encoding::UTF_8) + # Avoid failure on version bump + markdown.sub(/(class="version".{0,20})\d+(?:\.\d+){2}/i) do + Regexp.last_match(1) + RuboCop::Version::STRING + end + end + + it 'outputs the result in Markdown' do + expect(actual_markdown).to eq(expected_markdown) + end + + it 'outputs the cached result in Markdown' do + expect(actual_markdown_cached).to eq(expected_markdown) + end +end diff --git a/spec/rubocop/options_spec.rb b/spec/rubocop/options_spec.rb index 68b17943b8a4..9bc6c7ced7cd 100644 --- a/spec/rubocop/options_spec.rb +++ b/spec/rubocop/options_spec.rb @@ -95,6 +95,7 @@ def abs(path) [h]tml [j]son [ju]nit + [m]arkdown [o]ffenses [pa]cman [p]rogress (default)