Permalink
Browse files

Use at_exit to get rid of test framework behaviour

  • Loading branch information...
1 parent 86473d9 commit ca97e0eb1ce995afdecdc4f9623a74d1110b9892 @dcadenas committed Mar 9, 2012
Showing with 159 additions and 200 deletions.
  1. +1 −0 .gitignore
  2. +25 −30 README.md
  3. +5 −67 bin/rubydeps
  4. +16 −16 ext/call_site_analyzer/call_site_analyzer.c
  5. BIN lib/call_site_analyzer.bundle
  6. +68 −36 lib/rubydeps.rb
  7. +44 −51 spec/rubydeps_spec.rb
View
1 .gitignore
@@ -21,3 +21,4 @@ tmp
*.svg
## PROJECT::SPECIFIC
+rubydeps.dump
View
55 README.md
@@ -2,7 +2,9 @@
rubydeps
========
-A tool to create class dependency graphs from test suites. I think this is more useful than static analysis of the code base because of the high dynamic aspects of the language
+A tool to create class dependency graphs from test suites.
+
+I think this is more useful than static analysis of the code base because of the high dynamic aspects of the language
Sample output
@@ -11,38 +13,34 @@ Sample output
This is the result of running rubydeps on the [Rake](https://github.com/jimweirich/rake) tests:
```bash
-rubydeps testunit --class_name_filter='^Rake'
+rubydeps --class_name_filter='^Rake'
```
![Rake dependencies](https://github.com/dcadenas/rubydeps/raw/master/rake-deps.png)
+Usage
+---------------
-Command line usage
-------------------
-
+Rubydeps will run your test suite to record the call graph of your project and use it to create a [Graphviz](http://www.graphviz.org) dot graph.
-Rubydeps will run your test suite to record the call graph of your project and use it to create a dot graph.
+1. Add Rubydeps to your `Gemfile` and `bundle install`:
-First of all, be sure to step into the root directory of your project, rubydeps searches for ./spec or ./test dirs from there.
-For example, if we want to graph the Rails activemodel dependency graph we'd cd to rails/activemodel and from there we'd write:
+ gem 'rubydeps', :group => :test
-```bash
-rubydeps testunit #to run Test::Unit tests
-```
+2. Launch Rubydeps by inserting this line in your `test/test_helper.rb` (*or `spec_helper.rb`, cucumber `env.rb`, or whatever
+ your preferred test framework uses*):
-or
+ Rubydeps.start
-```bash
-rubydeps rspec #to run RSpec tests
-```
+3. Run your tests, a file named rubydeps.dump will be created in the project root.
-or
+4. The next step is reading the dump file to generate the graphviz dot graph `rubydeps.dot` with any filter you specify.
```bash
-rubydeps rspec2 #to run RSpec 2 tests
+rubydeps --path_filter='app/models'
```
-This will output a rubydeps.dot. You can convert the dot file to any image format you like using the dot utility that comes with the graphviz installation e.g.:
+5. Now you are in [Graphviz](http://www.graphviz.org) realm. You can convert the dot file to any image format with your prefered orientations and layouts with the dot utility that comes with the graphviz installation e.g.:
```bash
dot -Tsvg rubydeps.dot > rubydeps.svg
@@ -52,21 +50,18 @@ Notice that sometimes you may have missing dependencies as we graph the dependen
### Command line options
-The test commands, as seen above, are `testunit`, `rspec` and `rspec2`.
-
-The `--path_filter` option specifies a regexp that matches the path of the files you are interested in analyzing. For example you could have filters like `'project_name/app|project_name/lib'` to analyze only code that is located in the `app` and `lib` dirs or as an alternative you could just exclude some directory you are not interested using a negative regexp like `'project_name(?!.*test)'`
+* The `--path_filter` option specifies a regexp that matches the path of the files you are interested in analyzing. For example you could have filters like `'project_name/app|project_name/lib'` to analyze only code that is located in the `app` and `lib` dirs or as an alternative you could just exclude some directory you are not interested using a negative regexp like `'project_name(?!.*test)'`
-The `--class_name_filter` option is similar to the `--path_filter` options except that the regexp is matched against the class names (i.e. graph node names).
+* The `--class_name_filter` option is similar to the `--path_filter` options except that the regexp is matched against the class names (i.e. graph node names).
-The `--to_file` option dumps the dependency graph data to a file so you can do filtering later, it does not create a dot file.
+* The `--from_file` option is used to specify the dump file generated after the test (or block) run so you can try different filters without needing to rerun the tests. e.g.:
-The `--from_file` option is only available when you don't specify a test command. Its argument is the file dumped through `--to_file` in a previous run. When you use this option the tests (or block) are not ran, the dependency graph is loaded directly from the file. This is useful to avoid rerunning code that didn't change just for the purpose of filtering with different combinations e.g.:
+ ```bash
+ rubydeps --from_file='rubydeps.dump' --path_filter='app/models'
+ rubydeps --from_file='rubydeps.dump' --path_filter='app/models|app/controllers'
+ ```
-```bash
-rubydeps rspec2 --to_file='dependencies.dump'
-rubydeps --from_file='dependencies.dump' --path_filter='app/models'
-rubydeps --from_file='dependencies.dump' --path_filter='app/models|app/controllers'
-```
+ If you didn't rename the file you can skip this option as it will use the default `rubydeps.dump`
Library usage
-------------
@@ -76,7 +71,7 @@ Just require rubydeps and pass a block to analyze to the `analyze` method.
```ruby
require 'rubydeps'
-Rubydeps.analyze(:path_filter => path_filter_regexp, :class_name_filter => class_name_filter_regexp, :to_file => "dependencies.dump") do
+Rubydeps.analyze(:path_filter => path_filter_regexp, :class_name_filter => class_name_filter_regexp, :to_file => "rubydeps.dump") do
# your code goes here
end
```
View
72 bin/rubydeps
@@ -4,78 +4,16 @@ require 'thor'
module Rubydeps
class Runner < Thor
- desc "testunit", "Create the dependency graph after runnning the testunit tests"
- method_option :to_file, :type => :string
+ desc "", "Loads dependencies saved by a previous test run"
+ method_option :from_file, :type => :string, :default => Rubydeps.default_dump_file_name, :required => true
method_option :path_filter, :type => :string, :default => `pwd`.chomp, :required => true
method_option :class_name_filter, :type => :string, :default => '', :required => true
- def testunit
- require 'minitest/unit'
- require 'test/unit/assertions'
- require 'test/unit/testcase'
-
- $LOAD_PATH.unshift("#{`pwd`.chomp}/lib")
-
- #dirty hack so that minitest doesn't install the at_exit hook and we can run the tests in this same process
- ::MiniTest::Unit.class_variable_set("@@installed_at_exit", true)
-
- (Dir["./test/**/*_test.rb"] + Dir["./test/**/test_*.rb"]).each { |f| load f unless f =~ /^-/ }
-
- create_dependencies_dot_for(options) do
- ::MiniTest::Unit.new.run([])
- end
- end
-
- desc "rspec", "Create the dependency graph after runnning the rspec tests"
- method_option :to_file, :type => :string
- #TODO: this breaks when using underscores, investigate
- method_option :path_filter, :type => :string, :default => `pwd`.chomp, :required => true
- method_option :class_name_filter, :type => :string, :default => '', :required => true
- def rspec
- require 'spec'
-
- p = ::Spec::Runner::OptionParser.new($stderr, $stdout)
- p.parse(Dir["./spec/**/*_spec.rb"])
- op = p.options
-
- create_dependencies_dot_for(options) do
- ::Spec::Runner::CommandLine.run(op)
- end
- end
-
- desc "", "Loads dependencies saved by a --to_file option in a previous run. Doesn't run tests"
- method_option :from_file, :type => :string, :required => true
- method_option :path_filter, :type => :string, :default => `pwd`.chomp, :required => true
- method_option :class_name_filter, :type => :string, :default => '', :required => true
- default_task :load_deps
- def load_deps
- create_dependencies_dot_for(options)
- end
-
- desc "rspec2", "Create the dependency graph after runnning the rspec 2 tests"
- method_option :to_file, :type => :string
- method_option :path_filter, :type => :string, :default => `pwd`.chomp, :required => true
- method_option :class_name_filter, :type => :string, :default => '', :required => true
- def rspec2
- require 'rspec'
-
- op = ::RSpec::Core::ConfigurationOptions.new(Dir["./spec/**/*_spec.rb"])
- op.parse_options
-
- create_dependencies_dot_for(options) do
- ::RSpec::Core::CommandLine.new(op).run($stderr, $stdout)
- end
- end
-
- private
-
- def create_dependencies_dot_for(options)
+ default_task :create_dot
+ def create_dot
ARGV.clear
Rubydeps.analyze(:path_filter => Regexp.new(options[:path_filter]),
:class_name_filter => Regexp.new(options[:class_name_filter]),
- :to_file => options[:to_file],
- :from_file => options[:from_file]) do
- yield if block_given?
- end
+ :from_file => options[:from_file])
end
end
end
View
32 ext/call_site_analyzer/call_site_analyzer.c
@@ -116,25 +116,24 @@ static int uniq_calling_arrays(VALUE called_class, VALUE calling_class_array, VA
return ST_CONTINUE;
}
-static VALUE analyze(VALUE self){
- if(rb_block_given_p()) {
- dependency_array = rb_ary_new();
- rb_global_variable(&dependency_array);
+static VALUE start(VALUE self){
+ dependency_array = rb_ary_new();
+ rb_global_variable(&dependency_array);
- VALUE dependency_hash = rb_hash_new();
- rb_ary_push(dependency_array, dependency_hash);
+ VALUE dependency_hash = rb_hash_new();
+ rb_ary_push(dependency_array, dependency_hash);
- VALUE class_location_hash = rb_hash_new();
- rb_ary_push(dependency_array, class_location_hash);
+ VALUE class_location_hash = rb_hash_new();
+ rb_ary_push(dependency_array, class_location_hash);
- rb_add_event_hook(event_hook, RUBY_EVENT_CALL, Qnil);
- rb_yield(Qnil);
- rb_remove_event_hook(event_hook);
+ rb_add_event_hook(event_hook, RUBY_EVENT_CALL, Qnil);
- rb_hash_foreach(rb_ary_entry(dependency_array, 0), uniq_calling_arrays, 0);
- } else {
- rb_raise(rb_eArgError, "a block is required");
- }
+ return Qnil;
+}
+
+static VALUE result(VALUE self){
+ rb_remove_event_hook(event_hook);
+ rb_hash_foreach(rb_ary_entry(dependency_array, 0), uniq_calling_arrays, 0);
return dependency_array;
}
@@ -143,5 +142,6 @@ static VALUE rb_cCallSiteAnalyzer;
void Init_call_site_analyzer(){
rb_cCallSiteAnalyzer = rb_define_module("CallSiteAnalyzer");
- rb_define_singleton_method(rb_cCallSiteAnalyzer, "analyze", analyze, 0);
+ rb_define_singleton_method(rb_cCallSiteAnalyzer, "start", start, 0);
+ rb_define_singleton_method(rb_cCallSiteAnalyzer, "result", result, 0);
}
View
BIN lib/call_site_analyzer.bundle
Binary file not shown.
View
104 lib/rubydeps.rb
@@ -1,44 +1,61 @@
require 'graphviz'
-require 'set'
require 'call_site_analyzer'
module Rubydeps
+ def self.start(install_at_exit = true)
+ CallSiteAnalyzer.start
+ at_exit { self.do_at_exit } if install_at_exit
+ end
+
def self.analyze(options = {}, &block_to_analyze)
dependency_hash, class_location_hash = dependency_hash_for(options, &block_to_analyze)
+ create_output_file(dependency_hash, class_location_hash, options)
+ end
+
+ def self.dependency_hash_for(options = {}, &block_to_analyze)
+ dependency_hash, class_location_hash = calculate_or_load_dependencies(options, &block_to_analyze)
+
+ apply_filters(dependency_hash, class_location_hash, options)
+ [normalize_class_names(dependency_hash), class_location_hash]
+ end
+
+ def self.create_output_file(dependency_hash, class_location_hash, options)
if options[:to_file]
File.open(options[:to_file], 'wb') do |f|
f.write Marshal.dump([dependency_hash, class_location_hash])
end
else
- if dependency_hash
- g = GraphViz::new( "G", :use => 'dot', :mode => 'major', :rankdir => 'LR', :concentrate => 'true', :fontname => 'Arial')
- dependency_hash.each do |k,vs|
- if !k.empty? && !vs.empty?
- n1 = g.add_nodes(k.to_s)
- if vs.respond_to?(:each)
- vs.each do |v|
- unless v.empty?
- n2 = g.add_nodes(v.to_s)
- g.add_edges(n2, n1)
- end
- end
- end
- end
- end
+ create_dot_file(dependency_hash)
+ end
+ end
- g.output( :dot => "rubydeps.dot" )
- end
+ def self.do_at_exit
+ # Store the exit status of the test run since it goes away after calling the at_exit proc...
+ exit_status = if $!
+ $!.is_a?(SystemExit) ? $!.status : 1
end
+
+ dependency_hash, class_location_hash = CallSiteAnalyzer.result
+ create_output_file(dependency_hash, class_location_hash, :to_file => Rubydeps.default_dump_name, :class_name_filter => /.*/, :path_filter => /.*/)
+
+ exit exit_status if exit_status
end
- def self.dependency_hash_for(options = {}, &block_to_analyze)
- dependency_hash, class_location_hash = if options[:from_file]
- Marshal.load(File.binread(options[:from_file]))
- else
- CallSiteAnalyzer.analyze(&block_to_analyze)
- end
+ def self.calculate_or_load_dependencies(options, &block_to_analyze)
+ if options[:from_file]
+ Marshal.load(File.binread(options[:from_file]))
+ else
+ begin
+ self.start(false)
+ block_to_analyze.call()
+ ensure
+ return CallSiteAnalyzer.result
+ end
+ end
+ end
+ def self.apply_filters(dependency_hash, class_location_hash, options)
path_filter = options.fetch(:path_filter, /.*/)
class_name_filter = options.fetch(:class_name_filter, /.*/)
classes_to_remove = get_classes_to_remove(dependency_hash, class_location_hash, path_filter, class_name_filter)
@@ -50,13 +67,6 @@ def self.dependency_hash_for(options = {}, &block_to_analyze)
dependency_hash[called_class].member? klass_to_remove
end
- #transitive dependencies, hmmm, not sure is a good idea
- #if classes_calling_class_to_remove && !classes_calling_class_to_remove.empty?
- # classes_called_by_class_to_remove.each do |called_class|
- # dependency_hash[called_class] |= classes_calling_class_to_remove
- # end
- #end
-
dependency_hash.delete(klass_to_remove)
classes_called_by_class_to_remove.each do |called_class|
if dependency_hash[called_class]
@@ -67,10 +77,34 @@ def self.dependency_hash_for(options = {}, &block_to_analyze)
end
end
end
+ end
+ end
+
+ def self.normalize_class_name(klass)
+ good_class_name = klass.gsub(/#<(.+):(.+)>/, 'Instance of \1')
+ good_class_name.gsub!(/\([^\)]*\)/, "")
+ good_class_name.gsub(/0x[\da-fA-F]+/, '(hex number)')
+ end
+ def self.create_dot_file(dependency_hash)
+ return unless dependency_hash
+
+ g = GraphViz::new( "G", :use => 'dot', :mode => 'major', :rankdir => 'LR', :concentrate => 'true', :fontname => 'Arial')
+ dependency_hash.each do |k,vs|
+ if !k.empty? && !vs.empty?
+ n1 = g.add_nodes(k.to_s)
+ if vs.respond_to?(:each)
+ vs.each do |v|
+ unless v.empty?
+ n2 = g.add_nodes(v.to_s)
+ g.add_edges(n2, n1)
+ end
+ end
+ end
+ end
end
- [normalize_class_names(dependency_hash), class_location_hash]
+ g.output( :dot => "rubydeps.dot" )
end
def self.get_classes_to_remove(dependency_hash, class_location_hash, path_filter, class_name_filter)
@@ -84,9 +118,7 @@ def self.normalize_class_names(dependency_hash)
Hash[dependency_hash.map { |k,v| [normalize_class_name(k), v.map{|c| c == k ? nil : normalize_class_name(c)}.compact] }]
end
- def self.normalize_class_name(klass)
- good_class_name = klass.gsub(/#<(.+):(.+)>/, 'Instance of \1')
- good_class_name.gsub!(/\([^\)]*\)/, "")
- good_class_name.gsub(/0x[\da-fA-F]+/, '(hex number)')
+ def self.default_dump_name
+ "rubydeps.dump"
end
end
View
95 spec/rubydeps_spec.rb
@@ -49,6 +49,7 @@ def instance_method
describe "Rubydeps" do
include FileTestHelper
+
it "should show the class level dependencies" do
dependencies, _ = ::Rubydeps.dependency_hash_for do
class IHaveAClassLevelDependency
@@ -112,73 +113,65 @@ def s.attached_method
dependencies["GrandparentModule"].should == ["Grandparent"]
end
- sample_dir_structure = {'path1/class_a.rb' => <<-CLASSA,
- require './path1/class_b'
- require './path2/class_c'
- class A
- def depend_on_b_and_c
- B.new.b
- C.new.c
- end
- end
- CLASSA
- 'path1/class_b.rb' => 'class B; def b; end end',
- 'path2/class_c.rb' => 'class C; def c; end end'}
+ it "should create correct dependencies for 2 instance methods called in a row" do
+ dependencies, _ = ::Rubydeps.dependency_hash_for do
+ Son.new.instance_method_calling_another_instance_method(Parent.new)
+ end
- it "should not filter classes when no filter is specified" do
- with_files(sample_dir_structure) do
- load './path1/class_a.rb'
+ dependencies.should == {"Parent"=>["Son"]}
+ end
- dependencies, _ = ::Rubydeps.dependency_hash_for do
- A.new.depend_on_b_and_c
- end
+ context "with a dumped dependencies file" do
+ sample_dir_structure = {'path1/class_a.rb' => <<-CLASSA,
+ require '#{File.dirname(__FILE__)}/../lib/rubydeps'
- dependencies.should == {"B"=>["A"], "C"=>["A"]}
- end
- end
+ require './path1/class_b'
+ require './path2/class_c'
+ class A
+ def depend_on_b_and_c
+ B.new.b
+ C.new.c
+ end
+ end
- it "should filter classes when a path filter is specified" do
- with_files(sample_dir_structure) do
- load './path1/class_a.rb'
+ Rubydeps.start
+ A.new.depend_on_b_and_c
+ CLASSA
+ 'path1/class_b.rb' => 'class B; def b; end end',
+ 'path2/class_c.rb' => 'class C; def c; end end'}
- dependencies, _ = ::Rubydeps.dependency_hash_for(:path_filter => /path1/) do
- A.new.depend_on_b_and_c
+ it "should be a correct test file" do
+ with_files(sample_dir_structure) do
+ status = system("ruby -I#{File.dirname(__FILE__)}/../lib ./path1/class_a.rb")
+ status.should be_true
end
-
- dependencies.should == {"B"=>["A"]}
end
- end
- it "should filter classes when a class name filter is specified" do
- with_files(sample_dir_structure) do
- load './path1/class_a.rb'
+ it "should not filter classes when no filter is specified" do
+ with_files(sample_dir_structure) do
+ system("ruby -I#{File.dirname(__FILE__)}/../lib ./path1/class_a.rb")
- dependencies, _ = ::Rubydeps.dependency_hash_for(:class_name_filter => /C|A/) do
- A.new.depend_on_b_and_c
+ dependencies, _ = ::Rubydeps.dependency_hash_for(:from_file => 'rubydeps.dump')
+ dependencies.should == {"B"=>["A"], "C"=>["A"]}
end
-
- dependencies.should == {"C"=>["A"]}
end
- end
- it "should be capable of dumping the whole dependency data into a file for later filtering" do
- with_files(sample_dir_structure) do
- load './path1/class_a.rb'
+ it "should filter classes when a path filter is specified" do
+ with_files(sample_dir_structure) do
+ system("ruby -I#{File.dirname(__FILE__)}/../lib ./path1/class_a.rb")
- ::Rubydeps.analyze(:to_file => 'dependencies.file') do
- A.new.depend_on_b_and_c
+ dependencies, _ = ::Rubydeps.dependency_hash_for(:from_file => 'rubydeps.dump', :path_filter => /path1/)
+ dependencies.should == {"B"=>["A"]}
end
-
- dependencies, _ = ::Rubydeps.dependency_hash_for(:from_file => 'dependencies.file', :class_name_filter => /C|A/)
- dependencies.should == {"C"=>["A"]}
end
- end
- it "should create correct dependencies for 2 instance methods called in a row" do
- dependencies, _ = ::Rubydeps.dependency_hash_for do
- Son.new.instance_method_calling_another_instance_method(Parent.new)
- end
+ it "should filter classes when a class name filter is specified" do
+ with_files(sample_dir_structure) do
+ system("ruby -I#{File.dirname(__FILE__)}/../lib ./path1/class_a.rb")
- dependencies.should == {"Parent"=>["Son"]}
+ dependencies, _ = ::Rubydeps.dependency_hash_for(:from_file => 'rubydeps.dump', :class_name_filter => /C|A/)
+ dependencies.should == {"C"=>["A"]}
+ end
+ end
end
end

0 comments on commit ca97e0e

Please sign in to comment.