Ruby Software Complexity Metrics (Part One: Prerequisites — Abstract Syntactic Representation)

Cyclomatic Complexity, Perceived Complexity, and ABC Metric

Abhimanyu Singh
9 min readSep 29, 2019

The story has three parts:

Goal

Before we start with the pseudocode for the metrics calculation implementation and see a couple of code examples; we must familiarize ourselves with the abstract syntactic representations for the Ruby source code generated by Ruby Parser. So in the current part, we will see how conditionals, loops, operators, method invocations, and, exception handling get translated to AST. It helps to get the most sense out of the metrics calculation implementation used by RuboCop. And once we have done practicing, then we can start with developing some relationships amongst the metrics cyclomatic complexity, perceived complexity, and, ABC metric to provide a mathematical sense over configured maximums used for RuboCop analysis.

Overview

  • Cyclomatic Complexity is the number of linearly independent paths, i.e., one more than the total number of decision points.
  • Perceived Complexity is a measure of code readability.
  • ABC Metric is a size metric in terms of the total number of assignments, branches, and, conditions.

Method Invocation

Each method invocation contributes to the value of branches. Ruby parser defines method invocations using the send and csend nodes.

Foo.foo                          # branches = 1
# (send -- method invocation
# (const nil :Foo) :foo) -- invoking `foo` on `Foo`
foo.bar # branches = 2
# (send -- method invocation
# (send nil :foo) :bar) -- invoking `bar` on `foo`
foo&.bar # branches = 2
# (csend -- safe navigated method invocation
# (send nil :foo) :bar) -- invoking `bar` on `foo`
self.foo # branches = 1
# (send -- method invocation
# (self) :foo) -- invoking scoped `foo`
foo # branches = 1
# (send -- method invocation
# nil :foo) -- invoking unscoped `foo`

Assignments

Each assignment operation contributes to the value of the assignments. Ruby parser defines assignments using the lvasgn, gvasgn, ivasgn, cvasgn, masgn, casgn, op-asgn, and-asgn, and, or-asgn nodes.

foo = :foo                     # assignments = 1
# (lvasgn -- local variable assignment
# :foo -- variable name `foo`
# (sym :foo)) -- assigned value `:foo`
$foo = :foo # assignments = 1
# (gvasgn -- global variable assignment
# :$foo -- variable name `$foo`
# (sym :foo)) -- assigned value `:foo`
@foo = :foo # assignments = 1
# (ivasgn -- instance variable assignment
# :@foo -- variable name `@foo`
# (sym :foo)) -- assigned value `:foo`
@@foo = :foo # assignments = 1
# (cvasgn -- class variable assignment
# :@@foo -- variable name `@@foo`
# (sym :foo)) -- assigned value `:foo`
foo, bar = :foo, :bar # assignments = 3
# (masgn -- multiple variables assignment
# (mlhs
# (lvasgn :foo) -- assignment for `foo`
# (lvasgn :bar)) -- assignment for `bar`
# (array
# (sym :foo) -- assigned value `:foo` for `foo`
# (sym :bar))) -- assigned value `:bar` for `bar`
::FOO = :foo # assignments = 1
# (casgn -- top-level constant assignment
# (cbase) :FOO -- constant name `FOO`
# (sym :foo)) -- assigned value `:foo`
foo::FOO = :foo # assignments = 1, branches = 1
# (casgn -- scoped constant assignment
# (send nil :foo) :FOO -- constant name `foo::FOO`
# (sym :foo)) -- assigned value `:foo`
FOO = :foo # assignments = 1
# (casgn -- unscoped constant assignment
# nil :FOO -- constant name `FOO`
# (sym :foo)) -- assigned value `:foo`
foo += :foo # assignments = 2
# (op-asgn -- operator assignment
# (lvasgn :foo) :+ -- assignment for `foo` using `+`
# (sym :foo)) -- assigned value `:foo`
foo &&= :foo # assignments = 2
# (and-asgn -- and assignment
# (lvasgn :foo) -- assignment for `foo`
# (sym :foo)) -- assigned value `:foo`
foo ||= :foo # assignments = 2
# (or-asgn -- or assignment
# (lvasgn :foo) -- assignment for `foo`
# (sym :foo)) -- assigned value `:foo`

