society 1.1.1 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 28dbabe0d73d5d1b9534c4a920d2ff2f8d034e5f
4
- data.tar.gz: fca64907dbe65f20fb37a9b3f35c35df5882cdf2
3
+ metadata.gz: ec8cb5a95664084f02de65ed939e89ec04417295
4
+ data.tar.gz: 462b53c5009c262f69fab7ddb6c3ba60ce38f227
5
5
  SHA512:
6
- metadata.gz: 1cc19bd10ff36860d3fba973a339f633bb6719b6993ff9ba6af5fc88f583f4f12618bc796b68d1f79a71d7d3e1a1e3a6d09106c752127942cab5d9f27e0ed2ea
7
- data.tar.gz: 4796528f618344ebba13aca58243be429e0d32d0a72174e259b781eec4af56e48ee0f971d9a5ee75f96d550a969a34549613a77cfacb774048755e9d8c943ebc
6
+ metadata.gz: a84b67d0617f3074d37efe668bec9cb4184615dfa08954134adcfe32f433f546b3cdbe66c857b7a7bfc146a07fb30174f1bfba9216a94172f1e37b0455746c4a
7
+ data.tar.gz: 8d7d5eb2196abd41d6eeff66453131b1636cb906d34d2dc24469cae44eaadf0a2c5a4b03c7349eab64a5bb7e9292f3552b8f79ab2a61cdaf6bff548745e433c3
data/CODE_OF_CONDUCT.md CHANGED
@@ -1,13 +1,13 @@
1
- # CODE OF CONDUCT
2
-
3
- This project has adopted version 0.4 of the [Contributor Covenant](https://github.com/bantik/contributor_covenant/).
1
+ # Contributor Code of Conduct
4
2
 
5
3
  As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
6
4
 
7
- If any participant in this project has issues or takes exception with a contribution, they are obligated to provide constructive feedback and never resort to personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
5
+ We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
6
+
7
+ Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8
8
 
9
9
  Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10
10
 
11
11
  Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12
12
 
13
- We promise to extend courtesy and respect to everyone involved in this project regardless of gender, gender identity, sexual orientation, ability or disability, ethnicity, religion, or level of experience.
13
+ This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
data/Gemfile CHANGED
@@ -1,6 +1,6 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- ruby '>= 2.1.1'
3
+ raise 'Ruby version must be greater than 2.0' unless RUBY_VERSION.to_f > 2.0
4
4
 
5
5
  # Specify your gem's dependencies in society.gemspec
6
6
  gemspec
data/LICENSE.txt CHANGED
@@ -1,15 +1,22 @@
1
- Copyright (c) 2014 Coraline Ada Ehmke / Instructure.inc
1
+ Copyright (c) 2014 Coraline Ada Ehmke
2
2
 
3
- This program is free software: you can redistribute it and/or modify
4
- it under the terms of the GNU General Public License as published by
5
- the Free Software Foundation, either version 3 of the License, or
6
- (at your option) any later version.
3
+ MIT License
7
4
 
8
- This program is distributed in the hope that it will be useful,
9
- but WITHOUT ANY WARRANTY; without even the implied warranty of
10
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
- GNU General Public License for more details.
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
12
 
13
- You should have received a copy of the GNU General Public License
14
- along with this program in the file COPYING.txt. If not, see
15
- <http://www.gnu.org/licenses/>
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/lib/society.rb CHANGED
@@ -1,15 +1,14 @@
1
- require "analyst"
1
+ require "haml"
2
+ require "json"
3
+ require "ripper"
2
4
  require "fileutils"
3
5
  require "active_support/core_ext/string/inflections"
4
6
 
5
- require_relative "society/association_processor"
6
- require_relative "society/reference_processor"
7
7
  require_relative "society/edge"
8
- require_relative "society/clusterer"
9
- require_relative "society/formatter/graph/json"
8
+ require_relative "society/node"
9
+ require_relative "society/object_graph"
10
10
  require_relative "society/formatter/report/html"
11
11
  require_relative "society/formatter/report/json"
12
- require_relative "society/object_graph"
13
12
  require_relative "society/parser"
14
13
  require_relative "society/version"
15
14
 
data/lib/society/edge.rb CHANGED
@@ -1,14 +1,40 @@
1
1
  module Society
2
+
3
+ # The Edge class represents an edge between two nodes in a graph. An edge is
4
+ # assumed to represent a direct relationship between two Classes or Modules.
2
5
  class Edge
3
6
 
4
- attr_reader :from, :to
5
- attr_accessor :meta
7
+ attr_reader :to, :weight
8
+
9
+ # Public: Create a new Edge.
10
+ #
11
+ # to - Node to target.
12
+ # weight - Weight of the edge, representing the number of references to the
13
+ # node referenced. (Default: 1)
14
+ def initialize(to:, weight: 1)
15
+ @to = to
16
+ @weight = weight
17
+ end
18
+
19
+ # Public: Add two Edges' weights, returning a new Edge.
20
+ #
21
+ # edge - An Edge.
22
+ #
23
+ # Returns a new Edge if both edges target the same node.
24
+ # Returns nil otherwise.
25
+ def +(edge)
26
+ return nil unless edge.to == to
27
+
28
+ Edge.new(to: to, weight: weight + edge.weight)
29
+ end
6
30
 
7
- def initialize(from:, to:, meta:nil)
8
- @from = from
9
- @to = to
10
- @meta = meta
31
+ # Public: Return the name of the node to which the edge points.
32
+ #
33
+ # Returns a string.
34
+ def to_s
35
+ to.to_s
11
36
  end
37
+ alias_method :inspect, :to_s
12
38
 
13
39
  end
14
40
 
@@ -15,6 +15,7 @@ module Society
15
15
  write_html
16
16
  copy_assets
17
17
  write_json_data
18
+ puts "Results written to #{self.output_path}." unless self.output_path.nil?
18
19
  end
19
20
 
20
21
  private
@@ -24,14 +24,14 @@
24
24
  };
