mrackwitz/xcres

View on GitHub
lib/xcres/analyzer/strings_analyzer.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'xcres/analyzer/analyzer'
require 'json'

module XCRes

  # A +StringsAnalyzer+ scans the project for resources,
  # which should be included in the output file.
  #
  class StringsAnalyzer < Analyzer

    # @return [String]
    #         optional two-letter language code conforming ISO 639-1
    attr_accessor :default_language

    # Initialize a new analyzer
    #
    # @param [Xcodeproj::Project] project
    #        see #project.
    #
    # @param [Hash] options
    #        Possible options:
    #        * :default_language => see #default_language.
    #
    def initialize(project=nil, options={})
      super
      self.default_language = options[:default_language]
    end

    def analyze
      log 'Strings files in project: %s', strings_file_refs.map(&:path)
      log 'Native development languages: %s', native_dev_languages.to_a
      log 'Used languages for .strings files: %s', used_languages.to_a
      log 'Preferred languages: %s', languages.to_a
      log 'Strings files after language selection: %s', selected_strings_file_refs.map(&:path)

      @sections = [build_section]
    end

    # Build the section
    #
    # @return [Section]
    #
    def build_section
      selected_file_refs = selected_strings_file_refs

      # Apply ignore list
      file_paths = filter_exclusions(selected_file_refs.map(&:path))
      filtered_file_refs = selected_file_refs.select { |file_ref| file_paths.include? file_ref.path }
      rel_file_paths = filtered_file_refs.map { |p| p.real_path.relative_path_from(Pathname.pwd) }

      log 'Non-ignored .strings files: %s', rel_file_paths.map(&:to_s)

      keys_by_file = {}
      for path in rel_file_paths
        keys_by_file[path] = keys_by_file(path)
      end
      items = keys_by_file.values.reduce({}, :merge)

      new_section('Strings', items)
    end

    # Discover all references to .strings files in project (e.g. Localizable.strings)
    #
    # @return [Array<PBXFileReference>]
    #
    def strings_file_refs
      @strings_file_refs ||= find_file_refs_by_extname '.strings'
    end

    # Select strings files by language
    #
    # @return [Array<PBXFileReference>]
    #
    def selected_strings_file_refs
      @selected_strings_file_refs ||= strings_file_refs.select { |file_ref| languages.include? file_ref.name }
    end

    # Derive the used languages from given strings files
    #
    # @param [Array<PBXFileReference>] strings_file_refs
    #
    # @return [Set<String>]
    #
    def derive_used_languages(strings_file_refs)
      strings_file_refs.map(&:name).to_set
    end

    # All used languages in the project
    #
    # @return [Set<String>]
    #
    def used_languages
      @used_languages ||= derive_used_languages(strings_file_refs)
    end

    # Find preferred languages, which is:
    #   - either only the default_language, if specified
    #   - or the intersection of native development and used languages
    #   - or all used languages
    #
    # @return [Set<String>]
    #
    def languages
      if default_language != nil
        # Use specified default language as primary language
        [default_language]
      else
        # Calculate the intersection of native development and used languages,
        # fallback to the latter only, if it is empty
        languages = native_dev_languages & used_languages
        if languages.empty?
          used_languages
        else
          languages
        end
      end
    end

    # Discover Info.plist files by build settings of the application target
    #
    # @return [Set<Pathname>]
    #         the relative paths to the .plist-files
    #
    def info_plist_paths
      @info_plist_paths ||= target.build_configurations.map do |config|
        config.build_settings['INFOPLIST_FILE']
      end.compact.map { |file| Pathname(file) }.flatten.to_set
    end

    # Absolute file paths to Info.plist files by build settings.
    # See #info_plist_paths.
    #
    # @return [Set<Pathname>]
    #         the absolute paths to the .plist-files
    #
    def absolute_info_plist_paths
      info_plist_paths.map do |path|
        absolute_project_file_path(path)
      end.select do |path|
        if path.to_s.include?('$')
          warn "Couldn't resolve all placeholders in INFOPLIST_FILE %s.", path.to_s
          false
        else
          true
        end
      end
    end

    # Find the native development languages by trying to use the
    # "Localization native development region" from Info.plist
    #
    # @return [Set<String>]
    #
    def native_dev_languages
      @native_dev_languages ||= absolute_info_plist_paths.map do |path|
        begin
          read_plist_key(path, :CFBundleDevelopmentRegion)
        rescue ArgumentError => e
          warn e
        end
      end.compact.to_set
    end

    # Extracts a given key from a plist file given as a path
    #
    # @param  [Pathname] path
    #         the path of the plist file
    #
    # @param  [String] key
    #         the key, whose value should been extracted
    #
    # @return [String]
    #
    def read_plist_key(path, key)
      raise ArgumentError, "File '#{path}' doesn't exist" unless path.exist?
      raise ArgumentError, 'Path is required, but nil' if path.nil?
      raise ArgumentError, 'Key is required, but nil' if key.nil?
      out = `/usr/libexec/PlistBuddy -c "Print :#{key}" "#{path}" 2>&1`.chomp
      raise ArgumentError, "Error reading plist: #{out}" unless $?.success?
      out
    end

    # Read a .strings file given as a path
    #
    # @param [Pathname] path
    #        the path of the strings file
    #
    # @return [Hash]
    #
    def read_strings_file(path)
      raise ArgumentError, "File '#{path}' doesn't exist" unless path.exist?
      raise ArgumentError, "File '#{path}' is not a file" unless path.file?
      error = `plutil -lint -s "#{path}" 2>&1`
      raise ArgumentError, "File %s is malformed:\n#{error}" % path.to_s unless $?.success?
      json_or_error = `plutil -convert json "#{path}" -o -`.chomp
      raise ArgumentError, "File %s couldn't be converted to JSON.\n#{json_or_error}" % path.to_s unless $?.success?
      JSON.parse(json_or_error.force_encoding('UTF-8'))
    rescue EncodingError => e
      raise StandardError, "Encoding error in #{path}: #{e}"
    end

    # Calculate the absolute path for a file path given relative to the
    # project / its `$SRCROOT`.
    #
    # We need either absolute paths or relative paths to our current location.
    # Xcodeproj provides this for +PBXFileReference+, but this doesn't work
    # for file references in build settings.
    #
    # @param  [String|Pathname] file_path
    #         the path relative to the project.
    #
    # @return [Pathname]
    #
    def absolute_project_file_path(file_path)
      source_root = (project.path + '..').realpath
      if file_path.to_s.include?('$')
        Pathname(file_path.to_s.gsub(/\$[({]?SRCROOT[)}]?/, source_root.to_s))
      else
        source_root + file_path
      end
    end

    # Read a file and collect all its keys
    #
    # @param  [Pathname] path
    #         the path to the .strings file to read
    #
    # @return [Hash{String => Hash}]
    #
    def keys_by_file(path)
      begin
        # Load strings file contents
        strings = read_strings_file(path)

        # Reject generated identifiers used by Interface Builder
        strings.reject! { |key, _| /^[a-zA-Z0-9]{3}-[a-zA-Z0-9]{2,3}-[a-zA-Z0-9]{3}/.match(key) }

        keys = Hash[strings.map do |key, value|
          [key, { value: key, comment: value.gsub(/[\r\n]/, ' ') }]
        end]

        log 'Found %s keys in file %s', keys.count, path

        keys
      rescue ArgumentError => error
        raise ArgumentError, 'Error while reading %s: %s' % [path, error]
      end
    end

  end
end