module Society # The Parser class is responsible for producing an ObjectGraph from one or # more ruby sources. class Parser # Public: Generate a list of files from a collection of paths, creating a # new Parser with them. # Note: Since the files are not read, a new Parser MAY be returned such # that initiating processing will cause a crash later. # # file_paths - Any number of Strings representing paths to files. # # Returns a Parser. def self.for_files(*file_paths) files = file_paths.flatten.flat_map do |path| File.directory?(path) ? Dir.glob(File.join(path, '**', '*.rb')) : path end new(files.lazy.map { |f| File.read(f) }) end # Public: Create a Parser with a collection of ruby sources to be analyzed. # # source - Any number of Strings containing ruby source. # # Returns a Parser. def self.for_source(*source) new(source.lazy) end # Public: Create a Parser, staging ruby source files to be analyzed. # # source - An Enumerable containing ruby source strings. def initialize(source) @source = source.map { |file| graph_from(file) } end # Public: Generate a report from the object graph. # # format - A symbol representing any known output format. # output_path - Path to which output should be written. (default: nil) # # Returns nothing. def report(format, output_path=nil) raise ArgumentError, "Unknown format #{format}" unless known_formats.include?(format) options = { json_data: graph.to_json } options[:output_path] = output_path unless output_path.nil? FORMATTERS[format].new(options).write end # Public: Return the ObjectGraph representing the analyzed source. Calling # this method will trigger the analysis of the source if the object was # created with lazy enumerables. # # Returns an ObjectGraph. def graph @graph ||= resolve_known_edges(source.reduce(ObjectGraph.new, &:+)) end # Public: Return a list of known classes from the object graph. # # Returns an Array of Strings. def classes graph.map(&:name) end private attr_reader :source # AST Node with the current namespace and type (module/class) preserved. NSNode = Struct.new(:namespace, :type, :ast) # ActiveRecord edge, containing the direct reference and any arguments. AREdge = Struct.new(:reference, :args) NAMESPACE_NODES = [:class, :module] NAMESPACE_SEPARATOR = '::' CONSTANT_NAME_NODES = [:const_ref, :const_path_ref, :@const] ACTIVERECORD_NODES = %w(belongs_to has_one has_many has_and_belongs_to_many) # Internal: Generate an ObjectGraph from a string containing ruby source. # # source - String containing ruby source. # # Returns an ObjectGraph. def graph_from(source) ast = Ripper.sexp(source) nodes_from(ast).reduce(Society::ObjectGraph.new, &:<<) end # Internal: Generate a list of Nodes from a string containing ruby source. # Note: All edges are considered unresolved at this stage. # # ast - Array containing an abstract syntax tree generated by Ripper. # # Returns an Array of Nodes. def nodes_from(ast) walk_ast(ast).map do |name, data| init_node = Society::Node.new(name: name, type: data[:type]) find_edges(name, data[:ast]).reduce(init_node) do |node, new_edge| edge = [Society::Edge.new(to: new_edge)] type = data[:type] Society::Node.new(name: name, type: type, unresolved: edge) + node end end end # Internal: Isolate individual namespaces, generating a hash containing # Namespace => AST pairs. # # ast - Array containing an abstract syntax tree generated by Ripper. # # Returns a Hash mapping Namespace => AST. def walk_ast(ast) scoped_nodes = filter_namespace([], ast) scoped_nodes.reduce({}) do |nodes, node| namespace = node[:namespace] + [node_name(node[:namespace], node[:ast])] filter_namespace(namespace, node[:ast]).each do |sub| scoped_nodes.push(sub) end nodes.merge({ namespace.last => node }) end end # Internal: Generate a list of nodes representing a change of namespace # (classes/modules) from an abstract syntax tree, preserving the namespace # associated with them. # # namespace - Array containing the current namespace, to be preserved along # with the AST. # ast - AST to be searched for namespace separators. Note that due # to this object being globbed, mutating this object will not # mutate the state of the object passed to this method. # # Returns an Array of NSNodes. def filter_namespace(namespace, *ast) ast.reduce([]) do |nodes, node| if node.is_a?(Array) if NAMESPACE_NODES.include?(node.first) nodes.push(NSNode.new(namespace, node.first, node[1..-1])) else node.each { |sub| ast.push(sub) } end end nodes end end # Internal: Determine the name of a given node which creates a new # namespace (module/class). # # References to constants appear in the following two forms, with the # indicator that a constant follows (CONSTANT_NAME_NODES) always in the # leftmost branch: # [:const_ref, [:@const, "Klass", [1, 6]]] # and: # [:const_path_ref, # [:var_ref, [:@const, "Namespaced", [1, 6]]], # [:@const, "Klass", [1, 18]]] # # namespace - Array containing the current namespace, used to determine the # full namespace of the node. # ast - AST to be searched for references to constants. This object # is globbed, so it may be mutated safely. # # Raises ArgumentError if no name can be found. # Returns a String. def node_name(namespace, *ast) ast.reduce([]) do |path, node| if node.is_a?(Array) if CONSTANT_NAME_NODES.include?(node.first) name = path.push(node.flatten.select { |e| e.is_a?(String) }) return((namespace + name).flatten.join(NAMESPACE_SEPARATOR)) end ast.push(node.first) end path end raise(ArgumentError, 'No constant name found in the tree.') end # Internal: Find all references to edges (defined as references to external # constants) within the current scope. # # parent - String containing the name of the current node. # ast - AST to be searched for references to constants. # # Returns an Array of Strings and AREdges. def find_edges(parent, ast) direct_reference_edges(parent, ast) + activerecord_edges(ast) end # Internal: Find all explicit references to edges within the current scope. # # parent - String containing the name of the current node. # ast - AST to be searched for references to constants. This object is # globbed, so it may be mutated safely. # # Returns an Array of Strings. def direct_reference_edges(parent, *ast) ast.reduce([]) do |edges, node| if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first) if CONSTANT_NAME_NODES.include?(node.first) edges.push(node) else node.each { |sub| ast.push(sub) } end end edges end.map { |node| node_name([], node) }.reject { |node| parent == node } end # Internal: Find all references to edges via ActiveRecord associations # (belongs_to, has_one, has_many, has_and_belongs_to_many) in the current # scope. # # ast - AST to be searched for references to ActiveRecord associations. # This object is globbed, so it may be mutated safely. # # Returns an Array of AREdges. def activerecord_edges(*ast) activerecord_nodes(ast).reduce([]) do |edges, node| if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first) node_type, args = node if ACTIVERECORD_NODES.include?(node_type[1]) edges.push(activerecord_references(args)) end end edges end.compact.map { |edge| AREdge.new(edge[:reference], edge[:args]) } end # Internal: Find and return all instances of ActiveRecord association # nodes. # # These will match the following pattern: # [:command, # [:@ident, "has_many", [2, 10]], # [:args_add_block, # [[:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]], # [:bare_assoc_hash, # [[:assoc_new, # [:@label, "polymorphic:", [2, 33]], # [:var_ref, [:@kw, "true", [2, 46]]]]]]], false]] # Note: the bare_assoc_hash node is optional and only appears in cases # where additional arguments beyond the association name are passed. # # ast - AST to be searched for references to ActiveRecord associations. # # Returns an Array of AST nodes. def activerecord_nodes(ast) ast.reduce([]) do |nodes, node| if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first) if [:command].include?(node.first) if ACTIVERECORD_NODES.include?(node[1][1]) nodes.push(node[1..-1]) end else node.each { |sub| ast.push(sub) } end end nodes end end # Internal: Process argument blocks (args_add_block nodes), returning a # Hash representative of the arguments passed to a given ActiveRecord # association command. # # The block will match the following pattern: # [:args_add_block, # [[:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]], # [:bare_assoc_hash, # [[:assoc_new, # [:@label, "polymorphic:", [2, 33]], # [:var_ref, [:@kw, "true", [2, 46]]]]]]], false] # Note: the bare_assoc_hash node is optional and only appears in cases # where additional arguments beyond the association name are passed. # # args - AST representing an arguments block to be processed. # # Returns a Hash or nil. def activerecord_references(args) return nil unless args.is_a?(Array) && args.first == :args_add_block arg_tree = args[1] arg_tree.reduce({}) do |references, node| if node.is_a?(Array) && !NAMESPACE_NODES.include?(node.first) references.merge(process_reference_ast(node)) else references end end end # Internal: Process argument blocks (args_add_block nodes), returning a # Hash representative of one of the arguments passed to a given # ActiveRecord association command. # # The block will match the following patterns: # [:symbol_literal, [:symbol, [:@ident, "associations", [2, 22]]]] # or: # [:bare_assoc_hash, # [[:assoc_new, # [:@label, "polymorphic:", [2, 33]], # [:var_ref, [:@kw, "true", [2, 46]]]]]] # Note: the bare_assoc_hash node is optional and only appears in cases # where additional arguments beyond the association name are passed. # # node - AST representing an argument block to be processed. # # Returns a Hash. def process_reference_ast(node) if [:symbol_literal].include?(node.first) { reference: node.flatten.detect { |e| e.is_a?(String) } } elsif [:bare_assoc_hash].include?(node.first) { args: arguments_hash(node[1]) } else { } end end # Internal: Generate a Hash from a block describing a Hash. # # The block will match the following pattern: # [[:assoc_new, # [:@label, "polymorphic:", [2, 33]], # [:var_ref, [:@kw, "true", [2, 46]]]]] # # node - AST representing a hash definition block to be processed. # # Returns a Hash. def arguments_hash(node) node.select { |node| node.first == :assoc_new }.reduce({}) do |hash, node| key, val = node[1,2].map do |node| if node.is_a?(Array) node.flatten.detect { |element| element.is_a?(String) } end end key && val ? hash.merge({ key.gsub(/:/, '') => val }) : hash end end # Internal: Attempt to resolve all edges for the nodes contained within an # ObjectGraph. # # graph - ObjectGraph to process. # # Returns an ObjectGraph. def resolve_known_edges(graph) resolve_known_activerecord_edges(graph) + resolve_direct_edges(graph) end # Internal: Attempt to resolve all directly referenced edges for the nodes # contained within an ObjectGraph, discarding all unresolved edges after # this step. # # graph - ObjectGraph to process. # # Returns an ObjectGraph. def resolve_direct_edges(graph) known_nodes = graph.map(&:name) new_graph = graph.map do |node| known = node.unresolved.select { |edge| known_nodes.include?(edge.to) } Society::Node.new(name: node.name, type: node.type, edges: known) end Society::ObjectGraph.new(new_graph) end # Internal: Attempt to resolve all ActiveRecord association edges for the # nodes contained within an ObjectGraph, discarding all unresolved edges # after this step. # # graph - ObjectGraph to process. # # Returns an ObjectGraph. def resolve_known_activerecord_edges(graph) aredges = graph.map do |node| node.unresolved.select { |edge| edge.to.is_a?(AREdge) } .map(&:to).each_with_object(node).to_a.map(&:reverse) end.flatten(1) argraph = aredges.reduce(graph) do |graph, edge_tuple| node, edge = edge_tuple graph << add_meta_to_node(node, edge[:args], edge[:reference]) end graph + argraph.map { |n| resolve_activerecord_associations(argraph, n) } end # Internal: Generate a Node with metainformation populated from # ActiveRecord association information. # # node - Node object to which the arglist should be added as meta # information. # args_hash - Hash containing arguments passed to the ActiveRecord # association if any; nil otherwise. # ref - Reference for the ActiveRecord association. # # Returns a Node. def add_meta_to_node(node, args_hash, ref) refs = ({ reference: ref }).merge(args_hash || {}) node + Node.new(name: node.name, type: node.type, meta: [refs]) end # Internal: Generate a Node with edges resolved by searching the graph for # nodes with corresponding ActiveRecord associations (e.g. as: relations # for polymorphic ActiveRecord associations.) # # graph - ObjectGraph containing nodes to search for associations. # node - Node object for which ActiveRecord associations will be resolved. # # Returns a Node. def resolve_activerecord_associations(graph, node) return node if node.meta.empty? init_data = { name: node.name, type: node.type } node.meta.reduce(Society::Node.new(init_data)) do |node, meta| edges = edge_names_from_meta_node(graph, meta).map do |edge_name| Society::Edge.new(to: edge_name) end node + Society::Node.new(init_data.merge({ edges: edges })) end end # Internal: Determine all edges for a given ActiveRecord association based # on a search of a global graph for corresponding associations (e.g. as: # for polymorphic associations.) # Only one type of association will be resolved for any given set of # meta-information; the ActiveRecord reference itself is used as a # fallback. # # graph - ObjectGraph containing nodes to search for associations. # meta - Hash containing meta information to use in resolving the # association. # # Returns an Array of Strings. def edge_names_from_meta_node(graph, meta) edge = meta['class_name'] || process_through_meta_node(graph, meta) || process_polymorphic_meta_node(graph, meta) || meta[:reference] [edge].flatten.map(&:pluralize).map(&:classify) end # Internal: Resolve references for 'through' ActiveRecord associations. # # graph - ObjectGraph containing nodes to search for associations. # meta - Hash containing meta information to use in resolving the # association. # # Returns an Array of Strings. def process_through_meta_node(graph, meta) return nil unless meta['through'] through = meta['through'].pluralize.classify ref = meta['source'] || meta[:reference] graph.select { |n| n.name == through }.flat_map do |n| n.meta.select { |m| [ref, ref.singularize].include?(m[:reference]) } end.map { |meta| edge_names_from_meta_node(graph, meta) } end # Internal: Resolve references for polymorphic ActiveRecord associations. # # graph - ObjectGraph containing nodes to search for associations. # meta - Hash containing meta information to use in resolving the # association. # # Returns an Array of Strings. def process_polymorphic_meta_node(graph, meta) return nil unless meta['polymorphic'] graph.select do |n| n.meta.select { |m| m['as'] == meta[:reference] }.any? end.map(&:name) end FORMATTERS = { html: Society::Formatter::Report::HTML, json: Society::Formatter::Report::Json } # Internal: List known output formatters. # # Returns an Array of Symbols. def known_formats FORMATTERS.keys end end end