├── VERSION ├── .gitignore ├── Gemfile ├── example.png ├── lib └── code_explorer │ ├── version.rb │ ├── numbered_lines.rb │ ├── dot.rb │ ├── const_binding.rb │ ├── call_graph.rb │ └── consts.rb ├── bin ├── call-graph ├── required-files ├── class-dependencies └── code-explorer ├── CHANGELOG.md ├── code-explorer.gemspec └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.3.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /vendor/ 3 | Gemfile.lock 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mvidner/code-explorer/HEAD/example.png -------------------------------------------------------------------------------- /lib/code_explorer/version.rb: -------------------------------------------------------------------------------- 1 | module CodeExplorer 2 | VERSION = File.read(File.dirname(__FILE__) + "/../../VERSION").strip 3 | end 4 | -------------------------------------------------------------------------------- /bin/call-graph: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # method dependency graph 4 | 5 | $: << File.expand_path("../lib", __FILE__) 6 | 7 | require "code_explorer/call_graph" 8 | 9 | filename_rb = ARGV[0] 10 | dot = call_graph(filename_rb) 11 | filename_dot = filename_rb.sub(/\.rb$/, "") + ".dot" 12 | File.write(filename_dot, dot) 13 | -------------------------------------------------------------------------------- /bin/required-files: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # required-files ~/mygem/lib mygem 3 | # lists all files from mygem loaded when we "require mygem", 4 | # in the correct order 5 | INCLUDE=$1 6 | REQUIRE=$2 7 | strace -eopen ruby -I $INCLUDE -r $REQUIRE -e 1 |& \ 8 | grep -v ENOENT | \ 9 | grep $INCLUDE | \ 10 | uniq | \ 11 | sed -e 's/[^"]*"\([^"]*\)".*/\1/' 12 | -------------------------------------------------------------------------------- /lib/code_explorer/numbered_lines.rb: -------------------------------------------------------------------------------- 1 | require "coderay" 2 | 3 | # Convert plain text to HTML where lines are hyperlinkable. 4 | # Emulate RFC 5147 fragment identifier: #line=42 5 | def numbered_lines(text) 6 | # but CodeRay wants to remove the equal sign; 7 | tag = "lI" + "-Ne" # avoid the literal tag if we process our own source 8 | html = CodeRay.scan(text, :ruby).page(line_number_anchors: tag) 9 | html.gsub(tag, "line=") 10 | end 11 | -------------------------------------------------------------------------------- /bin/class-dependencies: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # class dependency graph 4 | 5 | require "parser/current" 6 | require "pp" 7 | 8 | require "code_explorer/consts" 9 | require "code_explorer/dot" 10 | 11 | def main 12 | asts = ARGV.map {|fn| ast_for_filename(fn)} 13 | cs = CodeExplorer::Consts.new 14 | cs.report_modules(asts) 15 | graph = cs.superclasses 16 | puts dot_from_hash(graph) 17 | end 18 | 19 | def ast_for_filename(fn) 20 | ruby = File.read(fn) 21 | Parser::CurrentRuby.parse(ruby) 22 | end 23 | 24 | main 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## 0.2.6 (2017-08-16) 3 | 4 | - Fixed running the web server from an installed gem. 5 | - Using [CodeRay][] for syntag highlighting. 6 | 7 | [CodeRay]: http://coderay.rubychan.de/ 8 | 9 | ## 0.2.5 (2016-08-03) 10 | 11 | - Respect PATH when running the Ruby programs (by frickler01). 12 | - Look also in `src/` for Class Inheritance, not only `lib/`. 13 | - Offer graphs also as PNG, not only as SVG. 14 | 15 | ## 0.2.0 (2016-06-21) 16 | 17 | - Added Class Inheritance to the code-explorer page. 18 | - Updated README. 19 | 20 | ## 0.1.0 (2016-06-20) 21 | 22 | - Initial release. 23 | -------------------------------------------------------------------------------- /lib/code_explorer/dot.rb: -------------------------------------------------------------------------------- 1 | 2 | # @param graph [Hash{String => Array}] vertex -> reachable vertices 3 | # @param hrefs [Hash{String => String}] vertex -> href 4 | def dot_from_hash(graph, hrefs = {}) 5 | dot = "" 6 | dot << "digraph g {\n" 7 | dot << "rankdir=LR;\n" 8 | graph.keys.sort.each do |vertex| 9 | href = hrefs[vertex] 10 | href = "href=\"#{href}\" " if href 11 | 12 | dot << "\"#{vertex}\"[#{href}];\n" 13 | destinations = graph[vertex].sort 14 | destinations.each do |d| 15 | dot << "\"#{vertex}\" -> \"#{d}\";\n" 16 | end 17 | end 18 | dot << "}\n" 19 | dot 20 | end 21 | -------------------------------------------------------------------------------- /code-explorer.gemspec: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require File.expand_path("../lib/code_explorer/version", __FILE__) 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "code-explorer" 7 | s.version = CodeExplorer::VERSION 8 | s.summary = "Explore Ruby code" 9 | s.description = "Find your way around source code written in Ruby" 10 | 11 | s.author = "Martin Vidner" 12 | s.email = "martin@vidner.net" 13 | s.homepage = "https://github.com/mvidner/code-explorer" 14 | s.license = "MIT" 15 | 16 | s.files = [ 17 | "CHANGELOG.md", 18 | "README.md", 19 | "VERSION", 20 | 21 | "bin/call-graph", 22 | "bin/class-dependencies", 23 | "bin/code-explorer", 24 | "bin/required-files", 25 | 26 | "lib/code_explorer/call_graph.rb", 27 | "lib/code_explorer/const_binding.rb", 28 | "lib/code_explorer/consts.rb", 29 | "lib/code_explorer/dot.rb", 30 | "lib/code_explorer/numbered_lines.rb", 31 | "lib/code_explorer/version.rb" 32 | ] 33 | 34 | s.executables = s.files.grep(/^bin\//) { |f| File.basename(f) } 35 | 36 | s.add_dependency "parser", "~> 3" 37 | s.add_dependency "sinatra", "~> 3" 38 | s.add_dependency "webrick", "~> 1" 39 | s.add_dependency "cheetah", "~> 0" # for calling dot (graphviz.rpm) 40 | s.add_dependency "coderay", "~> 1" # syntax highlighting 41 | end 42 | -------------------------------------------------------------------------------- /lib/code_explorer/const_binding.rb: -------------------------------------------------------------------------------- 1 | module CodeExplorer 2 | # tracks what constants are resolvable 3 | class ConstBinding 4 | def initialize(fqname, parent = nil) 5 | @fqname = fqname 6 | @parent = parent 7 | @known = {} 8 | end 9 | 10 | # @return [ConstBinding] the new scope 11 | def open_namespace(fqname) 12 | ns = @known[fqname] 13 | if ns.is_a? ConstBinding 14 | # puts "(reopening #{fqname})" 15 | else 16 | ns = self.class.new(fqname, self) 17 | @known[fqname] = ns 18 | end 19 | ns 20 | end 21 | 22 | # @return [ConstBinding] the parent scope 23 | def close_namespace 24 | @parent 25 | end 26 | 27 | def declare_const(fqname) 28 | if @known[fqname] 29 | # puts "warning: #{fqname} already declared" 30 | end 31 | @known[fqname] = :const 32 | end 33 | 34 | def resolve_declared_const(name) 35 | if @fqname.empty? 36 | name 37 | else 38 | "#{@fqname}::#{name}" 39 | end 40 | end 41 | 42 | def resolve_used_const(name) 43 | # puts "resolving #{name} in #{@fqname}, known #{@known.inspect}" 44 | candidate = resolve_declared_const(name) 45 | if @known.include?(candidate) 46 | candidate 47 | elsif @parent 48 | @parent.resolve_used_const(name) 49 | else 50 | name 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Explorer 2 | 3 | ## Tools 4 | 5 | ### code-explorer 6 | 7 | Starts a local web server which lets you apply the other tools to all `*.rb` 8 | files in a directory subtree. 9 | 10 | ### call-graph 11 | 12 | This makes a call graph among methods of a single Ruby file. 13 | 14 | I made it to help me orient myself in unfamiliar legacy code and to help 15 | identify cohesive parts that could be split out. 16 | 17 | ### class-dependencies 18 | 19 | Identifies fully qualified class names and makes an inheritance graph 20 | 21 | ## Requirements 22 | 23 | - [parser gem](https://github.com/whitequark/parser) (parses Ruby) 24 | - [Graphviz](http://www.graphviz.org/) (graph visualizer) 25 | - [Sinatra](http://www.sinatrarb.com/) (a small web framework) 26 | - [Cheetah](https://github.com/openSUSE/cheetah) (runs commands) 27 | - [CodeRay](http://coderay.rubychan.de/) (syntax highlighting) 28 | 29 | ## License 30 | 31 | MIT 32 | 33 | ## Running from Source 34 | 35 | ```sh 36 | bundle install --path vendor/bundle 37 | bundle exec code-explorer 38 | ``` 39 | 40 | ## Example 41 | 42 | [One file in YaST][p-rb] has around 2700 lines and 73 methods. The call graph 43 | below was made with 44 | ```console 45 | $ bin/call-graph ../yast/packager/src/modules/Packages.rb 46 | $ dot -Tpng -oPackages.png ../yast/packager/src/modules/Packages.dot 47 | ``` 48 | 49 | If the resulting size is too big, use ImageMagick: 50 | ```console 51 | $ convert Packages.png -resize 1200 Packages-small.png 52 | ``` 53 | 54 | [p-rb]: https://github.com/yast/yast-packager/blob/a0b38c046e6e4086a986047d0d7cd5d155af5024/src/modules/Packages.rb 55 | 56 | ![Packages.png, an example output](example.png) 57 | -------------------------------------------------------------------------------- /lib/code_explorer/call_graph.rb: -------------------------------------------------------------------------------- 1 | 2 | require "parser/current" 3 | require "pp" 4 | 5 | require_relative "dot" 6 | 7 | # filename_rb [String] file with a ruby program 8 | # @return a dot graph string 9 | def call_graph(filename_rb) 10 | ruby = File.read(filename_rb) 11 | ast = Parser::CurrentRuby.parse(ruby, filename_rb) 12 | defs = defs_from_ast(ast) 13 | def_names = defs.map {|d| def_name(d) } 14 | defs_to_hrefs = defs.map {|d| [def_name(d), "/files/" + def_location(d)] }.to_h 15 | 16 | defs_to_calls = {} 17 | defs.each do |d| 18 | calls = calls_from_def(d) 19 | call_names = calls.map {|c| send_name(c)} 20 | call_names = call_names.find_all{ |cn| def_names.include?(cn) } 21 | defs_to_calls[def_name(d)] = call_names 22 | end 23 | 24 | dot_from_hash(defs_to_calls, defs_to_hrefs) 25 | end 26 | 27 | def def_name(node) 28 | case node.type 29 | when :def 30 | name, _args, _body = *node 31 | when :defs 32 | _obj, name, _args, _body = *node 33 | else 34 | raise 35 | end 36 | name 37 | end 38 | 39 | def def_location(node) 40 | range = node.loc.expression 41 | file = range.source_buffer.name 42 | line = range.line 43 | "#{file}#line=#{line}" 44 | end 45 | 46 | def send_name(node) 47 | _receiver, name, *_args = *node 48 | name 49 | end 50 | 51 | class Defs < Parser::AST::Processor 52 | def initialize 53 | @defs = [] 54 | @sends = [] 55 | end 56 | 57 | def defs_from_ast(ast) 58 | @defs = [] 59 | process(ast) 60 | @defs 61 | end 62 | 63 | def sends_from_ast(ast) 64 | @sends = [] 65 | process(ast) 66 | @sends 67 | end 68 | 69 | def on_def(node) 70 | @defs << node 71 | super 72 | end 73 | 74 | def on_defs(node) 75 | @defs << node 76 | super 77 | end 78 | 79 | def on_send(node) 80 | @sends << node 81 | super 82 | end 83 | end 84 | 85 | def defs_from_ast(ast) 86 | Defs.new.defs_from_ast(ast) 87 | end 88 | 89 | def calls_from_def(ast) 90 | Defs.new.sends_from_ast(ast) 91 | end 92 | -------------------------------------------------------------------------------- /bin/code-explorer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "sinatra" 4 | require "cheetah" 5 | 6 | require "code_explorer/call_graph" 7 | require "code_explorer/consts" 8 | require "code_explorer/numbered_lines" 9 | 10 | def path_link(prefix, path, text) 11 | "#{text}" 12 | end 13 | 14 | configure do 15 | mime_type :png, "image/png" 16 | mime_type :svg, "image/svg+xml" 17 | puts "Browse:" 18 | puts " xdg-open http://localhost:#{settings.port}/" 19 | end 20 | 21 | get "/" do 22 | page = "" 23 | 24 | page << \ 25 | path_link("", "class-inheritance", 26 | "Class inheritance in {lib,src}/ svg") + " " + 27 | path_link("", "class-inheritance?format=png", 28 | "png") 29 | page << "
\n" 30 | 31 | files = Dir.glob("**/*.rb").sort 32 | page << files.map do |f| 33 | path_link("/files", f, f) + " " + 34 | path_link("/call-graph", f, "call graph svg") + " "+ 35 | path_link("/call-graph", f + "?format=png", "png") 36 | end.join("
\n") 37 | page 38 | end 39 | 40 | def ast_for_filename(fn) 41 | ruby = File.read(fn) 42 | Parser::CurrentRuby.parse(ruby) 43 | end 44 | 45 | def serve_dot(dot, type = :svg) 46 | graph = Cheetah.run(["dot", "-T#{type}"], stdin: dot, stdout: :capture) 47 | 48 | content_type type.to_sym 49 | graph 50 | end 51 | 52 | get "/class-inheritance" do 53 | format = params["format"] 54 | format = "svg" unless ["svg", "png"].include?(format) 55 | 56 | files = Dir.glob("{lib,src}/**/*.rb").sort 57 | asts = files.map {|fn| ast_for_filename(fn)} 58 | cs = CodeExplorer::Consts.new 59 | cs.report_modules(asts) 60 | graph = cs.superclasses 61 | 62 | serve_dot(dot_from_hash(graph), format) 63 | end 64 | 65 | get "/files/*" do |path| 66 | numbered_lines(File.read(path)) 67 | # send_file path, :type => :text 68 | end 69 | 70 | get "/call-graph/*" do |path| 71 | format = params["format"] 72 | format = "svg" unless ["svg", "png"].include?(format) 73 | 74 | dot = call_graph(path) 75 | 76 | serve_dot(dot, format) 77 | end 78 | 79 | Sinatra::Application.run! 80 | -------------------------------------------------------------------------------- /lib/code_explorer/consts.rb: -------------------------------------------------------------------------------- 1 | require_relative "const_binding" 2 | 3 | module CodeExplorer 4 | class Consts < Parser::AST::Processor 5 | include AST::Sexp 6 | 7 | # @return [ConstBinding] 8 | attr_reader :cb 9 | # @return [Hash{String => Array}] 10 | attr_reader :superclasses 11 | 12 | def initialize 13 | @cb = ConstBinding.new("") 14 | @superclasses = {} 15 | end 16 | 17 | def report_modules(asts) 18 | Array(asts).each do |ast| 19 | process(ast) 20 | end 21 | end 22 | 23 | def const_name_from_sexp(node) 24 | case node.type 25 | when :self 26 | "self" 27 | when :cbase 28 | "" 29 | when :const, :casgn 30 | parent, name, _maybe_value = *node 31 | if parent 32 | const_name_from_sexp(parent) + "::#{name}" 33 | else 34 | name.to_s 35 | end 36 | else 37 | raise "Unexpected #{node.type}" 38 | end 39 | end 40 | 41 | def new_scope(name, &block) 42 | @cb = cb.open_namespace(name) 43 | block.call 44 | @cb = cb.close_namespace 45 | end 46 | 47 | def on_module(node) 48 | name, _body = *node 49 | name = cb.resolve_declared_const(const_name_from_sexp(name)) 50 | # puts "module #{name}" 51 | 52 | new_scope(name) do 53 | super 54 | end 55 | end 56 | 57 | def on_class(node) 58 | name, parent, _body = *node 59 | parent ||= s(:const, s(:cbase), :Object) 60 | 61 | name = cb.resolve_declared_const(const_name_from_sexp(name)) 62 | parent = cb.resolve_used_const(const_name_from_sexp(parent)) 63 | # puts "class #{name} < #{parent}" 64 | 65 | @superclasses[name] = [parent] 66 | 67 | new_scope(name) do 68 | super 69 | end 70 | end 71 | 72 | def on_sclass(node) 73 | parent, _body = *node 74 | 75 | parent = const_name_from_sexp(parent) 76 | name = "<< #{parent}" # cheating 77 | # puts "class #{name}" 78 | 79 | new_scope(name) do 80 | super 81 | end 82 | end 83 | 84 | def on_casgn(node) 85 | name = cb.resolve_declared_const(const_name_from_sexp(node)) 86 | cb.declare_const(name) 87 | # puts "casgn #{name}" 88 | end 89 | 90 | def on_const(node) 91 | name = const_name_from_sexp(node) 92 | fqname = cb.resolve_used_const(name) 93 | # puts "CONST #{fqname}" 94 | end 95 | end 96 | end 97 | --------------------------------------------------------------------------------