Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schemas:
- schema.graphql
143 changes: 130 additions & 13 deletions Plugins/GraphQLGeneratorPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import PackagePlugin

@main
struct GraphQLGeneratorPlugin: BuildToolPlugin {
private static let schemaExtensions: Set<String> = ["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.
Expand Down Expand Up @@ -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(
Expand All @@ -57,9 +63,6 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin {
"graphql-generator-config.yml",
]

/// File extensions recognized as GraphQL schema files.
private static let schemaExtensions: Set<String> = ["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 {
Expand All @@ -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<URL> = []

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)
Expand All @@ -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")
Expand All @@ -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]
Expand All @@ -119,6 +235,7 @@ struct GraphQLGeneratorPlugin: BuildToolPlugin {

let inputFiles = commandInputFiles(
in: target.inputFiles,
sourceDirectory: sourceDirectory,
configFile: configFile
)

Expand Down
58 changes: 25 additions & 33 deletions Sources/GraphQLGenerator/GraphQLGeneratorCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["graphql", "gql"]

mutating func run() throws {
Expand Down Expand Up @@ -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<String> {
let fm = FileManager.default
var result: Set<String> = []

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<String> = []

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()
}
}
Loading