From 5a6e7c47fbf6d328f250e181b0c0f7c0cc69a7e2 Mon Sep 17 00:00:00 2001 From: Ankit Aggarwal Date: Tue, 12 Jun 2018 16:15:28 -0700 Subject: [PATCH] [Xcodeproj] Add support for automatic project generation This adds a `--watch` option to generate-xcodeproj subcommand. SwiftPM will do the necessary configuration and exec to the watchman-make tool. If the tool can't be found on the system, it'll emit help information to install it. Add support for automatically maintaining the generated Xcode project --- Sources/Basic/FileSystem.swift | 3 + Sources/Basic/Process.swift | 12 +-- Sources/Commands/SwiftPackageTool.swift | 17 +++- Sources/Commands/WatchmanHelper.swift | 103 +++++++++++++++++++++ Sources/Xcodeproj/generate().swift | 7 +- Tests/BasicTests/ProcessTests.swift | 8 +- Tests/CommandsTests/PackageToolTests.swift | 35 ++++++- 7 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 Sources/Commands/WatchmanHelper.swift diff --git a/Sources/Basic/FileSystem.swift b/Sources/Basic/FileSystem.swift index 60d6d7b7a78..8537cf9b966 100644 --- a/Sources/Basic/FileSystem.swift +++ b/Sources/Basic/FileSystem.swift @@ -94,6 +94,7 @@ public enum FileMode { case userUnWritable case userWritable + case executable public var cliArgument: String { switch self { @@ -101,6 +102,8 @@ public enum FileMode { return "u-w" case .userWritable: return "u+w" + case .executable: + return "+x" } } } diff --git a/Sources/Basic/Process.swift b/Sources/Basic/Process.swift index c7ea0c2b7e5..4ffb2f1c7fe 100644 --- a/Sources/Basic/Process.swift +++ b/Sources/Basic/Process.swift @@ -168,8 +168,8 @@ public final class Process: ObjectIdentifierProtocol { /// Cache of validated executables. /// /// Key: Executable name or path. - /// Value: If key was found in the search paths and is executable. - static private var validatedExecutablesMap = [String: Bool]() + /// Value: Path to the executable, if found. + static private var validatedExecutablesMap = [String: AbsolutePath?]() /// Create a new process instance. /// @@ -195,7 +195,7 @@ public final class Process: ObjectIdentifierProtocol { /// Returns true if the given program is present and executable in search path. /// /// The program can be executable name, relative path or absolute path. - func findExecutable(_ program: String) -> Bool { + public static func findExecutable(_ program: String) -> AbsolutePath? { return Process.executablesQueue.sync { // Check if we already have a value for the program. if let value = Process.validatedExecutablesMap[program] { @@ -206,9 +206,9 @@ public final class Process: ObjectIdentifierProtocol { pathString: getenv("PATH"), currentWorkingDirectory: localFileSystem.currentWorkingDirectory ) - // Lookup the executable. + // Lookup and cache the executable path. let value = lookupExecutablePath( - filename: program, searchPaths: envSearchPaths) != nil + filename: program, searchPaths: envSearchPaths) Process.validatedExecutablesMap[program] = value return value } @@ -229,7 +229,7 @@ public final class Process: ObjectIdentifierProtocol { } // Look for executable. - guard findExecutable(arguments[0]) else { + guard Process.findExecutable(arguments[0]) != nil else { throw Process.Error.missingExecutableProgram(program: arguments[0]) } diff --git a/Sources/Commands/SwiftPackageTool.swift b/Sources/Commands/SwiftPackageTool.swift index e5fd8d6e0ca..8be386de510 100644 --- a/Sources/Commands/SwiftPackageTool.swift +++ b/Sources/Commands/SwiftPackageTool.swift @@ -172,6 +172,15 @@ public class SwiftPackageTool: SwiftTool { print("generated:", xcodeprojPath.prettyPath(cwd: originalWorkingDirectory)) + // Run the file watcher if requested. + if options.xcodeprojOptions.enableAutogeneration { + try WatchmanHelper( + diagnostics: diagnostics, + watchmanScriptsDir: buildPath.appending(component: "watchman"), + packageRoot: packageRoot! + ).runXcodeprojWatcher(options.xcodeprojOptions) + } + case .describe: let graph = try loadPackageGraph() describe(graph.rootPackages[0].underlyingPackage, in: options.describeMode, on: stdoutStream) @@ -314,11 +323,15 @@ public class SwiftPackageTool: SwiftTool { $0.outputPath = $3?.path }) binder.bind( - option: generateXcodeParser.add( + generateXcodeParser.add( option: "--legacy-scheme-generator", kind: Bool.self, usage: "Use the legacy scheme generator"), + generateXcodeParser.add( + option: "--watch", kind: Bool.self, + usage: "Watch the filesystem and autogenerate the Xcode project if needed"), to: { - $0.xcodeprojOptions.useLegacySchemeGenerator = $1 + $0.xcodeprojOptions.useLegacySchemeGenerator = $1 ?? false + $0.xcodeprojOptions.enableAutogeneration = $2 ?? false }) let completionToolParser = parser.add( diff --git a/Sources/Commands/WatchmanHelper.swift b/Sources/Commands/WatchmanHelper.swift new file mode 100644 index 00000000000..1266b2700f1 --- /dev/null +++ b/Sources/Commands/WatchmanHelper.swift @@ -0,0 +1,103 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2018 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Basic +import Utility +import POSIX +import Xcodeproj + +struct WatchmanMissingDiagnostic: DiagnosticData { + static let id = DiagnosticID( + type: WatchmanMissingDiagnostic.self, + name: "org.swift.diags.watchman-missing", + description: { + $0 <<< "this feature requires 'watchman' to work" + $0 <<< "\n\n installation instructions for 'watchman' are available at https://facebook.github.io/watchman/docs/install.html#buildinstall" + } + ) +} + +final class WatchmanHelper { + + /// Name of the watchman-make tool. + static let watchmanMakeTool: String = "watchman-make" + + /// Directory where watchman script should be created. + let watchmanScriptsDir: AbsolutePath + + /// The package root. + let packageRoot: AbsolutePath + + /// The filesystem to operator on. + let fs: FileSystem + + let diagnostics: DiagnosticsEngine + + init( + diagnostics: DiagnosticsEngine, + watchmanScriptsDir: AbsolutePath, + packageRoot: AbsolutePath, + fs: FileSystem = localFileSystem + ) { + self.watchmanScriptsDir = watchmanScriptsDir + self.diagnostics = diagnostics + self.packageRoot = packageRoot + self.fs = fs + } + + func runXcodeprojWatcher(_ options: XcodeprojOptions) throws { + let scriptPath = try createXcodegenScript(options) + try run(scriptPath) + } + + func createXcodegenScript(_ options: XcodeprojOptions) throws -> AbsolutePath { + let scriptPath = watchmanScriptsDir.appending(component: "gen-xcodeproj.sh") + + let stream = BufferedOutputByteStream() + stream <<< "#!/usr/bin/env bash" <<< "\n\n\n" + stream <<< "# Autogenerated by SwiftPM. Do not edit!" <<< "\n\n\n" + stream <<< "set -eu" <<< "\n\n" + stream <<< "swift package generate-xcodeproj" + if let xcconfigOverrides = options.xcconfigOverrides { + stream <<< " --xcconfig-overrides " <<< xcconfigOverrides.asString + } + stream <<< "\n" + + try fs.createDirectory(scriptPath.parentDirectory, recursive: true) + try fs.writeFileContents(scriptPath, bytes: stream.bytes) + try fs.chmod(.executable, path: scriptPath) + + return scriptPath + } + + private func run(_ scriptPath: AbsolutePath) throws { + // Construct the arugments. + var args = [String]() + args += ["--settle", "2"] + args += ["-p", "Package.swift", "Package.resolved"] + args += ["--run", scriptPath.asString.shellEscaped()] + + // Find and execute watchman. + let watchmanMakeToolPath = try self.watchmanMakeToolPath() + + print("Starting:", watchmanMakeToolPath.asString, args.joined(separator: " ")) + + let pathRelativeToWorkingDirectory = watchmanMakeToolPath.relative(to: packageRoot) + try exec(path: watchmanMakeToolPath.asString, args: [pathRelativeToWorkingDirectory.asString] + args) + } + + private func watchmanMakeToolPath() throws -> AbsolutePath { + if let toolPath = Process.findExecutable(WatchmanHelper.watchmanMakeTool) { + return toolPath + } + diagnostics.emit(data: WatchmanMissingDiagnostic()) + throw Error.hasFatalDiagnostics + } +} diff --git a/Sources/Xcodeproj/generate().swift b/Sources/Xcodeproj/generate().swift index a905952b161..9680374a561 100644 --- a/Sources/Xcodeproj/generate().swift +++ b/Sources/Xcodeproj/generate().swift @@ -30,16 +30,21 @@ public struct XcodeprojOptions { /// Whether to use legacy scheme generation logic. public var useLegacySchemeGenerator: Bool + /// Run watchman to auto-generate the project file on changes. + public var enableAutogeneration: Bool + public init( flags: BuildFlags = BuildFlags(), xcconfigOverrides: AbsolutePath? = nil, isCodeCoverageEnabled: Bool? = nil, - useLegacySchemeGenerator: Bool? = nil + useLegacySchemeGenerator: Bool? = nil, + enableAutogeneration: Bool? = nil ) { self.flags = flags self.xcconfigOverrides = xcconfigOverrides self.isCodeCoverageEnabled = isCodeCoverageEnabled ?? false self.useLegacySchemeGenerator = useLegacySchemeGenerator ?? false + self.enableAutogeneration = enableAutogeneration ?? false } } diff --git a/Tests/BasicTests/ProcessTests.swift b/Tests/BasicTests/ProcessTests.swift index 158113a4c13..1e057ccbb6c 100644 --- a/Tests/BasicTests/ProcessTests.swift +++ b/Tests/BasicTests/ProcessTests.swift @@ -71,10 +71,10 @@ class ProcessTests: XCTestCase { func testFindExecutable() throws { mktmpdir { path in // This process should always work. - XCTAssertTrue(Process().findExecutable("ls")) + XCTAssertTrue(Process.findExecutable("ls") != nil) - XCTAssertFalse(Process().findExecutable("nonExistantProgram")) - XCTAssertFalse(Process().findExecutable("")) + XCTAssertEqual(Process.findExecutable("nonExistantProgram"), nil) + XCTAssertEqual(Process.findExecutable(""), nil) // Create a local nonexecutable file to test. let tempExecutable = path.appending(component: "nonExecutableProgram") @@ -85,7 +85,7 @@ class ProcessTests: XCTestCase { """) try withCustomEnv(["PATH": path.asString]) { - XCTAssertFalse(Process().findExecutable("nonExecutableProgram")) + XCTAssertEqual(Process.findExecutable("nonExecutableProgram"), nil) } } } diff --git a/Tests/CommandsTests/PackageToolTests.swift b/Tests/CommandsTests/PackageToolTests.swift index 7e65b25f388..c7f1c861748 100644 --- a/Tests/CommandsTests/PackageToolTests.swift +++ b/Tests/CommandsTests/PackageToolTests.swift @@ -12,7 +12,8 @@ import XCTest import Foundation import Basic -import Commands +@testable import Commands +import Xcodeproj import PackageModel import SourceControl import TestSupport @@ -489,6 +490,37 @@ final class PackageToolTests: XCTestCase { } } + func testWatchmanXcodeprojgen() { + mktmpdir { path in + let fs = localFileSystem + let diagnostics = DiagnosticsEngine() + + let scriptsDir = path.appending(component: "scripts") + let packageRoot = path.appending(component: "root") + + let helper = WatchmanHelper( + diagnostics: diagnostics, + watchmanScriptsDir: scriptsDir, + packageRoot: packageRoot) + + let script = try helper.createXcodegenScript( + XcodeprojOptions()) + + XCTAssertEqual(try fs.readFileContents(script), """ + #!/usr/bin/env bash + + + # Autogenerated by SwiftPM. Do not edit! + + + set -eu + + swift package generate-xcodeproj + + """) + } + } + static var allTests = [ ("testDescribe", testDescribe), ("testUsage", testUsage), @@ -507,5 +539,6 @@ final class PackageToolTests: XCTestCase { ("testPinning", testPinning), ("testPinningBranchAndRevision", testPinningBranchAndRevision), ("testSymlinkedDependency", testSymlinkedDependency), + ("testWatchmanXcodeprojgen", testWatchmanXcodeprojgen), ] }