concord-consortium/rigse

View on GitHub
rails/script/reference-tree.rb

Summary

Maintainability
C
1 day
Test Coverage

def print_md_table(title, headers, rows)
  puts "## #{title}"
  puts ''

  # add row index colum
  # we aren't using unshift so we don't modify the input arguments
  headers = [""] + headers
  rows = rows.map.with_index do |row, index|
    [(index+1).to_s] + row
  end

  padded_headers = headers.map.with_index do |header, index|
    max_cell_length = rows.map{|row| row[index].length}.max
    header.ljust(max_cell_length)
  end
  puts "| #{padded_headers.join(" | ")} |"
  puts "| #{padded_headers.map{|h| "-"*h.length}.join(" | ")} |"
  rows.each do |row|
    padded_cells = row.map.with_index{|item, index| item.ljust(padded_headers[index].length)}
    puts "| #{padded_cells.join(" | ")} |"
  end
  puts ''
end

def md_code(code)
  # escape the | since it is picked up by the md table parser
  escaped = code.gsub("|","\\|")
  "`#{escaped.strip}`"
end

# blocks are key'd by their filename if they are view
# if they are helper method, then they are keyd by filename#method
# if they are a controller then filename#method
# each block has a list of callers, these are other blocks that
# call or use this block

# We start with pre-existing helpers that are labeled with BASE

known_blocks = {}
blocks_to_process = {
  "BASE/remote_form_for" => {},
  "BASE/visual_effect" => {},
  "BASE/link_to_remote" => {},
  "BASE/link_to_function" => {},
  "BASE/sortable_element" => {},
  "BASE/drop_receiving_element" => {},
  "BASE/button_to_remote" => {},
  "BASE/draggable_element" => {},
  "BASE/observe_form" => {},
  "BASE/remote_function" => {},
  "BASE/render :update" => {},
  "BASE/render(:update" => {},
  "BASE/page <<" => {},
}

