Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Helper methods required for adding build file to the target #213

Merged
merged 7 commits into from
Jan 18, 2018
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- **Breaking:** `XCWorkspace.Data` renamed to `XCWorkspaceData` and removed `references`.
- Improved README examples. https://github.com/xcodeswift/xcproj/pull/212 by @ilyapuchka
- Added methods to get paths to workspace, project and breakpoints and shemes files, added public methods to write them separatery. https://github.com/xcodeswift/xcproj/pull/215 by @ilyapuchka
- Added helper methods for adding source file to the project. https://github.com/xcodeswift/xcproj/pull/213 by @ilyapuchka

## 2.0.0

Expand Down
4 changes: 2 additions & 2 deletions Fixtures/iOS/Project.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@
04D5C0A21F153921008A2F98 /* Private.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Private.h; sourceTree = "<group>"; };
23766C121EAA3484007A9026 /* iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
23766C151EAA3484007A9026 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
23766C171EAA3484007A9026 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = "<group>"; };
23766C171EAA3484007A9026 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ViewController.swift; path = /Users/ilyapuchka/dev/xcproj/Fixtures/iOS/iOS/ViewController.swift; sourceTree = "<absolute>"; };
23766C1A1EAA3484007A9026 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
23766C1C1EAA3484007A9026 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
23766C1F1EAA3484007A9026 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
23766C211EAA3484007A9026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
23766C261EAA3484007A9026 /* iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = iOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
23766C2A1EAA3484007A9026 /* iOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSTests.swift; sourceTree = "<group>"; };
23766C2A1EAA3484007A9026 /* iOSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = iOSTests.swift; path = iOSTests/iOSTests.swift; sourceTree = SOURCE_ROOT; };
23766C2C1EAA3484007A9026 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down
Empty file added Fixtures/iOS/iOS/newfile.swift
Empty file.
22 changes: 22 additions & 0 deletions Sources/xcproj/PBXProj+Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import Foundation
import PathKit

// MARK: - PBXProj Extension (Public)

public extension PBXProj {

/// Returns root project.
public var rootProject: PBXProject? {
return objects.projects[rootObject]
}

/// Returns root project's root group.
public var rootGroup: PBXGroup {
guard let rootProject = self.rootProject else {
fatalError("Missing root project")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is fatalError suitable here?
Maybe we can make rootGroup optional (public var rootGroup: PBXGroup?).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more suitable than requiring to unwrap this property which will be nil only in specific situations when accessed before project is correctly setup. In my opinion it would be better if API would not allow to create objects in such invalid or incomplete states (what it does now as far as I understand), but not sure what it will take to change it in this direction.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @ilyapuchka here. If we use an optional attribute, the developer could wrongly access that attribute and get unexpected behaviours as a result. If we fatalErrror they would know that they are doing something wrong and it'd save them some debugging time.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood!πŸ‘Œ

}
guard let rootGroup = objects.groups[rootProject.mainGroup] else {
fatalError("Root project has no root group")
}
return rootGroup
}

}

// MARK: - PBXProj Extension (Getters)

extension PBXProj {
Expand Down
178 changes: 177 additions & 1 deletion Sources/xcproj/PBXProjObjects+Helpers.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import PathKit

// MARK: - PBXProj.Objects Extension (Public)

Expand All @@ -15,7 +16,182 @@ public extension PBXProj.Objects {
targets.append(contentsOf: Array(aggregateTargets.values) as [PBXTarget])
return targets.filter { $0.name == name }
}


/// Retruns target's sources build phase.
///
/// - Parameter target: target object.
/// - Returns: target's sources build phase, if found.
public func sourcesBuildPhase(target: PBXTarget) -> PBXSourcesBuildPhase? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice additions @ilyapuchka πŸ‘

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sourcesBuildPhases is deprecated. Instead you should use objects.sourcesBuildPhases.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is exactly what is being used, this is extension on PBXObjects.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ‘ sorry, missed that.

return sourcesBuildPhases.first(where: { target.buildPhases.contains($0.key) })?.value
}

/// Returns all files in target's sources build phase.
///
/// - Parameter target: target object.
/// - Returns: all files in target's sources build phase, or empty array if sources build phase is not found.
public func sourceFiles(target: PBXTarget) -> [PBXFileElement] {
return sourcesBuildPhase(target: target)?.files
.flatMap({ buildFiles[$0]?.fileRef })
.flatMap(getFileElement(reference:)) ?? []
}

/// Returns group with the given name contained in the given parent group and its reference.
///
/// - Parameter groupName: group name.
/// - Parameter inGroup: parent group.
/// - Returns: group with the given name contained in the given parent group and its reference.
public func group(named groupName: String, inGroup: PBXGroup) -> (String, PBXGroup)? {
let children = inGroup.children
return groups.first(where: {
children.contains($0.key) && ($0.value.name == groupName || $0.value.path == groupName)
})
}

/// Adds new group with the give name to the given parent group.
/// Group name can be a path with components separated by `/`.
/// Will create new groups for intermediate path components or use existing groups.
/// Returns all new or existing groups in the path and their references.
///
/// - Parameters:
/// - groupName: group name, can be a path with components separated by `/`
/// - toGroup: parent group
/// - options: additional options, default is empty set.
/// - Returns: all new or existing groups in the path and their references.
public func addGroup(named groupName: String, to toGroup: PBXGroup, options: GroupAddingOptions = []) -> [(reference: String, group: PBXGroup)] {
return addGroups(groupName.components(separatedBy: "/"), to: toGroup, options: options)
}

private func addGroups(_ groupNames: [String], to toGroup: PBXGroup, options: GroupAddingOptions) -> [(reference: String, group: PBXGroup)] {
guard !groupNames.isEmpty else { return [] }
let newGroup = createOrGetGroup(named: groupNames[0], in: toGroup, options: options)
return [newGroup] + addGroups(Array(groupNames.dropFirst()), to: newGroup.1, options: options)
}

private func createOrGetGroup(named groupName: String, in parentGroup: PBXGroup, options: GroupAddingOptions) -> (reference: String, group: PBXGroup) {
if let existingGroup = group(named: groupName, inGroup: parentGroup) {
return existingGroup
}

let newGroup = PBXGroup(
children: [],
sourceTree: .group,
name: groupName,
path: options.contains(.withoutFolder) ? nil : groupName
)
let reference = generateReference(newGroup, groupName)
addObject(newGroup, reference: reference)
parentGroup.children.append(reference)
return (reference, newGroup)
}

/// Adds file at the give path to the project or returns existing file and its reference.
///
/// - Parameters:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These parameter descriptions do not match function.

/// - filePath: path to the file.
/// - sourceRoot: path to project's source root.
/// - Returns: new or existing file and its reference.
public func addFile(at filePath: Path, sourceRoot: Path) throws -> (reference: String, file: PBXFileReference) {
guard filePath.isFile else { throw XCodeProjEditingError.notAFile(path: filePath) }

if let existingFileReference = fileReferences.first(where: {
filePath == fullPath(fileElement: $0.value, reference: $0.key, sourceRoot: sourceRoot)
}) {
return (existingFileReference.key, existingFileReference.value)
}

let fileReference = PBXFileReference(
sourceTree: .absolute,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding absolute path is very restrictive. This will restrict the usage of this project to single path in a filesystem. It would be useful only for situations where project is always generated.

I recommend rethinking this helper function.
My suggestion:
addFile function will take filePath and optional PBXGroup where nil will be a fallback to mainGroup.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@allu22 indeed! I improved this method by adding group and source tree parameters. Source tree is required to calculate initial value for file path property, as it will be different for different source tree values, i.e. it can be relative to group or to project. Also added helper method to calculate relative paths. I made it public as this method can be used for example to update file path when moving it to a different group.
Some of the source tree values are not supported in file path calculation (file will be added with nil path), but we can extend it later, it feels that use cases for these values should be rare.

name: filePath.lastComponent,
explicitFileType: PBXFileReference.fileType(path: filePath),
lastKnownFileType: PBXFileReference.fileType(path: filePath),
path: filePath.string
)
let reference = generateReference(fileReference, filePath.string)
addObject(fileReference, reference: reference)
return (reference, fileReference)
}

/// Adds file to the given group.
/// If group already contains file with given reference method does nothing.
///
/// - Parameter group: group to add file to
/// - Parameter reference: file reference
public func addFile(toGroup group: PBXGroup, reference: String) {
guard !group.children.contains(reference) else { return }
group.children.append(reference)
}

/// Adds file to the given target's sources build phase or returns existing build file and its reference.
/// If target's sources build phase can't be found returns nil.
///
/// - Parameter target: target object
/// - Parameter reference: file reference
/// - Returns: new or existing build file and its reference
public func addBuildFile(toTarget target: PBXTarget, reference: String) -> (reference: String, file: PBXBuildFile)? {
guard let sourcesBuildPhase = sourcesBuildPhase(target: target) else { return nil }
if let existingBuildFile = buildFiles.first(where: { $0.value.fileRef == reference }) {
return (existingBuildFile.key, existingBuildFile.value)
}

let buildFile = PBXBuildFile(fileRef: reference)
let reference = generateReference(buildFile, reference)
addObject(buildFile, reference: reference)
sourcesBuildPhase.files.append(reference)
return (reference, buildFile)
}

/// Returns full path of the file element.
///
/// - Parameters:
/// - fileElement: a file element
/// - reference: a reference to this file element
/// - sourceRoot: path to the project's sourceRoot
/// - Returns: fully qualified file element path
public func fullPath(fileElement: PBXFileElement, reference: String, sourceRoot: Path) -> Path? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you add few unit tests to this function. I am not sure what cases it has to cover and how. Especially with groups.

switch fileElement.sourceTree {
case .absolute?:
return fileElement.path.flatMap({ Path($0) })
case .sourceRoot?:
return fileElement.path.flatMap({ Path($0, relativeTo: sourceRoot) })
case .group?:
guard let group = groups.first(where: { $0.value.children.contains(reference) }) else { return sourceRoot }
guard let groupPath = fullPath(fileElement: group.value, reference: group.key, sourceRoot: sourceRoot) else { return nil }
return fileElement.path.flatMap({ Path($0, relativeTo: groupPath) })
default:
return nil
}
}

}

public struct GroupAddingOptions: OptionSet {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
/// Create group without reference to folder
static let withoutFolder = GroupAddingOptions(rawValue: 1 << 0)
}

public enum XCodeProjEditingError: Error, CustomStringConvertible {
case notAFile(path: Path)

public var description: String {
switch self {
case .notAFile(let path):
return "\(path) is not a file path"
}
}
}

fileprivate extension Path {
init(_ string: String, relativeTo relativePath: Path) {
var path = Path(string)
if !path.isAbsolute {
path = (relativePath + path).absolute()
}
self.init(path.string)
}
}

// MARK: - PBXProj.Objects Extension (Internal)
Expand Down
100 changes: 100 additions & 0 deletions Tests/xcprojTests/XcodeProjIntegrationSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,106 @@ final class XcodeProjIntegrationSpec: XCTestCase {
fixturesPath() + "iOS/BuildSettings.xcodeproj/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist")
}

// MARK: - File add

func test_add_new_group() throws {
let project = projectiOS()!
let groups = project.pbxproj.objects.addGroup(named: "Group", to: project.pbxproj.rootGroup)
let (reference, group) = groups[0]

XCTAssertEqual(group.name, "Group")
XCTAssertNotNil(project.pbxproj.rootGroup.children.index(of: reference))
XCTAssertEqual(project.pbxproj.objects.groups[reference], group)

let existingGroups = project.pbxproj.objects.addGroup(named: "Group", to: project.pbxproj.rootGroup)
XCTAssertTrue(groups[0] == existingGroups[0])
}

func test_add_nested_group() throws {
let project = projectiOS()!
let groups = project.pbxproj.objects.addGroup(named: "New/Group", to: project.pbxproj.rootGroup)
let (reference1, group1) = groups[0]
let (reference2, group2) = groups[1]

XCTAssertEqual(group1.name, "New")
XCTAssertEqual(group2.name, "Group")

XCTAssertNotNil(project.pbxproj.rootGroup.children.index(of: reference1))
XCTAssertNotNil(group1.children.index(of: reference2))

XCTAssertEqual(project.pbxproj.objects.groups[reference1], group1)
XCTAssertEqual(project.pbxproj.objects.groups[reference2], group2)

let existingGroups = project.pbxproj.objects.addGroup(named: "New/Group", to: project.pbxproj.rootGroup)

XCTAssertTrue(groups[0] == existingGroups[0])

let newGroups = project.pbxproj.objects.addGroup(named: "New/Group1", to: project.pbxproj.rootGroup)

XCTAssertTrue(newGroups[0] == existingGroups[0])
XCTAssertNotNil(newGroups[0].group.children.index(of: groups[1].reference))
XCTAssertEqual(project.pbxproj.objects.groups[newGroups[1].reference], newGroups[1].group)
}

func test_add_new_file() throws {
let proj = projectiOS()!.pbxproj
let filePath = fixturesPath() + "iOS/iOS/newfile.swift"
let file = try proj.objects.addFile(at: filePath, sourceRoot: fixturesPath() + "iOS")

XCTAssertEqual(proj.objects.fileReferences[file.reference], file.file)
XCTAssertEqual(file.file.name, "newfile.swift")
XCTAssertEqual(file.file.sourceTree, PBXSourceTree.absolute)
XCTAssertEqual(file.file.path, filePath.string)

let existingFile = try proj.objects.addFile(at: filePath, sourceRoot: fixturesPath() + "iOS")

XCTAssertTrue(file == existingFile)
}

func test_add_not_a_file() throws {
let proj = projectiOS()!.pbxproj
let path = fixturesPath() + "iOS/iOS"
do {
_ = try proj.objects.addFile(at: path, sourceRoot: fixturesPath() + "iOS")
XCTFail("Adding not file path should throw error")
} catch {}
}

func test_add_new_build_file() throws {
let proj = projectiOS()!.pbxproj
let target = proj.objects.targets(named: "iOS").first!
let sourcesBuildPhase = proj.objects.sourcesBuildPhase(target: target)!
let filePath = fixturesPath() + "iOS/iOS/newfile.swift"
let file = try proj.objects.addFile(at: filePath, sourceRoot: fixturesPath() + "iOS")

let buildFile = proj.objects.addBuildFile(toTarget: target, reference: file.reference)!

XCTAssertEqual(proj.objects.buildFiles[buildFile.reference], buildFile.file)
XCTAssertNotNil(sourcesBuildPhase.files.index(of: buildFile.reference))

let existingBuildFile = proj.objects.addBuildFile(toTarget: target, reference: file.reference)!

XCTAssertTrue(existingBuildFile == buildFile)
}

func test_fullFilePath() throws {
let proj = projectiOS()!.pbxproj
let sourceRoot = fixturesPath() + "iOS"
let filePath = sourceRoot + "iOS"

let appDelegate = proj.objects.fileReferences.first(where: { Path($0.value.path!).lastComponent == "AppDelegate.swift" })!
let groupFullPath = proj.objects.fullPath(fileElement: appDelegate.value, reference: appDelegate.key, sourceRoot: sourceRoot)
XCTAssertEqual(groupFullPath, filePath + "AppDelegate.swift")

let viewController = proj.objects.fileReferences.first(where: { Path($0.value.path!).lastComponent == "ViewController.swift" })!
let absoluteFullPath = proj.objects.fullPath(fileElement: viewController.value, reference: viewController.key, sourceRoot: sourceRoot)
XCTAssertEqual(absoluteFullPath, filePath + "ViewController.swift")

let tests = proj.objects.fileReferences.first(where: { Path($0.value.path!).lastComponent == "iOSTests.swift" })!
let sourceRootFullPath = proj.objects.fullPath(fileElement: tests.value, reference: tests.key, sourceRoot: sourceRoot)
XCTAssertEqual(sourceRootFullPath, sourceRoot + "iOSTests/iOSTests.swift")
}

// MARK: - Private

private func fixtureWithoutWorkspaceProjectPath() -> Path {
Expand Down