Skip to content

Commit

Permalink
Improve support for configuring SPM Xcode projects (#253)
Browse files Browse the repository at this point in the history
Current support for Swift Package Manager is a bit rough for both Xcode
project and package manifest setups, and we lack clear guidance for the
latter in the README. Since Swift Package Manager is gaining popularity
in the iOS ecosystem, it makes sense to streamline our integration and
bring it up to parity with CocoaPods and Carthage.

- Make configured build phase portable between machines by rewriting
  paths in derived data
- Remove dependency on pcregrep in SwiftPM quick start guide
- Add ability to specify output directory in generate command
  • Loading branch information
andrewchang-bird authored Jan 6, 2022
1 parent b4f671d commit 8ee3e4d
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 39 deletions.
6 changes: 5 additions & 1 deletion Mockingbird.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
/* End PBXAggregateTarget section */

/* Begin PBXBuildFile section */
2803D96927781A4D00651C60 /* String+Regex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2803D96827781A4D00651C60 /* String+Regex.swift */; };
281B6246251DC7220084EBED /* MockingbirdShadowedTestsHost.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 281B6226251DC5AD0084EBED /* MockingbirdShadowedTestsHost.framework */; };
281B6286251DC7FC0084EBED /* ModuleNameShadowing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281B61F1251DBBDE0084EBED /* ModuleNameShadowing.swift */; };
281B62FF251DCCFC0084EBED /* MockingbirdShadowedTestsHostMocks.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 281B62FE251DCCFC0084EBED /* MockingbirdShadowedTestsHostMocks.generated.swift */; };
Expand Down Expand Up @@ -494,6 +495,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
2803D96827781A4D00651C60 /* String+Regex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Regex.swift"; sourceTree = "<group>"; };
281B61F1251DBBDE0084EBED /* ModuleNameShadowing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleNameShadowing.swift; sourceTree = "<group>"; };
281B6226251DC5AD0084EBED /* MockingbirdShadowedTestsHost.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MockingbirdShadowedTestsHost.framework; sourceTree = BUILT_PRODUCTS_DIR; };
281B62CF251DCAB90084EBED /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1166,6 +1168,7 @@
D36A3DCA24922D6B007964DC /* Encodable+SHA1.swift */,
2838FD2027756314007A1CB4 /* Path+Abbreviate.swift */,
28C28E7826C0EE4D00729617 /* Path+Symlink.swift */,
2803D96827781A4D00651C60 /* String+Regex.swift */,
28C28E7A26C0FB2700729617 /* TimeUnit.swift */,
);
path = Utils;
Expand Down Expand Up @@ -2109,7 +2112,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\n${BUILT_PRODUCTS_DIR}/MockingbirdCli generate \\\n --targets 'MockingbirdTestsHost' 'MockingbirdShadowedTestsHost' \\\n --outputs \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdTestsHostMocks.generated.swift\" \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdShadowedTestsHostMocks.generated.swift\" \\\n --support \"${SRCROOT}/Sources/MockingbirdSupport\" \\\n --diagnostics all \\\n --disable-cache \\\n --prune stub \\\n --verbose\n";
shellScript = "set -eu\n\n# Prevent Xcode 13 from running this script while indexing.\n[[ \"${ACTION}\" == \"indexbuild\" ]] && exit 0\n\n\"${BUILT_PRODUCTS_DIR}/MockingbirdCli\" generate \\\n --targets 'MockingbirdTestsHost' 'MockingbirdShadowedTestsHost' \\\n --outputs \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdTestsHostMocks.generated.swift\" \"${SRCROOT}/Tests/MockingbirdTests/Mocks/MockingbirdShadowedTestsHostMocks.generated.swift\" \\\n --support \"${SRCROOT}/Sources/MockingbirdSupport\" \\\n --diagnostics all \\\n --disable-cache \\\n --prune stub \\\n --verbose\n";
};
D372A59C2455878B0000E80A /* Generate Embedded Dylibs */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -2316,6 +2319,7 @@
D3DC37952492140D001E02A5 /* Generator+PruningPipeline.swift in Sources */,
2852643F2775882B000298B3 /* SwiftFilePath.swift in Sources */,
28C28E7B26C0FB2700729617 /* TimeUnit.swift in Sources */,
2803D96927781A4D00651C60 /* String+Regex.swift in Sources */,
28C2EE4A2771AA41003CD0D5 /* ExtendedGeneratorTypes.swift in Sources */,
OBJ_814 /* Generator.swift in Sources */,
OBJ_815 /* Installer.swift in Sources */,
Expand Down
29 changes: 17 additions & 12 deletions README-0.19.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Add the framework to your project:
In your project directory, resolve the derived data path. This can take a few moments.

