| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575 |
- #!/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
|