diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/graphql-generator-config.yaml b/Examples/HelloWorldServer/Sources/HelloWorldServer/graphql-generator-config.yaml new file mode 100644 index 0000000..d368ad0 --- /dev/null +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/graphql-generator-config.yaml @@ -0,0 +1,2 @@ +schemas: + - schema.graphql diff --git a/Plugins/GraphQLGeneratorPlugin.swift b/Plugins/GraphQLGeneratorPlugin.swift index 6d31c0c..f1f3628 100644 --- a/Plugins/GraphQLGeneratorPlugin.swift +++ b/Plugins/GraphQLGeneratorPlugin.swift @@ -3,6 +3,8 @@ import PackagePlugin @main struct GraphQLGeneratorPlugin: BuildToolPlugin { + private static let schemaExtensions: Set = ["graphql", "gql"] + /// Entry point for creating build commands for targets in Swift packages. func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { // This plugin only runs for Swift source targets. @@ -38,7 +40,11 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { arguments += ["--config", configURL.path] } - let inputFiles = commandInputFiles(in: target.sourceFiles, configFile: configFile) + let inputFiles = try commandInputFiles( + in: target.sourceFiles, + sourceDirectory: target.directoryURL, + configFile: configFile + ) return [ .buildCommand( @@ -57,9 +63,6 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { "graphql-generator-config.yml", ] - /// File extensions recognized as GraphQL schema files. - private static let schemaExtensions: Set = ["graphql", "gql"] - /// Finds the generator config file in the target's source files, if present. private func findConfigFile(in sourceFiles: FileList) -> URL? { let configs = sourceFiles.map(\.url).filter { @@ -69,12 +72,126 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { } /// Returns all files whose contents can affect generated output. - private func commandInputFiles(in sourceFiles: FileList, configFile: URL?) -> [URL] { - let schemaFiles = sourceFiles.map(\.url).filter { - Self.schemaExtensions.contains($0.pathExtension.lowercased()) + private func commandInputFiles( + in sourceFiles: FileList, + sourceDirectory: URL, + configFile: URL? + ) throws -> [URL] { + let schemaFiles: [URL] + if let configFile, let configuredPaths = try configuredSchemaPaths(in: configFile) { + schemaFiles = try resolveSchemaFiles(configuredPaths, relativeTo: sourceDirectory) + } else { + schemaFiles = sourceFiles.map(\.url).filter { + Self.schemaExtensions.contains($0.pathExtension.lowercased()) + } } return (configFile.map { [$0] } ?? []) + schemaFiles } + + /// Decodes the optional top-level `schemas` YAML sequence used by the generator. + private func configuredSchemaPaths(in configFile: URL) throws -> [String]? { + let contents = try String(contentsOf: configFile, encoding: .utf8) + let lines = contents.split(separator: "\n", omittingEmptySubsequences: false) + + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed == "schemas:" || trimmed.hasPrefix("schemas: ") else { continue } + + let inlineValue = trimmed.dropFirst("schemas:".count) + .trimmingCharacters(in: .whitespaces) + if inlineValue == "null" || inlineValue == "~" { + return nil + } + if inlineValue.hasPrefix("["), inlineValue.hasSuffix("]") { + let values = inlineValue.dropFirst().dropLast() + if values.trimmingCharacters(in: .whitespaces).isEmpty { + return [] + } + return values.split(separator: ",").map { + unquote($0.trimmingCharacters(in: .whitespaces)) + } + } + guard inlineValue.isEmpty else { + throw PluginConfigError.unsupportedSchemasFormat(configFile.path) + } + + let indentation = line.prefix { $0 == " " || $0 == "\t" }.count + var paths: [String] = [] + for nestedLine in lines.dropFirst(index + 1) { + let nestedIndentation = nestedLine.prefix { $0 == " " || $0 == "\t" }.count + let nested = nestedLine.trimmingCharacters(in: .whitespaces) + if nested.isEmpty || nested.hasPrefix("#") { continue } + if nestedIndentation <= indentation { break } + guard nested.hasPrefix("-") else { + throw PluginConfigError.unsupportedSchemasFormat(configFile.path) + } + paths.append(unquote(nested.dropFirst().trimmingCharacters(in: .whitespaces))) + } + return paths + } + + return nil + } + + private func unquote(_ value: String) -> String { + guard value.count >= 2, + let first = value.first, + let last = value.last, + (first == "\"" && last == "\"") || (first == "'" && last == "'") + else { + return value + } + return String(value.dropFirst().dropLast()) + } + + private func resolveSchemaFiles(_ paths: [String], relativeTo baseURL: URL) throws -> [URL] { + let fileManager = FileManager.default + var result: Set = [] + + for path in paths { + let resolvedURL = baseURL.appendingPathComponent(path).standardizedFileURL + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: resolvedURL.path, isDirectory: &isDirectory) else { + throw PluginConfigError.schemaPathNotFound(path, resolvedURL.path) + } + + if isDirectory.boolValue { + guard + let enumerator = fileManager.enumerator( + at: resolvedURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + continue + } + for case let fileURL as URL in enumerator + where (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true + && Self.schemaExtensions.contains(fileURL.pathExtension.lowercased()) + { + result.insert(fileURL.standardizedFileURL) + } + } else { + result.insert(resolvedURL) + } + } + + return result.sorted { $0.path < $1.path } + } +} + +private enum PluginConfigError: Error, CustomStringConvertible { + case unsupportedSchemasFormat(String) + case schemaPathNotFound(String, String) + + var description: String { + switch self { + case .unsupportedSchemasFormat(let path): + "Unsupported schemas format in \(path); use a YAML list of paths" + case .schemaPathNotFound(let path, let resolvedPath): + "Schema path not found: \(path) (resolved to \(resolvedPath))" + } + } } #if canImport(XcodeProjectPlugin) @@ -89,9 +206,7 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { let configFile = findConfigFile(in: target.inputFiles) // Derive the source directory from the target's input files - let sourceDirectory = - target.inputFiles.first?.url.deletingLastPathComponent().path - ?? context.xcodeProject.directoryURL.path + let sourceDirectory = context.xcodeProject.directoryURL // Find the generator tool let generatorTool = try context.tool(named: "GraphQLGenerator") @@ -100,14 +215,15 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { let outputDirectory = context.pluginWorkDirectoryURL let outputFiles = [ - outputDirectory.appendingPathComponent("Types.swift"), - outputDirectory.appendingPathComponent("Schema.swift"), + outputDirectory.appendingPathComponent("BuildGraphQLSchema.swift"), + outputDirectory.appendingPathComponent("GraphQLRawSDL.swift"), + outputDirectory.appendingPathComponent("GraphQLTypes.swift"), ] var arguments: [String] = [] // Pass the source directory for fallback schema discovery - arguments += ["--source-directory", sourceDirectory] + arguments += ["--source-directory", sourceDirectory.path] // Pass output directory arguments += ["--output-directory", outputDirectory.path] @@ -119,6 +235,7 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin { let inputFiles = commandInputFiles( in: target.inputFiles, + sourceDirectory: sourceDirectory, configFile: configFile ) diff --git a/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift b/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift index 9f3adad..c19b2a4 100644 --- a/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift +++ b/Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift @@ -29,7 +29,6 @@ struct GraphQLGeneratorCommand: ParsableCommand { @Flag(name: .long, help: "Enable verbose logging") var verbose: Bool = false - /// File extensions recognized as GraphQL schema files. private static let schemaExtensions: Set = ["graphql", "gql"] mutating func run() throws { @@ -105,48 +104,41 @@ struct GraphQLGeneratorCommand: ParsableCommand { // Otherwise, recursively scan the source directory itself let schemaPaths = configSchemas ?? ["./"] - let schemaFileSet = try resolvePaths( - schemaPaths, - relativeTo: URL(fileURLWithPath: sourceDirectory) - ) - return Array(schemaFileSet).sorted() - } - - /// Resolves file or directory paths into concrete schema file paths. Files are added directly while directories are expanded recursively. - private func resolvePaths(_ paths: [String], relativeTo baseURL: URL) throws -> Set { - let fm = FileManager.default - var result: Set = [] - - for path in paths { - let resolvedURL = baseURL.appendingPathComponent(path) - let resolvedPath = resolvedURL.path + let fileManager = FileManager.default + let sourceURL = URL(fileURLWithPath: sourceDirectory) + var schemaFiles: Set = [] + for path in schemaPaths { + let resolvedURL = sourceURL.appendingPathComponent(path).standardizedFileURL var isDirectory: ObjCBool = false - guard fm.fileExists(atPath: resolvedPath, isDirectory: &isDirectory) else { + guard fileManager.fileExists(atPath: resolvedURL.path, isDirectory: &isDirectory) + else { throw ValidationError( - "Schema path not found: \(path) (resolved to \(resolvedPath))" + "Schema path not found: \(path) (resolved to \(resolvedURL.path))" ) } if isDirectory.boolValue { - // Recursively finds all `.graphql` and `.gql` files under the directory - if let enumerator = fm.enumerator( - at: resolvedURL, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles] - ) { - for case let fileURL as URL in enumerator { - let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues?.isDirectory == true { continue } - if Self.schemaExtensions.contains(fileURL.pathExtension.lowercased()) { - result.insert(fileURL.path) - } - } + guard + let enumerator = fileManager.enumerator( + at: resolvedURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) + else { + continue + } + for case let fileURL as URL in enumerator + where (try? fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true + && Self.schemaExtensions.contains(fileURL.pathExtension.lowercased()) + { + schemaFiles.insert(fileURL.standardizedFileURL.path) } } else { - result.insert(resolvedPath) + schemaFiles.insert(resolvedURL.path) } } - return result + + return schemaFiles.sorted() } }