25
25
 
26
26
  NetworkGraph.prototype.transformData = function(data) {
27
- var nodes = data.nodes.map(function(node) {
28
- return { name: node.name, relations: [] };
29
- });
30
- data.edges.forEach(function(edge) {
31
- var targetName = nodes[edge.to].name;
32
- nodes[edge.from].relations.push(targetName);
33
- });
34
- return nodes;
27
+ return(Object.keys(data).map(function(node) {
28
+ return {
29
+ name: node,
30
+ relations: Object.keys(data[node]).filter(function(edge) {
31
+ return(Object.keys(data).indexOf(edge) != -1);
32
+ })
33
+ };
34
+ }));
35
35
  };
36
36
 
37
37
  NetworkGraph.prototype.init = function() {
@@ -249,12 +249,15 @@
249
249
  var findCluster = data.clusters ? _findCluster : function() { return 0; };
250
250
 
251
251
  return {
252
- nodes: data.nodes.map(function(node, index) {
253
- return { name: node.name, group: findCluster(index) };
252
+ nodes: Object.keys(data).map(function(node, index) {
253
+ return { name: node, group: findCluster(index) };
254
254
  }),
255
- links: data.edges.map(function(edge) {
256
- return { source: edge.from, target: edge.to, value: 1 };
257
- })
255
+ links: Object.keys(data).reduce(function(edges, node, source) {
256
+ var new_edges = Object.keys(data[node]).map(function(edge, target) {
257
+ return { source: source, target: target, value: data[node][edge] };
258
+ });
259
+ return(edges.concat(new_edges));
260
+ }, [])
258
261
  };
259
262
  };
260
263
 
@@ -0,0 +1,86 @@
1
+ module Society
2
+
3
+ # The Node class represents a single node in a graph. In this case, nodes
4
+ # are assumed to be either Classes or Modules.
5
+ class Node
6
+
7
+ attr_reader :name, :type, :edges, :unresolved, :meta
8
+
9
+ # Public: Creates a new node.
10
+ #
11
+ # name - Name to be assigned to the node. Assumed to be a Class or
12
+ # Module name.
13
+ # type - Type of node. Assumed to be :class or :module.
14
+ # edges - Edges which point to other nodes.
15
+ # unresolved - References to nodes which have not yet been resolved.
16
+ # (default: [])
17
+ # meta - Information to be tracked about the node itself.
18
+ # (default: [])
19
+ def initialize(name:, type:, edges: [], unresolved: [], meta: [])
20
+ @name = name
21
+ @type = type
22
+ @edges = edges
23
+ @unresolved = unresolved
24
+ @meta = meta
25
+ end
26
+
27
+ # Public: Reports whether another node intersects.
28
+ # Nodes are considered to be intersecting if their name and type are equal.
29
+ #
30
+ # node - Another Node.
31
+ #
32
+ # Returns true or false.
33
+ def intersects?(node)
34
+ node.name == name && node.type == type
35
+ end
36
+
37
+ # Public: Create a node representing the sum of the current node and
38
+ # another, intersecting node.
39
+ #
40
+ # node - Another node object.
41
+ #
42
+ # Returns self if self is passed.
43
+ # Returns nil if the nodes do not intersect.
44
+ # Returns a new node containing the sum of both nodes' edges otherwise.
45
+ def +(node)
46
+ return self if self == node
47
+ return nil unless self.intersects?(node)
48
+
49
+ new_edges = accumulate_edges(edges, node.edges)
50
+ new_unresolved = accumulate_edges(unresolved, node.unresolved)
51
+ new_meta = meta + node.meta
52
+
53
+ return Node.new(name: name, type: type, edges: new_edges,
54
+ unresolved: new_unresolved, meta: new_meta)
55
+ end
56
+
57
+ # Public: Return the name of the node.
58
+ #
59
+ # Returns a string.
60
+ def to_s
61
+ name.to_s
62
+ end
63
+ alias_method :inspect, :to_s
64
+
65
+ private
66
+
67
+ # Internal: Reduce two lists of edges to a single list with equivalent
68
+ # edges summed.
69
+ #
70
+ # edges_a - Array of Edges.
71
+ # edges_b - Array of Edges.
72
+ #
73
+ # Returns an array of Edges.
74
+ def accumulate_edges(edges_a, edges_b)
75
+ new_edges = (edges_a + edges_b).flatten.reduce([]) do |edges, edge|
76
+ if edges.detect { |e| e.to == edge.to }
77
+ edges.map { |e| e + edge || e }
78
+ else
79
+ edges + [edge]
80
+ end
81
+ end
82
+ end
83
+
84
+ end
85
+
86
+ end
@@ -1,12 +1,64 @@
1
1
  module Society
2
2
 
3
- class ObjectGraph
3
+ # The ObjectGraph class represents a graph of interrelated nodes as an Array
4
+ # of nodes which can be iterated over.
5
+ class ObjectGraph < Array
4
6
 
5
- attr_accessor :nodes, :edges
7
+ # Public: Override Array#initialize, accepting any number of nodes and
8
+ # lists of nodes including other ObjectGraphs to create a single graph from
9
+ # them.
10
+ #
11
+ # nodes - Any number of nodes or lists of nodes.
12
+ def initialize(*nodes)
13
+ super(nodes.flatten)
14
+ end
15
+
16
+ # Public: Add two graphs together, returning a new ObjectGraph containing
17
+ # the sum of all nodes contained in both.
18
+ #
19
+ # other - Another graph.
20
+ #
21
+ # Returns an ObjectGraph.
22
+ def +(other)
23
+ other.reduce(self) do |graph, node|
24
+ if graph.select { |n| n.intersects?(node) }.any?
25
+ Society::ObjectGraph.new(graph.map { |n| n + node || n })
26
+ else
27
+ Society::ObjectGraph.new(graph, node)
28
+ end
29
+ end
30
+ end
31
+
32
+ # Public: Create a new graph, adding another node.
33
+ #
34
+ # node - Node to be added to the new graph.
35
+ #
36
+ # Returns an ObjectGraph.
37
+ def <<(node)
38
+ self + Society::ObjectGraph.new(node)
39
+ end
40
+ alias_method :push, :<<
41
+
42
+ # Public: Return the graph represented as a Hash.
43
+ #
44
+ # Returns a hash.
45
+ def to_h
46
+ self.reduce({}) do |hash, node|
47
+ hash.merge({ node.name => node.edges })
48
+ end
49
+ end
6
50
 
7
- def initialize(nodes: nodes=[], edges: edges=[])
8
- @nodes = nodes
9
- @edges = edges
51
+ # Public: Return the graph as a JSON string.
52
+ #
53
+ # Returns a string.
54
+ def to_json
55
+ to_h.reduce({}) do |hash, node|
56
+ name, edges_raw = node
57
+ edges = edges_raw.reduce({}) do |edges, edge|
58
+ edges.merge({ edge.to => edge.weight })
59
+ end
60
+ hash.merge({ name => edges })
61
+ end.to_json
10
62
  end
11
63
 
12
64
  end
@@ -1,66 +1,489 @@
1
1
  module Society
2
2
 
3
+ # The Parser class is responsible for producing an ObjectGraph from one or
4
+ # more ruby sources.
3
5
  class Parser
4
6
 
7
+ # Public: Generate a list of files from a collection of paths, creating a
8
+ # new Parser with them.
9
+ # Note: Since the files are not read, a new Parser MAY be returned such
10
+ # that initiating processing will cause a crash later.
11
+ #
12
+ # file_paths - Any number of Strings representing paths to files.
13
+ #
14
+ # Returns a Parser.
5
15
  def self.for_files(*file_paths)
6
- new(::Analyst.for_files(*file_paths))
16
+ files = file_paths.flatten.flat_map do |path|
17
+ File.directory?(path) ? Dir.glob(File.join(path, '**', '*.rb')) : path
18
+ end
19
+ new(files.lazy.map { |f| File.read(f) })
7
20
  end
8
21
 
9
- def self.for_source(source)
10
- new(::Analyst.for_source(source))
22
+ # Public: Create a Parser with a collection of ruby sources to be analyzed.
23
+ #
24
+ # source - Any number of Strings containing ruby source.
25
+ #
26
+ # Returns a Parser.
27
+ def self.for_source(*source)
28
+ new(source.lazy)
11
29
  end
12
30
 
13
- attr_reader :analyzer
14
-
15
- def initialize(analyzer)
16
- @analyzer = analyzer
31
+ # Public: Create a Parser, staging ruby source files to be analyzed.
32
+ #
33
+ # source - An Enumerable containing ruby source strings.
34
+ def initialize(source)
35
+ @source = source.map { |file| graph_from(file) }
17
36
  end
18
37
 
38
+ # Public: Generate a report from the object graph.
39
+ #
40
+ # format - A symbol representing any known output format.
41
+ # output_path - Path to which output should be written. (default: nil)
42
+ #
43
+ # Returns nothing.
19
44
  def report(format, output_path=nil)
20
45
  raise ArgumentError, "Unknown format #{format}" unless known_formats.include?(format)
21
- options = { json_data: json_data }
46
+ options = { json_data: graph.to_json }
22
47
  options[:output_path] = output_path unless output_path.nil?
23
48
  FORMATTERS[format].new(options).write
24
49
  end
25
50
 
51
+ # Public: Return the ObjectGraph representing the analyzed source. Calling
52
+ # this method will trigger the analysis of the source if the object was
53
+ # created with lazy enumerables.
54
+ #
55
+ # Returns an ObjectGraph.
56
+ def graph
57
+ @graph ||= resolve_known_edges(source.reduce(ObjectGraph.new, &:+))
58
+ end
59
+
60
+ # Public: Return a list of known classes from the object graph.
61
+ #
62
+ # Returns an Array of Strings.
63
+ def classes
64
+ graph.map(&:name)
65
+ end
66
+
26
67
  private
27
68
 
28
- FORMATTERS = {
29
- html: Society::Formatter::Report::HTML,
30
- json: Society::Formatter::Report::Json
31
- }
69
+ attr_reader :source
32
70
 
33
- def classes
34
- @classes ||= analyzer.classes
71
+ # AST Node with the current namespace and type (module/class) preserved.
72
+ NSNode = Struct.new(:namespace, :type, :ast)
73
+ # ActiveRecord edge, containing the direct reference and any arguments.
74
+ AREdge = Struct.new(:reference, :args)
75
+
76
+ NAMESPACE_NODES = [:class, :module]
77
+ NAMESPACE_SEPARATOR = '::'
78
+ CONSTANT_NAME_NODES = [:const_ref, :const_path_ref, :@const]
79
+ ACTIVERECORD_NODES = %w(belongs_to has_one has_many
80
+ has_and_belongs_to_many)
81
+
82
+ # Internal: Generate an ObjectGraph from a string containing ruby source.
83
+ #
84
+ # source - String containing ruby source.
85
+ #
86
+ # Returns an ObjectGraph.
87
+ def graph_from(source)
88
+ ast = Ripper.sexp(source)
89
+ nodes_from(ast).reduce(Society::ObjectGraph.new, &:<<)
90
+ end
91
+
92
+ # Internal: Generate a list of Nodes from a string containing ruby source.
93
+ # Note: All edges are considered unresolved at this stage.
94
+ #
95
+ # ast - Array containing an abstract syntax tree generated by Ripper.
96
+ #
97
+ # Returns an Array of Nodes.
98
+ def nodes_from(ast)
99
+ walk_ast(ast).map do |name, data|
100
+ init_node = Society::Node.new(name: name, type: data[:type])
101
+ find_edges(name, data[:ast]).reduce(init_node) do |node, new_edge|
102
+ edge = [Society::Edge.new(to: new_edge)]
103
+ type = data[:type]
104
+ Society::Node.new(name: name, type: type, unresolved: edge) + node
105
+ end
106
+ end
35
107
  end
36
108
 
37
- def class_graph
38
- @class_graph ||= begin
39
- associations = associations_from(classes) + references_from(classes)
40
- # TODO: merge identical classes, and (somewhere else) deal with
41
- # identical associations too. need a WeightedEdge, and each
42
- # one will be unique on [from, to], but will have a weight
109
+ # Internal: Isolate individual namespaces, generating a hash containing
110
+ # Namespace => AST pairs.
111
+ #
112
+ # ast - Array containing an abstract syntax tree generated by Ripper.
113
+ #
114
+ # Returns a Hash mapping Namespace => AST.
115
+ def walk_ast(ast)
116
+ scoped_nodes = filter_namespace([], ast)
43
117
 
44
- ObjectGraph.new(nodes: classes, edges: associations)
118
+ scoped_nodes.reduce({}) do |nodes, node|
119
+ namespace = node[:namespace] + [node_name(node[:namespace], node[:ast])]
120
+ filter_namespace(namespace, node[:ast]).each do |sub|
121
+ scoped_nodes.push(sub)
122
+ end
123
+ nodes.merge({ namespace.last => node })
45
124
  end
46
125
  end
47
126
 
48
- def json_data
49
- Society::Formatter::Graph::JSON.new(class_graph).to_json
127
+ # Internal: Generate a list of nodes representing a change of namespace
128
+ # (classes/modules) from an abstract syntax tree, preserving the namespace
129
+ # associated with them.
130
+ #
131
+ # namespace - Array containing the current namespace, to be preserved along
132
+ # with the AST.
133
+ # ast - AST to be searched for namespace separators. Note that due
134
+ # to this object being globbed, mutating this object will not
135
+ # mutate the state of the object passed to this method.
136
+ #
137
+ # Returns an Array of NSNodes.
138
+ def filter_namespace(namespace, *ast)
139
+ ast.reduce([]) do |nodes, node|
140
+ if node.is_a?(Array)
141
+ if NAMESPACE_NODES.include?(node.first)
142
+ nodes.push(NSNode.new(namespace, node.first, node[1..-1]))
143
+ else
144
+ node.each { |sub| ast.push(sub) }
145
+ end
146
+ end
147
+ nodes
148
+ end
50
149
  end
51
150
 
52
- def known_formats
53
- FORMATTERS.keys
151
+ # Internal: Determine the name of a given node which creates a new
152
+ # namespace (module/class).
153
+ #
154
+ # References to constants appear in the following two forms, with the
155
+ # indicator that a constant follows (CONSTANT_NAME_NODES) always in the
156
+ # leftmost branch:
157
+ # [:const_ref, [:@const, "Klass", [1, 6]]]
158
+ # and:
159
+ # [:const_path_ref,
160
+ # [:var_ref, [:@const, "Namespaced", [1, 6]]],
161
+ # [:@const, "Klass", [1, 18]]]
162
+ #
163
+ # namespace - Array containing the current namespace, used to determine the
164
+ # full namespace of the node.
165
+ # ast - AST to be searched for references to constants. This object
166
+ # is globbed, so it may be mutated safely.
167
+ #
168
+ # Raises ArgumentError if no name can be found.
169
+ # Returns a String.
170
+ def node_name(namespace, *ast)
171
+ ast.reduce([]) do |path, node|
172
+ if node.is_a?(Array)
173
+ if CONSTANT_NAME_NODES.include?(node.first)
174
+ name = path.push(node.flatten.select { |e| e.is_a?(String) })
175
+ return((namespace + name).flatten.join(NAMESPACE_SEPARATOR))
176
+ end
177
+ ast.push(node.first)
178
+ end
179
+ path
180
+ end
181
+ raise(ArgumentError, 'No constant name found in the tree.')
182
+ end
183
+
184
+ # Internal: Find all references to edges (defined as references to external
185
+ # constants) within the current scope.
186
+ #
187
+ # parent - String containing the name of the current node.
188
+ # ast - AST to be searched for references to constants.
189
+ #
190
+ # Returns an Array of Strings and AREdges.
191
+ def find_edges(parent, ast)
192
+ direct_reference_edges(parent, ast) + activerecord_edges(ast)
193
+ end
194
+
195
+ # Internal: Find all explicit references to edges within the current scope.
196
+ #
197
+ # parent - String containing the name of the current node.
198
+ # ast - AST to be searched for references to constants. This object is
199
+ # globbed, so it may be mutated safely.
200
+ #
201
+ # Returns an Array of Strings.
202
+ def direct_reference_edges(parent, *ast)
203
+ ast.reduce([]) do |edges, node|
204
+ if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first)
205
+ if CONSTANT_NAME_NODES.include?(node.first)
206
+ edges.push(node)
207
+ else
208
+ node.each { |sub| ast.push(sub) }
209
+ end
210
+ end
211
+ edges
212
+ end.map { |node| node_name([], node) }.reject { |node| parent == node }
213
+ end
214
+
215
+ # Internal: Find all references to edges via ActiveRecord associations
216
+ # (belongs_to, has_one, has_many, has_and_belongs_to_many) in the current
217
+ # scope.
218
+ #
219
+ # ast - AST to be searched for references to ActiveRecord associations.
220
+ # This object is globbed, so it may be mutated safely.
221
+ #
222
+ # Returns an Array of AREdges.
223
+ def activerecord_edges(*ast)
224
+ activerecord_nodes(ast).reduce([]) do |edges, node|
225
+ if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first)
226
+ node_type, args = node
227
+ if ACTIVERECORD_NODES.include?(node_type[1])
228
+ edges.push(activerecord_references(args))
229
+ end
230
+ end
231
+ edges
232
+ end.compact.map { |edge| AREdge.new(edge[:reference], edge[:args]) }
233
+ end
234
+
235
+ # Internal: Find and return all instances of ActiveRecord association
236
+ # nodes.
237
+ #
238
+ # These will match the following pattern:
239
+ # [:command,
240
+ # [:@ident, "has_many", [2, 10]],
241
+ # [:args_add_block,
242
+ # [[:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]],
243
+ # [:bare_assoc_hash,
244
+ # [[:assoc_new,
245
+ # [:@label, "polymorphic:", [2, 33]],
246
+ # [:var_ref, [:@kw, "true", [2, 46]]]]]]], false]]
247
+ # Note: the bare_assoc_hash node is optional and only appears in cases
248
+ # where additional arguments beyond the association name are passed.
249
+ #
250
+ # ast - AST to be searched for references to ActiveRecord associations.
251
+ #
252
+ # Returns an Array of AST nodes.
253
+ def activerecord_nodes(ast)
254
+ ast.reduce([]) do |nodes, node|
255
+ if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first)
256
+ if [:command].include?(node.first)
257
+ if ACTIVERECORD_NODES.include?(node[1][1])
258
+ nodes.push(node[1..-1])
259
+ end
260
+ else
261
+ node.each { |sub| ast.push(sub) }
262
+ end
263
+ end
264
+ nodes
265
+ end
266
+ end
267
+
268
+ # Internal: Process argument blocks (args_add_block nodes), returning a
269
+ # Hash representative of the arguments passed to a given ActiveRecord
270
+ # association command.
271
+ #
272
+ # The block will match the following pattern:
273
+ # [:args_add_block,
274
+ # [[:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]],
275
+ # [:bare_assoc_hash,
276
+ # [[:assoc_new,
277
+ # [:@label, "polymorphic:", [2, 33]],
278
+ # [:var_ref, [:@kw, "true", [2, 46]]]]]]], false]
279
+ # Note: the bare_assoc_hash node is optional and only appears in cases
280
+ # where additional arguments beyond the association name are passed.
281
+ #
282
+ # args - AST representing an arguments block to be processed.
283
+ #
284
+ # Returns a Hash or nil.
285
+ def activerecord_references(args)
286
+ return nil unless args.is_a?(Array) && args.first == :args_add_block
287
+ arg_tree = args[1]
288
+ arg_tree.reduce({}) do |references, node|
289
+ if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first)
290
+ references.merge(process_reference_ast(node))
291
+ else
292
+ references
293
+ end
294
+ end
54
295
  end
