deps_grapher 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/Gemfile +16 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +327 -0
  6. data/Rakefile +12 -0
  7. data/bin/console +15 -0
  8. data/bin/deps_grapher +96 -0
  9. data/bin/setup +8 -0
  10. data/deps_grapher.gemspec +34 -0
  11. data/lefthook.yml +14 -0
  12. data/lib/deps_grapher/ast_processor.rb +211 -0
  13. data/lib/deps_grapher/ast_processor_policy.rb +45 -0
  14. data/lib/deps_grapher/cache_file.rb +48 -0
  15. data/lib/deps_grapher/cli.rb +29 -0
  16. data/lib/deps_grapher/command/analyzer.rb +89 -0
  17. data/lib/deps_grapher/command/init.rb +49 -0
  18. data/lib/deps_grapher/configuration.rb +99 -0
  19. data/lib/deps_grapher/context.rb +48 -0
  20. data/lib/deps_grapher/cytoscape/cose.rb +36 -0
  21. data/lib/deps_grapher/cytoscape/fcose.rb +36 -0
  22. data/lib/deps_grapher/cytoscape/klay.rb +27 -0
  23. data/lib/deps_grapher/cytoscape/template.erb +61 -0
  24. data/lib/deps_grapher/cytoscape.rb +88 -0
  25. data/lib/deps_grapher/dsl.rb +50 -0
  26. data/lib/deps_grapher/edge.rb +58 -0
  27. data/lib/deps_grapher/errors.rb +8 -0
  28. data/lib/deps_grapher/event.rb +63 -0
  29. data/lib/deps_grapher/graph.rb +71 -0
  30. data/lib/deps_grapher/graphile/generator.rb +42 -0
  31. data/lib/deps_grapher/graphile/graphile.erb +119 -0
  32. data/lib/deps_grapher/graphile/graphile.temp.erb +23 -0
  33. data/lib/deps_grapher/html_writer.rb +16 -0
  34. data/lib/deps_grapher/input.rb +40 -0
  35. data/lib/deps_grapher/layer/registry.rb +32 -0
  36. data/lib/deps_grapher/layer.rb +75 -0
  37. data/lib/deps_grapher/logging.rb +21 -0
  38. data/lib/deps_grapher/matcher.rb +28 -0
  39. data/lib/deps_grapher/node.rb +65 -0
  40. data/lib/deps_grapher/plugin_dsl.rb +12 -0
  41. data/lib/deps_grapher/plugin_loader.rb +29 -0
  42. data/lib/deps_grapher/source.rb +51 -0
  43. data/lib/deps_grapher/source_cache/class_name_extractor.rb +60 -0
  44. data/lib/deps_grapher/source_cache/registry.rb +64 -0
  45. data/lib/deps_grapher/source_cache.rb +49 -0
  46. data/lib/deps_grapher/version.rb +5 -0
  47. data/lib/deps_grapher/vis/box.rb +21 -0
  48. data/lib/deps_grapher/vis/dot.rb +17 -0
  49. data/lib/deps_grapher/vis/template.erb +36 -0
  50. data/lib/deps_grapher/vis.rb +69 -0
  51. data/lib/deps_grapher/visualizer/base.rb +74 -0
  52. data/lib/deps_grapher/visualizer/color/registry.rb +30 -0
  53. data/lib/deps_grapher/visualizer/color.rb +62 -0
  54. data/lib/deps_grapher/visualizer/command_option.rb +22 -0
  55. data/lib/deps_grapher/visualizer/downloader.rb +42 -0
  56. data/lib/deps_grapher/visualizer/js_option.rb +39 -0
  57. data/lib/deps_grapher/visualizer/registry.rb +37 -0
  58. data/lib/deps_grapher/visualizer.rb +11 -0
  59. data/lib/deps_grapher.rb +42 -0
  60. metadata +135 -0
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "parser/current"
4
+ require_relative "source_cache"
5
+ require_relative "node"
6
+ require_relative "edge"
7
+
8
+ module DepsGrapher
9
+ class AstProcessor < Parser::AST::Processor
10
+ include Logging
11
+
12
+ class << self
13
+ def processed
14
+ @processed ||= Set.new
15
+ end
16
+
17
+ def event_processed
18
+ @event_processed ||= Set.new
19
+ end
20
+
21
+ attr_writer :depth
22
+
23
+ def depth
24
+ @depth ||= 0
25
+ end
26
+ end
27
+
28
+ def initialize(file_path, graph, event_processors, advanced_const_resolver, ignore_errors)
29
+ super()
30
+ @file_path = file_path
31
+ @target = File.basename(file_path, ".*").camelize
32
+ @graph = graph
33
+ @event_processors = event_processors
34
+ @advanced_const_resolver = advanced_const_resolver
35
+ @ignore_errors = ignore_errors
36
+ end
37
+
38
+ def call
39
+ return if self.class.processed.include?(@file_path)
40
+
41
+ log do
42
+ source_buffer = Parser::Source::Buffer.new(@file_path)
43
+ parser = Parser::CurrentRuby.new
44
+ process parser.parse(source_buffer.read)
45
+ end
46
+ end
47
+
48
+ def on_module(ast_node)
49
+ const_node = ast_node.children[0]
50
+ name = extract_const_name const_node
51
+
52
+ @namespace_stack ||= []
53
+ @namespace_stack.push name
54
+
55
+ if name == @target
56
+ fully_qualified_class_name = @namespace_stack.join "::"
57
+ @current_node = Node.add fully_qualified_class_name, @file_path
58
+ self.class.processed << @file_path
59
+ end
60
+
61
+ super
62
+
63
+ @namespace_stack.pop
64
+ end
65
+ alias on_class on_module
66
+
67
+ def on_const(ast_node)
68
+ const_name = extract_const_name ast_node
69
+
70
+ process_recursively! const_name, ast_node do
71
+ Event.add name: :const_found, const_name: _1, location: _2
72
+ end
73
+
74
+ super
75
+ end
76
+
77
+ def on_send(ast_node)
78
+ if @current_node
79
+ receiver_node, method_name, = *ast_node
80
+
81
+ const_name = call_advanced_const_resolver ast_node
82
+ const_name ||= extract_const_name receiver_node
83
+
84
+ process_recursively! const_name, receiver_node do
85
+ Event.add name: :method_found, const_name: _1, location: _2, method: method_name
86
+ end
87
+ end
88
+
89
+ super
90
+ end
91
+
92
+ private
93
+
94
+ def call_event_processor(event)
95
+ return if event.blank? || @event_processors.blank?
96
+ return if self.class.event_processed.include?(event.key)
97
+
98
+ self.class.event_processed << event.key
99
+
100
+ @event_processors.each do |matcher, (prop, event_processor)|
101
+ if matcher.is_a?(Regexp)
102
+ next unless matcher.match? event.send(prop)
103
+ else
104
+ next unless matcher == event.send(prop)
105
+ end
106
+
107
+ event_processor.call event
108
+ end
109
+ end
110
+
111
+ def call_advanced_const_resolver(ast_node)
112
+ return nil unless @advanced_const_resolver.respond_to?(:call)
113
+
114
+ @advanced_const_resolver.call ast_node
115
+ end
116
+
117
+ def process_recursively!(const_name, ast_node)
118
+ return unless @current_node
119
+
120
+ begin
121
+ const_name, location = if ast_node
122
+ guess_source_location(const_name, ast_node)
123
+ else
124
+ find_source_location!(const_name)
125
+ end
126
+ rescue SourceLocationNotFound => e
127
+ raise e unless @ignore_errors
128
+ end
129
+
130
+ return unless const_name && location
131
+
132
+ event = yield const_name, location
133
+ call_event_processor event
134
+
135
+ return unless event&.processing?
136
+
137
+ node = Node.add const_name, location
138
+ edge = Edge.add @current_node, node
139
+
140
+ return unless edge
141
+
142
+ @graph.add_edge edge
143
+
144
+ self.class.new(
145
+ node.location,
146
+ @graph,
147
+ @event_processors,
148
+ @advanced_const_resolver,
149
+ @ignore_errors
150
+ ).call
151
+ end
152
+
153
+ def extract_const_name(ast_node)
154
+ return nil if ast_node.nil?
155
+
156
+ return nil unless ast_node.type == :const
157
+
158
+ base, name = *ast_node
159
+ if base.nil?
160
+ name.to_s
161
+ else
162
+ [extract_const_name(base), name].compact.join "::"
163
+ end
164
+ end
165
+
166
+ def guess_namespace(file_path)
167
+ const_name = SourceCache::Registry.fetch(file_path)
168
+ namespace = const_name.split("::")
169
+ parts = namespace.last
170
+ while parts.present?
171
+ return if yield(namespace)
172
+
173
+ parts = namespace.pop
174
+ end
175
+ end
176
+
177
+ def guess_source_location(const_name, ast_node)
178
+ return if const_name.blank?
179
+
180
+ find_source_location! const_name
181
+ rescue SourceLocationNotFound
182
+ guess_namespace(ast_node.location.name.source_buffer.name) do |ns|
183
+ return find_source_location! [*ns, const_name].compact.join("::")
184
+ rescue SourceLocationNotFound
185
+ nil
186
+ end
187
+ end
188
+
189
+ def find_source_location!(const_name)
190
+ return if const_name.blank?
191
+
192
+ location = SourceCache::Registry.fetch const_name
193
+ [const_name, location]
194
+ rescue SourceCacheNotFound
195
+ raise SourceLocationNotFound, "source location not found for #{const_name}"
196
+ end
197
+
198
+ def log
199
+ verbose do
200
+ indent = "*" * self.class.depth
201
+ indent << " " if indent.present?
202
+ "Analyzing #{indent}#{@file_path}"
203
+ end
204
+
205
+ self.class.depth += 1
206
+ yield
207
+ ensure
208
+ self.class.depth -= 1
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DepsGrapher
4
+ class AstProcessorPolicy
5
+ def initialize(context, &block)
6
+ @context = context
7
+ DSL.new(self).instance_eval(&block)
8
+ end
9
+
10
+ def const(matcher, &block)
11
+ event_processor matcher, :const_name, &block
12
+ end
13
+
14
+ def include_const(matcher)
15
+ const matcher, &:processing!
16
+ end
17
+
18
+ def exclude_const(matcher)
19
+ const matcher, &:skip_processing!
20
+ end
21
+
22
+ def location(matcher, &block)
23
+ event_processor matcher, :location, &block
24
+ end
25
+
26
+ def include_location(matcher)
27
+ location matcher, &:processing!
28
+ end
29
+
30
+ def exclude_location(matcher)
31
+ location matcher, &:skip_processing!
32
+ end
33
+
34
+ def event_processor(matcher, prop, &block)
35
+ @context.event_processors[matcher] = [prop, block]
36
+ end
37
+
38
+ def advanced_const_resolver(callable = nil, &block)
39
+ raise ArgumentError, "You must provide a block or callable" unless block || callable
40
+ raise ArgumentError, "The provided object must respond to #call" if callable && !callable.respond_to?(:call)
41
+
42
+ @context.advanced_const_resolver = block || callable
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DepsGrapher
4
+ class CacheFile
5
+ include Logging
6
+
7
+ def initialize(file:, ttl:)
8
+ @file = file
9
+ @ttl = ttl
10
+ end
11
+
12
+ def write(target)
13
+ return if File.exist?(@file)
14
+
15
+ FileUtils.mkdir_p File.dirname(@file)
16
+
17
+ info { "Writing cache to #{@file}" }
18
+
19
+ File.open(@file, "w") do |f|
20
+ Marshal.dump target, f
21
+ end
22
+ end
23
+
24
+ def read
25
+ return nil unless File.exist?(@file)
26
+
27
+ info { "Reading cache from #{@file} (#{File.size(@file)} bytes)" }
28
+
29
+ File.open(@file) do |f|
30
+ return Marshal.load f
31
+ end
32
+ end
33
+
34
+ def stale?
35
+ return false unless File.exist?(@file)
36
+
37
+ File.mtime(@file).to_i < (Time.now.to_i - @ttl)
38
+ end
39
+
40
+ def clean!(force: false)
41
+ return unless File.exist?(@file)
42
+ return unless force || stale?
43
+
44
+ FileUtils.rm_f @file
45
+ info { "Removed stale cache file: #{@file}" }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DepsGrapher
4
+ class Cli
5
+ include Logging
6
+
7
+ STATUS_SUCCESS = 0
8
+ STATUS_FAILURE = 1
9
+ private_constant :STATUS_SUCCESS, :STATUS_FAILURE
10
+
11
+ class << self
12
+ def run!(command)
13
+ new(command).run!
14
+ end
15
+ end
16
+
17
+ def initialize(command)
18
+ @command = command
19
+ end
20
+
21
+ def run!
22
+ @command.run!
23
+ STATUS_SUCCESS
24
+ rescue StandardError => e
25
+ error { e.backtrace.unshift(e.message).join("\n") }
26
+ STATUS_FAILURE
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DepsGrapher
4
+ module Command
5
+ class Analyzer
6
+ include Logging
7
+
8
+ def initialize(file_paths, context)
9
+ @file_paths = file_paths
10
+ @context = context
11
+ end
12
+
13
+ def run!
14
+ if file_paths.empty?
15
+ warn { "No files to analyze" }
16
+ return
17
+ end
18
+
19
+ clean_dir!
20
+ analyze!
21
+ visualize!
22
+ rescue TargetNodeNotFound => e
23
+ info { e.message }
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :file_paths, :context
29
+
30
+ def clean_dir!
31
+ context.clean_dir!
32
+ end
33
+
34
+ def analyze!
35
+ file_paths.each do |file_path|
36
+ file_path = File.expand_path file_path
37
+
38
+ unless File.exist?(file_path)
39
+ warn { "Skipping #{file_path}" }
40
+ next
41
+ end
42
+
43
+ ast_processor = AstProcessor.new(
44
+ file_path,
45
+ graph,
46
+ context.event_processors,
47
+ context.advanced_const_resolver,
48
+ context.ignore_errors
49
+ )
50
+
51
+ ast_processor.call
52
+ end
53
+ end
54
+
55
+ def visualize!
56
+ writer = context.create_writer
57
+ visualizer = context.create_visualizer
58
+ visualizer.accept!(*extract_graph_elements)
59
+ bytesize = writer.write visualizer.render
60
+
61
+ info { "Writing to #{writer.path} (#{bytesize} bytes)" }
62
+ info { "Run `open #{writer.path}` to view the graph" }
63
+ end
64
+
65
+ def extract_graph_elements
66
+ if context.target_path
67
+ source_path_matcher = nil
68
+ source_path_matcher = Matcher.new(context.source_path) if context.source_path.present?
69
+
70
+ target_path_matcher = Matcher.new(context.target_path)
71
+
72
+ message = []
73
+ message << "Searching paths"
74
+ message << "from `#{source_path_matcher}`" if source_path_matcher
75
+ message << "to `#{target_path_matcher}`"
76
+
77
+ info { message.join(" ") }
78
+ graph.find_path source_path_matcher, target_path_matcher
79
+ else
80
+ [Node.all, Edge.all]
81
+ end
82
+ end
83
+
84
+ def graph
85
+ @graph ||= context.create_graph
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "readline"
4
+ require "fileutils"
5
+
6
+ module DepsGrapher
7
+ module Command
8
+ class Init
9
+ include Logging
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def run!
16
+ dest = File.expand_path("graphile.rb")
17
+
18
+ return if File.exist?(dest) && !ask_yes_no("Overwrite `#{dest}`?")
19
+
20
+ context.generate_graphile dest
21
+
22
+ info { "\n`#{dest}` was created." }
23
+ info { "Please edit the configuration file." }
24
+ info { "Run `bundle exec deps_grapher -c #{File.basename(dest)}`." }
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :context
30
+
31
+ def ask_yes_no(question)
32
+ stty_save = `stty -g`.chomp
33
+ trap("INT") do
34
+ system "stty", stty_save
35
+ exit
36
+ end
37
+
38
+ while (input = Readline.readline("#{question} (y/n): ", true))
39
+ case input
40
+ when "y"
41
+ return true
42
+ when "n"
43
+ return false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DepsGrapher
4
+ class Configuration
5
+ include PluginDSL
6
+
7
+ class_attribute :path
8
+ class_attribute :root_dir, default: File.expand_path(File.join("..", ".."), __dir__)
9
+ class_attribute :visualizer
10
+ class_attribute :visualizer_options, default: { layers: [] }
11
+ class_attribute :source_path # source class name on graph
12
+ class_attribute :target_path # target class name on graph
13
+ class_attribute :clean, default: false
14
+ class_attribute :logger
15
+ class_attribute :cache_dir, default: File.expand_path(File.join("..", "..", "tmp", "deps_grapher", "cache"), __dir__)
16
+ class_attribute :cache_ttl, default: 60 * 5 # 5 minutes
17
+ class_attribute :output_dir, default: File.expand_path(File.join("..", "..", "tmp", "deps_grapher", "graph"), __dir__)
18
+ class_attribute :ignore_errors, default: false
19
+ class_attribute :verbose, default: false
20
+ class_attribute :dump, default: false
21
+
22
+ attr_accessor :layers
23
+
24
+ def initialize
25
+ self.logger = Logger.new($stderr).tap do
26
+ _1.formatter = ->(_, _, _, msg) { "#{msg}\n" }
27
+ end
28
+
29
+ self.visualizer = Visualizer::Registry.default_visualizer
30
+
31
+ @layers = {}
32
+ end
33
+
34
+ def available_visualizers
35
+ Visualizer::Registry.available_visualizers
36
+ end
37
+
38
+ def load_plugin!
39
+ PluginLoader.load! plugin_dir
40
+ end
41
+
42
+ def merge!(options)
43
+ options.each do |key, value|
44
+ send "#{key}=", value if respond_to?("#{key}=") && !value.nil?
45
+ end
46
+ end
47
+
48
+ def input
49
+ @input ||= Input.new(self)
50
+ end
51
+
52
+ def layer(&block)
53
+ Layer.new(&block).tap do |layer|
54
+ @layers[layer.name] = layer
55
+ end
56
+ end
57
+
58
+ def ast_processor_policy(&block)
59
+ AstProcessorPolicy.new context, &block
60
+ end
61
+
62
+ def plugin_dir(dir = nil)
63
+ return @plugin_dir unless dir
64
+
65
+ @plugin_dir = dir
66
+ end
67
+
68
+ def load!(file)
69
+ return unless file
70
+
71
+ file = File.expand_path file
72
+ raise ArgumentError, "no such file: #{file}" unless File.exist?(file)
73
+
74
+ self.path = file
75
+
76
+ content = File.read file
77
+
78
+ cache_key = Digest::MD5.hexdigest(content)
79
+
80
+ SourceCache::Registry.with_cache cache_key do
81
+ DSL.new(self).instance_eval content
82
+ end
83
+
84
+ return unless dump
85
+
86
+ at_exit do
87
+ warn ""
88
+ warn "=============================="
89
+ warn " Configuration"
90
+ warn "=============================="
91
+ puts content
92
+ end
93
+ end
94
+
95
+ def context
96
+ @context ||= Context.new self
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module DepsGrapher
6
+ class Context
7
+ extend Forwardable
8
+
9
+ def_delegators :@config,
10
+ :ignore_errors,
11
+ :source_path,
12
+ :target_path
13
+
14
+ attr_accessor :event_processors, :advanced_const_resolver
15
+
16
+ def initialize(config)
17
+ @config = config
18
+ @event_processors = {}
19
+ end
20
+
21
+ def clean_dir!
22
+ return unless @config.clean
23
+
24
+ FileUtils.rm_rf @config.output_dir
25
+ end
26
+
27
+ def generate_graphile(dest)
28
+ Graphile::Generator.new(@config).call dest
29
+ end
30
+
31
+ def generate_temp_graphile
32
+ generate_graphile nil
33
+ end
34
+
35
+ def create_writer
36
+ HtmlWriter.new @config.output_dir
37
+ end
38
+
39
+ def create_visualizer
40
+ downloader = Visualizer::Downloader.new @config.output_dir
41
+ Visualizer.fetch(@config.visualizer).new downloader, @config.visualizer_options
42
+ end
43
+
44
+ def create_graph
45
+ target_path ? Graph.new : NullGraph.new
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cytoscape"
4
+
5
+ module DepsGrapher
6
+ class Cytoscape
7
+ class Cose < self
8
+ command_option "cy:cose"
9
+
10
+ private
11
+
12
+ def required_js
13
+ super.tap do |js|
14
+ js << "https://unpkg.com/cose-base/cose-base.js"
15
+ end
16
+ end
17
+
18
+ def layout_options
19
+ option = Visualizer::JsOption.new(
20
+ name: :cose,
21
+ directed: true,
22
+ padding: 10,
23
+ nodeOverlap: 20,
24
+ refresh: 20,
25
+ numIter: 1000,
26
+ initialTemp: 200,
27
+ coolingFactor: 0.95,
28
+ minTemp: 1.0
29
+ )
30
+ option.add_function(name: :nodeRepulsion, args: :edge, body: "return 100000")
31
+ option.add_function(name: :idealEdgeLength, args: :edge, body: "return 100")
32
+ option.add_function(name: :edgeElasticity, args: :edge, body: "return 32")
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cose"
4
+
5
+ module DepsGrapher
6
+ class Cytoscape
7
+ class Fcose < Cose
8
+ command_option "cy:fcose"
9
+
10
+ private
11
+
12
+ def required_js
13
+ super.tap do |js|
14
+ js << "https://unpkg.com/cytoscape-fcose/cytoscape-fcose.js"
15
+ end
16
+ end
17
+
18
+ def layout_options
19
+ option = Visualizer::JsOption.new(
20
+ name: :fcose,
21
+ directed: true,
22
+ padding: 10,
23
+ nodeOverlap: 20,
24
+ refresh: 20,
25
+ numIter: 1000,
26
+ initialTemp: 200,
27
+ coolingFactor: 0.95,
28
+ minTemp: 1.0
29
+ )
30
+ option.add_function(name: :nodeRepulsion, args: :edge, body: "return 100000")
31
+ option.add_function(name: :idealEdgeLength, args: :edge, body: "return 100")
32
+ option.add_function(name: :edgeElasticity, args: :edge, body: "return 32")
33
+ end
34
+ end
35
+ end
36
+ end