The XcodeProj Package

Published: May 9, 2024

If you’re a developer that programs in Swift, changes are extremely high that you are familiar with Xcode. In this post, you’ll meet a package that can be very helpful when dealing with Xcode project files, and learn how to create a small tool to analyze them, and that can serve as inspiration for your own!

The .xcodeproj and .pbxproj files

If you worked with Xcode, you also probably had some git conflicts in the past which were quite hard to solve. This is because the .xcodeproj file is, in reality, a folder containing a .pbxproj file: an old-style plist file very hard to read visually. Fun fact: it was created by NeXTSTEP, even before Apple acquired them!

The .pbxproj file contains all the information of your project: its build settings, targets, and their settings, sources, resources, linked frameworks and more. Therefore, any changes to an Xcode project, such as adding a new target, or adding, removing, and moving files, are stored in this project file.

The IceCubes app pbxproj file, with almost 2,000 lines The IceCubes app pbxproj file, with almost 2,000 lines

.xcodeproj Tooling

Along the years, the iOS community has developed multiple solutions to enable working with Xcode project in a programmatic way, allowing to build tools on top of it. The lack of a first party dependency management solution, by Apple, led to a community-driven effort to allow integrating open source frameworks: CocoaPods. And to create Xcode projects with pod dependencies and modifying your project to import them, a foundation for generating projects was essential. To solve this, they built the Xcodeproj gem, and as CocoaPods, it is written in Ruby and can be used in Ruby scripts.

Another area where tooling around .pbxproj files is extremely useful is project generation. When working with other developers on a project, quite frequently changes in different branches can lead to git merge conflicts on the .pbxproj file. To avoid having to deal with it, there are a few alternatives out there that can convert a project specification (or manifest) into an .xcodeproj file. This way, it doesn’t need to be checked into source control, and can be generated at any time! But we’ll talk about this subject in a future post.

The XcodeProj Package

One of these tools is Tuist, which helps maintaining and scaling Xcode projects. As they built this tool in Swift, there was a need to be able to generate, read and write projects, also using Swift. For that reason, they built the XcodeProj package, which nowadays powers other open source tools as well. Per Tuist’s creator own words:

We wanted to build Tuist in Swift to encourage contributions, but the only library for reading, updating, and writing Xcode projects was written in Ruby. It took a lot of reverse-engineering of Xcode project internals and looking at the Ruby implementation to pull it off.

Pedro Piñera

Everything that you can do on the Xcode UI, you can do with XcodeProj: change project or target settings, change a target version, create targets, add or remove files to a target, and the list goes on.

Being a Swift package, you can use it in your own Swift tools and get started with it.

A Sample Tool to Analyze Projects

For this article, you will build a tool that is able to analyze a given Xcode project at a path, and print a summary of its targets. Although you don’t have to use the Swift Argument Parser, this tutorial will use it as a starting point, so you can extend it later.

On an empty folder called ProjectAnalyzer, run the following command on Terminal:

swift package init --type tool

This will create a Package.swift file, with an executable target containing the argument parser as dependency. Double click the Package.swift file on Finder, or run xed . to open the newly created tool on Xcode.

Adding the XcodeProj Dependency

To add a dependency on XcodeProj, you add it as a regular package dependency. First, add the package dependency:

dependencies: [
    // other dependencies above
    // .package(...)
    .package(url: "https://github.com/tuist/XcodeProj.git", from: "8.20.0")
]

Then add the XcodeProj product as a target dependency:

targets: [
    .executableTarget(
        name: "ProjectAnalyzer",
        dependencies: [
            // other dependencies above
            // .product(...)
            .product(name: "XcodeProj", package: "XcodeProj"),
        ]
    )
]

Give Xcode a few seconds, and you should see the resolved packages on the navigator. Notice how XcodeProj also brings PathKit, a package that helps dealing with and manipulating paths.

The path Option

As mentioned above, this command will receive a path as a named argument (an @Option), and try to open the Xcode project on that path and read some of its properties.

First, open the starting point of the tool, containing the @main attribute. If you followed the previous steps, it’s the ProjectAnalyzer.swift file. There, at the top, import both dependencies:

import PathKit
import XcodeProj

Now, add the path option to your command, inside the ProjectAnalyzer struct:

@Option(transform: { argument in Path.current + argument })
var path: Path