55
296
 
56
- def associations_from(all_classes)
57
- @association_processor ||= AssociationProcessor.new(all_classes)
58
- @association_processor.associations
297
+ # Internal: Process argument blocks (args_add_block nodes), returning a
298
+ # Hash representative of one of the arguments passed to a given
299
+ # ActiveRecord association command.
300
+ #
301
+ # The block will match the following patterns:
302
+ # [:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]]
303
+ # or:
304
+ # [:bare_assoc_hash,
305
+ # [[:assoc_new,
306
+ # [:@label, "polymorphic:", [2, 33]],
307
+ # [:var_ref, [:@kw, "true", [2, 46]]]]]]
308
+ # Note: the bare_assoc_hash node is optional and only appears in cases
309
+ # where additional arguments beyond the association name are passed.
310
+ #
311
+ # node - AST representing an argument block to be processed.
312
+ #
313
+ # Returns a Hash.
314
+ def process_reference_ast(node)
315
+ if [:symbol_literal].include?(node.first)
316
+ { reference: node.flatten.detect { |e| e.is_a?(String) } }
317
+ elsif [:bare_assoc_hash].include?(node.first)
318
+ { args: arguments_hash(node[1]) }
319
+ else
320
+ { }
321
+ end
322
+ end
323
+
324
+ # Internal: Generate a Hash from a block describing a Hash.
325
+ #
326
+ # The block will match the following pattern:
327
+ # [[:assoc_new,
328
+ # [:@label, "polymorphic:", [2, 33]],
329
+ # [:var_ref, [:@kw, "true", [2, 46]]]]]
330
+ #
331
+ # node - AST representing a hash definition block to be processed.
332
+ #
333
+ # Returns a Hash.
334
+ def arguments_hash(node)
335
+ node.select { |node| node.first == :assoc_new }.reduce({}) do |hash, node|
336
+ key, val = node[1,2].map do |node|
337
+ if node.is_a?(Array)
338
+ node.flatten.detect { |element| element.is_a?(String) }
339
+ end
340
+ end
341
+ key && val ? hash.merge({ key.gsub(/:/, '') => val }) : hash
342
+ end
343
+ end
344
+
345
+ # Internal: Attempt to resolve all edges for the nodes contained within an
346
+ # ObjectGraph.
347
+ #
348
+ # graph - ObjectGraph to process.
349
+ #
350
+ # Returns an ObjectGraph.
351
+ def resolve_known_edges(graph)
352
+ resolve_known_activerecord_edges(graph) + resolve_direct_edges(graph)
353
+ end
354
+
355
+ # Internal: Attempt to resolve all directly referenced edges for the nodes
356
+ # contained within an ObjectGraph, discarding all unresolved edges after
357
+ # this step.
358
+ #
359
+ # graph - ObjectGraph to process.
360
+ #
361
+ # Returns an ObjectGraph.
362
+ def resolve_direct_edges(graph)
363
+ known_nodes = graph.map(&:name)
364
+ new_graph = graph.map do |node|
365
+ known = node.unresolved.select { |edge| known_nodes.include?(edge.to) }
366
+ Society::Node.new(name: node.name, type: node.type, edges: known)
367
+ end
368
+ Society::ObjectGraph.new(new_graph)
59
369
  end
