analyst 0.0.1 → 0.13.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +4 -1
  3. data/.rspec +2 -0
  4. data/CODE_OF_CONDUCT.md +13 -0
  5. data/README.md +1 -0
  6. data/analyst.gemspec +12 -0
  7. data/lib/analyst/analyzer.rb +162 -0
  8. data/lib/analyst/cli.rb +42 -0
  9. data/lib/analyst/entity_parser/association.rb +24 -0
  10. data/lib/analyst/entity_parser/entities/class.rb +92 -0
  11. data/lib/analyst/entity_parser/entities/empty.rb +13 -0
  12. data/lib/analyst/entity_parser/entities/entity.rb +29 -0
  13. data/lib/analyst/entity_parser/entities/method.rb +16 -0
  14. data/lib/analyst/entity_parser/entities/module.rb +31 -0
  15. data/lib/analyst/formatters/base.rb +33 -0
  16. data/lib/analyst/formatters/csv.rb +43 -0
  17. data/lib/analyst/formatters/html.rb +87 -0
  18. data/lib/analyst/formatters/html_index.rb +47 -0
  19. data/lib/analyst/formatters/templates/index.html.haml +92 -0
  20. data/lib/analyst/formatters/templates/output.html.haml +114 -0
  21. data/lib/analyst/formatters/text.rb +56 -0
  22. data/lib/analyst/fukuzatsu/analyzer.rb +162 -0
  23. data/lib/analyst/fukuzatsu/cli.rb +42 -0
  24. data/lib/analyst/fukuzatsu/entity_parser/association.rb +24 -0
  25. data/lib/analyst/fukuzatsu/entity_parser/entities/class.rb +92 -0
  26. data/lib/analyst/fukuzatsu/entity_parser/entities/empty.rb +13 -0
  27. data/lib/analyst/fukuzatsu/entity_parser/entities/entity.rb +29 -0
  28. data/lib/analyst/fukuzatsu/entity_parser/entities/method.rb +16 -0
  29. data/lib/analyst/fukuzatsu/entity_parser/entities/module.rb +31 -0
  30. data/lib/analyst/fukuzatsu/formatters/base.rb +33 -0
  31. data/lib/analyst/fukuzatsu/formatters/csv.rb +43 -0
  32. data/lib/analyst/fukuzatsu/formatters/html.rb +87 -0
  33. data/lib/analyst/fukuzatsu/formatters/html_index.rb +47 -0
  34. data/lib/analyst/fukuzatsu/formatters/templates/index.html.haml +92 -0
  35. data/lib/analyst/fukuzatsu/formatters/templates/output.html.haml +114 -0
  36. data/lib/analyst/fukuzatsu/formatters/text.rb +56 -0
  37. data/lib/analyst/fukuzatsu/line_of_code.rb +19 -0
  38. data/lib/analyst/fukuzatsu/parsed_file.rb +85 -0
  39. data/lib/analyst/fukuzatsu/parsed_method.rb +32 -0
  40. data/lib/analyst/fukuzatsu/parser_original.rb +76 -0
  41. data/lib/analyst/fukuzatsu/rethink/parser.rb +346 -0
  42. data/lib/analyst/fukuzatsu/version.rb +3 -0
  43. data/lib/analyst/line_of_code.rb +19 -0
  44. data/lib/analyst/parsed_file.rb +85 -0
  45. data/lib/analyst/parsed_method.rb +32 -0
  46. data/lib/analyst/parser.rb +76 -0
  47. data/lib/analyst/rethink/parser.rb +346 -0
  48. data/lib/analyst/version.rb +1 -1
  49. data/lib/analyst.rb +17 -2
  50. data/spec/analyzer_spec.rb +122 -0
  51. data/spec/cli_spec.rb +48 -0
  52. data/spec/fixtures/eg_class.rb +8 -0
  53. data/spec/fixtures/eg_mod_class.rb +2 -0
  54. data/spec/fixtures/eg_mod_class_2.rb +5 -0
  55. data/spec/fixtures/eg_module.rb +2 -0
  56. data/spec/fixtures/module_with_class.rb +9 -0
  57. data/spec/fixtures/multiple_methods.rb +7 -0
  58. data/spec/fixtures/nested_methods.rb +8 -0
  59. data/spec/fixtures/program_1.rb +19 -0
  60. data/spec/fixtures/program_2.rb +25 -0
  61. data/spec/fixtures/program_3.rb +66 -0
  62. data/spec/fixtures/program_4.rb +1 -0
  63. data/spec/fixtures/single_class.rb +9 -0
  64. data/spec/fixtures/single_method.rb +3 -0
  65. data/spec/formatters/csv_spec.rb +37 -0
  66. data/spec/formatters/html_index_spec.rb +36 -0
  67. data/spec/formatters/html_spec.rb +48 -0
  68. data/spec/formatters/text_spec.rb +39 -0
  69. data/spec/parsed_file_spec.rb +67 -0
  70. data/spec/parsed_method_spec.rb +34 -0
  71. data/spec/spec_helper.rb +7 -0
  72. metadata +229 -2
