Recently, we now have been engaged on upgrading an out of date stack of 1 Ruby app.
This software was working on Ruby 2.4.
After dropping 50 unused gems, performing safety updates, and eliminating deprecation warnings, we determined it was time for a Ruby improve.
That is the place the story REALLY begins, and I encourage you to maintain studying even if you’re not within the previous Ruby model’s internals.
Ultimately, I will provide you with a robust device that can assist you observe down not resolving constants in your codebase.
High-level fixed lookup
One of many main adjustments launched in Ruby 2.5 was eradicating top-level fixed lookup.
It means a breaking change in how Ruby resolves constants. Let me clarify it with an instance.
class A
class B
finish
finish
class C
finish
In Ruby 2.4 and earlier, the output of calling A::B::C
is C
. Are you stunned? I used to be.
[1] pry(principal)> RUBY_VERSION
=> "2.4.5"
[2] pry(principal)> A::B::C
(pry):20: warning: toplevel fixed C referenced by A::B::C
=> C
The warning message means that we’re doing one thing which will have surprising outcomes.
If we tried to do the identical in Ruby 2.5 and later, we might get an error.
[1] pry(principal)> RUBY_VERSION
=> "2.5.9"
[2] pry(principal)> A::B::C
NameError: uninitialized fixed A::B::C
from (pry):8:in `<principal>'
Because the codebase was enormous and poorly examined, we needed to discover a sensible approach to observe down all of the locations the place this alteration would break the app.
It could be comparatively straightforward to grep with regexp all of the constants used within the codebase, however then we needed to discover out in the event that they resolved accurately from the context they’re getting used.
Parser gem
Paweł got here up with the concept to make use of a parser device for this function.
Examples of utilizing this highly effective gem have already been described by us on the weblog.
In brief, it permits parsing Ruby code into an AST (summary syntax tree) after which traversing it.
Processor
We began with extending the Parser::AST::Processor
class and overriding the on_const
methodology which will get trigerred for each fixed discovered within the code.
class Collector < Parser::AST::Processor
embody AST::Sexp
def initialize
@retailer = Set.new
@root_path = Rails.root
finish
def suspicious_consts
@retailer.to_a
finish
def on_const(node)
return if node.dad or mum.module_definition?
return if node.dad or mum.class_definition?
namespace = node.namespace
whereas namespace
return if namespace.lvar_type? # local_variable::SOME_CONSTANT
return if namespace.send_type? # obj.methodology::SomeClass
return if namespace.self_type? # self::SOME_CONSTANT
break if namespace.cbase_type? # we reached the highest stage
namespace = namespace.namespace
finish
const_string = Unparser.unparse(node)
if node.namespace&.cbase_type?
return if validate_const(const_string)
else
namespace_const_names =
node
.each_ancestor
.choose
.map mod.youngsters.first.const_name
.reverse
(namespace_const_names.dimension + 1).occasions do |i|
concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).be part of("::")
return if validate_const(concated)
finish
finish
retailer(const_string, node.location)
finish
def retailer(const_string, location)
@retailer << [
File.join(@root_path, location.name.to_s),
const_string
]
finish
def validate_const(namespaced_const_string)
eval(namespaced_const_string)
true
rescue NameError, LoadError
false
finish
finish
Guards within the on_const
methodology are there to skip constants which can be a part of the category/module definition. We search for usages solely.
return if node.dad or mum.module_definition?
return if node.dad or mum.class_definition?
Than, we drop all of the dynamic usages that are onerous to validate and wish particular dealing with.
namespace = node.namespace
whereas namespace
return if namespace.lvar_type? # local_variable::SOME_CONSTANT
return if namespace.send_type? # obj.methodology::SomeClass
return if namespace.self_type? # self::SOME_CONSTANT
break if namespace.cbase_type? # we reached the highest stage
namespace = namespace.namespace
finish
After that, we test if the filtered-out constants resolve accurately.
If the fixed is explicitly referenced from the top-level, we simply attempt to consider it.
In different instances, we should contemplate the namespace by which the fixed is used and attempt to name it with the total namespace prepended, after which with one stage much less, and so forth, till we attain the highest stage binding.
const_string = Unparser.unparse(node)
if node.namespace&.cbase_type?
return if validate_const(const_string)
else
namespace_const_names =
node
.each_ancestor
.choose
.map mod.youngsters.first.const_name
.reverse
(namespace_const_names.dimension + 1).occasions do |i|
concated = (namespace_const_names[0...namespace_const_names.size - i] + [node.const_name]).be part of("::")
return if validate_const(concated)
finish
finish
Lastly, we retailer constants that did not resolve with their location within the codebase.
retailer(const_string, node.location)
Runner
One other class to increase is Parser::Runner
which is liable for parsing the information and passing them to the processor.
On the finish, it prints all of the saved suspicious constants.
runner =
Class.new(Parser::Runner) do
def runner_name
"dudu"
finish
def course of(buffer)
parser = @parser_class.new(RuboCop::AST::Builder.new)
collector = Collector.new
collector.course of(parser.parse(buffer))
present(collector.suspicious_consts)
finish
def present(assortment)
return if assortment.empty?
places
assortment.every places pair.be part of("t")
finish
finish
runner.go(ARGV)
Outcomes
We ensured that keen loading is enabled and invoked the script on Ruby 2.4 and a couple of.5 to check the outcomes.
bundle exec ruby collector.rb app/ lib/
It turned out that there have been 52 constants that weren’t resolving accurately in Ruby 2.5 and solely 7 fewer in Ruby 2.4.
It means there have been already 45 potential sources of run-time errors within the codebase which weren’t detectable by present assessments! 🤯
Fortuitously, a few of them have been situated within the code that was not used anymore, so we may simply safely take away these strategies.
Bonus
We printed the script inside the context of the instance app on GitHub.
Test it out at: https://github.com/arkency/constants-resolver.
Copy and run collector.rb
in opposition to your codebase and see in case your app is freed from not resolving constants. For those who discover one thing, share this resolution with your folks to assist them keep away from issues too.