60
370
 
61
- def references_from(all_classes)
62
- @reference_processor ||= ReferenceProcessor.new(all_classes)
63
- @reference_processor.references
371
+ # Internal: Attempt to resolve all ActiveRecord association edges for the
372
+ # nodes contained within an ObjectGraph, discarding all unresolved edges
373
+ # after this step.
374
+ #
375
+ # graph - ObjectGraph to process.
376
+ #
377
+ # Returns an ObjectGraph.
378
+ def resolve_known_activerecord_edges(graph)
379
+ aredges = graph.map do |node|
380
+ node.unresolved.select { |edge| edge.to.is_a?(AREdge) }
381
+ .map(&:to).each_with_object(node).to_a.map(&:reverse)
382
+ end.flatten(1)
383
+ argraph = aredges.reduce(graph) do |graph, edge_tuple|
384
+ node, edge = edge_tuple
385
+ graph << add_meta_to_node(node, edge[:args], edge[:reference])
386
+ end
387
+ graph + argraph.map { |n| resolve_activerecord_associations(argraph, n) }
388
+ end
389
+
390
+ # Internal: Generate a Node with metainformation populated from
391
+ # ActiveRecord association information.
392
+ #
393
+ # node - Node object to which the arglist should be added as meta
394
+ # information.
395
+ # args_hash - Hash containing arguments passed to the ActiveRecord
396
+ # association if any; nil otherwise.
397
+ # ref - Reference for the ActiveRecord association.
398
+ #
399
+ # Returns a Node.
400
+ def add_meta_to_node(node, args_hash, ref)
401
+ refs = ({ reference: ref }).merge(args_hash || {})
402
+ node + Node.new(name: node.name, type: node.type, meta: [refs])
403
+ end
404
+
405
+ # Internal: Generate a Node with edges resolved by searching the graph for
406
+ # nodes with corresponding ActiveRecord associations (e.g. as: relations
407
+ # for polymorphic ActiveRecord associations.)
408
+ #
409
+ # graph - ObjectGraph containing nodes to search for associations.
410
+ # node - Node object for which ActiveRecord associations will be resolved.
411
+ #
412
+ # Returns a Node.
413
+ def resolve_activerecord_associations(graph, node)
414
+ return node if node.meta.empty?
415
+ init_data = { name: node.name, type: node.type }
416
+
417
+ node.meta.reduce(Society::Node.new(init_data)) do |node, meta|
418
+ edges = edge_names_from_meta_node(graph, meta).map do |edge_name|
419
+ Society::Edge.new(to: edge_name)
420
+ end
421
+ node + Society::Node.new(init_data.merge({ edges: edges }))
422
+ end
423
+ end
424
+
425
+ # Internal: Determine all edges for a given ActiveRecord association based
426
+ # on a search of a global graph for corresponding associations (e.g. as:
427
+ # for polymorphic associations.)
428
+ # Only one type of association will be resolved for any given set of
429
+ # meta-information; the ActiveRecord reference itself is used as a
430
+ # fallback.
431
+ #
432
+ # graph - ObjectGraph containing nodes to search for associations.
433
+ # meta - Hash containing meta information to use in resolving the
434
+ # association.
435
+ #
436
+ # Returns an Array of Strings.
437
+ def edge_names_from_meta_node(graph, meta)
438
+ edge = meta['class_name'] ||
439
+ process_through_meta_node(graph, meta) ||
440
+ process_polymorphic_meta_node(graph, meta) ||
441
+ meta[:reference]
442
+
443
+ [edge].flatten.map(&:pluralize).map(&:classify)
444
+ end
445
+
446
+ # Internal: Resolve references for 'through' ActiveRecord associations.
447
+ #
448
+ # graph - ObjectGraph containing nodes to search for associations.
449
+ # meta - Hash containing meta information to use in resolving the
450
+ # association.
451
+ #
452
+ # Returns an Array of Strings.
453
+ def process_through_meta_node(graph, meta)
454
+ return nil unless meta['through']
455
+
456
+ through = meta['through'].pluralize.classify
457
+ ref = meta['source'] || meta[:reference]
458
+ graph.select { |n| n.name == through }.flat_map do |n|
459
+ n.meta.select { |m| [ref, ref.singularize].include?(m[:reference]) }
460
+ end.map { |meta| edge_names_from_meta_node(graph, meta) }
461
+ end
462
+
463
+ # Internal: Resolve references for polymorphic ActiveRecord associations.
464
+ #
465
+ # graph - ObjectGraph containing nodes to search for associations.
466
+ # meta - Hash containing meta information to use in resolving the
467
+ # association.
468
+ #
469
+ # Returns an Array of Strings.
470
+ def process_polymorphic_meta_node(graph, meta)
471
+ return nil unless meta['polymorphic']
472
+ graph.select do |n|
473
+ n.meta.select { |m| m['as'] == meta[:reference] }.any?
474
+ end.map(&:name)
475
+ end
476
+
477
+ FORMATTERS = {
478
+ html: Society::Formatter::Report::HTML,
479
+ json: Society::Formatter::Report::Json
480
+ }
481
+
482
+ # Internal: List known output formatters.
483
+ #
484
+ # Returns an Array of Symbols.
485
+ def known_formats
486
+ FORMATTERS.keys
64
487
  end
65
488
 
66
489
  end