deps_grapher 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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