Managing Xcode project files programmatically
Let’s say you have a code generation tool that adds and deletes files from the project’s sources.
In addition to physically doing so, Xcode requires updating the project file to reflect these changes, i.e. adding and deleting file references.
Here is where the tricky part starts because this project.pbxproj
file is an endless old-style ASCII property list that is not meant to be edited manually. So, how do we deal with it?
Xcodeproj to the rescue
There is one good old friend called Xcodeproj that can help us with that. It’s a Ruby gem made by CocoaPods team that allows us to programmatically manipulate Xcode project files.
gem install xcodeproj
In my humble opinion, the documentation is not its strong suit. It took me almost a day to build and test the script below. I’m even a little jealous of you. Anyways, fasten the belt and follow the comments. Let’s jump right into it.
Picturesque script
# import the gem
require 'xcodeproj'
# create a function that selects all swift files that start with a given prefix
def select_swift_files_from(files:, that_start_with:)
files.select do |f|
f.start_with?(*that_start_with)
end.map do |f|
f.split.drop(1).join
end.select do |f|
f.end_with?('.swift')
end
end
# create an Xcode project object
project = Xcodeproj::Project.open(xcode_project)
# list all changed files
changed_files = `git status -s`.split("\n").map(&:strip)
# select all deleted swift files
deleted_swift_files = select_swift_files_from(files: changed_files, that_start_with: 'D')
# ...added ones
added_swift_files = select_swift_files_from(files: changed_files, that_start_with: ['A', '??'])
# ...renamed ones
renamed_swift_files = select_swift_files_from(files: changed_files, that_start_with: 'R')
# distribute renamed swift files between deleted and added ones
renamed_swift_files.each do |renamed_file|
content = renamed_file.split.drop(1).join.split('->').map(&:strip)
deleted_swift_files << content.first
added_swift_files << content.last
end
deleted_swift_files.each do |file_path|
# get the file reference
file = project.files.find { |f| f.full_path.to_s == file_path }
# delete the reference only if it exists
file.remove_from_project if file
end
added_swift_files.each do |file_path|
# determine if the file reference already exists
file_reference_exists = project.files.find { |f| f.full_path.to_s == file_path }
# move on to the next file if it does
next if file_reference_exists
# assign the main group to a variable
group = project.main_group
# represent the file path as an array of subfolders
split_file_path = file_path.split('/')
# get the file name and remove it from the array
file_name = split_file_path.pop
split_file_path.each do |subfolder|
if group[subfolder]
# move on to the next subfolder if the current one already has a reference
group = group[subfolder]
else
# create a new Xcode group if the current subfolder doesn't have a reference
group = group.new_group(subfolder)
# for some reason Xcodeproj sets the full path for the newly created group,
# whereas Xcode expects it to be just a folder name ¯\_(ツ)_/¯
group.path = subfolder
end
end
# create a new file reference
file_reference = group.new_file(file_path)
# once again, replace the full path in file reference to a file name
file_reference.path = file_name
# add the file reference to the first (main) target's `Compile Sources` build phase
# even though the main target will work for most cases, you might want to replace it with a specific one
project.targets.first.source_build_phase.add_file_reference(file_reference)
end
# save the project
project.save
Afterword
If you’re a Swift guy, you’d probably like to rewrite everything with XcodeProj made by Tuist, and I think that should be fun.
Let me know if you do, and share the source code, I’d love to see and try it out 🤠