Notice a few things this piece of code does:

  • Declares an option to accept a custom path. An option is a named argument, so the user can pass --path ~/Some/Path/To/MyApp.xcodeproj
  • The type of the property is Path, and not a string. To convert it to a Path, you can use the transform argument in the Option initializer. It receives a string, and returns the type of the property. As most respectable CLI tools, it takes into account the current working directory, and appends the argument to calculate the absolute path.

Now with the path ready, it’s time to read the project.

Reading Xcode Projects

Inside the run() method, add the following code:

//1
print("Analyzing .xcodeproj at:", path)

//2
let xcodeProj = try XcodeProj(path: path)

//3
try xcodeProj.pbxproj.projects.forEach { pbxProject in
  //4
  print("Project (pbxProject.name):")
  print("\t・(pbxProject.remotePackages.count) remote package(s)")
  print("\t・(pbxProject.localPackages.count) local package(s)")
  print("")
  try analyzeProjectTargets(pbxProject.targets)
}

You’ll see a build error as the last line calls an inexistent function. You’ll fix it soon, but first, a brief explanation of the code above:

  1. Let the user know the full, absolute given path
  2. Use the XcodeProj initializer that accepts a Path, to create an instance of it. Notice how this function requires using try, as the file might not exist at the path.
  3. As an .xcodeproj can technically contain more than one .pbxproj, iterate each one of them.
  4. Print the project name, the count of remote and local packages, and finally call the function that will analyze the targets in the project.

Reading a Target’s Properties

Now, the last step for building this tool. Add a method that will access each target’s properties we want to inspect, and print them:

private func analyzeProjectTargets(_ targets: [PBXTarget]) throws {
  try targets.forEach { target in
    print("\t・Target (target.name) ((target.productType?.fileExtension ?? "")):")
    print("\t\t・(try target.sourceFiles().count) source file(s)")
    print("\t\t・(try target.resourcesBuildPhase()?.files?.count ?? 0) resource(s)")
    print("\t\t・(target.packageProductDependencies.count) package dependencies")
    print("\t\t・(target.dependencies.count) dependencies")
    print("")
  }
}

This function takes each target, and prints the some information about them: the target name and its type (app, app extension), and the amount of source files, resources, and dependencies. Notice the usage of the \t character, a tab, to make the output more readable.

Running the Tool

To run the tool, you can either do it via Xcode or Terminal. To run via Xcode, remember to set the correct arguments by editing the scheme:

Opening the scheme Opening the scheme Opening the scheme Editing the scheme to pass arguments when running Editing the scheme to pass arguments when running Editing the scheme to pass arguments when running

To run via Terminal, in the root directory of the tool, run the following:

swift run ProjectAnalyzer --path <your-project-path>

Replace <your-project-path> with the path of the Xcode project you want the tool to run for.

For this tutorial, we’ll use the IceCubes app, an open source Mastodon client. When running the tool passing the path for the IceCubesApp.xcodeproj file, this is the result:

The output of ProjectAnalyzer The output of ProjectAnalyzer

When comparing the results with the target details on Xcode, it checks out 👍

The packages IceCubes depends on The packages IceCubes depends on The IceCubes main app target with numbers matching the tool output The IceCubes main app target with numbers matching the tool output

Challenge

Now is time for you to build your tools with XcodeProj!

As a challenge for this article, try modifying the tool to allow it to read multiple projects in a workspace.


Ideas for Tools

Here are some ideas of tools using XcodeProj that could improve your and your team workflow:

  • Run project validations, for example to ensure consistency throughout the project and targets. An existing tool that does something similar is XCLint by Matt Massicotte
  • Ensure that the group hierarchy matches the file-system
  • Prevent scripts from being added that might pose security risks
  • Creating your own DSL that converts a manifest or a configuration file to an XcodeProj, like Tuist does

Explore Further

Congrats for reaching the end of another article!

The source code of the tool in this post can be found on GitHub.

While we covered only reading .xcodeproj files but not writing, you could explore how to save changes (to your project or targets) back to the disk. To learn more, check out the XcodeProj package at GitHub, and its API reference documentation.

If you got interested by project generation, check out Tuist and XcodeGen.

If you want to learn more about building tools with the Swift Argument Parser, don’t miss our 3-part series on it!

See you at the next post. Have a good one!
Pure human brain content, no Generative AI added
Swift, Xcode, the Swift Package Manager, iOS, macOS, watchOS and Mac are trademarks of Apple Inc., registered in the U.S. and other countries.

© Swift Toolkit, 2024