2 minute read

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 🤠

Updated: