Files
firebase-ios-sdk/scripts/sync_project.rb
T
Gil 0729c9dfe9 Add tvOS support to Firestore (#2662)
* Create tvOS host app

* Standard project edits

  * Remove code signing
  * Force C99/C++11
  * Adjust plist file for new location
  * header search paths
  * LDFLAGS

* Add Firestore_{Tests,IntegrationTests}_tvOS

* Add tvOS tests to the Podfile

* Add Firestore tvOS xcodebuild to build.sh

* Teach sync_project to keep tvOS up-to-date

* Add Firestore tvOS to Travis
2019-03-28 11:43:43 -07:00

576 lines
15 KiB
Ruby
Executable File

#!/usr/bin/ruby
# Copyright 2018 Google
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Syncs Xcode project folder and target structure with the filesystem. This
# script finds all files on the filesystem that match the patterns supplied
# below and changes the project to match what it found.
#
# Run this script after adding/removing tests to keep the project in sync.
require 'pathname'
# Note that xcodeproj 1.5.8 appears to be broken
# https://github.com/CocoaPods/Xcodeproj/issues/572
gem 'xcodeproj', '!= 1.5.8'
require 'xcodeproj'
def main()
# Make all filenames relative to the project root.
Dir.chdir(File.join(File.dirname(__FILE__), '..'))
sync_firestore()
end
def sync_firestore()
project = Xcodeproj::Project.open('Firestore/Example/Firestore.xcodeproj')
# Enable warnings after opening the project to avoid the warnings in
# xcodeproj itself
$VERBOSE = true
s = Syncer.new(project, Dir.pwd)
# Files on the filesystem that should be ignored.
s.ignore_files = [
'CMakeLists.txt',
'InfoPlist.strings',
'*.orig',
'*.plist',
'.*',
]
# Folder groups in the Xcode project that contain tests.
s.test_groups = [
'Tests',
'CoreTests',
'CoreTestsProtos',
'SwiftTests',
]
['iOS', 'macOS', 'tvOS'].each do |platform|
s.target "Firestore_Tests_#{platform}" do |t|
t.source_files = [
'Firestore/Example/Tests/**',
'Firestore/core/test/**',
'Firestore/Protos/cpp/**',
'Firestore/third_party/Immutable/Tests/**',
]
t.exclude_files = [
# needs to be in project but not in target
'Firestore/Example/Tests/Tests-Info.plist',
# These files are integration tests, handled below
'Firestore/Example/Tests/Integration/**',
]
end
end
['iOS', 'macOS', 'tvOS'].each do |platform|
s.target "Firestore_IntegrationTests_#{platform}" do |t|
t.source_files = [
'Firestore/Example/Tests/Integration/**',
'Firestore/Example/Tests/Util/FSTEventAccumulator.mm',
'Firestore/Example/Tests/Util/FSTHelpers.mm',
'Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm',
'Firestore/Example/Tests/Util/XCTestCase+Await.mm',
'Firestore/Example/Tests/en.lproj/InfoPlist.strings',
'Firestore/core/test/firebase/firestore/testutil/**',
]
end
end
s.sync()
sort_project(project)
if project.dirty?
project.save()
end
end
# The definition of a test target including the target name, its source_files
# and exclude_files. A file is considered part of a target if it matches a
# pattern in source_files but does not match a pattern in exclude_files.
class TargetDef
def initialize(name)
@name = name
@source_files = []
@exclude_files = []
end
attr_accessor :name, :source_files, :exclude_files
# Returns true if the given relative_path matches this target's source_files
# but not its exclude_files.
#
# Args:
# - relative_path: a Pathname instance with a path relative to the project
# root.
def matches?(relative_path)
return matches_patterns(relative_path, @source_files) &&
!matches_patterns(relative_path, @exclude_files)
end
private
# Evaluates the relative_path against the given list of fnmatch patterns.
def matches_patterns(relative_path, patterns)
patterns.each do |pattern|
if relative_path.fnmatch?(pattern)
return true
end
end
return false
end
end
class Syncer
def initialize(project, root_dir)
@project = project
@root_dir = Pathname.new(root_dir)
@finder = DirectoryLister.new(@root_dir)
@seen_groups = {}
@test_groups = []
@targets = []
end
# Considers the given fnmatch glob patterns to be ignored by the syncer.
# Patterns are matched both against the basename and project-relative
# qualified pathname.
def ignore_files=(patterns)
@finder.add_patterns(patterns)
end
# Names the groups within the project that serve as roots for tests within
# the project.
def test_groups=(groups)
@test_groups = []
groups.each do |group|
project_group = @project[group]
if project_group.nil?
raise "Project does not contain group #{group}"
end
@test_groups.push(@project[group])
end
end
# Starts a new target block. Creates a new TargetDef and yields it.
def target(name, &block)
t = TargetDef.new(name)
@targets.push(t)
block.call(t)
end
# Synchronizes the filesystem with the project.
#
# Generally there are three separate ways a file is referenced within a project:
#
# 1. The file must be in the global list of files, assigning it a UUID.
# 2. The file must be added to folder groups, describing where it is in the
# folder view of the Project Navigator.
# 3. The file must be added to a target describing how it's built.
#
# The Xcodeproj library handles (1) for us automatically if we do (2).
#
# Synchronization essentially proceeds in two steps:
#
# 1. Sync the filesystem structure with the folder group structure. This has
# the effect of bringing (1) and (2) into sync.
# 2. Sync the global list of files with the targets.
def sync()
group_differ = GroupDiffer.new(@finder)
group_diffs = group_differ.diff(@test_groups)
sync_groups(group_diffs)
@targets.each do |target_def|
sync_target(target_def)
end
end
private
def sync_groups(diff_entries)
diff_entries.each do |entry|
if !entry.in_source && entry.in_target
remove_from_project(entry.ref)
end
if entry.in_source && !entry.in_target
add_to_project(entry.path)
end
end
end
# Removes the given file reference from the project after the file is found
# missing but references to it still exist in the project.
def remove_from_project(file_ref)
group = file_ref.parents[-1]
mark_change_in_group(relative_path(group))
puts " #{basename(file_ref)} - removed"
# If the file is gone, any build phase that refers to must also remove the
# file. Without this, the project will have build file references that
# contain no actual file.
@project.native_targets.each do |target|
target.build_phases.each do |phase|
if phase.include?(file_ref)
phase.remove_file_reference(file_ref)
end
end
end
file_ref.remove_from_project
end
# Adds the given file to the project, in a path starting from the test root
# that fully prefixes the file.
def add_to_project(path)
root_group = find_test_group_containing(path)
# Find or create the group to contain the path.
dir_rel_path = path.relative_path_from(root_group.real_path).dirname
group = root_group.find_subpath(dir_rel_path.to_s, true)
mark_change_in_group(relative_path(group))
file_ref = group.new_file(path.to_s)
puts " #{basename(file_ref)} - added"
return file_ref
end
# Finds a test group whose path prefixes the given entry. Starting from the
# project root may not work since not all test directories exist within the
# example app.
def find_test_group_containing(path)
@test_groups.each do |group|
rel = path.relative_path_from(group.real_path)
next if rel.to_s.start_with?('..')
return group
end
raise "Could not find an existing test group that's a parent of #{entry.path}"
end
def mark_change_in_group(group)
path = group.to_s
if !@seen_groups.has_key?(path)
puts "#{path} ..."
@seen_groups[path] = true
end
end
SOURCES = %w{.c .cc .m .mm}
def sync_target(target_def)
target = @project.native_targets.find { |t| t.name == target_def.name }
if !target
raise "Missing target #{target_def.name}"
end
files = find_files_for_target(target_def)
sources, resources = classify_files(files)
sync_build_phase(target, target.source_build_phase, sources)
end
def classify_files(files)
sources = {}
resources = {}
files.each do |file|
path = file.real_path
ext = path.extname
if SOURCES.include?(ext)
sources[path] = file
end
end
return sources, resources
end
def sync_build_phase(target, phase, sources)
# buffer changes to the phase to avoid modifying the array we're iterating
# over.
to_remove = []
phase.files.each do |build_file|
source_path = build_file.file_ref.real_path
if sources.has_key?(source_path)
# matches spec and existing target no action taken
sources.delete(source_path)
else
# in the phase but now missing in the groups
to_remove.push(build_file)
end
end
to_remove.each do |build_file|
mark_change_in_group(target.name)
source_path = build_file.file_ref.real_path
puts " #{relative_path(source_path)} - removed"
phase.remove_build_file(build_file)
end
sources.each do |path, file_ref|
mark_change_in_group(target.name)
phase.add_file_reference(file_ref)
puts " #{relative_path(file_ref)} - added"
end
end
def find_files_for_target(target_def)
result = []
@project.files.each do |file_ref|
next if file_ref.source_tree != '<group>'
rel = relative_path(file_ref)
if target_def.matches?(rel)
result.push(file_ref)
end
end
return result
end
def normalize_to_pathname(file_ref)
if !file_ref.is_a? Pathname
if file_ref.is_a? String
file_ref = Pathname.new(file_ref)
else
file_ref = file_ref.real_path
end
end
return file_ref
end
def basename(file_ref)
return normalize_to_pathname(file_ref).basename
end
def relative_path(file_ref)
file_ref = normalize_to_pathname(file_ref)
return file_ref.relative_path_from(@root_dir)
end
end
def sort_project(project)
project.groups.each do |group|
sort_group(group)
end
project.targets.each do |target|
target.build_phases.each do |phase|
phase.files.sort! { |a, b|
a.file_ref.real_path.basename <=> b.file_ref.real_path.basename
}
end
end
end
def sort_group(group)
group.groups.each do |child|
sort_group(child)
end
group.children.sort! do |a, b|
# Sort groups first
if a.isa == 'PBXGroup' && b.isa != 'PBXGroup'
-1
elsif a.isa != 'PBXGroup' && b.isa == 'PBXGroup'
1
elsif a.display_name && b.display_name
File.basename(a.display_name) <=> File.basename(b.display_name)
else
0
end
end
end
# Tracks how a file is referenced: in the project file, on the filesystem,
# neither, or both.
class DiffEntry
def initialize(path)
@path = path
@in_source = false
@in_target = false
@ref = nil
end
attr_reader :path
attr_accessor :in_source, :in_target, :ref
end
# Diffs folder groups against the filesystem directories referenced by those
# folder groups.
#
# This performs the diff starting from the directories referenced by the test
# groups in the project, finding files contained within them. When comparing
# the files it finds against the project this acts on absolute paths to avoid
# problems with arbitrary additional groupings in project structure that are
# standard, e.g. "Supporting Files" or "en.lproj" which either act as aliases
# for the parent or are folders that are omitted from the project view.
# Processing the diff this way allows these warts to be tolerated, even if they
# won't necessarily be recreated if an artifact is added to the filesystem.
class GroupDiffer
def initialize(dir_lister)
@dir_lister = dir_lister
@entries = {}
@dirs = {}
end
# Finds all tests on the filesystem contained within the paths of the given
# test groups and computes a list of DiffEntries describing the state of the
# files.
#
# Args:
# - groups: A list of PBXGroup objects representing folder groups within the
# project that contain tests.
#
# Returns:
# A list of DiffEntry objects, one for each test found. If the test exists on
# the filesystem, :in_source will be true. If the test exists in the project
# :in_target will be true and :ref will be set to the PBXFileReference naming
# the file.
def diff(groups) groups.each do |group| diff_project_files(group) end
return @entries.values.sort { |a, b| a.path.basename <=> b.path.basename }
end
private
# Recursively traverses all the folder groups in the Xcode project and finds
# files both on the filesystem and the group file listing.
def diff_project_files(group)
find_fs_files(group.real_path)
group.groups.each do |child|
diff_project_files(child)
end
group.files.each do |file_ref|
path = file_ref.real_path
entry = track_file(path)
entry.in_target = true
entry.ref = file_ref
if path.file?
entry.in_source = true
end
end
end
def find_fs_files(parent_path)
# Avoid re-traversing the filesystem
if @dirs.has_key?(parent_path)
return
end
@dirs[parent_path] = true
@dir_lister.entries(parent_path).each do |path|
if path.directory?
find_fs_files(path)
next
end
entry = track_file(path)
entry.in_source = true
end
end
def track_file(path)
if @entries.has_key?(path)
return @entries[path]
end
entry = DiffEntry.new(path)
@entries[path] = entry
return entry
end
end
# Finds files on the filesystem while ignoring files that have been declared to
# be ignored.
class DirectoryLister
def initialize(root_dir)
@root_dir = root_dir
@ignore_basenames = ['.', '..']
@ignore_pathnames = []
end
def add_patterns(patterns)
patterns.each do |pattern|
if File.basename(pattern) != pattern
@ignore_pathnames.push(File.join(@root_dir, pattern))
else
@ignore_basenames.push(pattern)
end
end
end
# Finds filesystem entries that are immediate children of the given Pathname,
# ignoring files that match the global ignore_files patterns.
def entries(path)
result = []
path.entries.each do |entry|
next if ignore_basename?(entry)
file = path.join(entry)
next if ignore_pathname?(file)
result.push(file)
end
return result
end
private
def ignore_basename?(basename)
@ignore_basenames.each do |ignore|
if basename.fnmatch(ignore)
return true
end
end
return false
end
def ignore_pathname?(file)
@ignore_pathnames.each do |ignore|
if file.fnmatch(ignore)
return true
end
end
return false
end
end
if __FILE__ == $0
main()
end