Skip to content
Closed
1 change: 1 addition & 0 deletions changelog/new_single_line_complexity_cop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#13595](https://github.com/rubocop/rubocop/pull/13595): Add a new `Metrics/SingleLineComplexity` cop. ([@kallin][])
6 changes: 6 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2742,6 +2742,12 @@ Metrics/PerceivedComplexity:
AllowedPatterns: []
Max: 8

Metrics/SingleLineComplexity:
Description: 'Metrics/AbcSize, calculated per line instead of per method.'
Enabled: true
VersionAdded: '<<next>>'
Max: 14

################## Migration #############################

Migration/DepartmentName:
Expand Down
1 change: 1 addition & 0 deletions lib/rubocop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@
require_relative 'rubocop/cop/metrics/module_length'
require_relative 'rubocop/cop/metrics/parameter_lists'
require_relative 'rubocop/cop/metrics/perceived_complexity'
require_relative 'rubocop/cop/metrics/single_line_complexity'

require_relative 'rubocop/cop/naming/accessor_method_name'
require_relative 'rubocop/cop/naming/ascii_identifiers'
Expand Down
104 changes: 104 additions & 0 deletions lib/rubocop/cop/metrics/single_line_complexity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Metrics
# Checks that the ABC size of each individual line is not higher than the
# configured maximum. Please see the `Metrics/AbcSize` cop for more
# information on how the ABC size is calculated.
#
class SingleLineComplexity < Base
MSG = 'Assignment Branch Condition size too high for line %<line>d. ' \
'[%<abc_vector>s %<complexity>.4g/%<max>.4g]'

def on_new_investigation
return unless (root = processed_source.ast)

top_level_nodes_per_line = top_level_nodes_per_line(root)

top_level_nodes_per_line.each do |line, nodes|
complexity, abc_vector = abc_score_for_nodes(nodes)

next unless complexity > max

msg = format(
self.class::MSG, line: line, complexity: complexity, abc_vector: abc_vector, max: max
)

add_offense(nodes.first, message: msg)
end
end

private

def abc_score_for_nodes(nodes)
scores = nodes.map do |node|
Utils::AbcSizeCalculator.calculate(node)
end

# in cases such as `a = 1; b = 2` there are multiple 'top-level' nodes on the same line
# we sum the scores of all nodes on the line
sum_complexity_scores(scores)
end

def sum_complexity_scores(scores)
vector_sums = []

scores.each do |score|
_, vector = score

# Parse and sum vector components
vector_values = vector[1..-2].split(',').map(&:to_f) # Remove < > and split by comma
vector_values.each_with_index do |value, i|
vector_sums[i] = (vector_sums[i] || 0) + value
end
end

total_score = Math.sqrt(vector_sums.sum { |sum| sum**2 }).round(2)

[total_score, "<#{vector_sums.join(', ')}>"]
end

def top_level_nodes_per_line(root)
nodes_per_line = nodes_per_line(root)

nodes_per_line.transform_values do |nodes_on_line|
nodes_on_line.reject do |node|
# if the node's parent is on this line, then this node is not top-level for the line
nodes_on_line.include?(node.parent)
end
end
end

def nodes_per_line(root)
nodes_per_line = {}

root.each_node do |child|
next unless child.source_range

line_range = child.source_range.first_line..child.source_range.last_line

# Skip nodes that span multiple lines
next if line_range.size > 1

next if heredoc_node?(child)

line_range.each do |line|
nodes_per_line[line] ||= []
nodes_per_line[line] << child
end
end
nodes_per_line
end

def heredoc_node?(node)
node.dstr_type?
end

def max
cop_config['Max']
end
end
end
end
end
71 changes: 71 additions & 0 deletions spec/rubocop/cop/metrics/single_line_complexity_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

RSpec.describe RuboCop::Cop::Metrics::SingleLineComplexity, :config do
let(:cop_config) { { 'Max' => 2 } }

context 'single line code' do
it 'registers an offense when ABC per line > 2' do
expect_offense(<<~RUBY)
a ? b : c
^^^^^^^^^ Assignment Branch Condition size too high for line 1. [<0.0, 3.0, 1.0> 3.16/2]
RUBY
end
end

context 'multi line code' do
it 'registers two offenses when 2 of 4 lines have ABC > 2' do
expect_offense(<<~RUBY)
a ? b : c
^^^^^^^^^ Assignment Branch Condition size too high for line 1. [<0.0, 3.0, 1.0> 3.16/2]
x = 1
c ? d : e
^^^^^^^^^ Assignment Branch Condition size too high for line 3. [<0.0, 3.0, 1.0> 3.16/2]
y = 2
RUBY
end

it 'accepts all lines when ABC per line always <= 2' do
expect_no_offenses(<<~RUBY)
x = 1 + 1
y = 2
RUBY
end
end

context 'inline statements' do
it 'rejects an offense when ABC > 2' do
expect_offense(<<~RUBY)
x = 1; y = 2; z = 3;
^^^^^^^^^^^^^^^^^^^ Assignment Branch Condition size too high for line 1. [<3.0, 0.0, 0.0> 3/2]
RUBY
end

it 'accepts when ABC == 2' do
expect_no_offenses(<<~RUBY)
x = 1; y = 2;
RUBY
end
end

it 'does not register an offense for a multiline heredoc' do
expect_no_offenses(<<~'RUBY')
<<~STRING
#{foo}
#{bar}
#{baz}
#{quux}
STRING
RUBY
end

it 'registers an offense for a complex line within a heredoc' do
expect_offense(<<~'RUBY')
<<~STRING

Heredoc with multiple lines
#{foo.bar.baz.quux}
^^^^^^^^^^^^^^^^^^^ Assignment Branch Condition size too high for line 4. [<0.0, 4.0, 0.0> 4/2]
STRING
RUBY
end
end
Loading