Comparison Operators

Ruby defines ==, ===, !=, <, <=, >, >=, and, <=>. Except for the spaceship operator (<=>), each operator contributes to the value of the conditions. The reason for the exclusion of the spaceship operator is the return value not being boolean. It is the only exception when send contributes to the value of conditions, not branches.

:foo == :bar                       # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :== -- operator `==`
# (sym :bar)) -- second operand `:bar`
:foo === :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :=== -- operator `===`
# (sym :bar)) -- second operand `:bar`
:foo != :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :!= -- operator `!=`
# (sym :bar)) -- second operand `:bar`
:foo < :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :< -- operator `<`
# (sym :bar)) -- second operand `:bar`
:foo <= :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :<= -- operator `<=`
# (sym :bar)) -- second operand `:bar`
:foo > :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :> -- operator `>`
# (sym :bar)) -- second operand `:bar`
:foo >= :bar # conditions = 1
# (send
# (sym :foo) -- first operand `:foo`
# :>= -- operator `>=`
# (sym :bar)) -- second operand `:bar`
:foo <=> :bar # conditions = 0
# (send
# (sym :foo) -- first operand `:foo`
# :<=> -- operator `<=>`
# (sym :bar)) -- second operand `:bar`

Conditionals

Ruby provides if…elsif…else, if-modifier, unless…else, unless-modifier, case…when…else, ?…: (ternary operator), &&, and, || to support conditionals. Ruby parser defines such expressions and statements using the if, case, when, and, and, or nodes. Except for the case node, each node is a decision point.

if foo                               # decision points = 2
:foo
elsif bar
:bar
else
:baz
end

# elsif is if nested under else
# if foo
# :foo
# else
# if bar
# :bar
# else
# :baz
# end
# end
#
# (if
# (send nil :foo) -- if condition `foo`
# (sym :foo) -- if body `:foo`
# (if
# (send nil :bar) -- elsif condition `bar`
# (sym :bar) -- elsif body `:bar`
# (sym :baz))) -- else body `:baz`
:foo if foo # decision points = 1
# (if
# (send nil :foo) -- if condition `foo`
# (sym :foo) -- if body `:foo`
# nil) -- else body `nil`
foo ? :foo : :bar # decision points = 1
# ternary operator is if...else
# if foo
# :foo
# else
# :bar
# end
#
# (if
# (send nil :foo) -- if condition `foo`
# (sym :foo) -- if body `:foo`
# (sym :bar)) -- else body `:bar`
unless foo # decision points = 1
:bar
else
:foo
end

# unless is negated if
# if !foo
# :bar
# else
# :foo
# end
#
# i.e.,
#
# if foo
# :foo
# else
# :bar
# end
#
# (if
# (send nil :foo) -- if condition `foo`
# (sym :foo) -- if body `:foo`
# (sym :bar)) -- else body `:bar`
:bar unless foo # decision points = 1
# (if
# (send nil :foo) -- if condition `foo`
# nil -- if body `nil`
# (sym :bar)) -- else body `:bar`
foo && bar && baz # decision points = 2
# && is nested if
# if foo
# if bar
# if baz
# end
# end
# end
#
# (and
# (and
# (send nil :foo)
# (send nil :bar))
# (send nil :baz))
foo and bar and baz # decision points = 2
# (and
# (and
# (send nil :foo)
# (send nil :bar))
# (send nil :baz))
foo || bar || baz # decision points = 2
# || is nested unless
# unless foo
# unless bar
# unless baz
# end
# end
# end
#
# (or
# (or
# (send nil :foo)
# (send nil :bar))
# (send nil :baz))
foo or bar or baz # decision points = 2
# (or
# (or
# (send nil :foo)
# (send nil :bar))
# (send nil :baz))
case msg # decision points = 3
when :foo
foo
when :bar
bar
when :baz
baz
else
qux
end

# (case
# (send nil :msg) -- case condition `msg`
# (when
# (sym :foo) -- when `:foo`
# (send nil :foo)) -- when body `foo`
# (when
# (sym :bar) -- when `:bar`
# (send nil :bar)) -- when body `bar`
# (when
# (sym :baz) -- when `:baz`
# (send nil :baz)) -- when body `baz`
# (send nil :qux)) -- else body `qux`