def find_call_to_block(block_name, file_name, line)
  # line.match("(?<![_\"])#{method}(?![_\"])")

  if(block_name.start_with?("BASE/"))
    method = block_name.slice(/BASE\/(.*)/, 1)
    if (method == "page <<")
      # special case 'page <<' to cut down on references
      # in a controller in practice there is a render :update match if there is a page <<
      return false if file_name.start_with?("controllers/")
    end
    # don't match the method if it is preceded by a _ or "
    line.match(/(?<![_"])#{Regexp.escape(method)}(?![_"])/)
  elsif(block_name.start_with?("views/"))
    block_file_name = block_name.split('/')[-1]
    # FIXME: skip the run_html for now since we don't handle formats yet
    if(block_file_name.start_with?("_") and !block_file_name.end_with?("run_html.haml"))
      # this is a partial
      # we should allow
      # ", ', or : before it
      # and when looking for the block in the same directory then we should
      # add the prefix of the folder, but when looking outside of the directory
      # then we need to include folder prefix
      block_directory = block_name.slice(/views\/(.*)\/[^\/]*/,1)
      if(file_name.start_with?("views/"))
        # relative refences are allow when a view is referencing a partial in the same folder
        relative_path_allowed = block_directory == file_name.slice(/views\/(.*)\/[^\/]*/,1)
      elsif(file_name.start_with?("controllers/"))
        # controllers can reference partials without the full name if they are in the same folder
        # example:
        #   partial: views/search/_material_unassigned_collections.html.haml
        #   reference_file: controllers/search_controller.rb
        # we need to take the block_directory strip off the last segment and turn it into
        #
        relative_path_allowed = file_name == "controllers/#{block_directory}_controller.rb"
      else
        # FIXME: we disable relative_path_allowed for everything else inorder to reduce false postives.
        # there is a '_user' partial so that ends up matching lots of stuff that isn't a partial
        # Once we add the match for `partial =>` or `partial:` then we can probably renable relative
        # paths and see if we pick up any more legitimate matches
        relative_path_allowed = false
      end

      block_partial_name = block_file_name.slice(/_([^\.]*)/,1)
      # FIXME: this is not a great match way to match 'partial'
      (relative_path_allowed and line.match(/partial.*["':]#{block_partial_name}(?![_a-zA-Z])/)) or
      (line.match(/partial.*["':]#{block_directory}\/#{block_partial_name}(?![_a-zA-Z])/))
    else
      # this is a top level view
      # when we start searching controllers we can probably figure out
      # which controllers would be referencing this view
      false
    end
  elsif(block_name.start_with?("helpers/") or block_name.start_with?("lib/"))
    method = block_name.split('#')[1]
    line.match(/(?<!def )(?<![_"])#{Regexp.escape(method)}(?![_"])/)
  elsif(block_name.start_with?("controllers/"))
    # we don't handle calls to controllers yet
    # a call to a controller would typically be in a view and would be a reference
    # to its route helper. So to make this work we need the output of rake routes
    # which we can process to find the name and the controller action
    false
  else
    # we should add additional matchers here
    false
  end
end

def current_block_name(file_name, last_def)
  # for now we just return the file_name
  # for helpers, controllers, and libs we need to also include
  # the last_def, but it needs to be stripped down
  if(file_name.start_with?("views/"))
    file_name
  elsif((file_name.start_with?("helpers/") or file_name.start_with?("lib/") or file_name.start_with?("controllers/")) and
    file_name.end_with?(".rb") and !last_def.nil?)
    # need to get the method name from the last def
    method = last_def.slice(/def ([^ (]*)/, 1)
    "#{file_name}##{method}"
  else
    puts "Unable to process: #{file_name}, function: #{last_def}"
  end
end

def grep_files(directory, blocks, new_blocks)
  original_dir = Dir.pwd
  if(directory == "lib")
    Dir.chdir "../lib"
    glob = '**/*.rb'
  else
    Dir.chdir "../app/#{directory}"
    glob = '**/*'
  end
  results = []
  last_def = ""
  Dir[glob].sort.each do |file_name|
    next unless File.file?(file_name)
    File.open file_name do |file|
      file_name = "#{directory}/#{file_name}"
      file.each_with_index do |line,line_number|
        if line.match("def ")
          last_def = line.strip
        end
        blocks.each do |block_name, block|
          if(find_call_to_block(block_name, file_name, line))
            caller_block_name = current_block_name(file_name, last_def)
            if(caller_block_name)
              # we are going to be running this function multiple times
              # over different directories. The directories shouldn't overlap
              # so there should never be an existing new_block
              new_blocks[caller_block_name] = {}
              if(block["callers"].nil?)
                block["callers"] = []
              end
              block["callers"] << {
                "name"=> caller_block_name,
                "line"=> line.strip
              }
            end
          end
        end
      end
    end
  end
  Dir.chdir original_dir
  results
end

require 'yaml'

# now we iterate until new_blocks is empty
# on each iteration we go through all of the files and for
# on each line we search for references to each new_block
# the searching is customized based on the current file name
# and block being searched for
while(blocks_to_process.length > 0)
  puts "Looking for callers of:"
  puts blocks_to_process.keys.to_yaml
  # initially we are just going to search the views directory but this will
  # expand to search the full app directly and the lib folder too
  new_blocks = {}
  grep_files("views", blocks_to_process, new_blocks)
  grep_files("helpers", blocks_to_process, new_blocks)
  grep_files("lib", blocks_to_process, new_blocks)
  grep_files("controllers", blocks_to_process, new_blocks)

  # this will overwrite any existing known_blocks that match the processed blocks
  # that case shouldn't happen though
  known_blocks.merge!(blocks_to_process)
  # inorder to handle circular references we should not re-process any new_blocks
  # that are known already
  blocks_to_process = {}
  new_blocks.each do |key,value|
    blocks_to_process[key] = value unless known_blocks.has_key?(key)
  end
end

# Add reverse tree info too
known_blocks.each do |name, block|
  next if block["callers"].nil?

  block["callers"].each do |caller|
    caller_name = caller["name"]
    calls = known_blocks[caller_name]["calls"]
    if calls.nil?
      calls = []
      known_blocks[caller_name]["calls"] = calls
    end
    calls << name
  end
end

sorted_known_blocks = known_blocks.sort.to_h
puts "Found Block Names:"
puts sorted_known_blocks.keys.to_yaml

output_file = "../../docs/prototype-rails-references.yaml"
puts "Writing: #{output_file}"

File.open(output_file, 'w') do |f|
  f.write "# This was generated using: cd rails/script; ruby reference-tree.rb\n"
  f.write sorted_known_blocks.to_yaml
end