balinterdi/missing_t

View on GitHub
lib/missing_t.rb

Summary

Maintainability
A
1 hr
Test Coverage
require "yaml"

#TODO: Should I feel about these 'global' helper functions?
def hashify(segments, value)
  return {} if segments.empty?
  s, *rest = segments
  if rest.empty?
    { s => value }
  else
    { s => hashify(rest, value) }
  end
end

def print_hash(h, level)
  h.each_pair do |k,v|
    if v.respond_to?(:each_pair)
      puts %(#{" " * (level*2)}#{k}:)
      print_hash(v, level+1)
    else
      puts %(#{" " * (level*2)}#{k}: #{v})
    end
  end
end

class Hash
  # idea snatched from deep_merge in Rails source code
  def deep_safe_merge(other_hash)
    self.merge(other_hash) do |key, oldval, newval|
      oldval = oldval.to_hash if oldval.respond_to?(:to_hash)
      newval = newval.to_hash if newval.respond_to?(:to_hash)
      if oldval.is_a? Hash
        if newval.is_a? Hash
          oldval.deep_safe_merge(newval)
        else
          oldval
        end
      else
        newval
      end
    end
  end

  def deep_safe_merge!(other_hash)
    replace(deep_safe_merge(other_hash))
  end

end

class MissingT

  class FileReader
    def read(file)
      IO.readlines(file).each do |line|
        yield line
      end
    end
  end

  VERSION = "0.4.1"

  def initialize(options={})
    @reader = options.fetch(:reader, FileReader.new)
    @languages = options[:languages]
    @path = options[:path]
  end

  def run
    missing = {}
    collect_missing.each do |file, message_strings|
      message_strings.each do |message_string, value|
        missing.deep_safe_merge! hashify(message_string.split('.'), value)
      end
    end

    missing.each do |language, missing_for_language|
      puts
      puts "#{language}:"
      print_hash(missing_for_language, 1)
    end
  end

  def collect_missing
    ts = translation_keys
    #TODO: If no translation keys were found and the languages were not given explicitly
    # issue a warning and bail out
    languages = @languages ? @languages : ts.keys
    get_missing_translations(translation_keys, translation_queries, languages)
  end

  def get_missing_translations(keys, queries, languages)
    languages.each_with_object({}) do |lang, missing|
      get_missing_translations_for_language(keys, queries, lang).each do |file, queries_for_language|
        missing[file] ||= {}
        missing[file].merge!(queries_for_language)
      end
    end
  end

  def translation_keys
    locales_pathes = ["config/locales/**/*.yml", "vendor/plugins/**/config/locales/**/*yml", "vendor/plugins/**/locale/**/*yml"]
    locales_pathes.each_with_object({}) do |path, translations|
      Dir.glob(path) do |file|
        t = open(file) { |f| YAML.load(f.read) }
        translations.deep_safe_merge!(t)
      end
    end
  end

  def translation_queries
    files_with_i18n_queries.each_with_object({}) do |file, queries|
      queries_in_file = extract_i18n_queries(file)
      if queries_in_file.any?
        queries[file] = queries_in_file
      end
    end
    #TODO: remove duplicate queries across files
  end

  def has_translation?(keys, lang, query)
    i18n_label(lang, query).split('.').each do |segment|
      return false unless segment =~ /#\{.*\}/ or (keys.respond_to?(:key?) and keys.key?(segment))
      keys = keys[segment]
    end
    true
  end

  def files_with_i18n_queries
    if @path
      path = File.expand_path(@path)
      if File.file?(path)
        [@path]
      else
        path.chomp!('/')
        [
          Dir.glob("#{path}/**/*.erb"),
          Dir.glob("#{path}/**/*.haml"),
          Dir.glob("#{path}/**/*.rb")
        ]
      end
    else
      [
        Dir.glob("app/**/*.erb"),
        Dir.glob("app/**/*.haml"),
        Dir.glob("app/**/models/**/*.rb"),
        Dir.glob("app/**/controllers/**/*.rb"),
        Dir.glob("app/**/helpers/**/*.rb")
      ]
    end.flatten
  end

  def extract_i18n_queries(file)
    ({}).tap do |queries|
      @reader.read(File.expand_path(file)) do |line|
        qs = scan_line(line)
        queries.merge!(qs)
      end
    end
  end

private

  def get_missing_translations_for_language(keys, queries, l)
    queries.each_with_object({}) do |(file, queries_in_file), missing_translations|
      queries_with_no_translation = queries_in_file.reject { |q, _| has_translation?(keys, l, q) }
      if queries_with_no_translation.any?
        missing_translations[file] = add_langauge_prefix(queries_with_no_translation, l)
      end
    end
  end

  def add_langauge_prefix(qs, l)
    qs.each_with_object({}) do |(q, v), with_prefix|
      with_prefix["#{l}.#{q}"] = v
    end
  end

  def i18n_label(lang, query)
    "#{lang}.#{query}"
  end

  def scan_line(line)
    with_parens = /[^\w]+(?:I18n\.translate|I18n\.t|translate|t)\s*\((['"](.*?)['"].*?)\)/
    no_parens = /[^\w]+(?:I18n\.translate|I18n\.t|translate|t)\s+(['"](.*?)['"].*?)/
    [with_parens, no_parens].each_with_object({}) do |pattern, extracted_queries|
      line.scan(pattern).each do |m|
        if m.any?
          message_string = m[1]
          _, *options = m[0].split(',')
          extracted_queries[message_string] = extract_default_value(options)
        end
      end
    end
  end

  def extract_default_value(message_string_options)
    [/:default\s*=>\s*['"](.*)['"]/, /default:\s*['"](.*)['"]/].each do |default_extractor|
      message_string_options.each do |option|
        if default_key_match=default_extractor.match(option)
          return default_key_match[1]
        end
      end
    end
    ''
  end


end