```console
$ DERIVED_DATA="$(xcodebuild -showBuildSettings | pcregrep -o1 'OBJROOT = (/.*)/Build')"
$ DERIVED_DATA="$(xcodebuild -showBuildSettings | sed -n 's|.*BUILD_ROOT = \(.*\)/Build/.*|\1|p'
```

Finally, configure the test target to generate mocks for specific modules or libraries.
Expand Down Expand Up @@ -198,30 +198,30 @@ let package = Package(
)
```

In your project directory, initialize the package.
In your package directory, initialize the dependency.

```console
$ xcodebuild -resolvePackageDependencies
$ swift package update Mockingbird
```

Next, save Bash script below in the same directory as your package manifest. Change the lines marked with `FIXME`.
Next, create a Bash script named `gen-mocks.sh` in the same directory as your package manifest. Copy the example below, making sure to change the lines marked with `FIXME`.

```bash
#!/bin/bash
set -eu
cd "$(dirname "$0")"
readonly derivedData="$(xcodebuild -showBuildSettings | pcregrep -o1 'OBJROOT = (/.*)/Build')"
swift package describe --type json > project.json
"${derivedData}/SourcePackages/checkouts/mockingbird/mockingbird" generate --project project.json \
--testbundle MyPackageTests \ # FIXME: The name of your test target.
--targets MyPackage MyLibrary1 MyLibrary2 # FIXME: Specific modules or libraries that should be mocked.
.build/checkouts/mockingbird/mockingbird generate --project project.json \
--output-dir Sources/MyPackageTests/MockingbirdMocks \ # FIXME: Where mocks should be generated.
--testbundle MyPackageTests \ # FIXME: Name of your test target.
--targets MyPackage MyLibrary1 MyLibrary2 # FIXME: Specific modules or libraries that should be mocked.
```

Ensure that the script runs and generates mock files.

```console
$ chmod u+x generate-mocks.sh
$ ./generate-mocks.sh
$ chmod u+x gen-mocks.sh
$ ./gen-mocks.sh
Generated file to MockingbirdMocks/MyPackageTests-MyPackage.generated.swift
Generated file to MockingbirdMocks/MyPackageTests-MyLibrary1.generated.swift
Generated file to MockingbirdMocks/MyPackageTests-MyLibrary2.generated.swift
Expand Down Expand Up @@ -728,8 +728,9 @@ Generate mocks for a set of targets in a project.
| Option | Default Value | Description |
| --- | --- | --- |
| `-t, --targets` | *(required)* | List of target names to generate mocks for. |
| `-o, --outputs` | [`(inferred)`](#--outputs) | List of mock output file paths for each target. |
| `-o, --outputs` | [`(inferred)`](#--outputs) | List of output file paths corresponding to each target. |
| `-p, --project` | [`(inferred)`](#--project) | Path to an Xcode project or a [JSON project description](https://github.com/birdrides/mockingbird/wiki/Manual-Setup#generating-mocks-for-non-xcode-projects). |
| `--output-dir` | [`(inferred)`](#--outputs) | The directory where generated files should be output. |
| `--srcroot` | [`(inferred)`](#--srcroot) | The directory containing your project’s source files. |
| `--support` | [`(inferred)`](#--support) | The directory containing [supporting source files](https://github.com/birdrides/mockingbird/wiki/Supporting-Source-Files). |
| `--testbundle` | [`(inferred)`](#--testbundle) | The name of the test bundle using the mocks. |
Expand Down Expand Up @@ -788,7 +789,7 @@ Mockingbird checks the environment variables `SRCROOT` and `SOURCE_ROOT` set by

#### `--outputs`

Mockingbird generates mocks into the directory `$(SRCROOT)/MockingbirdMocks` with the file name `$(PRODUCT_MODULE_NAME)Mocks.generated.swift`.
Mockingbird generates mocks into the directory `$(SRCROOT)/MockingbirdMocks` with the file name `$(PRODUCT_MODULE_NAME)Mocks-$(TEST_TARGET_NAME).generated.swift`.

#### `--support`

Expand All @@ -798,6 +799,10 @@ Mockingbird recursively looks for [supporting source files](https://github.com/b

Mockingbird checks the environment variables `TARGET_NAME` and `TARGETNAME` set by the Xcode build context and verifies that it refers to a valid Swift unit test target. The test bundle option must be set when using [JSON project descriptions](https://github.com/birdrides/mockingbird/wiki/Manual-Setup#generating-mocks-for-non-xcode-projects) in order to enable thunk stubs.

### `--generator`

Mockingbird uses the current executable path and attempts to make it relative to the project’s `SRCROOT` or derived data. To improve portability across development environments, avoid linking executables outside of project-specific directories.

### `--url`

Mockingbird uses the GitHub release artifacts located at `https://github.com/birdrides/mockingbird/releases/download`. Note that asset bundles are versioned by release.
Expand Down
10 changes: 9 additions & 1 deletion Sources/MockingbirdCli/Interface/Commands/Generate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ extension Mockingbird {
.customLong("output"),
.customShort("o")],
parsing: .upToNextOption,
help: "List of mock output file paths for each target.")
help: "List of output file paths corresponding to each target.")
var outputs: [SwiftFilePath] = [] // TODO: This will be optional in generator v2

@Option(help: "The directory where generated files should be output.")
var outputDir: DirectoryPath?

@Option(help: "The directory containing supporting source files.")
var support: DirectoryPath?

Expand Down Expand Up @@ -80,6 +83,7 @@ extension Mockingbird {
let project: Path
let srcroot: Path
let outputs: [Path]
let outputDir: Path?
let support: Path
let testbundle: String?
let header: [String]
Expand Down Expand Up @@ -134,6 +138,7 @@ extension Mockingbird {
project: validProject.path,
srcroot: validSrcroot.path,
outputs: outputs.map({ $0.path }),
outputDir: outputDir?.path, // Managed by the generator.
support: validSupportPath,
testbundle: validTestBundle?.name,
header: header,
Expand Down Expand Up @@ -162,6 +167,7 @@ extension Mockingbird {
environmentSourceRoot: arguments.environmentSourceRoot,
environmentTargetName: arguments.environmentTargetName,
outputPaths: arguments.outputs,
outputDir: arguments.outputDir,
supportPath: arguments.support,
header: arguments.header,
compilationCondition: arguments.condition,
Expand All @@ -185,6 +191,7 @@ extension Mockingbird.Generate: EncodableArguments {
case project
case srcroot
case outputs
case outputDir
case support
case testbundle
case header
Expand All @@ -211,6 +218,7 @@ extension Mockingbird.Generate: EncodableArguments {
try container.encode(project, forKey: .project)
try container.encode(srcroot, forKey: .srcroot)
try container.encode(outputs, forKey: .outputs)
try container.encode(outputDir, forKey: .outputDir)
try container.encode(support, forKey: .support)
try container.encode(testbundle, forKey: .testbundle)
try container.encode(header, forKey: .header)
Expand Down
29 changes: 18 additions & 11 deletions Sources/MockingbirdCli/Interface/Handlers/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ class Generator {
let environmentProjectFilePath: Path?
let environmentSourceRoot: Path?
let environmentTargetName: String?
let outputPaths: [Path]?
let outputPaths: [Path]
let outputDir: Path?
let supportPath: Path?
let header: [String]
let compilationCondition: String?
Expand Down Expand Up @@ -149,9 +150,9 @@ class Generator {
}

func generate() throws {
guard config.outputPaths == nil || config.inputTargetNames.count == config.outputPaths?.count else {
if !config.outputPaths.isEmpty && config.inputTargetNames.count != config.outputPaths.count {
throw Error.mismatchedInputsAndOutputs(inputCount: config.inputTargetNames.count,
outputCount: config.outputPaths?.count ?? 0)
outputCount: config.outputPaths.count)
}

if config.supportPath == nil {
Expand Down Expand Up @@ -185,12 +186,18 @@ class Generator {
})

// Resolve unspecified output paths to the default mock file output destination.
let outputPaths = try config.outputPaths ?? targets.map({ target throws -> Path in
try config.sourceRoot.mocksDirectory.mkpath()
return Generator.defaultOutputPath(for: target,
sourceRoot: config.sourceRoot,
environment: getBuildEnvironment)
})
let outputPaths: [Path] = try {
if !config.outputPaths.isEmpty {
return config.outputPaths
}
return try targets.map({ target throws -> Path in
let outputDir = config.outputDir ?? config.sourceRoot.mocksDirectory
try outputDir.mkpath()
return Generator.defaultOutputPath(for: target,
outputDir: outputDir,
environment: getBuildEnvironment)
})
}()

let queue = OperationQueue.createForActiveProcessors()

Expand Down Expand Up @@ -263,7 +270,7 @@ class Generator {

static func defaultOutputPath(for sourceTarget: TargetType,
testTarget: TargetType? = nil,
sourceRoot: Path,
outputDir: Path,
environment: () -> [String: Any]) -> Path {
let moduleName = sourceTarget.resolveProductModuleName(environment: environment)

Expand All @@ -275,7 +282,7 @@ class Generator {
prefix = "" // Probably installed on a source target instead of a test target.
}

return sourceRoot.mocksDirectory + "\(prefix)\(moduleName)\(Constants.generatedFileNameSuffix)"
return outputDir + "\(prefix)\(moduleName)\(Constants.generatedFileNameSuffix)"
}

static func resolveTarget(targetName: String,
Expand Down
95 changes: 81 additions & 14 deletions Sources/MockingbirdCli/Interface/Handlers/Installer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ struct Installer {
sourceTargets.map({ sourceTarget in
Generator.defaultOutputPath(for: .pbxTarget(sourceTarget),
testTarget: .pbxTarget(testTarget),
sourceRoot: config.sourceRoot,
outputDir: config.sourceRoot.mocksDirectory,
environment: { testProject.implicitBuildEnvironment })
})
guard outputPaths.count == sourceTargets.count else {
Expand All @@ -103,7 +103,7 @@ struct Installer {
}

// Add build phase to target and project.
let buildPhase = createBuildPhase(outputPaths: outputPaths)
let buildPhase = try createBuildPhase(outputPaths: outputPaths)
testTarget.buildPhases.insert(buildPhase, at: buildPhaseIndex)
testProject.pbxproj.add(object: buildPhase)

Expand All @@ -118,6 +118,61 @@ struct Installer {
try testProject.writePBXProj(path: config.projectPath, outputSettings: PBXOutputSettings())
}

/// Checks if a path is located in the same derived data directory as the test project. If so,
/// rewrites that path to use Bash substitution in the form `${DERIVED_DATA}/<subpath>`
private func rewriteDerivedDataPath(_ path: Path) throws -> String? {
// It's possible to use a custom derived data path (in addition to a custom build products
// location), but this is very uncommon so we won't try to handle it.
guard path.starts(with: Path("~/Library/Developer/Xcode/DerivedData/")) else {
return nil
}
log("Attempting to rewrite the derived data path \(path.abbreviate())")

guard let derivedDataPath = try resolveDerivedDataPath()?.abbreviated() else {
return nil
}

let pathString = path.abbreviated()
guard pathString.starts(with: derivedDataPath) else {
// Falls back to the abbreviated (home-relative) path, which is only portable between users on
// the same machine.
logWarning("The path \(pathString) is outside of the project’s derived data location ")
return nil
}

return SubstitutionStyle.bash.wrap("DERIVED_DATA") + pathString.dropFirst(derivedDataPath.count)
}

private func resolveDerivedDataPath() throws -> Path? {
let xcodebuild = Process()
xcodebuild.launchPath = "/usr/bin/env"
xcodebuild.arguments = ["xcodebuild", "-showBuildSettings"]
xcodebuild.currentDirectoryURL = config.projectPath.parent().url
xcodebuild.qualityOfService = .userInitiated

let stdout = Pipe()
xcodebuild.standardOutput = stdout

try xcodebuild.run()
xcodebuild.waitUntilExit()

let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
guard let buildSettings = String(data: stdoutData, encoding: .utf8) else {
logWarning("Unable to read the build settings from xcodebuild")
return nil
}

let components = buildSettings.components(matching: #"BUILD_ROOT = (.*)/Build"#)
guard let component = components.first, component.count == 2 else {
logWarning("Unable to parse the build settings from xcodebuild")
return nil
}

let derivedData = Path(String(component[1]))
log("Resolved the derived data directory to \(derivedData.abbreviate())")
return derivedData
}

private func findTarget(name: String,
project: XcodeProj,
testTarget: Bool) throws -> PBXTarget {
Expand Down Expand Up @@ -208,30 +263,42 @@ struct Installer {
xcodeproj.pbxproj.add(object: fileReference)
}

private func createBuildPhase(outputPaths: [Path]) -> PBXShellScriptBuildPhase {
let cliPath = config.cliPath.abbreviated(root: config.sourceRoot, variable: "SRCROOT")
private func createBuildPhase(outputPaths: [Path]) throws -> PBXShellScriptBuildPhase {
var scriptSections: [String] = [
"set -eu",

"""
# Prevent Xcode 13 from running this script while indexing.
[[ "${ACTION}" == "indexbuild" ]] && exit 0
""",
]

let cliPath: String
if let derivedDataCliPath = try rewriteDerivedDataPath(config.cliPath) {
cliPath = derivedDataCliPath
scriptSections.append(#"""
# Infer the derived data location from the build environment.
[[ -z "${DERIVED_DATA+x}" ]] && DERIVED_DATA="$(echo "${BUILD_ROOT}" | sed -n 's|\(.*\)/Build/.*|\1|p')"
"""#)
} else {
cliPath = config.cliPath.abbreviated(root: config.sourceRoot, variable: "SRCROOT")
}

var options = config.generatorOptions
// TODO: Remove this for generator v2. Only needed for backwards compatibility.
if config.outputPaths.isEmpty {
options += ["--outputs"] + outputPaths.map({ path in
path.abbreviated(root: config.sourceRoot, variable: "SRCROOT").doubleQuoted
})
}
scriptSections.append("\(doubleQuoted: cliPath) generate \(options.joined(separator: " "))")

return PBXShellScriptBuildPhase(
name: Constants.buildPhaseName,
outputPaths: outputPaths.map({ path in
path.abbreviated(root: config.sourceRoot, variable: "SRCROOT", style: .make)
}),
shellScript: """
set -eu
# Prevent Xcode 13 from running this script while indexing.
if [[ "${ACTION}" == "indexbuild" ]]; then
exit 0
fi
\(cliPath) generate \(options.joined(separator: " "))
""",
shellScript: scriptSections.joined(separator: "\n\n"),
alwaysOutOfDate: true)
}

Expand Down
24 changes: 24 additions & 0 deletions Sources/MockingbirdCli/Utils/String+Regex.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// String+Regex.swift
// MockingbirdCli
//
// Created by typealias on 12/25/21.
//

import Foundation

extension String {
/// Returns all matches and capture groups given a regex pattern. First element is the full match.
func components(matching pattern: String) -> [[Substring]] {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] }
return regex
.matches(in: self, range: NSMakeRange(0, count))
.map({ result -> [Substring] in
return (0..<result.numberOfRanges)
.map({ index -> NSRange in result.range(at: index) })
.filter({ range -> Bool in range.location != NSNotFound })
.compactMap({ range -> Range<Index>? in Range(range, in: self) })
.map({ range -> Substring in self[range] })
})
}
}

0 comments on commit 8ee3e4d

Please sign in to comment.