import ArgumentParser import AsyncAlgorithms import Foundation import Logging import Noora import Subprocess struct GenerateDocsCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "generate-docs", abstract: "Generate DocC documentation inside a Linux container.", aliases: ["gd"], ) @OptionGroup var global: GlobalOptions private static let doccPluginURL = "https://github.com/apple/swift-docc-plugin.git" private static let doccPluginMinVersion = "1.4.0" private static let targets = ["Inotify", "TaskCLI"] private static let hostingBasePath = "swift-inotify" private static let skipItems: Set = [".git", ".build", ".swiftpm", "public"] // MARK: - Run func run() async throws { let noora = Noora() let logger = global.makeLogger(labeled: "swift-inotify.cli.task.generate-docs") let fileManager = FileManager.default let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) noora.info("Generating DocC documentation on Linux.") logger.debug("Current directory", metadata: ["current-directory": "\(projectDirectory.path(percentEncoded: false))"]) let tempDirectory = try copyProject(from: projectDirectory) logger.info("Copied project to temporary directory.", metadata: ["path": "\(tempDirectory.path(percentEncoded: false))"]) defer { try? fileManager.removeItem(at: tempDirectory) logger.info("Cleaned up temporary directory.") } try await injectDoccPluginDependency(in: tempDirectory, logger: logger) let script = Self.makeRunScript() logger.debug("Container script", metadata: ["script": "\(script)"]) let dockerResult = try await Subprocess.run( .name("docker"), arguments: [ "run", "--rm", "-v", "\(tempDirectory.path(percentEncoded: false)):/code", "--platform", "linux/arm64", "-w", "/code", "swift:latest", "/bin/bash", "-c", script, ], preferredBufferSize: 10, ) { execution, standardInput, standardOutput, standardError in print("") let stdout = standardOutput.lines() let stderr = standardError.lines() for try await line in merge(stdout, stderr) { noora.passthrough("\(line)") } print("") } guard dockerResult.terminationStatus.isSuccess else { noora.error("Documentation generation failed.") return } try copyResults(from: tempDirectory, to: projectDirectory) noora.success( .alert("Documentation generated successfully.", takeaways: Self.targets.map { "./public/\($0.lowercased())/" } ) ) } private static func generateDocsCommand() -> String { Self.targets.map { "swift package generate-documentation --target \($0)" }.joined(separator: " && ") } private static func transformDocsForStaticHostingCommand() -> String { Self.targets.map { target in let lowercased = target.lowercased() return "docc process-archive transform-for-static-hosting" + " $OUTPUTS/\(target).doccarchive" + " --output-path ./public/\(lowercased)" + " --hosting-base-path \(Self.hostingBasePath)/\(lowercased)" }.joined(separator: " && ") } private static func outputPathsForDocTargets() -> String { Self.targets.map { "./public/\($0.lowercased())" }.joined(separator: " ") } private static func makeRunScript() -> String { let generateDocs = Self.generateDocsCommand() let transformDocs = Self.transformDocsForStaticHostingCommand() let outputDirs = Self.outputPathsForDocTargets() return "set -euo pipefail && \(generateDocs)" + " && OUTPUTS=.build/plugins/Swift-DocC/outputs" + " && mkdir -p \(outputDirs)" + " && \(transformDocs)" } // MARK: - Project Copy private func copyProject(from source: URL) throws -> URL { let fileManager = FileManager.default let tempDirectory = fileManager.temporaryDirectory.appending(path: "swift-inotify-docs-\(UUID().uuidString)") try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true) let contents = try fileManager.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) for item in contents { guard !Self.skipItems.contains(item.lastPathComponent) else { continue } try fileManager.copyItem(at: item, to: tempDirectory.appending(path: item.lastPathComponent)) } return tempDirectory } private func copyResults(from tempDirectory: URL, to projectDirectory: URL) throws { let fileManager = FileManager.default let source = tempDirectory.appending(path: "public") let destination = projectDirectory.appending(path: "public") if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) { try fileManager.removeItem(at: destination) } try fileManager.copyItem(at: source, to: destination) } // MARK: - Dependency Injection private func injectDoccPluginDependency(in directory: URL, logger: Logger) async throws { let packageSwiftURL = directory.appending(path: "Package.swift") let contents = try String(contentsOf: packageSwiftURL, encoding: .utf8) guard !contents.contains("swift-docc-plugin") else { logger.info("swift-docc-plugin dependency already present.") return } let result = try await Subprocess.run( .name("swift"), arguments: [ "package", "--package-path", directory.path(percentEncoded: false), "add-dependency", Self.doccPluginURL, "--from", Self.doccPluginMinVersion, ], ) { _ in } guard result.terminationStatus.isSuccess else { throw GenerateDocsError.dependencyInjectionFailed } logger.info("Injected swift-docc-plugin dependency.") } } enum GenerateDocsError: Error, CustomStringConvertible { case dependencyInjectionFailed var description: String { switch self { case .dependencyInjectionFailed: "Failed to add swift-docc-plugin dependency to Package.swift." } } }