Skip to content

Commit 345be1d

Browse files
marcandremergify[bot]
authored andcommitted
[Fixes #8564] Metrics/AbcSize: Add optional discount for repeated "attributes"
1 parent 0647c04 commit 345be1d

File tree

8 files changed

+280
-5
lines changed

8 files changed

+280
-5
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#8564](https://github.com/rubocop-hq/rubocop/issues/8564): `Metrics/AbcSize`: Add optional discount for repeated "attributes". ([@marcandre][])

config/default.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2128,10 +2128,11 @@ Metrics/AbcSize:
21282128
- https://en.wikipedia.org/wiki/ABC_Software_Metric
21292129
Enabled: true
21302130
VersionAdded: '0.27'
2131-
VersionChanged: '0.81'
2131+
VersionChanged: '<<next>>'
21322132
# The ABC size is a calculated magnitude, so this number can be an Integer or
21332133
# a Float.
21342134
IgnoredMethods: []
2135+
CountRepeatedAttributes: true
21352136
Max: 17
21362137

21372138
Metrics/BlockLength:

lib/rubocop.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
require_relative 'rubocop/cop/mixin/line_length_help'
9090
require_relative 'rubocop/cop/mixin/match_range'
9191
require_relative 'rubocop/cop/metrics/utils/repeated_csend_discount'
92+
require_relative 'rubocop/cop/metrics/utils/repeated_attribute_discount'
9293
require_relative 'rubocop/cop/mixin/method_complexity'
9394
require_relative 'rubocop/cop/mixin/method_preference'
9495
require_relative 'rubocop/cop/mixin/min_body_length'

lib/rubocop/cop/metrics/abc_size.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ module Metrics
77
# configured maximum. The ABC size is based on assignments, branches
88
# (method calls), and conditions. See http://c2.com/cgi/wiki?AbcMetric
99
# and https://en.wikipedia.org/wiki/ABC_Software_Metric.
10+
#
11+
# You can have repeated "attributes" calls count as a single "branch".
12+
# For this purpose, attributes are any method with no argument; no attempt
13+
# is meant to distinguish actual `attr_reader` from other methods.
14+
#
15+
# @example CountRepeatedAttributes: false (default is true)
16+
#
17+
# # `model` and `current_user`, refenced 3 times each,
18+
# # are each counted as only 1 branch each if
19+
# # `CountRepeatedAttributes` is set to 'false'
20+
#
21+
# def search
22+
# @posts = model.active.visible_by(current_user)
23+
# .search(params[:q])
24+
# @posts = model.some_process(@posts, current_user)
25+
# @posts = model.another_process(@posts, current_user)
26+
#
27+
# render 'pages/search/page'
28+
# end
29+
#
30+
# This cop also takes into account `IgnoredMethods` (defaults to `[]`)
1031
class AbcSize < Base
1132
include MethodComplexity
1233

@@ -16,7 +37,10 @@ class AbcSize < Base
1637
private
1738

1839
def complexity(node)
19-
Utils::AbcSizeCalculator.calculate(node)
40+
Utils::AbcSizeCalculator.calculate(
41+
node,
42+
discount_repeated_attributes: !cop_config['CountRepeatedAttributes']
43+
)
2044
end
2145
end
2246
end

lib/rubocop/cop/metrics/utils/abc_size_calculator.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module Utils
1313
class AbcSizeCalculator
1414
include IteratingBlock
1515
include RepeatedCsendDiscount
16+
prepend RepeatedAttributeDiscount
1617

1718
# > Branch -- an explicit forward program branch out of scope -- a
1819
# > function call, class method call ..
@@ -24,8 +25,8 @@ class AbcSizeCalculator
2425
# > http://c2.com/cgi/wiki?AbcMetric
2526
CONDITION_NODES = CyclomaticComplexity::COUNTED_NODES.freeze
2627

27-
def self.calculate(node)
28-
new(node).calculate
28+
def self.calculate(node, discount_repeated_attributes: false)
29+
new(node, discount_repeated_attributes: discount_repeated_attributes).calculate
2930
end
3031

3132
# TODO: move to rubocop-ast
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Metrics
6+
module Utils
7+
# @api private
8+
#
9+
# Identifies repetitions `{c}send` calls with no arguments:
10+
#
11+
# foo.bar
12+
# foo.bar # => repeated
13+
# foo.bar.baz.qux # => inner send repeated
14+
# foo.bar.baz.other # => both inner send repeated
15+
# foo.bar(2) # => not repeated
16+
#
17+
# It also invalidates sequences if a receiver is reassigned:
18+
#
19+
# xx.foo.bar
20+
# xx.foo.baz # => inner send repeated
21+
# self.xx = any # => invalidates everything so far
22+
# xx.foo.baz # => no repetition
23+
# self.xx.foo.baz # => all repeated
24+
#
25+
module RepeatedAttributeDiscount
26+
extend NodePattern::Macros
27+
include RuboCop::AST::Sexp
28+
29+
# Plug into the calculator
30+
def initialize(node, discount_repeated_attributes: false)
31+
super(node)
32+
return unless discount_repeated_attributes
33+
34+
self_attributes = {} # Share hash for `(send nil? :foo)` and `(send (self) :foo)`
35+
@known_attributes = {
36+
s(:self) => self_attributes,
37+
nil => self_attributes
38+
}
39+
# example after running `obj = foo.bar; obj.baz.qux`
40+
# { nil => {foo: {bar: {}}},
41+
# s(self) => same hash ^,
42+
# s(:lvar, :obj) => {baz: {qux: {}}}
43+
# }
44+
end
45+
46+
def discount_repeated_attributes?
47+
defined?(@known_attributes)
48+
end
49+
50+
def evaluate_branch_nodes(node)
51+
return if discount_repeated_attributes? && discount_repeated_attribute?(node)
52+
53+
super
54+
end
55+
56+
def calculate_node(node)
57+
update_repeated_attribute(node) if discount_repeated_attributes?
58+
super
59+
end
60+
61+
private
62+
63+
def_node_matcher :attribute_call?, <<~PATTERN
64+
( {csend send} _receiver _method # and no parameters
65+
)
66+
PATTERN
67+
68+
def discount_repeated_attribute?(send_node)
69+
return false unless attribute_call?(send_node)
70+
71+
repeated = true
72+
find_attributes(send_node) do |hash, lookup|
73+
return false if hash.nil?
74+
75+
repeated = false
76+
hash[lookup] = {}
77+
end
78+
79+
repeated
80+
end
81+
82+
def update_repeated_attribute(node)
83+
return unless (receiver, method = setter_to_getter(node))
84+
85+
calls = find_attributes(receiver) { return }
86+
if method # e.g. `self.foo = 42`
87+
calls.delete(method)
88+
else # e.g. `var = 42`
89+
calls.clear
90+
end
91+
end
92+
93+
def_node_matcher :root_node?, <<~PATTERN
94+
{ nil? | self # e.g. receiver of `my_method` or `self.my_attr`
95+
| lvar | ivar | cvar | gvar # e.g. receiver of `var.my_method`
96+
| const } # e.g. receiver of `MyConst.foo.bar`
97+
PATTERN
98+
99+
# Returns the "known_attributes" for the `node` by walking the receiver tree
100+
# If at any step the subdirectory does not exist, it is yielded with the
101+
# associated key (method_name)
102+
# If the node is not a series of `(c)send` calls with no arguments,
103+
# then `nil` is yielded
104+
def find_attributes(node, &block)
105+
if attribute_call?(node)
106+
calls = find_attributes(node.receiver, &block)
107+
value = node.method_name
108+
elsif root_node?(node)
109+
calls = @known_attributes
110+
value = node
111+
else
112+
return yield nil
113+
end
114+
115+
calls.fetch(value) do
116+
yield [calls, value]
117+
end
118+
end
119+
120+
VAR_SETTER_TO_GETTER = {
121+
lvasgn: :lvar,
122+
ivasgn: :ivar,
123+
cvasgn: :cvar,
124+
gvasgn: :gvar
125+
}.freeze
126+
127+
# @returns `[receiver, method | nil]` for the given setter `node`
128+
# or `nil` if it is not a setter.
129+
def setter_to_getter(node)
130+
if (type = VAR_SETTER_TO_GETTER[node.type])
131+
# (lvasgn :my_var (int 42)) => [(lvar my_var), nil]
132+
[s(type, node.children.first), nil]
133+
elsif node.shorthand_asgn? # (or-asgn (send _receiver :foo) _value)
134+
# (or-asgn (send _receiver :foo) _value) => [_receiver, :foo]
135+
node.children.first.children
136+
elsif node.respond_to?(:setter_method?) && node.setter_method?
137+
# (send _receiver :foo= (int 42) ) => [_receiver, :foo]
138+
method_name = node.method_name[0...-1].to_sym
139+
[node.receiver, method_name]
140+
end
141+
end
142+
end
143+
end
144+
end
145+
end
146+
end

spec/rubocop/cop/metrics/abc_size_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,36 @@ def self.foo
134134
end
135135
end
136136
end
137+
138+
context 'when CountRepeatedAttributes is `false`' do
139+
let(:cop_config) { { 'Max' => 0, 'CountRepeatedAttributes' => false } }
140+
141+
it 'does not count repeated attributes' do
142+
expect_offense(<<~RUBY)
143+
def foo
144+
^^^^^^^ Assignment Branch Condition size for foo is too high. [<0, 1, 0> 1/0]
145+
bar
146+
self.bar
147+
bar
148+
end
149+
RUBY
150+
end
151+
end
152+
153+
context 'when CountRepeatedAttributes is `true`' do
154+
let(:cop_config) { { 'Max' => 0, 'CountRepeatedAttributes' => true } }
155+
156+
it 'counts repeated attributes' do
157+
expect_offense(<<~RUBY)
158+
def foo
159+
^^^^^^^ Assignment Branch Condition size for foo is too high. [<0, 3, 0> 3/0]
160+
bar
161+
self.bar
162+
bar
163+
end
164+
RUBY
165+
end
166+
end
137167
end
138168

139169
context 'when Max is 2' do

spec/rubocop/cop/metrics/utils/abc_size_calculator_spec.rb

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
RSpec.describe RuboCop::Cop::Metrics::Utils::AbcSizeCalculator do
44
describe '#calculate' do
5-
subject(:vector) { described_class.calculate(node).last }
5+
subject(:vector) do
6+
described_class.calculate(node,
7+
discount_repeated_attributes: discount_repeated_attributes).last
8+
end
9+
10+
let(:discount_repeated_attributes) { false }
611

712
let(:node) { parse_source(source).ast }
813

@@ -319,5 +324,71 @@ def method_name
319324

320325
it { is_expected.to eq '<0, 1, 0>' }
321326
end
327+
328+
context 'when counting repeated calls' do
329+
let(:discount_repeated_attributes) { false }
330+
let(:source) { <<~RUBY }
331+
def method_name(var)
332+
var.foo
333+
var.foo
334+
bar
335+
bar
336+
end
337+
RUBY
338+
339+
it { is_expected.to eq '<1, 4, 0>' }
340+
end
341+
342+
context 'when discounting repeated calls' do
343+
let(:discount_repeated_attributes) { true }
344+
345+
context 'when root receiver is a var' do
346+
let(:source) { <<~RUBY }
347+
def method_name(var) # 1, 0, 0
348+
var.foo.bar.baz # +3
349+
var.foo.bar.qux # +1
350+
var.foo.bar = 42 # +1 +1 (partial invalidation)
351+
var.foo # +0
352+
var.foo.bar # +1
353+
var.foo.bar.baz # +1
354+
var = 42 # +1 (complete invalidation)
355+
var.foo.bar # +2
356+
end
357+
RUBY
358+
359+
it { is_expected.to eq '<3, 9, 0>' }
360+
end
361+
362+
context 'when root receiver is self/nil' do
363+
let(:source) { <<~RUBY }
364+
def method_name # 0, 0, 0
365+
self.foo.bar.baz # +3
366+
foo.bar.qux # +1
367+
foo.bar = 42 # +1 +1 (partial invalidation)
368+
foo # +0
369+
self.foo.bar # +1
370+
foo&.bar.baz # +1 (C += 0 since `csend` treated as `send`)
371+
self.foo ||= 42 # +1 +1 +1 (complete invalidation)
372+
self.foo.bar # +2
373+
end
374+
RUBY
375+
376+
it { is_expected.to eq '<2, 9, 1>' }
377+
end
378+
379+
context 'when some calls have arguments' do
380+
let(:source) { <<~RUBY }
381+
def method_name(var) # 1, 0, 0
382+
var.foo(42).bar # +2
383+
var.foo(42).bar # +2
384+
var.foo.bar # +2
385+
var.foo.bar # +0
386+
var.foo.bar(42) # +1
387+
end
388+
RUBY
389+
390+
it { is_expected.to eq '<1, 7, 0>' }
391+
end
392+
end
322393
end
323394
end

0 commit comments

Comments
 (0)