Created
May 11, 2026 00:05
-
-
Save thomasklemm/51808e34eae3d98ba311fd3f1f9693a2 to your computer and use it in GitHub Desktop.
Custom RuboCop cop: convert inline rubocop:disable to standalone disable/enable pairs (rubyfmt-compatible)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # frozen_string_literal: true | |
| module RuboCop | |
| module Cop | |
| module Custom | |
| # Flags inline `rubocop:disable` and `rubocop:todo` comments on code lines | |
| # and converts them to standalone disable/enable pairs. | |
| # | |
| # rubyfmt places all comments on their own line. For `rubocop:disable`, | |
| # this silently widens the scope from one line to the rest of the file. | |
| # See https://github.com/fables-tales/rubyfmt/issues/881 — the rubyfmt | |
| # maintainers consider this expected behavior. This cop is the | |
| # codebase-side fix: proactively convert inline disables to explicit | |
| # disable/enable pairs so rubyfmt can safely reformat them. | |
| # Approach suggested by @froydnj (rubyfmt collaborator), used at Stripe. | |
| # | |
| # `rubocop:todo` gets a standalone comment above but no `enable` — it | |
| # signals "still needs fixing" rather than a deliberate narrow-scope | |
| # exemption. | |
| class NoEndOfLineRubocopDisables < Base | |
| include RangeHelp | |
| extend AutoCorrector | |
| MSG = "Inline `rubocop:%<directive>s` — convert to standalone disable/enable pair." | |
| DIRECTIVE_PATTERN = /\A#\s*rubocop:(disable|todo)\s+(.+)/ | |
| def on_new_investigation | |
| processed_source.comments.each do |comment| | |
| next unless comment.text.include?("rubocop:") | |
| match = comment.text.match(DIRECTIVE_PATTERN) | |
| next unless match | |
| line = processed_source.buffer.source_line(comment.location.line) | |
| next if line[0...comment.location.column].strip.empty? | |
| directive_type = match[1] | |
| add_offense(comment, message: format(MSG, directive: directive_type)) do |corrector| | |
| autocorrect_comment(corrector, comment, directive_type, match[2], line) | |
| end | |
| end | |
| end | |
| private | |
| def autocorrect_comment(corrector, comment, directive_type, directive_body, line) | |
| indent = line[/\A(\s*)/, 1] | |
| whole_line = range_by_whole_lines(comment.location.expression, include_final_newline: true) | |
| code_end_col = line[0...comment.location.column].rstrip.length | |
| corrector.remove(range_between(whole_line.begin_pos + code_end_col, comment.location.expression.end_pos)) | |
| corrector.insert_before(whole_line, "#{indent}# rubocop:#{directive_type} #{directive_body}\n") | |
| return unless directive_type == "disable" | |
| corrector.insert_after(whole_line, "#{indent}# rubocop:enable #{extract_cop_names(directive_body)}\n") | |
| end | |
| def extract_cop_names(directive_body) | |
| directive_body.split("--").first.strip | |
| end | |
| end | |
| end | |
| end | |
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # frozen_string_literal: true | |
| require "minitest/autorun" | |
| require "rubocop" | |
| require_relative "../../../../.rubocop/cop/custom/no_end_of_line_rubocop_disables" | |
| class NoEndOfLineRubocopDisablesTest < Minitest::Test | |
| CONFIG = RuboCop::ConfigStore.new.for_pwd | |
| REGISTRY = RuboCop::Cop::Registry.global | |
| def setup | |
| @cop = RuboCop::Cop::Custom::NoEndOfLineRubocopDisables.new(CONFIG) | |
| end | |
| def offenses_for(source_code) | |
| investigate(source_code).offenses | |
| end | |
| def corrected_source(source_code) | |
| result = investigate(source_code) | |
| return source_code if result.offenses.empty? | |
| rewriter = Parser::Source::TreeRewriter.new(result.offenses.first.location.source_buffer) | |
| result.offenses.each do |offense| | |
| rewriter.merge!(offense.corrector) if offense.corrector | |
| end | |
| rewriter.process | |
| end | |
| # 1. Simple disable → gets converted | |
| def test_detects_inline_disable | |
| source = <<~RUBY | |
| user.update_column(:role, "admin") # rubocop:disable Rails/SkipsModelValidations | |
| RUBY | |
| offenses = offenses_for(source) | |
| assert_equal 1, offenses.size | |
| assert_includes offenses.first.message, "rubocop:disable" | |
| end | |
| def test_autocorrects_inline_disable | |
| source = <<~RUBY | |
| user.update_column(:role, "admin") # rubocop:disable Rails/SkipsModelValidations | |
| RUBY | |
| expected = <<~RUBY | |
| # rubocop:disable Rails/SkipsModelValidations | |
| user.update_column(:role, "admin") | |
| # rubocop:enable Rails/SkipsModelValidations | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| # 2. Simple todo → converted without enable | |
| def test_detects_inline_todo | |
| source = <<~RUBY | |
| x = 1 # rubocop:todo Style/Foo | |
| RUBY | |
| offenses = offenses_for(source) | |
| assert_equal 1, offenses.size | |
| assert_includes offenses.first.message, "rubocop:todo" | |
| end | |
| def test_autocorrects_inline_todo_without_enable | |
| source = <<~RUBY | |
| x = 1 # rubocop:todo Style/Foo | |
| RUBY | |
| expected = <<~RUBY | |
| # rubocop:todo Style/Foo | |
| x = 1 | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| # 3. Standalone comment → NOT flagged | |
| def test_ignores_standalone_disable_comment | |
| source = <<~RUBY | |
| # rubocop:disable Rails/SkipsModelValidations | |
| user.update_column(:role, "admin") | |
| # rubocop:enable Rails/SkipsModelValidations | |
| RUBY | |
| assert_empty offenses_for(source) | |
| end | |
| def test_ignores_standalone_todo_comment | |
| source = <<~RUBY | |
| # rubocop:todo Style/Foo | |
| x = 1 | |
| RUBY | |
| assert_empty offenses_for(source) | |
| end | |
| # 4. Multiple cops on one disable | |
| def test_autocorrects_multiple_cops | |
| source = <<~RUBY | |
| do_something # rubocop:disable Metrics/AbcSize, Metrics/MethodLength | |
| RUBY | |
| expected = <<~RUBY | |
| # rubocop:disable Metrics/AbcSize, Metrics/MethodLength | |
| do_something | |
| # rubocop:enable Metrics/AbcSize, Metrics/MethodLength | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| # 5. Disable with reason text | |
| def test_autocorrects_disable_with_reason | |
| source = <<~RUBY | |
| drop_table :claims # rubocop:disable Rails/ReversibleMigration -- one-way data cleanup | |
| RUBY | |
| expected = <<~RUBY | |
| # rubocop:disable Rails/ReversibleMigration -- one-way data cleanup | |
| drop_table :claims | |
| # rubocop:enable Rails/ReversibleMigration | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| # 6. Indented code in class/method → correct indentation | |
| def test_autocorrects_indented_code | |
| source = <<~RUBY | |
| class Foo | |
| def bar | |
| x.save! # rubocop:disable Rails/SaveBang | |
| end | |
| end | |
| RUBY | |
| expected = <<~RUBY | |
| class Foo | |
| def bar | |
| # rubocop:disable Rails/SaveBang | |
| x.save! | |
| # rubocop:enable Rails/SaveBang | |
| end | |
| end | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| # 7. Regular trailing comment → NOT flagged | |
| def test_ignores_regular_trailing_comment | |
| source = <<~RUBY | |
| x = 1 # some explanation | |
| RUBY | |
| assert_empty offenses_for(source) | |
| end | |
| def test_ignores_regular_comment_with_rubocop_mention | |
| source = <<~RUBY | |
| x = 1 # see rubocop docs | |
| RUBY | |
| assert_empty offenses_for(source) | |
| end | |
| # Edge: indented standalone comment → NOT flagged | |
| def test_ignores_indented_standalone_comment | |
| source = <<~RUBY | |
| class Foo | |
| # rubocop:disable Rails/SaveBang | |
| def bar | |
| x.save! | |
| end | |
| # rubocop:enable Rails/SaveBang | |
| end | |
| RUBY | |
| assert_empty offenses_for(source) | |
| end | |
| # Multiple inline disables on separate lines | |
| def test_autocorrects_multiple_lines | |
| source = <<~RUBY | |
| a = 1 # rubocop:disable Style/Foo | |
| b = 2 # rubocop:disable Style/Bar | |
| RUBY | |
| expected = <<~RUBY | |
| # rubocop:disable Style/Foo | |
| a = 1 | |
| # rubocop:enable Style/Foo | |
| # rubocop:disable Style/Bar | |
| b = 2 | |
| # rubocop:enable Style/Bar | |
| RUBY | |
| assert_equal expected, corrected_source(source) | |
| end | |
| private | |
| def investigate(source_code) | |
| source = RuboCop::ProcessedSource.new(source_code, RUBY_VERSION.to_f) | |
| source.config = CONFIG | |
| source.registry = REGISTRY | |
| commissioner = RuboCop::Cop::Commissioner.new([@cop], raise_error: true) | |
| commissioner.investigate(source) | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment