18F/identity-idp

View on GitHub
lib/makefile_help_parser.rb

Summary

Maintainability
A
0 mins
Test Coverage
B
82%
# frozen_string_literal: true

require 'open3'

# Parses comment strings (## help) out of the Makefile
# and also uses the `make --print-data-base` output to expand out targets based on variables
class MakefileHelpParser
  Rule = Struct.new(:target, :template, :comment, keyword_init: true)

  attr_reader :makefile_path

  def initialize(makefile_path = 'Makefile')
    @makefile_path = makefile_path
  end

  # @return [String]
  def pretty_rules
    rules = parse_rules

    longest_target = rules.map { |r| r.target.size }.max
    longest_template = rules.map { |r| r.template&.size.to_i }.max

    rules.sort_by(&:target).map do |rule|
      [
        blue(rule.target.ljust(longest_target)),
        (rule.template || '').ljust(longest_template),
        rule.comment,
      ].join(' ')
    end.join("\n")
  end

  def blue(str)
    "\033[36m#{str}\033[0m"
  end

  # @return [Array<Rule>]
  def parse_rules
    target_comments = build_target_comments
    expanded_targets = build_expanded_targets

    target_comments.map do |target, (comment, lineno)|
      if target.include?('$(')
        Rule.new(
          target: matching_target(
            lineno: lineno,
            template: target,
            target_comments: target_comments,
            expanded_targets: expanded_targets,
          ),
          template: target,
          comment: comment,
        )
      else
        Rule.new(
          target: target,
          comment: comment,
        )
      end
    end
  end

  # @param [Integer] lineno line number in original Makefile
  # @param [String] template like "tmp/$(HOST)-$(PORT).crt"
  # @param [Hash<String, Array(String, Integer)>] target_comments (see #build_target_comments)
  # @return [Hash<String, Array(String, Integer)>] expanded_targets (see #build_expanded_targets)
  # @return [String, nil] a target that matches it like "tmp/localhost-3000.crt"
  def matching_target(lineno:, template:, target_comments:, expanded_targets:)
    # "tmp/$(HOST)-$(PORT).crt" into %r|tmp/.+?-.+?.crt|
    rule_regexp = Regexp.new(template.gsub(/\$\([^)]+\)/, '.+?'))

    expanded_targets[lineno].find do |rule|
      rule_regexp.match?(rule) && !target_comments.key?(rule)
    end
  end

  # Map of target => [comment, lineno]
  # target might have variables like $(HOST)
  # @return [Hash<String, Array(String, Integer)>]
  def build_target_comments
    raw_makefile = File.readlines(makefile_path)

    raw_makefile.map.with_index.select do |line, _lineno|
      line.include?(' ## ')
    end.flat_map do |line, lineno|
      targets, rest = line.chomp.split(':', 2)
      _sources, comment = rest.split(' ## ')

      targets.split(' ').map { |target| [target, [comment, lineno + 2]] }
    end.to_h
  end

  # Maps line numbers to expanded targets
  # @return [Hash<Integer, Set<String>>]
  def build_expanded_targets
    expanded_makefile, _stderr, _status = Open3.capture3(
      'make', '-f', makefile_path, '--dry-run', '--print-data-base'
    )

    targets = Hash.new { |h, k| h[k] = Set.new }

    expanded_makefile.split("\n\n").map do |stanza|
      m = stanza.match(/^#  .* \(from [`']#{makefile_path}', line (?<lineno>\d+)\):$/)
      [stanza, m && m[:lineno].to_i]
    end.
      select { |_stanza, lineno| lineno }.
      each do |stanza, lineno|
        target = stanza.split("\n").first.split(':').first

        targets[lineno] << target
      end

    targets
  end
end

# rubocop:disable Rails/Output
if $PROGRAM_NAME == __FILE__
  puts MakefileHelpParser.new.pretty_rules
end
# rubocop:enable Rails/Output