diff --git a/lib/spoom/model.rb b/lib/spoom/model.rb index b537099c..f149684b 100644 --- a/lib/spoom/model.rb +++ b/lib/spoom/model.rb @@ -1,7 +1,10 @@ # typed: strict # frozen_string_literal: true +require_relative "location" require_relative "parse" require_relative "model/model" require_relative "model/namespace_visitor" require_relative "model/builder" +require_relative "model/reference" +require_relative "model/references_visitor" diff --git a/lib/spoom/model/reference.rb b/lib/spoom/model/reference.rb new file mode 100644 index 00000000..0a9c96fc --- /dev/null +++ b/lib/spoom/model/reference.rb @@ -0,0 +1,35 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + class Model + # A reference to something that looks like a constant or a method + # + # Constants could be classes, modules, or actual constants. + # Methods could be accessors, instance or class methods, aliases, etc. + class Reference < T::Struct + extend T::Sig + + class Kind < T::Enum + enums do + Constant = new("constant") + Method = new("method") + end + end + + const :kind, Kind + const :name, String + const :location, Spoom::Location + + sig { returns(T::Boolean) } + def constant? + kind == Kind::Constant + end + + sig { returns(T::Boolean) } + def method? + kind == Kind::Method + end + end + end +end diff --git a/lib/spoom/model/references_visitor.rb b/lib/spoom/model/references_visitor.rb new file mode 100644 index 00000000..e8898107 --- /dev/null +++ b/lib/spoom/model/references_visitor.rb @@ -0,0 +1,200 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + class Model + # Visit a file to collect all the references to constants and methods + class ReferencesVisitor < Visitor + extend T::Sig + + sig { returns(T::Array[Reference]) } + attr_reader :references + + sig { params(file: String).void } + def initialize(file) + super() + + @file = file + @references = T.let([], T::Array[Reference]) + end + + sig { override.params(node: Prism::AliasMethodNode).void } + def visit_alias_method_node(node) + reference_method(node.old_name.slice, node) + end + + sig { override.params(node: Prism::AndNode).void } + def visit_and_node(node) + reference_method(node.operator_loc.slice, node) + super + end + + sig { override.params(node: Prism::BlockArgumentNode).void } + def visit_block_argument_node(node) + expression = node.expression + case expression + when Prism::SymbolNode + reference_method(expression.unescaped, expression) + else + visit(expression) + end + end + + sig { override.params(node: Prism::CallAndWriteNode).void } + def visit_call_and_write_node(node) + visit(node.receiver) + reference_method(node.read_name.to_s, node) + reference_method(node.write_name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::CallOperatorWriteNode).void } + def visit_call_operator_write_node(node) + visit(node.receiver) + reference_method(node.read_name.to_s, node) + reference_method(node.write_name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::CallOrWriteNode).void } + def visit_call_or_write_node(node) + visit(node.receiver) + reference_method(node.read_name.to_s, node) + reference_method(node.write_name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::CallNode).void } + def visit_call_node(node) + visit(node.receiver) + + name = node.name.to_s + reference_method(name, node) + + case name + when "<", ">", "<=", ">=" + # For comparison operators, we also reference the `<=>` method + reference_method("<=>", node) + end + + visit(node.arguments) + visit(node.block) + end + + sig { override.params(node: Prism::ClassNode).void } + def visit_class_node(node) + visit(node.superclass) if node.superclass + visit(node.body) + end + + sig { override.params(node: Prism::ConstantAndWriteNode).void } + def visit_constant_and_write_node(node) + reference_constant(node.name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::ConstantOperatorWriteNode).void } + def visit_constant_operator_write_node(node) + reference_constant(node.name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::ConstantOrWriteNode).void } + def visit_constant_or_write_node(node) + reference_constant(node.name.to_s, node) + visit(node.value) + end + + sig { override.params(node: Prism::ConstantPathNode).void } + def visit_constant_path_node(node) + visit(node.parent) + reference_constant(node.name.to_s, node) + end + + sig { override.params(node: Prism::ConstantPathWriteNode).void } + def visit_constant_path_write_node(node) + visit(node.target.parent) + visit(node.value) + end + + sig { override.params(node: Prism::ConstantReadNode).void } + def visit_constant_read_node(node) + reference_constant(node.name.to_s, node) + end + + sig { override.params(node: Prism::ConstantWriteNode).void } + def visit_constant_write_node(node) + visit(node.value) + end + + sig { override.params(node: Prism::LocalVariableAndWriteNode).void } + def visit_local_variable_and_write_node(node) + name = node.name.to_s + reference_method(name, node) + reference_method("#{name}=", node) + visit(node.value) + end + + sig { override.params(node: Prism::LocalVariableOperatorWriteNode).void } + def visit_local_variable_operator_write_node(node) + name = node.name.to_s + reference_method(name, node) + reference_method("#{name}=", node) + visit(node.value) + end + + sig { override.params(node: Prism::LocalVariableOrWriteNode).void } + def visit_local_variable_or_write_node(node) + name = node.name.to_s + reference_method(name, node) + reference_method("#{name}=", node) + visit(node.value) + end + + sig { override.params(node: Prism::LocalVariableWriteNode).void } + def visit_local_variable_write_node(node) + reference_method("#{node.name}=", node) + visit(node.value) + end + + sig { override.params(node: Prism::ModuleNode).void } + def visit_module_node(node) + visit(node.body) + end + + sig { override.params(node: Prism::MultiWriteNode).void } + def visit_multi_write_node(node) + node.lefts.each do |const| + case const + when Prism::LocalVariableTargetNode + reference_method("#{const.name}=", node) + end + end + visit(node.value) + end + + sig { override.params(node: Prism::OrNode).void } + def visit_or_node(node) + reference_method(node.operator_loc.slice, node) + super + end + + private + + sig { params(name: String, node: Prism::Node).void } + def reference_constant(name, node) + @references << Reference.new(name: name, kind: Reference::Kind::Constant, location: node_location(node)) + end + + sig { params(name: String, node: Prism::Node).void } + def reference_method(name, node) + @references << Reference.new(name: name, kind: Reference::Kind::Method, location: node_location(node)) + end + + sig { params(node: Prism::Node).returns(Location) } + def node_location(node) + Location.from_prism(@file, node.location) + end + end + end +end diff --git a/test/spoom/model/references_visitor_test.rb b/test/spoom/model/references_visitor_test.rb new file mode 100644 index 00000000..c53555a8 --- /dev/null +++ b/test/spoom/model/references_visitor_test.rb @@ -0,0 +1,309 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module Spoom + class Model + class ReferencesVisitorTest < Minitest::Test + extend T::Sig + + def test_visit_constant_references + refs = visit(<<~RB) + puts C1 + puts C2::C3::C4 + puts foo::C5 + puts C6.foo + foo = C7 + C8 << 42 + C9 += 42 + C10 ||= 42 + C11 &&= 42 + C12[C13] + C14::IGNORED1 = 42 # IGNORED1 is an assignment + C15::C16 << 42 + C17::C18 += 42 + C19::C20 ||= 42 + C21::C22 &&= 42 + puts "\#{C23}" + + ::IGNORED2 = 42 # IGNORED2 is an assignment + puts "IGNORED3" + puts :IGNORED4 + RB + + assert_equal( + [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12", + "C13", + "C14", + "C15", + "C16", + "C17", + "C18", + "C19", + "C20", + "C21", + "C22", + "C23", + ], + refs.select(&:constant?).map(&:name), + ) + end + + def test_visit_constant_references_value + refs = visit(<<~RB) + IGNORED1 = C1 + IGNORED2 = [C2::C3] + C4 << C5 + C6 += C7 + C8 ||= C9 + C10 &&= C11 + C12[C13] = C14 + RB + + assert_equal( + [ + "C1", + "C2", + "C3", + "C4", + "C5", + "C6", + "C7", + "C8", + "C9", + "C10", + "C11", + "C12", + "C13", + "C14", + ], + refs.select(&:constant?).map(&:name), + ) + end + + def test_visit_class_references + refs = visit(<<~RB) + C1.new + + class IGNORED < ::C2; end + class IGNORED < C3; end + class IGNORED < C4::C5; end + RB + + assert_equal( + ["C1", "C2", "C3", "C4", "C5"], + refs.select(&:constant?).map(&:name), + ) + end + + def test_visit_module_references + refs = visit(<<~RB) + module X + include M1 + include M2::M3 + extend M4 + extend M5::M6 + prepend M7 + prepend M8::M9 + end + + M10.include M11 + M12.extend M13 + M14.prepend M15 + RB + + assert_equal( + ["M1", "M2", "M3", "M4", "M5", "M6", "M7", "M8", "M9", "M10", "M11", "M12", "M13", "M14", "M15"], + refs.select(&:constant?).map(&:name), + ) + end + + def test_visit_method_references + refs = visit(<<~RB) + m1 + m2(m3) + m4 m5 + self.m6 + self.m7(m8) + self.m9 m10 + C.m11 + C.m12(m13) + C.m14 m15 + m16.m17 + m18.m19(m20) + m21.m22 m23 + + m24.m25.m26 + + !m27 # The `!` is collected and will count as one more reference + m28&.m29 + m30(&m32) + m32 { m33 } + m34 do m35 end + m36[m37] # The `[]` is collected and will count as one more reference + + def foo(&block) + m38(&block) + end + + m39(&:m40) + m41(&m42) + m43(m44, &m45(m46)) + m47, m48 = Foo.m49 + RB + + refs = refs.select(&:method?).map(&:name) + assert_equal(51, refs.size) # 49 + 2 (including `[]` and `!`) + + refs.each do |ref| + assert(ref =~ /^(m(\d+)(=)?)|\[\]|!$/) + end + end + + def test_visit_method_assign_references + refs = visit(<<~RB) + m1= 42 + m2=(42) + m3 = m4.m5 + m6.m7.m8 = m9.m10 + @c.m11 = 42 + m12, m13 = 42 + RB + + assert_equal( + ["m1=", "m2=", "m3=", "m4", "m5", "m6", "m7", "m8=", "m9", "m10", "m11=", "m12=", "m13="], + refs.select(&:method?).map(&:name), + ) + end + + def test_visit_method_opassign_references + refs = visit(<<~RB) + m1 += 42 + m2 |= 42 + m3 ||= 42 + m4 &&= 42 + m5.m6 += m7 + m8.m9 ||= m10 + m11.m12 &&= m13 + RB + + assert_equal( + [ + "m1", + "m1=", + "m2", + "m2=", + "m3", + "m3=", + "m4", + "m4=", + "m5", + "m6", + "m6=", + "m7", + "m8", + "m9", + "m9=", + "m10", + "m11", + "m12", + "m12=", + "m13", + ], + refs.select(&:method?).map(&:name), + ) + end + + def test_visit_method_keyword_arguments_references + refs = visit(<<~RB) + m1.m2(dead: 42, m3:) + RB + + assert_equal( + ["m1", "m2", "m3"], + refs.select(&:method?).map(&:name), + ) + end + + def test_visit_method_forward_references + refs = visit(<<~RB) + def foo(...) + bar(...) + end + RB + + assert_equal( + ["bar"], + refs.select(&:method?).map(&:name), + ) + end + + def test_visit_method_operators + refs = visit(<<~RB) + x != x + x % x + x & x + x && x + x * x + x ** x + x + x + x - x + x / x + x << x + x == x + x === x + x >> x + x ^ x + x | x + x || x + RB + + assert_equal( + [ + "x", + "!=", + "%", + "&", + "&&", + "*", + "**", + "+", + "-", + "/", + "<<", + "==", + "===", + ">>", + "^", + "|", + "||", + ], + refs.select(&:method?).map(&:name).uniq, + ) + end + + private + + def visit(code) + node = Spoom.parse_ruby(code, file: "-") + + v = ReferencesVisitor.new("-") + v.visit(node) + v.references + end + end + end +end