From 9485f2577ab006e5928ef3bfdf8f0e65f990d9f1 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Mon, 17 Jun 2024 16:27:23 -0400 Subject: [PATCH 1/3] Introduce Model representation Signed-off-by: Alexandre Terrasa --- lib/spoom.rb | 1 + lib/spoom/model.rb | 5 + lib/spoom/model/model.rb | 231 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 lib/spoom/model.rb create mode 100644 lib/spoom/model/model.rb diff --git a/lib/spoom.rb b/lib/spoom.rb index 8bf49d7d..b311170a 100644 --- a/lib/spoom.rb +++ b/lib/spoom.rb @@ -15,6 +15,7 @@ class Error < StandardError; end require "spoom/file_collector" require "spoom/context" require "spoom/colors" +require "spoom/model" require "spoom/deadcode" require "spoom/sorbet" require "spoom/cli" diff --git a/lib/spoom/model.rb b/lib/spoom/model.rb new file mode 100644 index 00000000..4eda1a85 --- /dev/null +++ b/lib/spoom/model.rb @@ -0,0 +1,5 @@ +# typed: strict +# frozen_string_literal: true + +require_relative "parse" +require_relative "model/model" diff --git a/lib/spoom/model/model.rb b/lib/spoom/model/model.rb new file mode 100644 index 00000000..7d9251fd --- /dev/null +++ b/lib/spoom/model/model.rb @@ -0,0 +1,231 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + class Model + extend T::Sig + + class Error < Spoom::Error; end + + # A Symbol is a uniquely named entity in the Ruby codebase + # + # A symbol can have multiple definitions, e.g. a class can be reopened. + # Sometimes a symbol can have multiple definitions of different types, + # e.g. `foo` method can be defined both as a method and as an attribute accessor. + class Symbol + extend T::Sig + + # The full, unique name of this symbol + sig { returns(String) } + attr_reader :full_name + + # The definitions of this symbol (where it exists in the code) + sig { returns(T::Array[SymbolDef]) } + attr_reader :definitions + + sig { params(full_name: String).void } + def initialize(full_name) + @full_name = full_name + @definitions = T.let([], T::Array[SymbolDef]) + end + + # The short name of this symbol + sig { returns(String) } + def name + T.must(@full_name.split("::").last) + end + + sig { returns(String) } + def to_s + @full_name + end + end + + # A SymbolDef is a definition of a Symbol + # + # It can be a class, module, constant, method, etc. + # A SymbolDef has a location pointing to the actual code that defines the symbol. + class SymbolDef + extend T::Sig + extend T::Helpers + + abstract! + + # The symbol this definition belongs to + sig { returns(Symbol) } + attr_reader :symbol + + # The enclosing namespace this definition belongs to + sig { returns(T.nilable(Namespace)) } + attr_reader :owner + + # The actual code location of this definition + sig { returns(Location) } + attr_reader :location + + sig { params(symbol: Symbol, owner: T.nilable(Namespace), location: Location).void } + def initialize(symbol, owner:, location:) + @symbol = symbol + @owner = owner + @location = location + + symbol.definitions << self + owner.children << self if owner + end + + # The full name of the symbol this definition belongs to + sig { returns(String) } + def full_name + @symbol.full_name + end + + # The short name of the symbol this definition belongs to + sig { returns(String) } + def name + @symbol.name + end + end + + # A class or module + class Namespace < SymbolDef + abstract! + + sig { returns(T::Array[SymbolDef]) } + attr_reader :children + + sig { returns(T::Array[Mixin]) } + attr_reader :mixins + + sig { params(symbol: Symbol, owner: T.nilable(Namespace), location: Location).void } + def initialize(symbol, owner:, location:) + super(symbol, owner: owner, location: location) + + @children = T.let([], T::Array[SymbolDef]) + @mixins = T.let([], T::Array[Mixin]) + end + end + + class SingletonClass < Namespace; end + + class Class < Namespace + sig { returns(T.nilable(String)) } + attr_accessor :superclass_name + + sig do + params( + symbol: Symbol, + owner: T.nilable(Namespace), + location: Location, + superclass_name: T.nilable(String), + ).void + end + def initialize(symbol, owner:, location:, superclass_name: nil) + super(symbol, owner: owner, location: location) + + @superclass_name = superclass_name + end + end + + class Module < Namespace; end + + class Constant < SymbolDef + sig { returns(String) } + attr_reader :value + + sig { params(symbol: Symbol, owner: T.nilable(Namespace), location: Location, value: String).void } + def initialize(symbol, owner:, location:, value:) + super(symbol, owner: owner, location: location) + + @value = value + end + end + + # A method or an attribute accessor + class Property < SymbolDef + abstract! + + sig { returns(T::Array[Sig]) } + attr_reader :sigs + + sig { params(symbol: Symbol, owner: T.nilable(Namespace), location: Location, sigs: T::Array[Sig]).void } + def initialize(symbol, owner:, location:, sigs: []) + super(symbol, owner: owner, location: location) + + @sigs = sigs + end + end + + class Method < Property; end + + class Attr < Property + abstract! + end + + class AttrReader < Attr; end + class AttrWriter < Attr; end + class AttrAccessor < Attr; end + + # A mixin (include, prepend, extend) to a namespace + class Mixin + extend T::Sig + extend T::Helpers + + abstract! + + sig { returns(String) } + attr_reader :name + + sig { params(name: String).void } + def initialize(name) + @name = name + end + end + + class Include < Mixin; end + class Prepend < Mixin; end + class Extend < Mixin; end + + # A Sorbet signature (sig block) + class Sig + extend T::Sig + + sig { returns(String) } + attr_reader :string + + sig { params(string: String).void } + def initialize(string) + @string = string + end + end + + # Model + + # All the symbols registered in this model + sig { returns(T::Hash[String, Symbol]) } + attr_reader :symbols + + sig { void } + def initialize + @symbols = T.let({}, T::Hash[String, Symbol]) + end + + # Get a symbol by it's full name + # + # Raises an error if the symbol is not found + sig { params(full_name: String).returns(Symbol) } + def [](full_name) + symbol = @symbols[full_name] + raise Error, "Symbol not found: #{full_name}" unless symbol + + symbol + end + + # Register a new symbol by it's full name + # + # If the symbol already exists, it will be returned. + sig { params(full_name: String).returns(Symbol) } + def register_symbol(full_name) + @symbols[full_name] ||= Symbol.new(full_name) + end + end +end From 45494e06c62721178eac5ba8183de50e31272bef Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Thu, 13 Jun 2024 09:41:21 -0400 Subject: [PATCH 2/3] Introduce NamespaceVisitor An abstract Visitor implementation that keeps track of the traversed namespaces. Signed-off-by: Alexandre Terrasa --- lib/spoom/model.rb | 1 + lib/spoom/model/namespace_visitor.rb | 50 +++++++++ test/spoom/model/namespace_visitor_test.rb | 112 +++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 lib/spoom/model/namespace_visitor.rb create mode 100644 test/spoom/model/namespace_visitor_test.rb diff --git a/lib/spoom/model.rb b/lib/spoom/model.rb index 4eda1a85..b4b5d6f4 100644 --- a/lib/spoom/model.rb +++ b/lib/spoom/model.rb @@ -3,3 +3,4 @@ require_relative "parse" require_relative "model/model" +require_relative "model/namespace_visitor" diff --git a/lib/spoom/model/namespace_visitor.rb b/lib/spoom/model/namespace_visitor.rb new file mode 100644 index 00000000..9f2d47a8 --- /dev/null +++ b/lib/spoom/model/namespace_visitor.rb @@ -0,0 +1,50 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + class Model + class NamespaceVisitor < Visitor + extend T::Helpers + + abstract! + + sig { void } + def initialize + super() + + @names_nesting = T.let([], T::Array[String]) + end + + sig { override.params(node: T.nilable(Prism::Node)).void } + def visit(node) + case node + when Prism::ClassNode, Prism::ModuleNode + constant_path = node.constant_path.slice + + if constant_path.start_with?("::") + full_name = constant_path.delete_prefix("::") + + # We found a top level definition such as `class ::A; end`, we need to reset the name nesting + old_nesting = @names_nesting.dup + @names_nesting.clear + @names_nesting << full_name + + super + + # Restore the name nesting once we finished visited the class + @names_nesting.clear + @names_nesting = old_nesting + else + @names_nesting << constant_path + + super + + @names_nesting.pop + end + else + super + end + end + end + end +end diff --git a/test/spoom/model/namespace_visitor_test.rb b/test/spoom/model/namespace_visitor_test.rb new file mode 100644 index 00000000..7deade57 --- /dev/null +++ b/test/spoom/model/namespace_visitor_test.rb @@ -0,0 +1,112 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module Spoom + class Model + class NamespaceVisitorTest < Minitest::Test + extend T::Sig + + class NamespacesForLocs < NamespaceVisitor + sig { returns(T::Hash[String, String]) } + attr_reader :namespaces_for_locs + + sig { void } + def initialize + super() + @namespaces_for_locs = T.let({}, T::Hash[String, String]) + end + + sig { override.params(node: Prism::ClassNode).void } + def visit_class_node(node) + @namespaces_for_locs[loc_string(node.location)] = @names_nesting.join("::") + super + end + + sig { override.params(node: Prism::ModuleNode).void } + def visit_module_node(node) + @namespaces_for_locs[loc_string(node.location)] = @names_nesting.join("::") + super + end + + private + + sig { params(loc: Prism::Location).returns(String) } + def loc_string(loc) + Location.from_prism("-", loc).to_s + end + end + + def test_visit_empty + namespaces = namespaces_for_locs("") + assert_empty(namespaces) + end + + def test_visit_classes + namespaces = namespaces_for_locs(<<~RB) + class C1 + class C2; end + class ::C3; end + class C4::C5; end + end + + class ::C6; end + class C7::C8; end + class ::C9::C10; end + RB + + assert_equal( + { + "-:1:0-5:3" => "C1", + "-:2:2-2:15" => "C1::C2", + "-:3:2-3:17" => "C3", + "-:4:2-4:19" => "C1::C4::C5", + "-:7:0-7:15" => "C6", + "-:8:0-8:17" => "C7::C8", + "-:9:0-9:20" => "C9::C10", + }, + namespaces, + ) + end + + def test_visit_modules + namespaces = namespaces_for_locs(<<~RB) + module M1 + module M2; end + module ::M3; end + module M4::M5; end + end + + module ::M6; end + module M7::M8; end + module ::M9::M10; end + RB + + assert_equal( + { + "-:1:0-5:3" => "M1", + "-:2:2-2:16" => "M1::M2", + "-:3:2-3:18" => "M3", + "-:4:2-4:20" => "M1::M4::M5", + "-:7:0-7:16" => "M6", + "-:8:0-8:18" => "M7::M8", + "-:9:0-9:21" => "M9::M10", + }, + namespaces, + ) + end + + private + + sig { params(rb: String).returns(T::Hash[String, String]) } + def namespaces_for_locs(rb) + node = Spoom.parse_ruby(rb, file: "foo.rb") + + visitor = NamespacesForLocs.new + visitor.visit(node) + visitor.namespaces_for_locs + end + end + end +end From a6cad31f739a80d09e685d6f3822a3276f26ac60 Mon Sep 17 00:00:00 2001 From: Alexandre Terrasa Date: Mon, 17 Jun 2024 16:27:49 -0400 Subject: [PATCH 3/3] Introduce Model Builder Signed-off-by: Alexandre Terrasa --- lib/spoom/model.rb | 1 + lib/spoom/model/builder.rb | 224 +++++++++++++++++++ test/spoom/model/builder_test.rb | 356 +++++++++++++++++++++++++++++++ 3 files changed, 581 insertions(+) create mode 100644 lib/spoom/model/builder.rb create mode 100644 test/spoom/model/builder_test.rb diff --git a/lib/spoom/model.rb b/lib/spoom/model.rb index b4b5d6f4..b537099c 100644 --- a/lib/spoom/model.rb +++ b/lib/spoom/model.rb @@ -4,3 +4,4 @@ require_relative "parse" require_relative "model/model" require_relative "model/namespace_visitor" +require_relative "model/builder" diff --git a/lib/spoom/model/builder.rb b/lib/spoom/model/builder.rb new file mode 100644 index 00000000..9be74769 --- /dev/null +++ b/lib/spoom/model/builder.rb @@ -0,0 +1,224 @@ +# typed: strict +# frozen_string_literal: true + +module Spoom + class Model + # Populate a Model by visiting the nodes from a Ruby file + class Builder < NamespaceVisitor + extend T::Sig + + sig { params(model: Model, file: String).void } + def initialize(model, file) + super() + + @model = model + @file = file + @namespace_nesting = T.let([], T::Array[Namespace]) + @last_sigs = T.let([], T::Array[Sig]) + end + + # Classes + + sig { override.params(node: Prism::ClassNode).void } + def visit_class_node(node) + @namespace_nesting << Class.new( + @model.register_symbol(@names_nesting.join("::")), + owner: @namespace_nesting.last, + location: node_location(node), + superclass_name: node.superclass&.slice, + ) + super + @namespace_nesting.pop + @last_sigs.clear + end + + sig { override.params(node: Prism::SingletonClassNode).void } + def visit_singleton_class_node(node) + @namespace_nesting << SingletonClass.new( + @model.register_symbol(@names_nesting.join("::")), + owner: @namespace_nesting.last, + location: node_location(node), + ) + super + @namespace_nesting.pop + @last_sigs.clear + end + + # Modules + + sig { override.params(node: Prism::ModuleNode).void } + def visit_module_node(node) + @namespace_nesting << Module.new( + @model.register_symbol(@names_nesting.join("::")), + owner: @namespace_nesting.last, + location: node_location(node), + ) + super + @namespace_nesting.pop + @last_sigs.clear + end + + # Constants + + sig { override.params(node: Prism::ConstantPathWriteNode).void } + def visit_constant_path_write_node(node) + @last_sigs.clear + + name = node.target.slice + full_name = if name.start_with?("::") + name.delete_prefix("::") + else + [*@names_nesting, name].join("::") + end + + Constant.new( + @model.register_symbol(full_name), + owner: @namespace_nesting.last, + location: node_location(node), + value: node.value.slice, + ) + + super + end + + sig { override.params(node: Prism::ConstantWriteNode).void } + def visit_constant_write_node(node) + @last_sigs.clear + + Constant.new( + @model.register_symbol([*@names_nesting, node.name.to_s].join("::")), + owner: @namespace_nesting.last, + location: node_location(node), + value: node.value.slice, + ) + + super + end + + sig { override.params(node: Prism::MultiWriteNode).void } + def visit_multi_write_node(node) + @last_sigs.clear + + node.lefts.each do |const| + case const + when Prism::ConstantTargetNode, Prism::ConstantPathTargetNode + Constant.new( + @model.register_symbol([*@names_nesting, const.slice].join("::")), + owner: @namespace_nesting.last, + location: node_location(const), + value: node.value.slice, + ) + end + end + + super + end + + # Methods + + sig { override.params(node: Prism::DefNode).void } + def visit_def_node(node) + recv = node.receiver + + if !recv || recv.is_a?(Prism::SelfNode) + Method.new( + @model.register_symbol([*@names_nesting, node.name.to_s].join("::")), + owner: @namespace_nesting.last, + location: node_location(node), + sigs: collect_sigs, + ) + end + + super + end + + # Accessors + + sig { override.params(node: Prism::CallNode).void } + def visit_call_node(node) + return if node.receiver + + current_namespace = @namespace_nesting.last + + case node.name + when :attr_accessor + sigs = collect_sigs + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::SymbolNode) + + AttrAccessor.new( + @model.register_symbol([*@names_nesting, arg.slice.delete_prefix(":")].join("::")), + owner: current_namespace, + location: node_location(arg), + sigs: sigs, + ) + end + when :attr_reader + sigs = collect_sigs + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::SymbolNode) + + AttrReader.new( + @model.register_symbol([*@names_nesting, arg.slice.delete_prefix(":")].join("::")), + owner: current_namespace, + location: node_location(arg), + sigs: sigs, + ) + end + when :attr_writer + sigs = collect_sigs + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::SymbolNode) + + AttrWriter.new( + @model.register_symbol([*@names_nesting, arg.slice.delete_prefix(":")].join("::")), + owner: current_namespace, + location: node_location(arg), + sigs: sigs, + ) + end + when :include + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode) + next unless current_namespace + + current_namespace.mixins << Include.new(arg.slice) + end + when :prepend + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode) + next unless current_namespace + + current_namespace.mixins << Prepend.new(arg.slice) + end + when :extend + node.arguments&.arguments&.each do |arg| + next unless arg.is_a?(Prism::ConstantReadNode) || arg.is_a?(Prism::ConstantPathNode) + next unless current_namespace + + current_namespace.mixins << Extend.new(arg.slice) + end + when :sig + @last_sigs << Sig.new(node.slice) + else + @last_sigs.clear + super + end + end + + private + + sig { returns(T::Array[Sig]) } + def collect_sigs + sigs = @last_sigs + @last_sigs = [] + sigs + 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/builder_test.rb b/test/spoom/model/builder_test.rb new file mode 100644 index 00000000..546be666 --- /dev/null +++ b/test/spoom/model/builder_test.rb @@ -0,0 +1,356 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +module Spoom + class Model + class BuilderTest < Minitest::Test + extend T::Sig + + def test_empty + model = model("") + + assert_empty(model.symbols) + end + + def test_raises_when_symbol_not_found + model = model("") + + assert_raises(Model::Error) do + model["Foo"] + end + end + + def test_symbol_definitions + model = model(<<~RB) + class C1; end + + class C1 + class C2; end + end + + class C1::C2; end + + class C1 + class ::C1::C2; end + end + RB + + assert_equal( + ["foo.rb:1:0-1:13", "foo.rb:3:0-5:3", "foo.rb:9:0-11:3"], + model["C1"].definitions.map(&:location).map(&:to_s), + ) + + assert_equal( + ["foo.rb:4:2-4:15", "foo.rb:7:0-7:17", "foo.rb:10:2-10:21"], + model["C1::C2"].definitions.map(&:location).map(&:to_s), + ) + end + + def test_class_names + model = model(<<~RB) + class C1 + class C2; end + class ::C3; end + class C4::C5; end + end + + class ::C6; end + class C7::C8; end + class ::C9::C10; end + RB + + assert_equal( + ["C1", "C2", "C3", "C5", "C6", "C8", "C10"], + model.symbols.values.map(&:name), + ) + assert_equal( + ["C1", "C1::C2", "C3", "C1::C4::C5", "C6", "C7::C8", "C9::C10"], + model.symbols.values.map(&:full_name), + ) + end + + def test_class_superclass_names + model = model(<<~RB) + class C1 < A; end + class C1 < ::B; end + RB + + assert_equal(["A", "::B"], model["C1"].definitions.map { |c| T.cast(c, Class).superclass_name }) + end + + def test_mixin_names + model = model(<<~RB) + class C1 + include M1 + prepend ::M2 + end + + class C1 + extend M3 + end + + module M2 + include M1, M3 + include ignored1 + end + + include ignored2 + RB + + assert_equal( + [ + "C1: Include(M1), Prepend(::M2)", + "C1: Extend(M3)", + "M2: Include(M1), Include(M3)", + ], + model.symbols.values + .flat_map(&:definitions) + .filter { |d| d.is_a?(Namespace) } + .map do |d| + "#{d.full_name}: #{T.cast(d, Namespace).mixins.map { |m| "#{class_name(m)}(#{m.name})" }.join(", ")}" + end, + ) + end + + def test_module_names + model = model(<<~RB) + module M1 + module M2; end + module ::M3; end + module M4::M5; end + end + + module ::M6; end + module M7::M8; end + module ::M9::M10; end + RB + + assert_equal( + ["M1", "M2", "M3", "M5", "M6", "M8", "M10"], + model.symbols.values.map(&:name), + ) + assert_equal( + ["M1", "M1::M2", "M3", "M1::M4::M5", "M6", "M7::M8", "M9::M10"], + model.symbols.values.map(&:full_name), + ) + end + + def test_constant_names + model = model(<<~RB) + A = 1 + + module M + B = 2 + end + + M::C = 3 + + class C + D = 3 + + module M + E = 4 + end + + ::F = 5 + ::G::H = 6 + M::I = 7 + end + RB + + assert_equal( + ["A", "M::B", "M::C", "C::D", "C::M::E", "F", "G::H", "C::M::I"], + model.symbols.values + .flat_map(&:definitions) + .filter { |d| d.is_a?(Constant) } + .map(&:full_name), + ) + end + + def test_methods + model = model(<<~RB) + def m1; end + def self.m2; end + def C1.ignored; end + def C1::ignored; end + + class C1 + def m3; end + def self.m4; end + + class << self + def m5; end + def self.m6; end + end + end + RB + + assert_equal( + ["m1", "m2", "C1::m3", "C1::m4", "C1::m5", "C1::m6"], + model.symbols.values + .flat_map(&:definitions) + .filter { |d| d.is_a?(Method) } + .map(&:full_name), + ) + end + + def test_attrs + model = model(<<~RB) + attr_reader :a1 + attr_writer :a2 + attr_accessor :a3, :a4 + + class C1 + attr_reader :a5 + attr_writer :a6 + end + + class C1 + self.attr_reader :ignored + self.attr_writer :ignored + self.attr_accessor :ignored + end + + C1.attr_reader :ignored + C1.attr_writer :ignored + C1.attr_accessor :ignored + + attr_reader ignored + attr_writer ignored + attr_accessor ignored + RB + + assert_equal( + [ + "AttrReader(a1)", + "AttrWriter(a2)", + "AttrAccessor(a3)", + "AttrAccessor(a4)", + "AttrReader(C1::a5)", + "AttrWriter(C1::a6)", + ], + model.symbols.values + .flat_map(&:definitions) + .filter { |d| d.is_a?(Attr) } + .map { |d| "#{class_name(d)}(#{d.full_name})" }, + ) + end + + def test_definition_owners + model = model(<<~RB) + class C1 + attr_reader :p1 + end + + class C1 + class C2 + def p2; end + end + end + + class C1::C2 + C3 = 42 + def p3; end + end + + class C1 + class ::C1::C2 + def p4; end + end + end + RB + + assert_equal( + [ + "C1: ", + "C1::p1: C1", + "C1::C2: C1", + "C1::C2: ", + "C1::C2::p2: C1::C2", + "C1::C2::C3: C1::C2", + "C1::C2::p3: C1::C2", + "C1::C2::p4: C1::C2", + ], + model.symbols.values + .flat_map(&:definitions) + .map { |d| "#{d.full_name}: #{d.owner&.full_name || ""}" } + .uniq, + ) + end + + def test_definition_children + model = model(<<~RB) + class C1 + attr_reader :p1, :p2 + end + + class C1 + class C2 + def p3; end + C3 = 42 + end + end + RB + + assert_equal( + [ + "C1: C1::p1, C1::p2", + "C1: C1::C2", + "C1::C2: C1::C2::p3, C1::C2::C3", + ], + model.symbols.values + .flat_map(&:definitions) + .filter { |s| s.is_a?(Namespace) } + .map { |d| "#{d.full_name}: #{T.cast(d, Namespace).children.map(&:full_name).join(", ")}" } + .uniq, + ) + end + + def test_sigs + model = model(<<~RB) + sig { void } + sig { returns(Integer) } + def m1; end + + class C1 + sig { void } + attr_reader :p1, :p2 + + sig { returns(Integer) } # discarded + end + RB + + assert_equal( + [ + "m1: sig { void }, sig { returns(Integer) }", + "C1::p1: sig { void }", + "C1::p2: sig { void }", + ], + model.symbols.values + .flat_map(&:definitions) + .filter { |d| d.is_a?(Property) } + .map { |d| "#{d.full_name}: #{T.cast(d, Property).sigs.map(&:string).join(", ")}" }, + ) + end + + private + + sig { params(rb: String).returns(Model) } + def model(rb) + node = Spoom.parse_ruby(rb, file: "foo.rb") + + model = Model.new + builder = Builder.new(model, "foo.rb") + builder.visit(node) + model + end + + sig { params(obj: Object).returns(String) } + def class_name(obj) + T.must(obj.class.name&.split("::")&.last) + end + end + end +end