From a942b15483c8a3308c3034f3bdb041875ee48935 Mon Sep 17 00:00:00 2001 From: "T. R. Bernstein" Date: Thu, 12 Mar 2026 18:10:31 +0100 Subject: [PATCH] Add generate-docs command to build task The Swift Docc has to run in a Linux container to be able to build the documentation, as it needs access to the inotify.h header files. --- .gitignore | 1 + Sources/TaskCLI/Command.swift | 2 +- Sources/TaskCLI/GenerateDocs Command.swift | 178 +++++++++++++++++++++ Sources/TaskCLI/TaskCLI.docc/TaskCLI.md | 17 +- 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 Sources/TaskCLI/GenerateDocs Command.swift diff --git a/.gitignore b/.gitignore index 24e5b0a..1666ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .build +public diff --git a/Sources/TaskCLI/Command.swift b/Sources/TaskCLI/Command.swift index cbc1edf..3f6c4a4 100644 --- a/Sources/TaskCLI/Command.swift +++ b/Sources/TaskCLI/Command.swift @@ -4,6 +4,6 @@ import ArgumentParser struct Command: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Project tasks of Astzweig's Swift Inotify project.", - subcommands: [TestCommand.self] + subcommands: [TestCommand.self, GenerateDocsCommand.self] ) } diff --git a/Sources/TaskCLI/GenerateDocs Command.swift b/Sources/TaskCLI/GenerateDocs Command.swift new file mode 100644 index 0000000..c72d998 --- /dev/null +++ b/Sources/TaskCLI/GenerateDocs Command.swift @@ -0,0 +1,178 @@ +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." + } + } +} diff --git a/Sources/TaskCLI/TaskCLI.docc/TaskCLI.md b/Sources/TaskCLI/TaskCLI.docc/TaskCLI.md index 0627eee..d75e982 100644 --- a/Sources/TaskCLI/TaskCLI.docc/TaskCLI.md +++ b/Sources/TaskCLI/TaskCLI.docc/TaskCLI.md @@ -4,7 +4,7 @@ The build tool for the Swift Inotify project. ## Overview -`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running the integration test suite inside a Linux Docker container, so you can validate the inotify-dependent code on the correct platform even when developing on macOS. +`TaskCLI` is a small command-line executable (exposed as `task` in `Package.swift`) that automates project-level workflows. Its primary purpose is running integration tests and generating documentation inside Linux Docker containers, so you can validate inotify-dependent code on the correct platform even when developing on macOS. ### Running the Tests @@ -19,6 +19,16 @@ This launches a `swift:latest` Docker container with the repository mounted at ` The container is started with `--security-opt systempaths=unconfined` so that the limit tests can write to `/proc/sys/fs/inotify/*`. +### Generating Documentation + +```bash +swift run task generate-docs +``` + +This copies the project to a temporary directory, injects the `swift-docc-plugin` dependency via `swift package add-dependency` (if absent), and runs documentation generation inside a `swift:latest` Docker container. The resulting static sites are written to `./public/inotify/` and `./public/taskcli/`, ready for deployment to GitHub Pages. + +The working tree is never modified — all changes happen in the temporary copy, which is cleaned up automatically. + ### Verbosity Pass one or more `-v` flags to increase log output: @@ -40,7 +50,12 @@ Docker must be installed and running on the host machine. The container uses the - ``Command`` - ``TestCommand`` +- ``GenerateDocsCommand`` ### Configuration - ``GlobalOptions`` + +### Errors + +- ``GenerateDocsError``