├── .gitattributes ├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── bin ├── .keep └── ruby_detective ├── docs ├── preview.html └── preview.png ├── lib ├── .keep ├── ruby_detective.rb └── ruby_detective │ ├── ast │ ├── file_parser.rb │ ├── interpreter.rb │ ├── node_factory.rb │ └── nodes │ │ ├── absolute_path_sign_node.rb │ │ ├── class_declaration_node.rb │ │ ├── constant_reference_node.rb │ │ ├── generic_node.rb │ │ ├── module_declaration_node.rb │ │ ├── query.rb │ │ └── value_node.rb │ ├── json_builder.rb │ ├── runner.rb │ ├── source_representation │ ├── data_store.rb │ ├── dependency_resolver.rb │ ├── entities │ │ ├── base.rb │ │ ├── constant.rb │ │ └── klass.rb │ └── query.rb │ └── ui_generator.rb ├── ruby_detective.gemspec ├── spec ├── fixtures │ ├── nested_class.rb │ ├── nested_module.rb │ └── simple_class.rb ├── helpers │ └── ast_helpers.rb ├── lib │ ├── ast │ │ ├── file_parser_spec.rb │ │ ├── interpreter_spec.rb │ │ └── nodes │ │ │ ├── ast_node_shared_example.rb │ │ │ ├── class_declaration_node_spec.rb │ │ │ ├── generic_node_spec.rb │ │ │ ├── module_declaration_node_spec.rb │ │ │ └── query_spec.rb │ └── source_representation │ │ ├── dependency_resolver_spec.rb │ │ └── query_spec.rb └── spec_helper.rb ├── ui ├── .gitignore ├── README.md ├── babel.config.js ├── package.json ├── public │ └── index.html.erb ├── src │ ├── App.vue │ ├── assets │ │ ├── graph.svg │ │ └── logo.svg │ ├── components │ │ ├── ClassCard.vue │ │ └── DependencyGraph.vue │ ├── main.js │ └── plugins │ │ └── element.js ├── vue.config.js └── yarn.lock └── views └── template.html.erb /.gitattributes: -------------------------------------------------------------------------------- 1 | views/*.html.erb linguist-detectable=false 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tmp/ 3 | spec/examples.txt 4 | coverage/ 5 | ruby_detective.html 6 | ruby_detective-*.gem 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.1 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | ruby_detective (0.1.0) 5 | parser (~> 2.6.5) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | ast (2.4.0) 11 | coderay (1.1.2) 12 | diff-lcs (1.3) 13 | docile (1.3.2) 14 | json (2.3.0) 15 | method_source (0.9.2) 16 | parser (2.6.5.0) 17 | ast (~> 2.4.0) 18 | pry (0.12.2) 19 | coderay (~> 1.1.0) 20 | method_source (~> 0.9.0) 21 | rake (13.0.1) 22 | rspec (3.9.0) 23 | rspec-core (~> 3.9.0) 24 | rspec-expectations (~> 3.9.0) 25 | rspec-mocks (~> 3.9.0) 26 | rspec-core (3.9.0) 27 | rspec-support (~> 3.9.0) 28 | rspec-expectations (3.9.0) 29 | diff-lcs (>= 1.2.0, < 2.0) 30 | rspec-support (~> 3.9.0) 31 | rspec-mocks (3.9.0) 32 | diff-lcs (>= 1.2.0, < 2.0) 33 | rspec-support (~> 3.9.0) 34 | rspec-support (3.9.0) 35 | simplecov (0.17.1) 36 | docile (~> 1.1) 37 | json (>= 1.8, < 3) 38 | simplecov-html (~> 0.10.0) 39 | simplecov-html (0.10.2) 40 | 41 | PLATFORMS 42 | ruby 43 | 44 | DEPENDENCIES 45 | pry 46 | rake (~> 13.0.1) 47 | rspec (~> 3.9.0) 48 | ruby_detective! 49 | simplecov (~> 0.17.1) 50 | 51 | BUNDLED WITH 52 | 1.17.2 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Victor A.M. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ruby Detective 2 | ### Investigating your code dependencies 3 | 4 | Ruby Detective is a gem that parses your code, finds it's dependencies and outputs an interactive .html file that you can use to explore the dependency network of the code. 5 | 6 | This is the UI for the Ruby Detective project by the way: 7 | 8 | ### [Click here to access the live preview](https://victor-am.github.io/ruby_detective/preview.html) 9 | 10 | [![Preview](docs/preview.png?raw=true)](https://victor-am.github.io/ruby_detective/preview.html) 11 | 12 | ***:** Due to Ruby metaprogramming super-powers (and by extension Rails heavy use of those) it's unfeasible to find every single dependency, so we can only guarantee that explicit constants will be pointed as dependencies. 13 | 14 | ## Main features 15 | - Explorable and interactive network graph of the project dependencies 16 | - Graph nodes colored by namespace, making it easier to spot contexts 17 | - Useful information like lines of code, number of dependencies and dependents, etc 18 | - Outputs a fully self-contained .html file that can be easily shared 19 | 20 | ## Installation 21 | **Make sure you have Ruby 2.5 or higher installed (lower versions are not supported)** 22 | 23 | ``` 24 | gem install ruby_detective 25 | ``` 26 | 27 | ## Usage 28 | 29 | ``` 30 | cd my-project-folder 31 | ruby_detective . 32 | ``` 33 | 34 | This should output an html file at the end that is completely self-contained, and can be shared around with your peers :D 35 | 36 | ### Some tips 37 | - Click on a node to bring it's card to the top of the list on the left 38 | - Click twice on a node to add it to the graph, allowing to navigate through dependencies 39 | - Use the filters on the right to customize the graph, toggling off the "Show second-level dependency edges" option can be specially useful 40 | 41 | *This gem was inspired by @emad-elsaid library [rubrowser](https://github.com/emad-elsaid/rubrowser).* 42 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :test => :spec 7 | rescue LoadError 8 | # no rspec available 9 | end 10 | 11 | desc "Rebuilds the template file in views/template.html.erb and commits it" 12 | task :build_ui do 13 | system <<~SHELL 14 | cd ui && 15 | yarn build && 16 | cd - && 17 | git add . && 18 | git commit -m 'Build the UI template' 19 | SHELL 20 | end 21 | 22 | VERSION_LINE_REGEX = /s\.version.*$/ 23 | GEMSPEC_FILE = "ruby_detective.gemspec" 24 | 25 | desc "Bumps the gem version and commits it with an annotated tag" 26 | task :bump_version, [:new_version] do |_t, args| 27 | new_version = args[:new_version] 28 | 29 | gemspec_file = File.read(GEMSPEC_FILE) 30 | version_line = gemspec_file.scan(VERSION_LINE_REGEX).first 31 | 32 | new_version_line = version_line.sub(/\d+\.\d+\.\d+/, new_version) 33 | new_gemspec_file = gemspec_file.sub(VERSION_LINE_REGEX, new_version_line) 34 | File.open(GEMSPEC_FILE, "w") {|file| file.puts new_gemspec_file } 35 | 36 | system <<~SHELL 37 | git add . && 38 | git commit -m 'Bump version to #{new_version}' && 39 | git tag v#{new_version} 40 | SHELL 41 | end 42 | 43 | desc "Builds the gem" 44 | task :build_gem do 45 | system "gem build ruby_detective.gemspec" 46 | end 47 | -------------------------------------------------------------------------------- /bin/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victor-am/ruby_detective/ea08fc377c48b3d3324cf0b849af25caa8306052/bin/.keep -------------------------------------------------------------------------------- /bin/ruby_detective: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'ruby_detective' 4 | parser = RubyDetective::Runner.new(ARGV[0]) 5 | parser.run 6 | -------------------------------------------------------------------------------- /docs/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victor-am/ruby_detective/ea08fc377c48b3d3324cf0b849af25caa8306052/docs/preview.png -------------------------------------------------------------------------------- /lib/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/victor-am/ruby_detective/ea08fc377c48b3d3324cf0b849af25caa8306052/lib/.keep -------------------------------------------------------------------------------- /lib/ruby_detective.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | end 3 | 4 | require "parser/current" 5 | 6 | require "ruby_detective/runner" 7 | require "ruby_detective/json_builder" 8 | require "ruby_detective/ui_generator" 9 | 10 | require "ruby_detective/source_representation/data_store" 11 | require "ruby_detective/source_representation/query" 12 | require "ruby_detective/source_representation/dependency_resolver" 13 | 14 | require "ruby_detective/source_representation/entities/base" 15 | require "ruby_detective/source_representation/entities/klass" 16 | require "ruby_detective/source_representation/entities/constant" 17 | 18 | require "ruby_detective/ast/file_parser" 19 | require "ruby_detective/ast/interpreter" 20 | require "ruby_detective/ast/node_factory" 21 | 22 | require "ruby_detective/ast/nodes/query" 23 | require "ruby_detective/ast/nodes/generic_node" 24 | require "ruby_detective/ast/nodes/value_node" 25 | require "ruby_detective/ast/nodes/constant_reference_node" 26 | require "ruby_detective/ast/nodes/class_declaration_node" 27 | require "ruby_detective/ast/nodes/module_declaration_node" 28 | require "ruby_detective/ast/nodes/absolute_path_sign_node" 29 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/file_parser.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | class FileParser 4 | attr_reader :path, :project_path, :rich_ast 5 | 6 | def initialize(file_path, project_path) 7 | @path = file_path 8 | @project_path = project_path 9 | end 10 | 11 | def parse 12 | code = File.read(path) 13 | 14 | raw_ast = Parser::CurrentRuby.parse(code) 15 | return false if raw_ast.nil? # Empty file scenario 16 | 17 | factory = AST::NodeFactory.new(raw_ast, file_path: clean_path) 18 | @rich_ast = factory.build 19 | factory.process_all_children 20 | 21 | AST::Interpreter.interpret_node_and_populate_store( 22 | rich_ast, 23 | clean_path 24 | ) 25 | end 26 | 27 | private 28 | 29 | def clean_path 30 | path.sub(project_path, "") 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/interpreter.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | class Interpreter 4 | attr_reader :rich_ast, :classes, :file_path 5 | 6 | def initialize(rich_ast, file_path) 7 | @rich_ast = rich_ast 8 | @file_path = file_path 9 | end 10 | 11 | def self.interpret_node_and_populate_store(*args) 12 | new(*args).interpret_node_and_populate_store 13 | end 14 | 15 | def interpret_node_and_populate_store 16 | register_classes_and_constants 17 | 18 | true 19 | end 20 | 21 | private 22 | 23 | def register_classes_and_constants 24 | rich_ast.query.class_declarations.map do |class_node| 25 | class_representation = register_class(class_node) 26 | register_constants_referenced_in_class(class_representation) 27 | end 28 | end 29 | 30 | def register_class(node) 31 | data_store = SourceRepresentation::DataStore.instance 32 | data_store.register_class( 33 | node.class_name, 34 | node.short_namespace, 35 | inheritance_class_name: node.inheritance_class_name, 36 | file_path: node.file_path, 37 | first_line: node.first_line, 38 | last_line: node.last_line 39 | ) 40 | end 41 | 42 | def register_constants_referenced_in_class(class_representation) 43 | constant_nodes = rich_ast.query 44 | .top_level_constant_references(where: { namespace: class_representation.name }) 45 | .uniq{ |cr| cr.constant_path } # Removes duplicated constants 46 | 47 | constant_nodes.each do |constant_node| 48 | data_store = SourceRepresentation::DataStore.instance 49 | data_store.register_constant( 50 | constant_node.constant_name, 51 | constant_node.constant_path[0..-2], 52 | file_path: file_path, 53 | caller: class_representation 54 | ) 55 | end 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/node_factory.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | class NodeFactory 4 | attr_reader :node, :rich_node, :file_path, :parent_node 5 | 6 | # A dictionary that converts the Parser gem type to our Rich AST type 7 | NODE_TYPE_DICTIONARY = { 8 | const: :constant, 9 | class: :class, 10 | module: :module, 11 | cbase: :absolute_path_sign 12 | } 13 | # The following types also exist: 14 | # 15 | # value - the last node of a branch, can be nil, a string, a symbol, etc... 16 | # generic - a broader "others" type, for any nodes not mapped out 17 | 18 | def initialize(node, file_path:, parent_node: nil) 19 | @node = node 20 | @rich_node = nil 21 | @file_path = file_path 22 | @parent_node = parent_node 23 | end 24 | 25 | def build 26 | @rich_node = node_class.new(node, file_path: file_path, parent_node: parent_node) 27 | end 28 | 29 | def process_all_children 30 | rich_node.raw_children.each do |raw_child_node| 31 | factory = self.class.new( 32 | raw_child_node, 33 | file_path: file_path, 34 | parent_node: rich_node 35 | ) 36 | child_node = factory.build 37 | 38 | rich_node.children << child_node 39 | factory.process_all_children 40 | end 41 | end 42 | 43 | private 44 | 45 | def node_class 46 | case node_type 47 | when :class 48 | Nodes::ClassDeclarationNode 49 | when :module 50 | Nodes::ModuleDeclarationNode 51 | when :constant 52 | Nodes::ConstantReferenceNode 53 | when :absolute_path_sign 54 | Nodes::AbsolutePathSignNode 55 | when :value 56 | Nodes::ValueNode 57 | when :generic 58 | Nodes::GenericNode 59 | end 60 | end 61 | 62 | def node_type 63 | if not_an_ast_node? 64 | :value 65 | elsif NODE_TYPE_DICTIONARY.key?(node.type) 66 | NODE_TYPE_DICTIONARY[node.type] 67 | else 68 | :generic 69 | end 70 | end 71 | 72 | def not_an_ast_node? 73 | !node.is_a?(Parser::AST::Node) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/absolute_path_sign_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class AbsolutePathSignNode < GenericNode 5 | def type 6 | :absolute_path_sign 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/class_declaration_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class ClassDeclarationNode < GenericNode 5 | CLASS_NAME_NODE_INDEX = 0 6 | def class_name 7 | children[CLASS_NAME_NODE_INDEX].constant_name 8 | end 9 | 10 | INHERITANCE_CLASS_NAME_NODE_INDEX = 1 11 | def inheritance_class_name 12 | inherited_class_constant = children[INHERITANCE_CLASS_NAME_NODE_INDEX] 13 | # If this child isn't a ConstantReference node it means it doesn't 14 | # have a declared class inheritance like "class Foo::Bar" 15 | return unless inherited_class_constant.constant_reference_node? 16 | 17 | inherited_class_constant.constant_name 18 | end 19 | 20 | PREPENDED_NAMESPACE_NODE_INDEX = 0 21 | def declared_namespace 22 | prepended_namespace = 23 | children[CLASS_NAME_NODE_INDEX] 24 | .children[PREPENDED_NAMESPACE_NODE_INDEX] 25 | 26 | return [class_name] unless prepended_namespace.constant_reference_node? 27 | # This scenario happens when we have a class declaration with inline 28 | # namespace, like this: class MyNamespace::MyClass 29 | prepended_namespace.constant_path + [class_name] 30 | end 31 | 32 | def type 33 | :class 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/constant_reference_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class ConstantReferenceNode < GenericNode 5 | CONSTANT_NAME_INDEX = 1 6 | def constant_name 7 | children[CONSTANT_NAME_INDEX].value 8 | end 9 | 10 | # A top level constant is for example the "Bar" from "Foo::Bar". 11 | # We access it by checking if the parent is another constant, if it is 12 | # it means the constant is a nested one and not top level. 13 | def top_level_constant? 14 | !parent_node.constant_reference_node? 15 | end 16 | 17 | # Recursively builds the constant path by traversing it's children, 18 | # that way we can compose a path composed of multiple namespaces, 19 | # for example: Foo::Bar::Batz as [:Foo, :Bar, :Batz]. 20 | NESTED_CONSTANT_INDEX = 0 21 | def constant_path 22 | nested_constant = children[NESTED_CONSTANT_INDEX] 23 | 24 | if nested_constant.constant_reference_node? 25 | nested_constant.constant_path + [constant_name] 26 | elsif nested_constant.absolute_path_sign_node? 27 | # This is used to signify that the constant path was forced to start 28 | # from the root, for example: "::Foo::Bar" 29 | [:"::"] + [constant_name] 30 | else 31 | [constant_name] 32 | end 33 | end 34 | 35 | def type 36 | :constant 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/generic_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class GenericNode 5 | attr_reader :ast_node, :children, :file_path, :parent_node 6 | 7 | def initialize(ast_node, file_path:, parent_node:) 8 | @ast_node = ast_node 9 | @file_path = file_path 10 | @children = [] 11 | @parent_node = parent_node 12 | end 13 | 14 | def short_namespace 15 | namespace[0..-2] 16 | end 17 | 18 | def namespace 19 | build_namespace(self) 20 | end 21 | 22 | def declared_namespace 23 | [] 24 | end 25 | 26 | def class_declaration_node? 27 | type == :class 28 | end 29 | 30 | def module_declaration_node? 31 | type == :module 32 | end 33 | 34 | def constant_reference_node? 35 | type == :constant 36 | end 37 | 38 | def absolute_path_sign_node? 39 | type == :absolute_path_sign 40 | end 41 | 42 | def value_node? 43 | type == :value 44 | end 45 | 46 | def generic_node? 47 | type == :generic 48 | end 49 | 50 | def type 51 | :generic 52 | end 53 | 54 | def first_line 55 | # When the node represents something that is not directly in the code 56 | # the `ast_node.loc.expression` can be nil, and since `.line` is just 57 | # sugar syntax for `loc.expression.line` it would throw an error. 58 | ast_node.loc.line rescue nil 59 | end 60 | 61 | def last_line 62 | # When the node represents something that is not directly in the code 63 | # the `ast_node.loc.expression` can be nil, and since `.last_line` is just 64 | # sugar syntax for `loc.expression.last_line` it would throw an error. 65 | ast_node.loc.last_line rescue nil 66 | end 67 | 68 | def query 69 | Query.new(self) 70 | end 71 | 72 | def raw_children 73 | ast_node.children 74 | end 75 | 76 | private 77 | 78 | def build_namespace(node, acc = []) 79 | return acc.flatten.compact if node.nil? 80 | 81 | acc.prepend(node.declared_namespace) 82 | build_namespace(node.parent_node, acc) 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/module_declaration_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class ModuleDeclarationNode < GenericNode 5 | MODULE_NAME_NODE_INDEX = 0 6 | def module_name 7 | children[MODULE_NAME_NODE_INDEX].constant_name 8 | end 9 | 10 | # TODO: Add support for inline namespacing (ex class Foo::Bar) 11 | def declared_namespace 12 | [module_name] 13 | end 14 | 15 | def type 16 | :module 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/query.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class Query 5 | attr_reader :node 6 | 7 | def initialize(node) 8 | @node = node 9 | end 10 | 11 | # TODO: accept multiple criteria 12 | def where(criteria = {}) 13 | case 14 | when criteria.key?(:type) 15 | type_validation_function = ->(node) { node.type == criteria[:type] } 16 | deep_search(node, [type_validation_function]) 17 | else 18 | deep_search(node) 19 | end 20 | end 21 | 22 | # TODO: ignore constant definitions, only return constant references 23 | def constant_references(where: {}) 24 | constants = deep_search(node, [:constant_reference_node?]) 25 | 26 | case 27 | when where.key?(:namespace) 28 | constants.select { |c| c.namespace.include?(where[:namespace].to_sym) } 29 | else 30 | constants 31 | end 32 | end 33 | 34 | # TODO: ignore constant definitions, only return constant references 35 | # This finds all constants, ignoring the nested ones. 36 | # For example: 37 | # The "Foo::Bar" code contain two constants, but this method will only bring 38 | # up one (the Bar one), with access to it's full path. 39 | def top_level_constant_references(where: {}) 40 | constants = deep_search(node, [:constant_reference_node?, :top_level_constant?]) 41 | 42 | case 43 | when where.key?(:namespace) 44 | constants.select { |c| c.namespace.include?(where[:namespace].to_sym) } 45 | else 46 | constants 47 | end 48 | end 49 | 50 | def class_declarations 51 | deep_search(node, [:class_declaration_node?]) 52 | end 53 | 54 | private 55 | 56 | def deep_search(node, validation_methods = [], acc: []) 57 | return if node.value_node? 58 | 59 | validation_result = validation_methods.map do |validation_method| 60 | if validation_method.is_a?(Symbol) 61 | node.respond_to?(validation_method) && node.public_send(validation_method) 62 | elsif validation_method.is_a?(Proc) 63 | begin 64 | validation_method.call(node) 65 | rescue NoMethodError 66 | false 67 | end 68 | else 69 | raise ArgumentError, "Unexpected validation method data type" 70 | end 71 | end 72 | 73 | # Only appends the node to the results if all validations passed 74 | acc << node if validation_result.all? 75 | 76 | node.children.each { |child| send(__method__, child, validation_methods, acc: acc) } 77 | acc.uniq 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ruby_detective/ast/nodes/value_node.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module AST 3 | module Nodes 4 | class ValueNode < GenericNode 5 | attr_reader :value 6 | 7 | def initialize(value, *args) 8 | super(value, *args) 9 | @value = value 10 | end 11 | 12 | def type 13 | :value 14 | end 15 | 16 | def first_line 17 | parent_node.first_line 18 | end 19 | 20 | def last_line 21 | parent_node.last_line 22 | end 23 | 24 | def raw_children 25 | [] 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ruby_detective/json_builder.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module RubyDetective 4 | class JSONBuilder 5 | attr_reader :classes 6 | 7 | def initialize 8 | data_store = SourceRepresentation::DataStore.instance 9 | @classes = data_store.classes 10 | end 11 | 12 | def self.build(*args) 13 | new(*args).build 14 | end 15 | 16 | def build 17 | classes_data_as_json = classes.map do |c| 18 | { 19 | name: c.name, 20 | full_name: c.path_as_text, 21 | namespace: c.namespace_as_text, 22 | lines_of_code: c.lines_of_code, 23 | dependencies: c.dependencies.map(&:path_as_text), 24 | dependents: c.dependents.map(&:path_as_text), 25 | file_path: c.file_path 26 | } 27 | end.to_json 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/ruby_detective/runner.rb: -------------------------------------------------------------------------------- 1 | require "parser/current" 2 | 3 | module RubyDetective 4 | class Runner 5 | attr_reader :project_path, :classes, :modules 6 | 7 | def initialize(project_path) 8 | if [nil, "", " "].include? project_path 9 | @project_path = "." 10 | else 11 | @project_path = project_path 12 | end 13 | end 14 | 15 | def run 16 | puts "Processing files..." 17 | Dir.glob("#{project_path}/**/*.rb") do |file_path| 18 | AST::FileParser.new(file_path, project_path).parse 19 | end 20 | 21 | puts "Finding dependencies..." 22 | SourceRepresentation::DataStore.instance.resolve_dependencies 23 | 24 | if ENV["ENV"] == "development" 25 | puts "Generating output .json file..." 26 | json = ::RubyDetective::JSONBuilder.build 27 | 28 | output_file_path = "ui/src/data.json" 29 | File.delete(output_file_path) if File.exist?(output_file_path) 30 | File.open(output_file_path, "w") { |file| file << json } 31 | else 32 | puts "Generating output HTML file..." 33 | UIGenerator.generate 34 | end 35 | 36 | puts "Done!" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/data_store.rb: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | 3 | # This class is used as a database of sorts during the 4 | # analysis execution. 5 | module RubyDetective 6 | module SourceRepresentation 7 | class DataStore 8 | include Singleton 9 | 10 | attr_accessor :classes, :constants 11 | 12 | def initialize 13 | @classes = [] 14 | @constants = [] 15 | end 16 | 17 | def query 18 | Query.new 19 | end 20 | 21 | def clear! 22 | initialize 23 | end 24 | 25 | def inspect 26 | "#" 27 | end 28 | 29 | def resolve_dependencies 30 | DependencyResolver.resolve_and_populate_store 31 | end 32 | 33 | def register_class(name, namespace, inheritance_class_name:, file_path:, first_line:, last_line:) 34 | klass = Entities::Klass.new( 35 | name, 36 | namespace, 37 | inheritance_class_name: inheritance_class_name, 38 | file_path: file_path, 39 | first_line: first_line, 40 | last_line: last_line 41 | ) 42 | 43 | existing_class = query.classes(where: { path: klass.path }).first 44 | 45 | if existing_class 46 | existing_class.merge(klass) 47 | existing_class 48 | else 49 | @classes << klass 50 | klass 51 | end 52 | end 53 | 54 | def register_constant(name, namespace, file_path:, caller:, refers_to: nil) 55 | constant = Entities::Constant.new( 56 | name, 57 | namespace, 58 | caller: caller, 59 | refers_to: refers_to, 60 | file_path: file_path 61 | ) 62 | @constants << constant 63 | constant 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/dependency_resolver.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module SourceRepresentation 3 | class DependencyResolver 4 | attr_reader :classes 5 | 6 | def initialize 7 | @classes = DataStore.instance.classes 8 | end 9 | 10 | def self.resolve_and_populate_store 11 | new.resolve_and_populate_store 12 | end 13 | 14 | def resolve_and_populate_store 15 | register_dependencies_and_dependents 16 | true 17 | end 18 | 19 | private 20 | 21 | def register_dependencies_and_dependents 22 | classes.each do |klass| 23 | klass.constants.each do |constant| 24 | referred_class = find_referred_class(constant) 25 | next if referred_class.nil? 26 | 27 | constant.register_referred_class(referred_class) 28 | end 29 | end 30 | end 31 | 32 | def find_referred_class(constant) 33 | classes.select do |klass| 34 | constant.possible_paths_of_referenced_entity.find do |possible_path| 35 | klass.path == possible_path 36 | end 37 | end.compact.first 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/entities/base.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module SourceRepresentation 3 | module Entities 4 | class Base 5 | ROOT_SIGN_SYMBOL = :"::" 6 | 7 | def absolute_path? 8 | namespace.first == ROOT_SIGN_SYMBOL 9 | end 10 | 11 | def path 12 | namespace + [name] 13 | end 14 | 15 | def path_without_root_sign 16 | namespace_without_root_sign + [name] 17 | end 18 | 19 | # Removes the :"::" symbol from the namespace 20 | def namespace_without_root_sign 21 | if absolute_path? 22 | namespace[1..-1] 23 | else 24 | namespace 25 | end 26 | end 27 | 28 | def path_as_text 29 | if absolute_path? 30 | "::" + path_without_root_sign.join("::") 31 | else 32 | path.join("::") 33 | end 34 | end 35 | 36 | def namespace_as_text 37 | if absolute_path? 38 | "::" + namespace_without_root_sign.join("::") 39 | else 40 | namespace.join("::") 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/entities/constant.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module SourceRepresentation 3 | module Entities 4 | class Constant < Base 5 | attr_reader :name, :namespace, :file_path, :caller, :refers_to 6 | 7 | def initialize(name, namespace, file_path:, caller:, refers_to: nil) 8 | @name = name 9 | @namespace = namespace 10 | @file_path = file_path 11 | @refers_to = refers_to 12 | @caller = caller 13 | @data_store = SourceRepresentation::DataStore.instance 14 | end 15 | 16 | def caller_namespace 17 | caller.namespace 18 | end 19 | 20 | def register_referred_class(klass) 21 | @refers_to = klass 22 | end 23 | 24 | def possible_paths_of_referenced_entity 25 | # If the constant was like "::Foo::Bar" there is only one possible 26 | # match: the exact path described in the constant 27 | return [path_without_root_sign] if absolute_path? 28 | 29 | possible_parent_namespaces 30 | .map { |possible_parent| possible_parent + path } 31 | .push(path) 32 | end 33 | 34 | private 35 | attr_reader :data_store 36 | 37 | def possible_parent_namespaces 38 | (caller_namespace.size - 1) 39 | .downto(0) 40 | .map { |i| caller_namespace[0..i] } 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/entities/klass.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module SourceRepresentation 3 | module Entities 4 | class Klass < Base 5 | attr_reader :name, :namespace, :file_path, :inheritance_class_name, :lines_of_code 6 | 7 | def initialize(name, namespace, inheritance_class_name: nil, file_path:, first_line:, last_line:) 8 | @name = name 9 | @namespace = namespace 10 | @file_path = file_path 11 | @inheritance_class_name = inheritance_class_name 12 | @lines_of_code = last_line - first_line + 1 13 | @data_store = SourceRepresentation::DataStore.instance 14 | end 15 | 16 | def constants 17 | data_store 18 | .query 19 | .constants(where: { caller: self }) 20 | end 21 | 22 | def dependencies 23 | constants 24 | .map(&:refers_to) 25 | .compact 26 | .reject{ |c| c.name == name } # Removes circular dependencies 27 | end 28 | 29 | def dependents 30 | data_store.query 31 | .constants(where: { refers_to: self }) 32 | .map(&:caller) 33 | .compact 34 | .reject{ |c| c.name == name } # Removes circular dependencies 35 | end 36 | 37 | def merge(duplicate) 38 | @inheritance_class_name ||= duplicate.inheritance_class_name 39 | @lines_of_code += duplicate.lines_of_code 40 | end 41 | 42 | private 43 | attr_reader :data_store 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/ruby_detective/source_representation/query.rb: -------------------------------------------------------------------------------- 1 | module RubyDetective 2 | module SourceRepresentation 3 | class Query 4 | attr_reader :store 5 | 6 | def initialize 7 | @store = DataStore.instance 8 | end 9 | 10 | def constants(where: {}) 11 | constants = store.constants 12 | 13 | case 14 | when where.key?(:refers_to) 15 | constants.select { |c| c.refers_to == where[:refers_to] } 16 | when where.key?(:caller) 17 | constants.select { |c| c.caller == where[:caller] } 18 | else 19 | constants 20 | end 21 | end 22 | 23 | def classes(where: {}) 24 | classes = store.classes 25 | 26 | case 27 | when where.key?(:path) 28 | classes.select { |c| c.path == where[:path] } 29 | else 30 | classes 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/ruby_detective/ui_generator.rb: -------------------------------------------------------------------------------- 1 | require "erb" 2 | 3 | module RubyDetective 4 | class UIGenerator 5 | def self.generate 6 | # Variable used inside the template 7 | classes_data_as_json = JSONBuilder.build 8 | 9 | template_path = File.join(File.dirname(__FILE__), "../../views/template.html.erb") 10 | erb_template = File.read(template_path) 11 | ui_source_code = ERB.new(erb_template).result(binding) 12 | 13 | output_file_path = 'ruby_detective.html' 14 | File.delete(output_file_path) if File.exist?(output_file_path) 15 | File.open(output_file_path, 'w') { |file| file << ui_source_code } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /ruby_detective.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "ruby_detective" 3 | s.version = "0.1.0" 4 | s.date = "2020-01-20" 5 | s.summary = "Allows to investigate your ruby project dependency network" 6 | s.description = "Ruby Detective is a gem that parses your code, finds it's dependencies and outputs a interactive .html file that you can use to explore the dependency network of the code." 7 | s.authors = ["Victor Marques"] 8 | s.email = "victor.atmorning@gmail.com" 9 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(spec|docs|ui|coverage|)/}) } 10 | s.homepage = "https://github.com/victor-am/ruby_detective" 11 | s.license = "MIT" 12 | s.bindir = "bin" 13 | s.executables << "ruby_detective" 14 | s.require_paths = ["lib"] 15 | 16 | s.add_dependency "parser", "~> 2.6.5" 17 | 18 | s.add_development_dependency "rake", "~> 13.0.1" 19 | s.add_development_dependency "rspec", "~> 3.9.0" 20 | s.add_development_dependency "simplecov", "~> 0.17.1" 21 | s.add_development_dependency "pry" 22 | end 23 | -------------------------------------------------------------------------------- /spec/fixtures/nested_class.rb: -------------------------------------------------------------------------------- 1 | class SimpleClass 2 | attr_reader :name 3 | 4 | SOME_CONSTANT = 'Foo' 5 | 6 | def initialize 7 | @name = SOME_CONSTANT 8 | @type = OtherClass.new 9 | end 10 | 11 | class NestedClass 12 | NESTED_CONSTANT = 'Bar' 13 | 14 | def self.some_method 15 | NESTED_CONSTANT 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/fixtures/nested_module.rb: -------------------------------------------------------------------------------- 1 | module SimpleModule 2 | attr_reader :name 3 | SOME_CONSTANT = 'Foo' 4 | 5 | module NestedModule 6 | NESTED_CONSTANT = 'Bar' 7 | 8 | def some_method 9 | NESTED_CONSTANT 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/fixtures/simple_class.rb: -------------------------------------------------------------------------------- 1 | class SimpleClass 2 | attr_reader :name 3 | 4 | SOME_CONSTANT = 'Foo' 5 | 6 | def initialize 7 | @name = SOME_CONSTANT 8 | @type = OtherClass.new 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/helpers/ast_helpers.rb: -------------------------------------------------------------------------------- 1 | FIXTURES_PATH = "#{File.dirname(__FILE__)}/../fixtures/" 2 | 3 | def load_code_as_raw_ast_node(file_name) 4 | code = File.read(FIXTURES_PATH + file_name.to_s + ".rb") 5 | Parser::CurrentRuby.parse(code) 6 | end 7 | 8 | def load_code_as_rich_ast_node(file_name) 9 | ast_node = load_code_as_raw_ast_node(file_name) 10 | factory = RubyDetective::AST::NodeFactory.new(ast_node, file_path: "fixtures/#{file_name}.rb") 11 | rich_node = factory.build 12 | factory.process_all_children 13 | 14 | rich_node 15 | end 16 | -------------------------------------------------------------------------------- /spec/lib/ast/file_parser_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | 3 | RSpec.describe RubyDetective::AST::FileParser do 4 | let(:data_store) { RubyDetective::SourceRepresentation::DataStore.instance } 5 | before { data_store.clear! } 6 | 7 | it "parses the file to a rich AST" do 8 | file_path = "#{FIXTURES_PATH}/simple_class.rb" 9 | project_path = FIXTURES_PATH 10 | 11 | subject = described_class.new(file_path, project_path) 12 | subject.parse 13 | 14 | expect(subject.rich_ast).to be_a(RubyDetective::AST::Nodes::GenericNode) 15 | end 16 | 17 | it "removes the project path from the file path" do 18 | file_path = "#{FIXTURES_PATH}/simple_class.rb" 19 | project_path = FIXTURES_PATH 20 | 21 | subject = described_class.new(file_path, project_path) 22 | subject.parse 23 | 24 | expect(data_store.classes.first.file_path).to eq("/simple_class.rb") 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/lib/ast/interpreter_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | 3 | RSpec.describe RubyDetective::AST::Interpreter do 4 | let(:data_store) { RubyDetective::SourceRepresentation::DataStore.instance } 5 | before { data_store.clear! } 6 | 7 | it "finds and registers all classes in the data store" do 8 | rich_ast = load_code_as_rich_ast_node(:nested_class) 9 | subject = described_class.new(rich_ast, "fixtures/nested_class.rb") 10 | subject.interpret_node_and_populate_store 11 | 12 | expect(data_store.classes.map(&:name)).to eq([:SimpleClass, :NestedClass]) 13 | end 14 | 15 | it "finds and registers all constants in the data store" do 16 | rich_ast = load_code_as_rich_ast_node(:nested_class) 17 | subject = described_class.new(rich_ast, "fixtures/nested_class.rb") 18 | subject.interpret_node_and_populate_store 19 | 20 | expected_constants = [ 21 | :NESTED_CONSTANT, 22 | :NESTED_CONSTANT, 23 | :NestedClass, 24 | :NestedClass, 25 | :OtherClass, 26 | :SOME_CONSTANT, 27 | :SimpleClass 28 | ] 29 | 30 | expect(data_store.constants.map(&:name)).to contain_exactly(*expected_constants) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/lib/ast/nodes/ast_node_shared_example.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_examples "AST node" do |node| 2 | describe "#query" do 3 | it "returns a Query object wrapping the node itself" do 4 | expect(node.query.node).to eq(node) 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/lib/ast/nodes/class_declaration_node_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | require_relative 'ast_node_shared_example' 3 | 4 | root_node = load_code_as_rich_ast_node(:nested_class) 5 | subject = root_node.query.where(type: :class).last 6 | 7 | RSpec.describe RubyDetective::AST::Nodes::ClassDeclarationNode do 8 | it_behaves_like "AST node", subject 9 | 10 | describe "#namespace" do 11 | it "generates the namespace correctly" do 12 | expect(subject.namespace).to eq([:SimpleClass, :NestedClass]) 13 | end 14 | end 15 | 16 | describe "#class_name" do 17 | it "returns the name of the class declared by the node" do 18 | expect(subject.class_name).to eq(:NestedClass) 19 | end 20 | end 21 | 22 | describe "#inheritance_class_name" do 23 | it "returns nil if no class" do 24 | expect(subject.inheritance_class_name).to eq(nil) 25 | end 26 | end 27 | 28 | describe "#declared_namespace" do 29 | it "returns the namespace declared by the node" do 30 | expect(subject.declared_namespace).to eq([:NestedClass]) 31 | end 32 | end 33 | 34 | describe "#first_line" do 35 | it "returns the first line of the node" do 36 | expect(subject.first_line).to eq(11) 37 | end 38 | end 39 | 40 | describe "#last_line" do 41 | it "returns the first line of the node" do 42 | expect(subject.last_line).to eq(17) 43 | end 44 | end 45 | 46 | describe "type check methods" do 47 | it "#class_declaration_node? returns true" do 48 | expect(subject.class_declaration_node?).to eq(true) 49 | end 50 | 51 | it "#module_declaration_node? returns false" do 52 | expect(subject.module_declaration_node?).to eq(false) 53 | end 54 | 55 | it "#constant_reference_node? returns false" do 56 | expect(subject.constant_reference_node?).to eq(false) 57 | end 58 | 59 | it "#value_node? returns false" do 60 | expect(subject.value_node?).to eq(false) 61 | end 62 | 63 | it "#generic_node? returns true" do 64 | expect(subject.generic_node?).to eq(false) 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/lib/ast/nodes/generic_node_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | require_relative 'ast_node_shared_example' 3 | 4 | root_node = load_code_as_rich_ast_node(:nested_class) 5 | subject = root_node.query.where(type: :generic).last 6 | 7 | RSpec.describe RubyDetective::AST::Nodes::GenericNode do 8 | it_behaves_like "AST node", subject 9 | 10 | describe "#namespace" do 11 | it "generates the namespace correctly" do 12 | expect(subject.namespace).to eq([:SimpleClass, :NestedClass]) 13 | end 14 | end 15 | 16 | describe "#declared_namespace" do 17 | it "returns nil" do 18 | expect(subject.declared_namespace).to eq([]) 19 | end 20 | end 21 | 22 | describe "#first_line" do 23 | it "returns the first line of the node" do 24 | expect(subject.first_line).to eq(nil) 25 | end 26 | end 27 | 28 | describe "#last_line" do 29 | it "returns the first line of the node" do 30 | expect(subject.last_line).to eq(nil) 31 | end 32 | end 33 | 34 | describe "type check methods" do 35 | it "#class_declaration_node? returns false" do 36 | expect(subject.class_declaration_node?).to eq(false) 37 | end 38 | 39 | it "#module_declaration_node? returns false" do 40 | expect(subject.module_declaration_node?).to eq(false) 41 | end 42 | 43 | it "#constant_reference_node? returns false" do 44 | expect(subject.constant_reference_node?).to eq(false) 45 | end 46 | 47 | it "#value_node? returns false" do 48 | expect(subject.value_node?).to eq(false) 49 | end 50 | 51 | it "#generic_node? returns true" do 52 | expect(subject.generic_node?).to eq(true) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/ast/nodes/module_declaration_node_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | require_relative 'ast_node_shared_example' 3 | 4 | root_node = load_code_as_rich_ast_node(:nested_module) 5 | subject = root_node.query.where(type: :module).last 6 | 7 | RSpec.describe RubyDetective::AST::Nodes::ModuleDeclarationNode do 8 | it_behaves_like "AST node", subject 9 | 10 | describe "#namespace" do 11 | it "generates the namespace correctly" do 12 | expect(subject.namespace).to eq([:SimpleModule, :NestedModule]) 13 | end 14 | end 15 | 16 | describe "#module_name" do 17 | it "returns the name of the module declared by the node" do 18 | expect(subject.module_name).to eq(:NestedModule) 19 | end 20 | end 21 | 22 | describe "#declared_namespace" do 23 | it "returns the namespace declared by the node" do 24 | expect(subject.declared_namespace).to eq([:NestedModule]) 25 | end 26 | end 27 | 28 | describe "#first_line" do 29 | it "returns the first line of the node" do 30 | expect(subject.first_line).to eq(5) 31 | end 32 | end 33 | 34 | describe "#last_line" do 35 | it "returns the first line of the node" do 36 | expect(subject.last_line).to eq(11) 37 | end 38 | end 39 | 40 | describe "type check methods" do 41 | it "#class_declaration_node? returns false" do 42 | expect(subject.class_declaration_node?).to eq(false) 43 | end 44 | 45 | it "#module_declaration_node? returns true" do 46 | expect(subject.module_declaration_node?).to eq(true) 47 | end 48 | 49 | it "#constant_reference_node? returns false" do 50 | expect(subject.constant_reference_node?).to eq(false) 51 | end 52 | 53 | it "#value_node? returns false" do 54 | expect(subject.value_node?).to eq(false) 55 | end 56 | 57 | it "#generic_node? returns true" do 58 | expect(subject.generic_node?).to eq(false) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/lib/ast/nodes/query_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | 3 | RSpec.describe RubyDetective::AST::Nodes::Query do 4 | describe "#constant_references" do 5 | it "returns all the constants referenced in the node" do 6 | node = load_code_as_rich_ast_node(:simple_class) 7 | subject = described_class.new(node) 8 | 9 | results = subject.constant_references 10 | 11 | expect(results.map(&:constant_name)).to eq([:SimpleClass, :SOME_CONSTANT, :OtherClass]) 12 | end 13 | 14 | it "returns only matched constants when filtered by namespace" do 15 | node = load_code_as_rich_ast_node(:nested_class) 16 | subject = described_class.new(node) 17 | 18 | results = subject.constant_references(where: { namespace: :NestedClass }) 19 | 20 | expect(results.map(&:constant_name)).to eq([:NestedClass, :NESTED_CONSTANT]) 21 | end 22 | end 23 | 24 | describe "#class_declarations" do 25 | it "returns all the classes declared in the node" do 26 | node = load_code_as_rich_ast_node(:nested_class) 27 | subject = described_class.new(node) 28 | 29 | results = subject.class_declarations 30 | 31 | expect(results.map(&:class_name)).to eq([:SimpleClass, :NestedClass]) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/lib/source_representation/dependency_resolver_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | 3 | RSpec.describe RubyDetective::SourceRepresentation::DependencyResolver do 4 | let(:data_store) { RubyDetective::SourceRepresentation::DataStore.instance } 5 | before { data_store.clear! } 6 | 7 | before do 8 | simple_class = RubyDetective::SourceRepresentation::Entities::Klass.new( 9 | :SimpleClass, 10 | [], 11 | file_path: "fixtures/simple_class.rb", 12 | first_line: 1, 13 | last_line: 81 14 | ) 15 | 16 | another_class = RubyDetective::SourceRepresentation::Entities::Klass.new( 17 | :AnotherClass, 18 | [], 19 | file_path: "fixtures/another_class.rb", 20 | first_line: 1, 21 | last_line: 36 22 | ) 23 | 24 | constant = RubyDetective::SourceRepresentation::Entities::Constant.new( 25 | :SomeConstant, 26 | [], 27 | file_path: "fixtures/simple_class.rb", 28 | caller: simple_class, 29 | refers_to: another_class 30 | ) 31 | 32 | data_store.classes = [simple_class, another_class] 33 | data_store.constants = [constant] 34 | end 35 | 36 | it "registers the correct dependencies on the class representations" do 37 | described_class.resolve_and_populate_store 38 | 39 | expect(data_store.classes[0].dependencies.map(&:name)).to eq([:AnotherClass]) 40 | expect(data_store.classes[1].dependencies.map(&:name)).to eq([]) 41 | end 42 | 43 | it "registers the correct dependents on the class representations" do 44 | described_class.resolve_and_populate_store 45 | 46 | expect(data_store.classes[0].dependents.map(&:name)).to eq([]) 47 | expect(data_store.classes[1].dependents.map(&:name)).to eq([:SimpleClass]) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/lib/source_representation/query_spec.rb: -------------------------------------------------------------------------------- 1 | require './spec/spec_helper' 2 | 3 | RSpec.describe RubyDetective::SourceRepresentation::Query do 4 | let(:data_store) { RubyDetective::SourceRepresentation::DataStore.instance } 5 | 6 | let(:simple_class) do 7 | RubyDetective::SourceRepresentation::Entities::Klass.new( 8 | :SimpleClass, 9 | [], 10 | file_path: "fixtures/simple_class.rb", 11 | first_line: 1, 12 | last_line: 81 13 | ) 14 | end 15 | 16 | let(:another_class) do 17 | RubyDetective::SourceRepresentation::Entities::Klass.new( 18 | :AnotherClass, 19 | [], 20 | file_path: "fixtures/another_class.rb", 21 | first_line: 1, 22 | last_line: 36 23 | ) 24 | end 25 | 26 | let(:constant) do 27 | RubyDetective::SourceRepresentation::Entities::Constant.new( 28 | :SomeConstant, 29 | [], 30 | file_path: "fixtures/simple_class.rb", 31 | caller: simple_class, 32 | refers_to: another_class 33 | ) 34 | end 35 | 36 | before do 37 | data_store.clear! 38 | data_store.classes = [simple_class, another_class] 39 | data_store.constants = [constant] 40 | end 41 | 42 | describe "#constants" do 43 | it "returns all the constants" do 44 | query = described_class.new 45 | expect(query.constants.map(&:name)).to eq([:SomeConstant]) 46 | end 47 | 48 | it "returns only matching constants when filtered by caller" do 49 | query = described_class.new 50 | expect(query.constants(where: { caller: simple_class }).map(&:name)).to eq([:SomeConstant]) 51 | expect(query.constants(where: { caller: another_class }).map(&:name)).to eq([]) 52 | end 53 | 54 | it "returns only matching constants when filtered by refers_to" do 55 | query = described_class.new 56 | expect(query.constants(where: { refers_to: another_class }).map(&:name)).to eq([:SomeConstant]) 57 | expect(query.constants(where: { refers_to: simple_class }).map(&:name)).to eq([]) 58 | end 59 | end 60 | 61 | describe "#classes" do 62 | it "returns all the classes" do 63 | query = described_class.new 64 | expect(query.classes.map(&:name)).to eq([:SimpleClass, :AnotherClass]) 65 | end 66 | 67 | it "returns only matching classes when filtered by path" do 68 | query = described_class.new 69 | expect(query.classes(where: { path: simple_class.path }).map(&:name)).to eq([:SimpleClass]) 70 | expect(query.classes(where: { path: [:foo, :bar] }).map(&:name)).to eq([]) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "simplecov" 2 | SimpleCov.start do 3 | add_filter "/spec/" 4 | end 5 | 6 | require "./lib/ruby_detective" 7 | require_relative "helpers/ast_helpers" 8 | 9 | RSpec.configure do |config| 10 | # rspec-expectations config goes here. You can use an alternate 11 | # assertion/expectation library such as wrong or the stdlib/minitest 12 | # assertions if you prefer. 13 | config.expect_with :rspec do |expectations| 14 | # This option will default to `true` in RSpec 4. It makes the `description` 15 | # and `failure_message` of custom matchers include text for helper methods 16 | # defined using `chain`, e.g.: 17 | # be_bigger_than(2).and_smaller_than(4).description 18 | # # => "be bigger than 2 and smaller than 4" 19 | # ...rather than: 20 | # # => "be bigger than 2" 21 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 22 | end 23 | 24 | # rspec-mocks config goes here. You can use an alternate test double 25 | # library (such as bogus or mocha) by changing the `mock_with` option here. 26 | config.mock_with :rspec do |mocks| 27 | # Prevents you from mocking or stubbing a method that does not exist on 28 | # a real object. This is generally recommended, and will default to 29 | # `true` in RSpec 4. 30 | mocks.verify_partial_doubles = true 31 | end 32 | 33 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 34 | # have no way to turn it off -- the option exists only for backwards 35 | # compatibility in RSpec 3). It causes shared context metadata to be 36 | # inherited by the metadata hash of host groups and examples, rather than 37 | # triggering implicit auto-inclusion in groups with matching metadata. 38 | config.shared_context_metadata_behavior = :apply_to_host_groups 39 | 40 | # The settings below are suggested to provide a good initial experience 41 | # with RSpec, but feel free to customize to your heart's content. 42 | # This allows you to limit a spec run to individual examples or groups 43 | # you care about by tagging them with `:focus` metadata. When nothing 44 | # is tagged with `:focus`, all examples get run. RSpec also provides 45 | # aliases for `it`, `describe`, and `context` that include `:focus` 46 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 47 | config.filter_run_when_matching :focus 48 | 49 | # Allows RSpec to persist some state between runs in order to support 50 | # the `--only-failures` and `--next-failure` CLI options. We recommend 51 | # you configure your source control system to ignore this file. 52 | config.example_status_persistence_file_path = "spec/examples.txt" 53 | 54 | # Limits the available syntax to the non-monkey patched syntax that is 55 | # recommended. For more details, see: 56 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 57 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 58 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 59 | config.disable_monkey_patching! 60 | 61 | # This setting enables warnings. It's recommended, but in some cases may 62 | # be too noisy due to issues in dependencies. 63 | config.warnings = true 64 | 65 | # Many RSpec users commonly either run the entire suite or an individual 66 | # file, and it's useful to allow more verbose output when running an 67 | # individual spec file. 68 | if config.files_to_run.one? 69 | # Use the documentation formatter for detailed output, 70 | # unless a formatter has already been configured 71 | # (e.g. via a command-line flag). 72 | config.default_formatter = "doc" 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | /src/data.json 24 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # ruby-detective 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ruby-detective", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "core-js": "^3.4.3", 12 | "element-ui": "^2.4.5", 13 | "fuse.js": "^3.4.6", 14 | "vis-network": "^6.4.6", 15 | "vue": "^2.6.10", 16 | "lodash": "4.17.19" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "^4.1.0", 20 | "@vue/cli-plugin-eslint": "^4.1.0", 21 | "@vue/cli-service": "^4.1.0", 22 | "babel-eslint": "^10.0.3", 23 | "eslint": "^5.16.0", 24 | "eslint-plugin-vue": "^5.0.0", 25 | "html-loader": "^0.5.5", 26 | "html-webpack-plugin": "^4.0.0-beta.11", 27 | "inline-source-webpack-plugin": "^1.4.1", 28 | "vue-cli-plugin-element": "^1.0.1", 29 | "vue-svg-loader": "^0.15.0", 30 | "vue-template-compiler": "^2.6.10" 31 | }, 32 | "eslintConfig": { 33 | "root": true, 34 | "env": { 35 | "node": true 36 | }, 37 | "extends": [ 38 | "plugin:vue/essential", 39 | "eslint:recommended" 40 | ], 41 | "rules": {}, 42 | "parserOptions": { 43 | "parser": "babel-eslint" 44 | } 45 | }, 46 | "browserslist": [ 47 | "> 1%", 48 | "last 2 versions" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /ui/public/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Ruby Detective 8 | 9 | 10 | 13 | 14 | 15 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 155 | 156 | 256 | -------------------------------------------------------------------------------- /ui/src/assets/graph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | graph 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ui/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Logo 5 | Created with Sketch. 6 | 7 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /ui/src/components/ClassCard.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 49 | 50 | 102 | -------------------------------------------------------------------------------- /ui/src/components/DependencyGraph.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 156 | 157 | 176 | -------------------------------------------------------------------------------- /ui/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import './plugins/element.js' 4 | 5 | Vue.config.productionTip = false 6 | 7 | // If in production fetches the data from the HTML, otherwise (development mode) 8 | // it gets it through the file src/data.json. 9 | if (process.env.NODE_ENV == 'production') { 10 | const classesDataString = document.getElementById("classes-data").innerHTML 11 | window.CLASSES_DATA = JSON.parse(classesDataString) 12 | } else { 13 | window.CLASSES_DATA = require('./data.json') 14 | } 15 | 16 | new Vue({ 17 | render: h => h(App), 18 | }).$mount('#app') 19 | -------------------------------------------------------------------------------- /ui/src/plugins/element.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Element from 'element-ui' 3 | import 'element-ui/lib/theme-chalk/index.css' 4 | import locale from 'element-ui/lib/locale/lang/en' 5 | 6 | Vue.use(Element, { locale }) 7 | -------------------------------------------------------------------------------- /ui/vue.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const InlineSourceWebpackPlugin = require('inline-source-webpack-plugin'); 3 | 4 | module.exports = { 5 | chainWebpack: (config) => { 6 | const svgRule = config.module.rule('svg'); 7 | 8 | svgRule.uses.clear(); 9 | 10 | svgRule 11 | .use('babel-loader') 12 | .loader('babel-loader') 13 | .end() 14 | .use('vue-svg-loader') 15 | .loader('vue-svg-loader'); 16 | }, 17 | configureWebpack: { 18 | plugins: [ 19 | new HtmlWebpackPlugin({ 20 | title: 'Ruby Detective', 21 | template: 'html-loader!public/index.html.erb', 22 | filename: '../../views/template.html.erb' 23 | }), 24 | new InlineSourceWebpackPlugin({ 25 | compress: true, 26 | rootpath: './src', 27 | noAssetMatch: 'warn' 28 | }) 29 | ] 30 | } 31 | } 32 | --------------------------------------------------------------------------------