diff --git a/Plugins/MetaProtocolCodable/Config.swift b/Plugins/MetaProtocolCodable/Config.swift index 128671242..4c81b2fea 100644 --- a/Plugins/MetaProtocolCodable/Config.swift +++ b/Plugins/MetaProtocolCodable/Config.swift @@ -22,6 +22,11 @@ struct Config { /// /// Files from the target which includes plugin and target dependencies /// present in current package manifest are checked. + case direct + /// Represents to check all local targets. + /// + /// Files from the target which includes plugin and all targets + /// that are in the same project/package. case local /// Represents to check current target and all dependencies. /// diff --git a/Plugins/MetaProtocolCodable/Plugin.swift b/Plugins/MetaProtocolCodable/Plugin.swift index a6c8b4510..5cbef458a 100644 --- a/Plugins/MetaProtocolCodable/Plugin.swift +++ b/Plugins/MetaProtocolCodable/Plugin.swift @@ -17,20 +17,11 @@ struct MetaProtocolCodable: BuildToolPlugin { /// /// - Parameter target: The target including plugin. /// - Returns: The config if provided, otherwise default config. - func fetchConfig(for target: SourceModuleTarget) async throws -> Config { - let fileManager = FileManager.default - let directory = target.directory.string - let contents = try fileManager.contentsOfDirectory(atPath: directory) - let file = contents.first { file in - let path = Path(file) - let name = path.stem - .components(separatedBy: .alphanumerics.inverted) - .joined(separator: "") - .lowercased() - return name == "metacodableconfig" - } - guard let file else { return .init(scan: .target) } - let pathStr = target.directory.appending([file]).string + func fetchConfig( + for target: Target + ) throws -> Config { + let pathStr = try target.configPath(named: "metacodableconfig") + guard let pathStr else { return .init(scan: .target) } let path = Config.url(forFilePath: pathStr) let conf = try Data(contentsOf: path) let pConf = try? PropertyListDecoder().decode(Config.self, from: conf) @@ -38,7 +29,8 @@ struct MetaProtocolCodable: BuildToolPlugin { return config } - /// Invoked by SwiftPM to create build commands for a particular target. + /// Invoked by build systems to create build commands for a particular + /// target. /// /// Creates build commands that produces intermediate files scanning /// swift source files according to configuration. Final build command @@ -49,14 +41,14 @@ struct MetaProtocolCodable: BuildToolPlugin { /// - target: The target including plugin. /// /// - Returns: The commands to be executed during build. - func createBuildCommands( - context: PluginContext, target: Target - ) async throws -> [Command] { - guard let target = target as? SourceModuleTarget else { return [] } + func createBuildCommands( + in context: Context, for target: Context.Target + ) throws -> [Command] where Context: MetaProtocolCodablePluginContext { + // Get config let tool = try context.tool(named: "ProtocolGen") - // Get Config - let config = try await fetchConfig(for: target) - let (allTargets, imports) = config.scanInput(for: target) + let config = try fetchConfig(for: target) + let (allTargets, imports) = config.scanInput(for: target, in: context) + // Setup folder let genFolder = context.pluginWorkDirectory.appending(["ProtocolGen"]) try FileManager.default.createDirectory( @@ -115,45 +107,49 @@ struct MetaProtocolCodable: BuildToolPlugin { } } -extension Config { - /// Returns targets to scan and import modules based on current - /// configuration. +extension MetaProtocolCodable { + /// Invoked by SwiftPM to create build commands for a particular target. /// - /// Based on configuration, the targets for which source files need - /// to be checked and the modules that will be imported in final syntax - /// generated is returned. + /// Creates build commands that produces intermediate files scanning + /// swift source files according to configuration. Final build command + /// generates syntax aggregating all intermediate files. /// - /// - Parameter target: The target including plugin. - /// - Returns: The targets to scan and modules to import. - func scanInput( - for target: SourceModuleTarget - ) -> (targets: [SourceModuleTarget], modules: [String]) { - let allTargets: [SourceModuleTarget] - let modules: [String] - switch scan { - case .target: - allTargets = [target] - modules = [] - case .local: - var targets = target.dependencies.compactMap { dependency in - return switch dependency { - case .target(let target): - target.sourceModule - default: - nil - } - } - modules = targets.map(\.moduleName) - targets.append(target) - allTargets = targets - case .recursive: - var targets = target.recursiveTargetDependencies.compactMap { - return $0 as? SourceModuleTarget - } - modules = targets.map(\.moduleName) - targets.append(target) - allTargets = targets - } - return (allTargets, modules) + /// - Parameters: + /// - context: The package and environmental inputs context. + /// - target: The target including plugin. + /// + /// - Returns: The commands to be executed during build. + func createBuildCommands( + context: PluginContext, target: Target + ) async throws -> [Command] { + guard let target = target as? SourceModuleTarget else { return [] } + return try self.createBuildCommands( + in: context, for: SwiftPackageTarget(module: target) + ) + } +} + +#if canImport(XcodeProjectPlugin) +@_implementationOnly import XcodeProjectPlugin + +extension MetaProtocolCodable: XcodeBuildToolPlugin { + /// Invoked by Xcode to create build commands for a particular target. + /// + /// Creates build commands that produces intermediate files scanning + /// swift source files according to configuration. Final build command + /// generates syntax aggregating all intermediate files. + /// + /// - Parameters: + /// - context: The package and environmental inputs context. + /// - target: The target including plugin. + /// + /// - Returns: The commands to be executed during build. + func createBuildCommands( + context: XcodePluginContext, target: XcodeTarget + ) throws -> [Command] { + return try self.createBuildCommands( + in: context, for: target + ) } } +#endif diff --git a/Plugins/MetaProtocolCodable/PluginContext.swift b/Plugins/MetaProtocolCodable/PluginContext.swift new file mode 100644 index 000000000..d89b88498 --- /dev/null +++ b/Plugins/MetaProtocolCodable/PluginContext.swift @@ -0,0 +1,64 @@ +@_implementationOnly import PackagePlugin + +/// Provides information about the package for which the plugin is invoked, +/// as well as contextual information based on the plugin's stated intent +/// and requirements. +/// +/// Build systems can provide their own conformance implementations. +protocol MetaProtocolCodablePluginContext { + /// The source code module type associated with this context. + /// + /// Build can customize target type based on build context. + associatedtype Target: MetaProtocolCodableSourceTarget + /// The path of a writable directory into which the plugin or the build + /// commands it constructs can write anything it wants. This could include + /// any generated source files that should be processed further, and it + /// could include any caches used by the build tool or the plugin itself. + /// + /// The plugin is in complete control of what is written under this + /// directory, and the contents are preserved between builds. + /// + /// A plugin would usually create a separate subdirectory of this directory + /// for each command it creates, and the command would be configured to + /// write its outputs to that directory. The plugin may also create other + /// directories for cache files and other file system content that either + /// it or the command will need. + var pluginWorkDirectory: Path { get } + /// The targets which are local to current context. + /// + /// These targets are included in the same package/project as this context. + /// These targets are scanned if `local` scan mode provided in config. + var localTargets: [Target] { get } + /// Looks up and returns the path of a named command line executable tool. + /// + /// The executable must be provided by an executable target or a binary + /// target on which the package plugin target depends. This function throws + /// an error if the tool cannot be found. The lookup is case sensitive. + /// + /// - Parameter name: The executable tool name. + /// - Returns: The executable tool. + func tool(named name: String) throws -> PluginContext.Tool +} + +extension PluginContext: MetaProtocolCodablePluginContext { + /// The targets which are local to current context. + /// + /// Includes all the source code targets of the package. + var localTargets: [SwiftPackageTarget] { + return `package`.targets.compactMap { target in + guard let sourceModule = target.sourceModule else { return nil } + return SwiftPackageTarget(module: sourceModule) + } + } +} + +#if canImport(XcodeProjectPlugin) +@_implementationOnly import XcodeProjectPlugin + +extension XcodePluginContext: MetaProtocolCodablePluginContext { + /// The targets which are local to current context. + /// + /// Includes all the targets of the Xcode project. + var localTargets: [XcodeTarget] { xcodeProject.targets } +} +#endif diff --git a/Plugins/MetaProtocolCodable/SourceTarget/MetaProtocolCodableSourceTarget.swift b/Plugins/MetaProtocolCodable/SourceTarget/MetaProtocolCodableSourceTarget.swift new file mode 100644 index 000000000..594d6843b --- /dev/null +++ b/Plugins/MetaProtocolCodable/SourceTarget/MetaProtocolCodableSourceTarget.swift @@ -0,0 +1,85 @@ +@_implementationOnly import PackagePlugin + +/// Represents a target consisting of a source code module, +/// containing `Swift` source files. +/// +/// Targets from multiple build system can support this plugin +/// by providing conformance. +protocol MetaProtocolCodableSourceTarget { + /// Type representing sequence of files. + associatedtype FileSequence: Sequence + where FileSequence.Element == FileList.Element + + /// The name of the module produced + /// by the target. + /// + /// This is used as additional imports in + /// plugin generated code. + var moduleName: String { get } + /// The targets on which the current target depends on. + /// + /// These targets are scanned if `direct` scan mode + /// provided in config. + var dependencyTargets: [Self] { get } + /// All the targets on which current target depends on. + /// + /// These targets are scanned if `recursive` scan mode + /// provided in config. + var recursiveTargets: [Self] { get } + + /// A list of source files in the target that have the given + /// filename suffix. + /// + /// The list can possibly be empty if no file matched. + /// + /// - Parameter suffix: The name suffix. + /// - Returns: The matching files. + func sourceFiles(withSuffix suffix: String) -> FileSequence + /// The absolute path to config file if provided. + /// + /// The file name comparison is case-insensitive + /// and if no match found `nil` is returned. + /// + /// - Parameter name: The config file name. + /// - Returns: The config file path. + func configPath(named name: String) throws -> String? +} + +extension Config { + /// Returns targets to scan and import modules based on current + /// configuration. + /// + /// Based on configuration, the targets for which source files need + /// to be checked and the modules that will be imported in final syntax + /// generated is returned. + /// + /// - Parameter target: The target including plugin. + /// - Returns: The targets to scan and modules to import. + func scanInput( + for target: Context.Target, in context: Context + ) -> (targets: [Context.Target], modules: [String]) { + let allTargets: [Context.Target] + let modules: [String] + switch scan { + case .target: + allTargets = [target] + modules = [] + case .direct: + var targets = target.dependencyTargets + modules = targets.map(\.moduleName) + targets.append(target) + allTargets = targets + case .local: + allTargets = context.localTargets + modules = allTargets.lazy.map(\.moduleName).filter { module in + return module != target.moduleName + } + case .recursive: + var targets = target.recursiveTargets + modules = targets.map(\.moduleName) + targets.append(target) + allTargets = targets + } + return (allTargets, modules) + } +} diff --git a/Plugins/MetaProtocolCodable/SourceTarget/SwiftPackageTarget.swift b/Plugins/MetaProtocolCodable/SourceTarget/SwiftPackageTarget.swift new file mode 100644 index 000000000..edf3e9786 --- /dev/null +++ b/Plugins/MetaProtocolCodable/SourceTarget/SwiftPackageTarget.swift @@ -0,0 +1,79 @@ +@_implementationOnly import Foundation +@_implementationOnly import PackagePlugin + +/// Represents an SwiftPM target. +/// +/// Uses `SourceModuleTarget` to provide conformances. +struct SwiftPackageTarget { + /// The actual module for this target. + /// + /// The conformances provided uses this module. + let module: any SourceModuleTarget +} + +extension SwiftPackageTarget: MetaProtocolCodableSourceTarget { + /// The name of the module produced + /// by the target. + /// + /// This is derived from target name or SwiftPM customized name. + var moduleName: String { module.moduleName } + + /// The targets on which the current target depends on. + /// + /// Represents direct dependencies of the target. + var dependencyTargets: [Self] { + return module.dependencies.lazy.compactMap { dependency in + return switch dependency { + case .target(let target): + target.sourceModule + default: + nil + } + }.map { Self.init(module: $0) } + } + + /// All the targets on which current target depends on. + /// + /// Represents direct and transient dependencies of the target. + var recursiveTargets: [Self] { + return module.recursiveTargetDependencies.lazy + .compactMap { $0.sourceModule } + .map { Self.init(module: $0) } + } + + /// A list of source files in the target that have the given + /// filename suffix. + /// + /// The list can possibly be empty if no file matched. + /// + /// - Parameter suffix: The name suffix. + /// - Returns: The matching files. + func sourceFiles(withSuffix suffix: String) -> FileList { + return module.sourceFiles(withSuffix: suffix) + } + + /// The absolute path to config file if provided. + /// + /// The file name comparison is case-insensitive + /// and if no match found `nil` is returned. + /// + /// The file is checked only in the module directory + /// and not in any of its sub-directories. + /// + /// - Parameter name: The config file name. + /// - Returns: The config file path. + func configPath(named name: String) throws -> String? { + let fileManager = FileManager.default + let directory = module.directory.string + let contents = try fileManager.contentsOfDirectory(atPath: directory) + let file = contents.first { file in + let path = Path(file) + return name.lowercased() == path.stem + .components(separatedBy: .alphanumerics.inverted) + .joined(separator: "") + .lowercased() + } + guard let file else { return nil } + return module.directory.appending([file]).string + } +} diff --git a/Plugins/MetaProtocolCodable/SourceTarget/XcodeTarget.swift b/Plugins/MetaProtocolCodable/SourceTarget/XcodeTarget.swift new file mode 100644 index 000000000..fca39ffe4 --- /dev/null +++ b/Plugins/MetaProtocolCodable/SourceTarget/XcodeTarget.swift @@ -0,0 +1,72 @@ +#if canImport(XcodeProjectPlugin) +@_implementationOnly import PackagePlugin +@_implementationOnly import XcodeProjectPlugin + +extension XcodeTarget: MetaProtocolCodableSourceTarget { + /// The name of the module produced + /// by the target. + /// + /// This is derived from target product name if present, + /// falling back to target name. + var moduleName: String { product?.name ?? displayName } + + /// The targets on which the current target depends on. + /// + /// Represents direct dependencies of the target. + var dependencyTargets: [Self] { + return dependencies.compactMap { dependency in + return switch dependency { + case .target(let target): + target + default: + nil + } + } + } + + /// All the targets on which current target depends on. + /// + /// Represents direct and transient dependencies of the target. + var recursiveTargets: [Self] { + return dependencies.flatMap { dependency in + switch dependency { + case .target(let target): + var targets = [target] + targets.append(contentsOf: target.recursiveTargets) + return targets + default: + return [] + } + } + } + + /// A list of source files in the target that have the given + /// filename suffix. + /// + /// The list can possibly be empty if no file matched. + /// + /// - Parameter suffix: The name suffix. + /// - Returns: The matching files. + func sourceFiles(withSuffix suffix: String) -> [FileList.Element] { + return self.inputFiles.filter { $0.path.string.hasSuffix(suffix) } + } + + /// The absolute path to config file if provided. + /// + /// The file name comparison is case-insensitive + /// and if no match found `nil` is returned. + /// + /// All the files in the target are checked. + /// + /// - Parameter name: The config file name. + /// - Returns: The config file path. + func configPath(named name: String) -> String? { + return inputFiles.first { file in + return name.lowercased() == file.path.stem + .components(separatedBy: .alphanumerics.inverted) + .joined(separator: "") + .lowercased() + }?.path.string + } +} +#endif diff --git a/Sources/MetaCodable/MetaCodable.docc/Limitations.md b/Sources/MetaCodable/MetaCodable.docc/Limitations.md index 339642318..57c829314 100644 --- a/Sources/MetaCodable/MetaCodable.docc/Limitations.md +++ b/Sources/MetaCodable/MetaCodable.docc/Limitations.md @@ -72,3 +72,7 @@ For `actor`s ``Codable()`` generates `Decodable` conformance, while `Encodable` To generate `Encodable` conformance, the `encode(to:)` method must be `nonisolated` to `actor`, and since `encode(to:)` method must be synchronous making it `nonisolated` will prevent accessing mutable properties. Due to these limitations, `Encodable` conformance isn't generated, users has to implement the conformance manually. + +### Why `MetaProtocolCodable` plugin can't scan Xcode target dependencies? + +Currently Swift Package Manager always returns empty list for Xcode target dependencies as noted in [this bug](https://github.com/apple/swift-package-manager/issues/6003). Hence `MetaProtocolCodable` can currently only scan the files from the target or from the project including the target.