@@ -0,0 +1,76 @@
1
+ require 'fileutils'
2
+
3
+ module Analyst
4
+
5
+ class Parser
6
+
7
+ attr_reader :start_path, :parsed_files
8
+ attr_reader :threshold, :formatter
9
+ attr_reader :start_time
10
+
11
+ OUTPUT_DIRECTORY = "doc/Analyst"
12
+
13
+ def initialize(path, formatter, threshold=0)
14
+ @start_path = path
15
+ @formatter = formatter
16
+ @threshold = threshold
17
+ @start_time = Time.now
18
+ reset_output_directory
19
+ end
20
+
21
+ def parsed_files
22
+ @parsed_files = source_files.map do |path_to_file|
23
+ parse_source_file(path_to_file)
24
+ end
25
+ end
26
+
27
+ def report
28
+ self.parsed_files.each do |file|
29
+ print "."
30
+ formatter.new(file, OUTPUT_DIRECTORY, file.source).export
31
+ end
32
+ puts
33
+ write_report_index
34
+ report_complexity
35
+ end
36
+
37
+ private
38
+
39
+ def reset_output_directory
40
+ begin
41
+ FileUtils.remove_dir(OUTPUT_DIRECTORY)
42
+ rescue Errno::ENOENT
43
+ end
44
+ FileUtils.mkpath(OUTPUT_DIRECTORY)
45
+ end
46
+
47
+ def source_files
48
+ if File.directory?(start_path)
49
+ return Dir.glob(File.join(start_path, "**", "*.rb"))
50
+ else
51
+ return [start_path]
52
+ end
53
+ end
54
+
55
+ def parse_source_file(path_to_file, options={})
56
+ ParsedFile.new(path_to_file: path_to_file)
57
+ end
58
+
59
+ def report_complexity
60
+ return if self.threshold == 0
61
+ complexities = self.parsed_files.map(&:complexity)
62
+ return if complexities.max.to_i <= self.threshold
63
+ puts "Maximum complexity of #{complexities.max} exceeds #{options['threshold']} threshold!"
64
+ exit 1
65
+ end
66
+
67
+ def write_report_index
68
+ return unless self.formatter.writes_to_file_system?
69
+ puts "Results written to #{OUTPUT_DIRECTORY} "
70
+ return unless self.formatter.has_index?
71
+ formatter.index_class.new(parsed_files.map(&:summary), OUTPUT_DIRECTORY).export
72
+ end
73
+
74
+ end
75
+
76
+ end
@@ -0,0 +1,346 @@
1
+ require 'pry'
2
+
3
+ module Z
4
+
5
+ class Parser
6
+ extend Forwardable
7
+
8
+ attr_reader :corpus
9
+
10
+ def_delegators :@corpus, :classes, :associations
11
+
12
+ def initialize(path)
13
+ @corpus = Corpus.new(path)
14
+ end
15
+
16
+ def class_graph
17
+ {
18
+ nodes: corpus.classes,
19
+ edges: corpus.associations
20
+ #nodes: [1,2,3],
21
+ #edges: [{source: 1, target: 2, strength: 1}, {source: 2, target: 1, strength: 4}]
22
+ }
23
+ end
24
+ end
25
+
26
+
27
+ class Corpus
28
+
29
+ attr_reader :path, :classes, :associations
30
+
31
+ def initialize(path)
32
+ @path = path
33
+ parse!
34
+ end
35
+
36
+ def files
37
+ @files ||= file_paths.map {|path| File.new(path)}
38
+ end
39
+
40
+ def parse!
41
+ @classes = extract_classes
42
+ @associations = link_associations
43
+ #@associations = combine_identical_associations
44
+ end
45
+
46
+ private
47
+
48
+ def extract_classes
49
+ classes = []
50
+ files.each {|f| f.classes.each {|c| merge_class(classes, c) } }
51
+ classes
52
+ end
53
+
54
+ def link_associations
55
+ all = classes.map(&:associations).flatten
56
+ all.each do |assoc|
57
+ target = classes.detect {|c| c.full_name == assoc.target_class }
58
+ if target
59
+ assoc.target = target
60
+ else
61
+ puts "WARNING: Couldn't find target: #{assoc.target_class}"
62
+ end
63
+ end
64
+ end
65
+
66
+ def file_paths
67
+ if ::File.directory?(path)
68
+ Dir.glob(::File.join(path, "**", "*.rb"))
69
+ else
70
+ [path]
71
+ end
72
+ end
73
+
74
+ def merge_class(classes, klass)
75
+ existing = classes.find {|c| c.full_name == klass.full_name}
76
+ if existing
77
+ existing.merge(klass)
78
+ else
79
+ classes << klass
80
+ end
81
+ end
82
+
83
+ end
84
+
85
+
86
+ class File
87
+
88
+ attr_reader :path
89
+
90
+ def initialize(path)
91
+ @path = path
92
+ end
93
+
94
+ def contents
95
+ @contents ||= ::File.open(path, 'r').read
96
+ end
97
+
98
+ def ast
99
+ @ast ||= ::Parser::CurrentRuby.parse(contents)
100
+ end
101
+
102
+ def classes
103
+ return @classes if @classes
104
+ @classes = []
105
+ parse!
106
+ @classes
107
+ end
108
+
109
+ def parse!
110
+ parse_ast
111
+ true
112
+ end
113
+
114
+ private
115
+
116
+ # to log node types that haven't been dealt with
117
+ def unhandled_node_types
118
+ @unhandled_node_types ||= Set.new
119
+ end
120
+
121
+ # contains Module, Class, Method... eventually Block and other stuff maybe
122
+ def context_stack
123
+ @context_stack ||= [Entities::Empty.new]
124
+ end
125
+
126
+ def node_parsers
127
+ @node_parsers ||= Hash.new(:default_node_parser).merge!(
128
+ :class => :class_node_parser,
129
+ :module => :module_node_parser,
130
+ :def => :method_node_parser,
131
+ :send => :send_node_parser
132
+ # TODO: make a method parser, which pushes the the context_stack so that things inside method bodies
133
+ # are treated differently than those inside class or module bodies. same with Block (right?)
134
+ )
135
+ end
136
+
137
+ # search the whole tree for classes
138
+ def parse_ast(node=ast)
139
+ # files can start with (module), (class), or (begin)
140
+ # EDIT: or (send), or (def), or.... just about anything
141
+ return unless node.respond_to? :type
142
+ send(node_parsers[node.type], node)
143
+ end
144
+
145
+ def default_node_parser(node)
146
+ unhandled_node_types.add(node.type)
147
+ return unless node.respond_to? :children
148
+ node.children.each { |cnode| parse_ast(cnode) }
149
+ end
150
+
151
+ def class_node_parser(node)
152
+ name_node, super_node, content_node = node.children
153
+ klass = Entities::Class.new(context_stack.last, node)
154
+ @classes << klass
155
+ context_stack.push klass
156
+ parse_ast(content_node)
157
+ context_stack.pop
158
+ end
159
+
160
+ def module_node_parser(node)
161
+ name_node, content_node = node.children
162
+ mod = Entities::Module.new(context_stack.last, node)
163
+ context_stack.push mod
164
+ parse_ast(content_node)
165
+ context_stack.pop
166
+ end
167
+
168
+ def method_node_parser(node)
169
+ name, args_node, content_node = node.children
170
+ method = Entities::Method.new(context_stack.last, node)
171
+ context_stack.push method
172
+ parse_ast(content_node)
173
+ context_stack.pop
174
+ end
175
+
176
+ def send_node_parser(node)
177
+ target_node, method_name, *args = node.children
178
+ # if method_name is an association, then analyze the args to see what class it's associated with!
179
+ # and "it", btw, is the context_stack.last... so ya, also make sure this analysis is only done when we're in a
180
+ # class, NOT when we're in a method, NOT when we're in a defs, NOT when we're in an (sclass) (comes from
181
+ # class << self) -- got that?? those nodes should push something like 'Unsupported' onto the stack, or something
182
+ # just to keep track of where we are at any given point, and make sure we only care about sends that
183
+ # are truly at the right scope.
184
+ context_stack.last.handle_send_node(node)
185
+ # basically, if it's a class, it'll see if this is an association. if so, it'll store it by name. later on, we go
186
+ # thru and connect the pointers.
187
+ end
188
+ end
189
+
190
+
191
+ module Entities
192
+
193
+ class Entity
194
+ attr_reader :parent
195
+
196
+ def handle_send_node(node)
197
+ # abstract method. btw, this feels wrong -- send should be an entity too. but for now, whatevs.
198
+ end
199
+
200
+ def full_name
201
+ throw "this is abstract method, fool"
202
+ end
203
+
204
+ def inspect
205
+ "\#<#{self.class}:#{object_id} full_name=#{full_name}>"
206
+ end
207
+ end
208
+
209
+
210
+ class Empty < Entity
211
+ def full_name
212
+ ''
213
+ end
214
+ end
215
+
216
+
217
+ class Method < Entity
218
+ attr_reader :ast
219
+
220
+ def initialize(parent, ast)
221
+ @parent = parent
222
+ @ast = ast
223
+ end
224
+
225
+ def name
226
+ ast.children.first.to_s
227
+ end
228
+
229
+ def full_name
230
+ parent.full_name + '#' + name
231
+ end
232
+ end
233
+
234
+
235
+ class Module < Entity
236
+ attr_reader :ast
237
+
238
+ def initialize(parent, ast)
239
+ @parent = parent
240
+ @ast = ast
241
+ end
242
+
243
+ def name
244
+ const_node_array(ast.children.first).join('::')
245
+ end
246
+
247
+ def full_name
248
+ parent.full_name.empty? ? name : parent.full_name + '::' + name
249
+ end
250
+
251
+ private
252
+
253
+ # takes a (const) node and returns an array specifying the fully-qualified
254
+ # constant name that it represents. ya know, so CoolModule::SubMod::SweetClass
255
+ # would be parsed to:
256
+ # (const
257
+ # (const
258
+ # (const nil :CoolModule) :SubMod) :SweetClass)
259
+ # and passing that node here would return [:CoolModule, :SubMod, :SweetClass]
260
+ def const_node_array(node)
261
+ return [] if node.nil?
262
+ raise "expected (const) node or nil, got (#{node.type})" unless node.type == :const
263
+ const_node_array(node.children.first) << node.children[1]
264
+ end
265
+ end
266
+
267
+
268
+ class Class < Module
269
+
270
+ # pretend, for now, that we always have an AR. i.e., always look for associations.
271
+ ASSOCIATIONS = [:belongs_to, :has_one, :has_many, :has_and_belongs_to_many]
272
+
273
+ def handle_send_node(node)
274
+ # FIXME: this doesn't feel right, cuz (send) should probably be an entity too, especially
275
+ # since you can have nested sends... but i'm doing it this way for now.
276
+ target, method_name, *args = node.children
277
+ if ASSOCIATIONS.include? method_name
278
+ add_association(method_name, args)
279
+ end
280
+ end
281
+
282
+ def associations
283
+ @associations ||= []
284
+ end
285
+
286
+ def merge(other_class)
287
+ other_class.associations.each do |other_assoc|
288
+ duplicate = associations.detect do |assoc|
289
+ assoc.type == other_assoc.type && assoc.target_class == other_assoc.target_class
290
+ end
291
+ unless duplicate
292
+ associations << Association.new(type: other_assoc.type, source: self, target_class: other_assoc.target_class)
293
+ end
294
+ end
295
+ end
296
+
297
+ private
298
+
299
+ def add_association(method_name, args)
300
+ if args.size > 1
301
+ # args.last is a hash that might contain a class_name or a through
302
+ target_class = hash_val_str(args.last, :class_name)
303
+ end
304
+ target_class ||= begin
305
+ symbol_node = args.first
306
+ symbol_name = symbol_node.children.first
307
+ table_name = ::ActiveSupport::Inflector.pluralize(symbol_name)
308
+ ::ActiveSupport::Inflector.classify(table_name)
309
+ end
310
+ assoc = Association.new(type: method_name, source: self, target_class: target_class)
311
+ associations << assoc
312
+ end
313
+
314
+ # give it a (hash) node and a key (a symbol) and it'll look for that key in the hash and
315
+ # return the associated value as long as it's a string. if key isn't found, returns nil.
316
+ # if key is found but val isn't (str), throw exception. yep, this is pretty bespoke.
317
+ def hash_val_str(node, key)
318
+ return unless node.type == :hash
319
+ pair = node.children.detect do |pair_node|
320
+ key_sym_node = pair_node.children.first
321
+ key == key_sym_node.children.first
322
+ end
323
+ if pair
324
+ val_node = pair.children.last
325
+ throw "Bad type. Expected (str), got (#{val_node.type})" unless val_node.type == :str
326
+ val_node.children.first
327
+ end
328
+ end
329
+ end
330
+
331
+ end
332
+
333
+
334
+ class Association
335
+ attr_reader :type, :source, :target_class
336
+ attr_accessor :target
337
+
338
+ def initialize(type:, source:, target_class:)
339
+ @type = type
340
+ @source = source
341
+ @target_class = target_class
342
+ end
343
+ end
344
+
345
+ end
346
+
@@ -0,0 +1,3 @@
1
+ module Analyst
2
+ VERSION = "1.0.6"
3
+ end
@@ -0,0 +1,19 @@
1
+ class LineOfCode
2
+
3
+ include PoroPlus
4
+ include Ephemeral::Base
5
+
6
+ attr_accessor :line_number, :range, :content
7
+
8
+ def self.containing(locs, start_index, end_index)
9
+ locs.inject([]) do |a, loc|
10
+ a << loc if loc.in_range?(start_index) || loc.in_range?(end_index)
11
+ a
12
+ end.compact
13
+ end
14
+
15
+ def in_range?(index)
16
+ self.range.include?(index)
17
+ end
18
+
19
+ end
@@ -0,0 +1,85 @@
1
+ class ParsedFile
2
+
3
+ attr_accessor :lines_of_code, :source
4
+ attr_accessor :complexity, :path_to_file, :class_name, :path_to_results
5
+
6
+ def initialize(path_to_file: path_to_file, class_name: class_name=nil, complexity: complexity)
7
+ @path_to_file = path_to_file
8
+ @class_name = class_name
9
+ @lines_of_code = []
10
+ @complexity = complexity
11
+ @source = parse!
12
+ end
13
+
14
+ def class_name
15
+ @class_name ||= analyzer.class_name
16
+ end
17
+
18
+ def class_references
19
+ @class_references ||= analyzer.constants
20
+ end
21
+
22
+ def average_complexity
23
+ methods.map(&:complexity).reduce(:+) / methods.count.to_f
24
+ end
25
+
26
+ def complexity
27
+ @complexity ||= analyzer.complexity
28
+ end
29
+
30
+ def methods
31
+ @methods ||= analyzer.methods
32
+ end
33
+
34
+ def method_counts
35
+ referenced_methods = methods.map(&:references).flatten
36
+ referenced_methods.inject({}) do |hash, method|
37
+ hash[method] ||= 0
38
+ hash[method] += 1
39
+ hash
40
+ end
41
+ end
42
+
43
+ def source
44
+ return @source if @source
45
+ end_pos = 0
46
+ self.lines_of_code = []
47
+ @source = File.readlines(self.path_to_file).each_with_index do |line, index|
48
+ start_pos = end_pos + 1
49
+ end_pos += line.size
50
+ self.lines_of_code << LineOfCode.new(line_number: index + 1, range: (start_pos..end_pos))
51
+ line
52
+ end.join
53
+ end
54
+
55
+ def summary
56
+ {
57
+ path_to_file: self.path_to_file,
58
+ results_file: self.path_to_results,
59
+ source: source,
60
+ class_name: self.class_name,
61
+ complexity: complexity
62
+ }
63
+ end
64
+
65
+ private
66
+
67
+ def analyzer
68
+ @analyzer ||= Analyzer.new(content)
69
+ end
70
+
71
+ def content
72
+ @content ||= File.open(path_to_file, "r").read
73
+ end
74
+
75
+ def parse!
76
+ end_pos = 0
77
+ File.readlines(self.path_to_file).each_with_index do |line, index|
78
+ start_pos = end_pos + 1
79
+ end_pos += line.size
80
+ self.lines_of_code << LineOfCode.new(line_number: index + 1, range: (start_pos..end_pos))
81
+ line
82
+ end.join
83
+ end
84
+
85
+ end
@@ -0,0 +1,32 @@
1
+ class ParsedMethod
2
+
3
+ attr_reader :name, :content, :type, :complexity, :references
4
+
5
+ def initialize(name: name, content: content, type: type, refs: refs=[], complexity: complexity)
6
+ @name = name
7
+ @content = content
8
+ @type = type
9
+ @references = refs
10
+ @complexity = complexity
11
+ end
12
+
13
+ def complexity
14
+ @complexity ||= analyzer.complexity
15
+ end
16
+
17
+ def name
18
+ return "" if self.type == :none
19
+ "#{prefix}#{@name}"
20
+ end
21
+
22
+ def prefix
23
+ return "." if self.type == :class
24
+ return "#" if self.type == :instance
25
+ return "*"
26
+ end
27
+
28
+ def analyzer
29
+ Analyzer.new(self.content)
30
+ end
31
+
32
+ end
@@ -0,0 +1,76 @@
1
+ require 'fileutils'
2
+
3
+ module Analyst
4
+
5
+ class Parser
6
+
7
+ attr_reader :start_path, :parsed_files
8
+ attr_reader :threshold, :formatter
9
+ attr_reader :start_time
10
+
11
+ OUTPUT_DIRECTORY = "doc/Analyst"
12
+
13
+ def initialize(path, formatter, threshold=0)
14
+ @start_path = path
15
+ @formatter = formatter
16
+ @threshold = threshold
17
+ @start_time = Time.now
18
+ reset_output_directory
19
+ end
20
+
21
+ def parsed_files
22
+ @parsed_files = source_files.map do |path_to_file|
23
+ parse_source_file(path_to_file)
24
+ end
25
+ end
26
+
27
+ def report
28
+ self.parsed_files.each do |file|
29
+ print "."
30
+ formatter.new(file, OUTPUT_DIRECTORY, file.source).export
31
+ end
32
+ puts
33
+ write_report_index
34
+ report_complexity
35
+ end
36
+
37
+ private
38
+
39
+ def reset_output_directory
40
+ begin
41
+ FileUtils.remove_dir(OUTPUT_DIRECTORY)
42
+ rescue Errno::ENOENT
43
+ end
44
+ FileUtils.mkpath(OUTPUT_DIRECTORY)
45
+ end
46
+
47
+ def source_files
48
+ if File.directory?(start_path)
49
+ return Dir.glob(File.join(start_path, "**", "*.rb"))
50
+ else
51
+ return [start_path]
52
+ end
53
+ end
54
+
55
+ def parse_source_file(path_to_file, options={})
56
+ ParsedFile.new(path_to_file: path_to_file)
57
+ end
58
+
59
+ def report_complexity
60
+ return if self.threshold == 0
61
+ complexities = self.parsed_files.map(&:complexity)
62
+ return if complexities.max.to_i <= self.threshold
63
+ puts "Maximum complexity of #{complexities.max} exceeds #{options['threshold']} threshold!"
64
+ exit 1
65
+ end
66
+
67
+ def write_report_index
68
+ return unless self.formatter.writes_to_file_system?
69
+ puts "Results written to #{OUTPUT_DIRECTORY} "
70
+ return unless self.formatter.has_index?
71
+ formatter.index_class.new(parsed_files.map(&:summary), OUTPUT_DIRECTORY).export
72
+ end
73
+
74
+ end
75
+
76
+ end