From d78a03db21eb5b82b130aba8b5146a85cc60c2eb Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Thu, 16 Jan 2025 17:15:38 -0800 Subject: [PATCH] Enable customization of arguments in Xcode package plugin (#149) Also enable the Xcode package plugin to run a Release binary. --- .../.safedi/configuration/include.csv | 1 + .../project.pbxproj | 75 +++----- .../SafeDI.swift | 37 ---- .../project.pbxproj | 41 ++--- .../InstallCLIPluginCommand.swift | 99 ++++++++--- Plugins/InstallSafeDITool/Shared.swift | 1 + .../SafeDIGenerateDependencyTree.swift | 161 ++++++++---------- Plugins/SafeDIGenerator/Shared.swift | 1 + Plugins/Shared.swift | 118 +++++++++++++ README.md | 6 +- SafeDI.podspec | 2 +- Sources/SafeDITool/SafeDITool.swift | 40 ++++- .../Helpers/SafeDIToolTestExecution.swift | 14 +- .../SafeDIToolCodeGenerationErrorTests.swift | 6 +- .../SafeDIToolCodeGenerationTests.swift | 33 ++++ 15 files changed, 409 insertions(+), 226 deletions(-) create mode 100644 Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv delete mode 100644 Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDI.swift create mode 120000 Plugins/InstallSafeDITool/Shared.swift create mode 120000 Plugins/SafeDIGenerator/Shared.swift create mode 100644 Plugins/Shared.swift diff --git a/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv b/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv new file mode 100644 index 00000000..c5ab0f32 --- /dev/null +++ b/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv @@ -0,0 +1 @@ +Subproject \ No newline at end of file diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj index 77e79959..24ac4da2 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj @@ -3,12 +3,10 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ - 3203E2232BF94B7D0094A988 /* SafeDI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3203E2222BF94B7D0094A988 /* SafeDI.swift */; }; - 3203E2312BF94D630094A988 /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 3203E2302BF94D630094A988 /* SafeDI */; }; 324F1ECF2B314E030001AC0C /* NameEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECE2B314E030001AC0C /* NameEntryView.swift */; }; 324F1ED22B3150480001AC0C /* NoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ED12B3150480001AC0C /* NoteView.swift */; }; 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32756FE52B24C042006BDD24 /* ExampleApp.swift */; }; @@ -20,7 +18,8 @@ 3289B40D2BF955A10053F2E4 /* StringStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECC2B314DB20001AC0C /* StringStorage.swift */; }; 3289B40E2BF955A10053F2E4 /* AnyObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ED62B3156810001AC0C /* AnyObservableObject.swift */; }; 3289B40F2BF955A10053F2E4 /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECA2B314D8D0001AC0C /* UserService.swift */; }; - 3289B4112BF955DF0053F2E4 /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 3289B4102BF955DF0053F2E4 /* SafeDI */; }; + 32B72E192D39763900F5EB6F /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 32B72E182D39763900F5EB6F /* SafeDI */; }; + 32B72E1B2D39764200F5EB6F /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 32B72E1A2D39764200F5EB6F /* SafeDI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,7 +47,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3203E2222BF94B7D0094A988 /* SafeDI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDI.swift; sourceTree = ""; }; 324F1ECA2B314D8D0001AC0C /* UserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; 324F1ECC2B314DB20001AC0C /* StringStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringStorage.swift; sourceTree = ""; }; 324F1ECE2B314E030001AC0C /* NameEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameEntryView.swift; sourceTree = ""; }; @@ -68,7 +66,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3203E2312BF94D630094A988 /* SafeDI in Frameworks */, + 32B72E192D39763900F5EB6F /* SafeDI in Frameworks */, 3289B4072BF955720053F2E4 /* Subproject.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -77,7 +75,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 3289B4112BF955DF0053F2E4 /* SafeDI in Frameworks */, + 32B72E1B2D39764200F5EB6F /* SafeDI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -120,7 +118,6 @@ 32756FE92B24C044006BDD24 /* Assets.xcassets */, 32756FEB2B24C044006BDD24 /* ExampleMultiProjectIntegration.entitlements */, 32756FEC2B24C044006BDD24 /* Preview Content */, - 3203E2222BF94B7D0094A988 /* SafeDI.swift */, ); path = ExampleMultiProjectIntegration; sourceTree = ""; @@ -169,7 +166,6 @@ isa = PBXNativeTarget; buildConfigurationList = 32756FF12B24C044006BDD24 /* Build configuration list for PBXNativeTarget "ExampleMultiProjectIntegration" */; buildPhases = ( - 3203E2192BF7B4630094A988 /* Run SafeDITool */, 32756FDE2B24C042006BDD24 /* Sources */, 32756FDF2B24C042006BDD24 /* Frameworks */, 32756FE02B24C042006BDD24 /* Resources */, @@ -178,11 +174,12 @@ buildRules = ( ); dependencies = ( + 32B72E1D2D39765B00F5EB6F /* PBXTargetDependency */, 3289B4062BF955720053F2E4 /* PBXTargetDependency */, ); name = ExampleMultiProjectIntegration; packageProductDependencies = ( - 3203E2302BF94D630094A988 /* SafeDI */, + 32B72E182D39763900F5EB6F /* SafeDI */, ); productName = ExampleMultiProjectIntegration; productReference = 32756FE22B24C042006BDD24 /* ExampleMultiProjectIntegration.app */; @@ -203,7 +200,7 @@ ); name = Subproject; packageProductDependencies = ( - 3289B4102BF955DF0053F2E4 /* SafeDI */, + 32B72E1A2D39764200F5EB6F /* SafeDI */, ); productName = Subproject; productReference = 3289B4012BF955710053F2E4 /* Subproject.framework */; @@ -237,7 +234,7 @@ ); mainGroup = 32756FD92B24C042006BDD24; packageReferences = ( - 3203E22C2BF94D3F0094A988 /* XCRemoteSwiftPackageReference "SafeDI" */, + 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */, ); productRefGroup = 32756FE32B24C042006BDD24 /* Products */; projectDirPath = ""; @@ -268,29 +265,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 3203E2192BF7B4630094A988 /* Run SafeDITool */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run SafeDITool"; - outputFileListPaths = ( - ); - outputPaths = ( - $PROJECT_DIR/ExampleMultiProjectIntegration/SafeDI.swift, - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "xcrun swift run --package-path \"$BUILD_ROOT/../../SourcePackages/checkouts/SafeDI\" --scratch-path \"$BUILD_DIR/SafeDITool-$CONFIGURATION\" SafeDITool --include \"$PROJECT_DIR/ExampleMultiProjectIntegration\" \"$PROJECT_DIR/Subproject\" --dependency-tree-output \"$PROJECT_DIR/ExampleMultiProjectIntegration/SafeDI.swift\"\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 32756FDE2B24C042006BDD24 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -299,7 +273,6 @@ 324F1ECF2B314E030001AC0C /* NameEntryView.swift in Sources */, 324F1ED22B3150480001AC0C /* NoteView.swift in Sources */, 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */, - 3203E2232BF94B7D0094A988 /* SafeDI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -321,6 +294,10 @@ target = 3289B4002BF955710053F2E4 /* Subproject */; targetProxy = 3289B4052BF955720053F2E4 /* PBXContainerItemProxy */; }; + 32B72E1D2D39765B00F5EB6F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 32B72E1C2D39765B00F5EB6F /* SafeDIGenerator */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -630,28 +607,28 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 3203E22C2BF94D3F0094A988 /* XCRemoteSwiftPackageReference "SafeDI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/dfed/SafeDI"; - requirement = { - branch = main; - kind = branch; - }; +/* Begin XCLocalSwiftPackageReference section */ + 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../SafeDI; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 3203E2302BF94D630094A988 /* SafeDI */ = { + 32B72E182D39763900F5EB6F /* SafeDI */ = { isa = XCSwiftPackageProductDependency; - package = 3203E22C2BF94D3F0094A988 /* XCRemoteSwiftPackageReference "SafeDI" */; productName = SafeDI; }; - 3289B4102BF955DF0053F2E4 /* SafeDI */ = { + 32B72E1A2D39764200F5EB6F /* SafeDI */ = { isa = XCSwiftPackageProductDependency; - package = 3203E22C2BF94D3F0094A988 /* XCRemoteSwiftPackageReference "SafeDI" */; + package = 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */; productName = SafeDI; }; + 32B72E1C2D39765B00F5EB6F /* SafeDIGenerator */ = { + isa = XCSwiftPackageProductDependency; + package = 32B72E172D39763900F5EB6F /* XCLocalSwiftPackageReference "../../../SafeDI" */; + productName = "plugin:SafeDIGenerator"; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 32756FDA2B24C042006BDD24 /* Project object */; diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDI.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDI.swift deleted file mode 100644 index 56ee513e..00000000 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDI.swift +++ /dev/null @@ -1,37 +0,0 @@ -// This file was generated by the SafeDIGenerateDependencyTree build tool plugin. -// Any modifications made to this file will be overwritten on subsequent builds. -// Please refrain from editing this file directly. - -#if canImport(Combine) -import Combine -#endif -#if canImport(Foundation) -import Foundation -#endif -#if canImport(SafeDI) -import SafeDI -#endif -#if canImport(Subproject) -import Subproject -#endif -#if canImport(SwiftUI) -import SwiftUI -#endif - -extension NotesApp { - public init() { - let stringStorage: StringStorage = UserDefaults.instantiate() - let userService: any UserService = DefaultUserService(stringStorage: stringStorage) - func __safeDI_nameEntryViewBuilder() -> NameEntryView { - NameEntryView(userService: userService) - } - let nameEntryViewBuilder = Instantiator(__safeDI_nameEntryViewBuilder) - func __safeDI_noteViewBuilder(userName: String) -> NoteView { - NoteView(userName: userName, userService: userService, stringStorage: stringStorage) - } - let noteViewBuilder = Instantiator { - __safeDI_noteViewBuilder(userName: $0) - } - self.init(userService: userService, stringStorage: stringStorage, nameEntryViewBuilder: nameEntryViewBuilder, noteViewBuilder: noteViewBuilder) - } -} \ No newline at end of file diff --git a/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj b/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj index 77e19c5d..85682e9f 100644 --- a/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj +++ b/Examples/ExampleProjectIntegration/ExampleProjectIntegration.xcodeproj/project.pbxproj @@ -3,10 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ + 320A3ECF2D39655700D8EC2E /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 320A3ECE2D39655700D8EC2E /* SafeDI */; }; 324F1ECB2B314D8D0001AC0C /* UserService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECA2B314D8D0001AC0C /* UserService.swift */; }; 324F1ECD2B314DB20001AC0C /* StringStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECC2B314DB20001AC0C /* StringStorage.swift */; }; 324F1ECF2B314E030001AC0C /* NameEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECE2B314E030001AC0C /* NameEntryView.swift */; }; @@ -15,7 +16,6 @@ 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32756FE52B24C042006BDD24 /* ExampleApp.swift */; }; 32756FEA2B24C044006BDD24 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32756FE92B24C044006BDD24 /* Assets.xcassets */; }; 32756FEE2B24C044006BDD24 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 32756FED2B24C044006BDD24 /* Preview Assets.xcassets */; }; - 32B280A32B28176D00A33FED /* SafeDI in Frameworks */ = {isa = PBXBuildFile; productRef = 32B280A22B28176D00A33FED /* SafeDI */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -36,7 +36,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 32B280A32B28176D00A33FED /* SafeDI in Frameworks */, + 320A3ECF2D39655700D8EC2E /* SafeDI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,11 +121,11 @@ buildRules = ( ); dependencies = ( - 321042552B2533F30088785D /* PBXTargetDependency */, + 320A3ED32D3967BD00D8EC2E /* PBXTargetDependency */, ); name = ExampleProjectIntegration; packageProductDependencies = ( - 32B280A22B28176D00A33FED /* SafeDI */, + 320A3ECE2D39655700D8EC2E /* SafeDI */, ); productName = ExampleProjectIntegration; productReference = 32756FE22B24C042006BDD24 /* ExampleProjectIntegration.app */; @@ -156,7 +156,7 @@ ); mainGroup = 32756FD92B24C042006BDD24; packageReferences = ( - 321042512B2533ED0088785D /* XCRemoteSwiftPackageReference "SafeDI" */, + 320A3ECD2D39655700D8EC2E /* XCLocalSwiftPackageReference "../../../SafeDI" */, ); productRefGroup = 32756FE32B24C042006BDD24 /* Products */; projectDirPath = ""; @@ -196,9 +196,9 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 321042552B2533F30088785D /* PBXTargetDependency */ = { + 320A3ED32D3967BD00D8EC2E /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 321042542B2533F30088785D /* SafeDIGenerator */; + productRef = 320A3ED22D3967BD00D8EC2E /* SafeDIGenerator */; }; /* End PBXTargetDependency section */ @@ -416,27 +416,22 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 321042512B2533ED0088785D /* XCRemoteSwiftPackageReference "SafeDI" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/dfed/SafeDI.git"; - requirement = { - branch = main; - kind = branch; - }; +/* Begin XCLocalSwiftPackageReference section */ + 320A3ECD2D39655700D8EC2E /* XCLocalSwiftPackageReference "../../../SafeDI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../../SafeDI; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 321042542B2533F30088785D /* SafeDIGenerator */ = { + 320A3ECE2D39655700D8EC2E /* SafeDI */ = { isa = XCSwiftPackageProductDependency; - package = 321042512B2533ED0088785D /* XCRemoteSwiftPackageReference "SafeDI" */; - productName = "plugin:SafeDIGenerator"; + productName = SafeDI; }; - 32B280A22B28176D00A33FED /* SafeDI */ = { + 320A3ED22D3967BD00D8EC2E /* SafeDIGenerator */ = { isa = XCSwiftPackageProductDependency; - package = 321042512B2533ED0088785D /* XCRemoteSwiftPackageReference "SafeDI" */; - productName = SafeDI; + package = 320A3ECD2D39655700D8EC2E /* XCLocalSwiftPackageReference "../../../SafeDI" */; + productName = "plugin:SafeDIGenerator"; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Plugins/InstallSafeDITool/InstallCLIPluginCommand.swift b/Plugins/InstallSafeDITool/InstallCLIPluginCommand.swift index 0b144635..521ba1b2 100644 --- a/Plugins/InstallSafeDITool/InstallCLIPluginCommand.swift +++ b/Plugins/InstallSafeDITool/InstallCLIPluginCommand.swift @@ -34,25 +34,17 @@ struct InstallSafeDITool: CommandPlugin { Diagnostics.error("No package origin found for SafeDI package") exit(1) } - switch safeDIOrigin { - case let .repository(url, displayVersion, _): - // As of Xcode 16.0 Beta 6, the display version is of the form "Optional(version)". - // This regular expression is duplicated by SafeDIGenerateDependencyTree since plugins can not share code. - guard let versionMatch = try /Optional\((.*?)\)|^(.*?)$/.firstMatch(in: displayVersion), - let versionSubstring = versionMatch.output.1 ?? versionMatch.output.2 - else { - Diagnostics.error("Could not extract version for SafeDI") - exit(1) - } - let version = String(versionSubstring) - let safediFolder = context.package.directoryURL.appending( - component: ".safedi" - ) - let expectedToolFolder = safediFolder.appending( - component: version - ) - let expectedToolLocation = expectedToolFolder.appending(component: "safeditool") + guard let version = context.safeDIVersion, + let expectedToolFolder = context.expectedToolFolder, + let expectedToolLocation = context.expectedToolLocation + else { + Diagnostics.error("Could not extract version for SafeDI") + exit(1) + } + + switch safeDIOrigin { + case let .repository(url, _, _): guard let url = URL(string: url)?.deletingPathExtension() else { Diagnostics.error("No package url found for SafeDI package") exit(1) @@ -91,7 +83,7 @@ struct InstallSafeDITool: CommandPlugin { at: downloadedURL, to: expectedToolLocation ) - let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") + let gitIgnoreLocation = context.safediFolder.appending(component: ".gitignore") if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path()) { try """ */\(expectedToolLocation.lastPathComponent) @@ -111,3 +103,72 @@ struct InstallSafeDITool: CommandPlugin { } } } + +#if canImport(XcodeProjectPlugin) + import XcodeProjectPlugin + + extension InstallSafeDITool: XcodeCommandPlugin { + func performCommand( + context: XcodeProjectPlugin.XcodePluginContext, + arguments _: [String] + ) throws { + let version = context.safeDIVersion + let safediFolder = context.safediFolder + let expectedToolFolder = context.expectedToolFolder + let expectedToolLocation = context.expectedToolLocation + + #if arch(arm64) + let toolName = "SafeDITool-arm64" + #elseif arch(x86_64) + let toolName = "SafeDITool-x86_64" + #else + Diagnostics.error("Unexpected architecture type") + exit(1) + #endif + + let githubDownloadURL = context.safeDIOrigin.appending( + components: "releases", + "download", + version, + toolName + ) + + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() + Task.detached { + defer { dispatchGroup.leave() } + let (downloadedURL, _) = try await URLSession.shared.download( + for: URLRequest(url: githubDownloadURL) + ) + let downloadedFileAttributes = try FileManager.default.attributesOfItem(atPath: downloadedURL.path()) + guard let currentPermissions = downloadedFileAttributes[.posixPermissions] as? NSNumber, + // Add executable attributes to the downloaded file. + chmod(downloadedURL.path(), mode_t(currentPermissions.uint32Value) | S_IXUSR | S_IXGRP | S_IXOTH) == 0 + else { + Diagnostics.error("Failed to make downloaded file \(downloadedURL.path()) executable") + exit(1) + } + try FileManager.default.createDirectory( + at: expectedToolFolder, + withIntermediateDirectories: true + ) + try FileManager.default.moveItem( + at: downloadedURL, + to: expectedToolLocation + ) + let gitIgnoreLocation = safediFolder.appending(component: ".gitignore") + if !FileManager.default.fileExists(atPath: gitIgnoreLocation.path()) { + try """ + */\(expectedToolLocation.lastPathComponent) + """.write( + to: gitIgnoreLocation, + atomically: true, + encoding: .utf8 + ) + } + } + // Force the command to wait until the async work is done. + dispatchGroup.wait() + } + } +#endif diff --git a/Plugins/InstallSafeDITool/Shared.swift b/Plugins/InstallSafeDITool/Shared.swift new file mode 120000 index 00000000..f125446d --- /dev/null +++ b/Plugins/InstallSafeDITool/Shared.swift @@ -0,0 +1 @@ +../Shared.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index aaaf3663..d5c447d4 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -46,32 +46,43 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { .sourceFiles(withSuffix: ".swift") .map(\.url) } - let inputSourcesFilePath = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv").path() - try Data( - (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) }) - .joined(separator: ",") - .utf8 - ) - .write(toPath: inputSourcesFilePath) + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") + try (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) }) + .joined(separator: ",") + .write( + to: inputSourcesFile, + atomically: true, + encoding: .utf8 + ) + + let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") + let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path()) { + [ + "--include-file-path", + includeCSV.path(), + ] + } else { + [] + } + let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") + let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path()) { + [ + "--additional-imported-modules-file-path", + additionalImportedModulesCSV.path(), + ] + } else { + [] + } + let arguments = [ - inputSourcesFilePath, + inputSourcesFile.path(), "--dependency-tree-output", outputSwiftFile.path(), - ] + ] + includeArguments + additionalImportedModulesArguments let downloadedToolLocation = context.downloadedToolLocation let safeDIVersion = context.safeDIVersion - if context.hasSafeDIFolder, let safeDIVersion, downloadedToolLocation == nil { - Diagnostics.error(""" - \(context.safediFolder.path()) exists, but contains no SafeDITool binary for version \(safeDIVersion). - - To install the release SafeDITool binary for version \(safeDIVersion), run: - \tswift package --package-path \(context.package.directoryURL.path()) --allow-network-connections all --allow-writing-to-package-directory safedi-release-install - - To use a debug SafeDITool binary instead, remove previous installs by running: - \trm -rf \(context.safediFolder.path()) - """) - } else if downloadedToolLocation == nil, let safeDIVersion { + if downloadedToolLocation == nil, let safeDIVersion { Diagnostics.warning(""" Using a debug SafeDITool binary, which is 15x slower than the release version. @@ -137,7 +148,9 @@ extension Target { // As of Xcode 15.0.1, Swift Package Plugins in Xcode are unable // to inspect target dependencies. As a result, this Xcode plugin // only works if it is running on a single-module project, or if - // all `@Instantiable`-decorated types are in the target module. + // all `@Instantiable`-decorated types are in the target module, + // or if a .safedi/configuration/include.csv directs the plugin + // to search additional modules for Swift files. // https://github.com/apple/swift-package-manager/issues/6003 let inputSwiftFiles = target .inputFiles @@ -149,19 +162,50 @@ extension Target { } let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift") - let inputSourcesFilePath = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv").path() - try Data( - inputSwiftFiles - .map { $0.path(percentEncoded: false) } - .joined(separator: ",") - .utf8 - ) - .write(toPath: inputSourcesFilePath) + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") + try inputSwiftFiles + .map { $0.path(percentEncoded: false) } + .joined(separator: ",") + .write( + to: inputSourcesFile, + atomically: true, + encoding: .utf8 + ) + + let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") + let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path()) { + [ + "--include-file-path", + includeCSV.path(), + ] + } else { + [] + } + let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") + let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path()) { + [ + "--additional-imported-modules-file-path", + additionalImportedModulesCSV.path(), + ] + } else { + [] + } + let arguments = [ - inputSourcesFilePath, + inputSourcesFile.path(), "--dependency-tree-output", outputSwiftFile.path(), - ] + ] + includeArguments + additionalImportedModulesArguments + + let downloadedToolLocation = context.downloadedToolLocation + let safeDIVersion = context.safeDIVersion + if downloadedToolLocation == nil { + Diagnostics.warning(""" + Using a debug SafeDITool binary, which is 15x slower than the release version. + + To install the release SafeDITool binary for this version, run the `InstallSafeDITool` command plugin. + """) + } return try [ .buildCommand( @@ -177,61 +221,6 @@ extension Target { } #endif -extension Data { - fileprivate func write(toPath filePath: String) throws { - #if os(Linux) - try write(to: URL(fileURLWithPath: filePath)) - #else - guard #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) else { - try write(to: URL(fileURLWithPath: filePath)) - } - try write(to: URL(filePath: filePath)) - #endif - } -} - -extension PackagePlugin.PluginContext { - var safeDIVersion: String? { - guard let safeDIOrigin = package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { - return nil - } - switch safeDIOrigin { - case let .repository(_, displayVersion, _): - // This regular expression is duplicated by InstallSafeDITool since plugins can not share code. - guard let versionMatch = try? /Optional\((.*?)\)|^(.*?)$/.firstMatch(in: displayVersion), - let version = versionMatch.output.1 ?? versionMatch.output.2 - else { - return nil - } - return String(version) - case .registry, .root, .local: - fallthrough - @unknown default: - return nil - } - } - - var hasSafeDIFolder: Bool { - FileManager.default.fileExists(atPath: safediFolder.path()) - } - - var safediFolder: URL { - package.directoryURL.appending( - component: ".safedi" - ) - } - - var downloadedToolLocation: URL? { - guard let safeDIVersion else { return nil } - let location = safediFolder.appending( - components: safeDIVersion, - "safeditool" - ) - guard FileManager.default.fileExists(atPath: location.path()) else { return nil } - return location - } -} - extension Array where Element: Equatable { public func dropLast(ifEquals value: Element) -> [Element] { if last == value { diff --git a/Plugins/SafeDIGenerator/Shared.swift b/Plugins/SafeDIGenerator/Shared.swift new file mode 120000 index 00000000..f125446d --- /dev/null +++ b/Plugins/SafeDIGenerator/Shared.swift @@ -0,0 +1 @@ +../Shared.swift \ No newline at end of file diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift new file mode 100644 index 00000000..f7a887df --- /dev/null +++ b/Plugins/Shared.swift @@ -0,0 +1,118 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import PackagePlugin + +#if canImport(XcodeProjectPlugin) + import XcodeProjectPlugin + + extension XcodeProjectPlugin.XcodePluginContext { + var safeDIVersion: String { + // As of Xcode 15.0, Xcode command plugins have no way to read the package manifest, therefore we must hardcode the version number. + // It is okay for this number to be behind the most current release if the inputs and outputs to SafeDITool have not changed. + // Unlike SPM plugins, Xcode plugins can not determine the current version number, so we must hardcode it. + "1.1.0-beta-1" + } + + var safeDIOrigin: URL { + // As of Xcode 15.0, Xcode command plugins have no way to read the package manifest, therefore we must hardcode the package. + // This means that forks of this repository must update this URL manually to ensure their own release binary is downloaded by this tool. + URL(string: "https://github.com/dfed/SafeDI")! + } + + var safediFolder: URL { + xcodeProject.directoryURL.appending( + component: ".safedi" + ) + } + + var expectedToolFolder: URL { + let location = safediFolder.appending( + component: safeDIVersion + ) + return location + } + + var expectedToolLocation: URL { + let location = expectedToolFolder.appending( + component: "safeditool" + ) + return location + } + + var downloadedToolLocation: URL? { + guard FileManager.default.fileExists(atPath: expectedToolLocation.path()) else { return nil } + return expectedToolLocation + } + } +#endif + +extension PackagePlugin.PluginContext { + var safeDIVersion: String? { + guard let safeDIOrigin = package.dependencies.first(where: { $0.package.displayName == "SafeDI" })?.package.origin else { + return nil + } + switch safeDIOrigin { + case let .repository(_, displayVersion, _): + // As of Xcode 16.0 Beta 6, the display version is of the form "Optional(version)". + // This regular expression is duplicated by SafeDIGenerateDependencyTree since plugins can not share code. + guard let versionMatch = try? /Optional\((.*?)\)|^(.*?)$/.firstMatch(in: displayVersion), + let version = versionMatch.output.1 ?? versionMatch.output.2 + else { + return nil + } + return String(version) + case .registry, .root, .local: + fallthrough + @unknown default: + return nil + } + } + + var safediFolder: URL { + package.directoryURL.appending( + component: ".safedi" + ) + } + + var expectedToolFolder: URL? { + guard let safeDIVersion else { return nil } + let location = safediFolder.appending( + component: safeDIVersion + ) + return location + } + + var expectedToolLocation: URL? { + guard let expectedToolFolder else { return nil } + let location = expectedToolFolder.appending( + component: "safeditool" + ) + return location + } + + var downloadedToolLocation: URL? { + guard let expectedToolLocation, + FileManager.default.fileExists(atPath: expectedToolLocation.path()) + else { return nil } + return expectedToolLocation + } +} diff --git a/README.md b/README.md index 905a9d5a..1b73455a 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,12 @@ SafeDI provides a code generation plugin named `SafeDIGenerator`. This plugin wo #### Swift package manager -##### Single-module Xcode projects +##### Xcode project If your first-party code comprises a single module in an `.xcodeproj`, once your Xcode project depends on the SafeDI package you can integrate the Swift Package Plugin simply by going to your target’s `Build Phases`, expanding the `Run Build Tool Plug-ins` drop-down, and adding the `SafeDIGenerator` as a build tool plug-in. You can see this integration in practice in the [ExampleProjectIntegration](Examples/ExampleProjectIntegration) project. +If your Xcode project comprises multiple modules, follow the above steps, and then create a `.safedi/configuration/include.csv` file containing a comma-separated list of folders outside of your root module that SafeDI will scan for Swift source files. The `.safedi/` folder must be placed in the same folder as your `*.xcodeproj`, and the paths must be relative to the same folder. You can see this integration in practice in the [ExampleMultiProjectIntegration](Examples/ExampleMultiProjectIntegration) project. To ensure that generated SafeDI code includes imports to all of your required modules, you may need to create a `.safedi/configuration/additionalImportedModules.csv` with a comma-separated list of modules to import. + ##### Swift package If your first-party code is entirely contained in a Swift Package with one or more modules, you can add the following lines to your root target’s definition: @@ -122,7 +124,7 @@ You can see this integration in practice in the [ExampleCocoaPodsIntegration](Ex #### Additional configurations -`SafeDITool` is designed to integrate into projects of any size or shape. If your first-party code comprises multiple modules in Xcode, or a mix of Xcode Projects and Swift Packages, or some other configuration, once your Xcode project depends on the SafeDI package you will need to utilize the `SafeDITool` command-line executable directly in a pre-build script similar to the CocoaPods integration described above. +`SafeDITool` is designed to integrate into projects of any size or shape. If your first-party code comprises a mix of Xcode Projects and Swift Packages or some other configuration, once your Xcode project depends on the SafeDI package you will need to utilize the `SafeDITool` command-line executable directly in a pre-build script similar to the CocoaPods integration described above. `SafeDITool` can parse all of your Swift files at once, or for even better performance, the tool can be run on each dependent module as part of the build. Run `swift run SafeDITool --help` to see documentation of the tool’s supported arguments. diff --git a/SafeDI.podspec b/SafeDI.podspec index c2196439..088236ca 100644 --- a/SafeDI.podspec +++ b/SafeDI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'SafeDI' - s.version = '1.0.0' + s.version = '1.1.0' s.summary = 'Compile-time-safe dependency injection' s.homepage = 'https://github.com/dfed/SafeDI' s.license = 'MIT' diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index fa4d1ff8..53b0e61b 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -34,9 +34,13 @@ struct SafeDITool: AsyncParsableCommand, Sendable { @Option(parsing: .upToNextOption, help: "Directories containing Swift files to include, relative to the executing directory.") var include: [String] = [] + @Option(help: "A path to a CSV file comprising directories containing Swift files to include, relative to the executing directory.") var includeFilePath: String? + @Option(parsing: .upToNextOption, help: "The names of modules to import in the generated dependency tree. This list is in addition to the import statements found in files that declare @Instantiable types.") var additionalImportedModules: [String] = [] - @Option(help: "The desired output location of a file a SafeDI representation of this module. Only include this option when running on a project‘s non-root module. Must have a `.safedi` suffix") var moduleInfoOutput: String? + @Option(help: "A path to a CSV file comprising the names of modules to import in the generated dependency tree. This list is in addition to the import statements found in files that declare @Instantiable types.") var additionalImportedModulesFilePath: String? + + @Option(help: "The desired output location of a file a SafeDI representation of this module. Only include this option when running on a project‘s non-root module. Must have a `.safedi` suffix.") var moduleInfoOutput: String? @Option(help: "A path to a CSV file containing paths of SafeDI representations of other modules to parse.") var dependentModuleInfoFilePath: String? @@ -47,8 +51,8 @@ struct SafeDITool: AsyncParsableCommand, Sendable { // MARK: Internal func run() async throws { - if swiftSourcesFilePath == nil, include.isEmpty { - throw ValidationError("Must provide either 'swift-sources-file-path' or '--include'.") + if swiftSourcesFilePath == nil, include.isEmpty, includeFilePath == nil { + throw ValidationError("Must provide 'swift-sources-file-path', '--include', or '--include-file-path'.") } let (dependentModuleInfo, module) = try await ( @@ -107,7 +111,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { ) } let generator = try DependencyTreeGenerator( - importStatements: dependentModuleInfo.flatMap(\.imports) + additionalImportedModules.map { ImportStatement(moduleName: $0) } + module.imports, + importStatements: dependentModuleInfo.flatMap(\.imports) + allAdditionalImportedModules.map { ImportStatement(moduleName: $0) } + module.imports, typeDescriptionToFulfillingInstantiableMap: resolveSafeDIFulfilledTypes( instantiables: normalizedInstantiables ) @@ -160,7 +164,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } } let fileFinder = await fileFinder - for included in include { + for included in try allDirectoriesToIncludes { taskGroup.addTask { let includedURL = included.asFileURL let includedFileEnumerator = fileFinder @@ -231,7 +235,31 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } } - var moduleInfoURLs: Set { + private var allDirectoriesToIncludes: [String] { + get throws { + if let includeFilePath { + try include + String(contentsOfFile: includeFilePath, encoding: .utf8) + .components(separatedBy: CharacterSet(arrayLiteral: ",")) + .removingEmpty() + } else { + include + } + } + } + + private var allAdditionalImportedModules: [String] { + get throws { + if let additionalImportedModulesFilePath { + try additionalImportedModules + String(contentsOfFile: additionalImportedModulesFilePath, encoding: .utf8) + .components(separatedBy: CharacterSet(arrayLiteral: ",")) + .removingEmpty() + } else { + additionalImportedModules + } + } + } + + private var moduleInfoURLs: Set { get throws { if let dependentModuleInfoFilePath { try .init( diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index 1ee46d4a..c0444f30 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -28,9 +28,11 @@ import XCTest func executeSafeDIToolTest( swiftFileContent: [String], dependentModuleInfoPaths: [String] = [], + additionalImportedModules: [String] = [], buildDependencyTreeOutput: Bool = false, buildDOTFileOutput: Bool = false, - filesToDelete: inout [URL] + filesToDelete: inout [URL], + includeFolders: [String] = [] ) async throws -> TestOutput { let swiftFileCSV = URL.temporaryFile let swiftFiles = try swiftFileContent @@ -52,11 +54,19 @@ func executeSafeDIToolTest( let moduleInfoOutput = URL.temporaryFile.appendingPathExtension("safedi") let dependencyTreeOutput = URL.temporaryFile.appendingPathExtension("swift") let dotTreeOutput = URL.temporaryFile.appendingPathExtension("dot") + + let includeFile = URL.temporaryFile.appendingPathExtension("include.csv") + try includeFolders.joined(separator: ",").write(to: includeFile, atomically: true, encoding: .utf8) + let additionalImportedModulesFile = URL.temporaryFile.appendingPathExtension("additionalImportedModules.csv") + try additionalImportedModules.joined(separator: ",").write(to: additionalImportedModulesFile, atomically: true, encoding: .utf8) + fileFinder = StubFileFinder(files: swiftFiles) // Successfully execute the file finder code path. var tool = SafeDITool() tool.swiftSourcesFilePath = swiftFileCSV.relativePath - tool.include = ["Fake"] + tool.include = [] + tool.includeFilePath = !includeFolders.isEmpty ? includeFile.relativePath : nil tool.additionalImportedModules = [] + tool.additionalImportedModulesFilePath = !additionalImportedModules.isEmpty ? additionalImportedModulesFile.relativePath : nil tool.moduleInfoOutput = moduleInfoOutput.relativePath tool.dependentModuleInfoFilePath = dependentModuleInfoPaths.isEmpty ? nil : dependentModuleInfoFileCSV.relativePath tool.dependencyTreeOutput = buildDependencyTreeOutput ? dependencyTreeOutput.relativePath : nil diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index b11036a0..8ae17689 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1805,7 +1805,9 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { var tool = SafeDITool() tool.swiftSourcesFilePath = nil tool.include = ["Fake"] + tool.includeFilePath = nil tool.additionalImportedModules = [] + tool.additionalImportedModulesFilePath = nil tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil tool.dependencyTreeOutput = nil @@ -1819,12 +1821,14 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase { var tool = SafeDITool() tool.swiftSourcesFilePath = nil tool.include = [] + tool.includeFilePath = nil tool.additionalImportedModules = [] + tool.additionalImportedModulesFilePath = nil tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil tool.dependencyTreeOutput = nil tool.dotFileOutput = nil - await assertThrowsError("Must provide either 'swift-sources-file-path' or '--include'.") { + await assertThrowsError("Must provide 'swift-sources-file-path', '--include', or '--include-file-path'.") { try await tool.run() } } diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 2a9786b5..f6b5b2ac 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -3006,6 +3006,39 @@ final class SafeDIToolCodeGenerationTests: XCTestCase { ) } + @MainActor + func test_run_successfullyGeneratesOutputFileWhenNoRootFoundAndAdditionalImportedModulesSet() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct NotRoot { + public init() {} + } + """, + ], + additionalImportedModules: ["Test"], + buildDependencyTreeOutput: true, + filesToDelete: &filesToDelete, + includeFolders: ["Fake"] + ) + + XCTAssertEqual( + try XCTUnwrap(output.dependencyTree), + """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + #if canImport(Test) + import Test + #endif + + // No root @Instantiable-decorated types found, or root types already had a `public init()` method. + """ + ) + } + @MainActor func test_run_successfullyGeneratesOutputFileWhenIsRootIsFalse() async throws { let output = try await executeSafeDIToolTest(