Loops

Ruby provides for, while, while-modifier, begin…end…while, until, until-modifier, and, begin…end…until to support loops. Ruby parser defines loops using the for, while, while-post, until, and, until-post nodes. Except for the nodes while-post and until-post, each node is a decision point as the loops have an exit condition. The reason for the exclusion of the while-post and until-post is the behavior of these loops, which mimics do…while construct, where the condition is evaluated after one execution. The loop…do and times…do are not considered to be a decision point either.

for msg in %i[foo bar baz]             # decision points = 1
puts msg
end

# (for
# (lvasgn :msg) -- variable name `msg`
# (array -- array `[:foo, :bar, :baz]`
# (sym :foo)
# (sym :bar)
# (sym :baz))
# (send nil :puts -- for body `puts msg`
# (lvar :msg)))
while foo # decision points = 1
puts :foo
end

# (while
# (send nil :foo) -- while condition `foo`
# (send nil :puts -- while body `puts :foo`
# (sym :foo)))
puts :foo while foo # decision points = 1
# (while
# (send nil :foo) -- while condition `foo`
# (send nil :puts -- while body `puts :foo`
# (sym :foo)))
begin # decision points = 0
puts :foo
end while foo

# (while-post
# (send nil :foo)
# (kwbegin
# (send nil :puts
# (sym :foo))))
until bar # decision points = 1
puts :foo
end

# (until
# (send nil :bar) -- until condition `bar`
# (send nil :puts -- until body `puts :foo`
# (sym :foo)))
puts :foo until bar # decision points = 1
# (until
# (send nil :bar) -- until condition `bar`
# (send nil :puts -- until body `puts :foo`
# (sym :foo)))
begin # decision points = 0
puts :foo
end until bar

# (until-post
# (send nil :bar)
# (kwbegin
# (send nil :puts
# (sym :foo))))
loop do # decision points = 0
puts :foo
break unless bar
end

# (block
# (send nil :loop)
# (args)
# (begin
# (send nil :puts
# (sym :foo))
# (if
# (send nil :bar) nil
# (break))))
foo.times do # decision points = 0
puts :foo
end

# (block
# (send
# (send nil :foo) :times)
# (args)
# (send nil :puts
# (sym :foo)))

Exception Handling

Ruby provides begin…rescue…else…ensure to support exception handling. Ruby parser defines such a construct using the rescue, resbody, and, ensure nodes. All the rescued exceptions are identified by multiple rescue bodies, corresponding to each exception. Thus, all the rescue occurrences together are considered one decision point.

begin                                   # decision points = 1
foobar
rescue FooException => e
log_foo e
rescue BarException => e
log_bar e
else
:baz
ensure
:qux
end

# (kwbegin
# (ensure
# (rescue
# (send nil :foobar)
# (resbody
# (array
# (const nil :FooException)) -- exception `FooException`
# (lvasgn :e) -- variable name `e`
# (send nil :log_foo -- rescue body `log_foo e`
# (lvar :e)))
# (resbody
# (array
# (const nil :BarException)) -- exception `BarException`
# (lvasgn :e) -- variable name `e`
# (send nil :log_bar -- rescue body `log_bar e`
# (lvar :e)))
# (sym :baz)) -- else body `:baz`
# (sym :qux))) -- ensure body `:qux`

Summary

We gathered the requirements to compute cyclomatic complexity, perceived complexity, and, ABC metric:

  • The method invocation nodes: send and csend.
  • The assignment nodes: lvasgn, gvasgn, ivasgn, cvasgn, casgn, masgn, op-asgn, and-asgn, and, or-asgn.
  • The decision point nodes: if, and, or, when, for, while, until, and, rescue and case node.
  • The comparison operators: ==, ===, !=, <, <=, >, and >=.

It is time to start with metrics calculation implementation and practice some examples in the second part of the story.

📝 Read this story later in Journal.

👩‍💻 Wake up every Sunday morning to the week’s most noteworthy stories in Tech waiting in your inbox. Read the Noteworthy in Tech newsletter.

--

--

Abhimanyu Singh

Staff Software Engineer at HackerRank. Passionate about tech, career growth, and math. Exploring number theory and sharing insights on personal development.