Skip to content

Instantly share code, notes, and snippets.

@thomasklemm
Created May 11, 2026 00:05
Show Gist options
  • Select an option

  • Save thomasklemm/51808e34eae3d98ba311fd3f1f9693a2 to your computer and use it in GitHub Desktop.

Select an option

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)
# 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
# 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