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] # def strings_file_refs @strings_file_refs ||= find_file_refs_by_extname '.strings' end # Select strings files by language # # @return [Array] # 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] strings_file_refs # # @return [Set] # def derive_used_languages(strings_file_refs) strings_file_refs.map(&:name).to_set end # All used languages in the project # # @return [Set] # 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] # 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] # 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] # 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] # 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]{3}){2}/.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