From e2bfb8280bc120ae947bc52524a46d1731610755 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 + Package.swift | 1 + Sources/TaskCLI/Command.swift | 2 +- Sources/TaskCLI/DoccFinder.swift | 37 ++++ .../GenerateDocumentation Command.swift | 180 ++++++++++++++++++ Sources/TaskCLI/TaskCLI.docc/TaskCLI.md | 17 +- 6 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 Sources/TaskCLI/DoccFinder.swift create mode 100644 Sources/TaskCLI/GenerateDocumentation Command.swift diff --git a/.gitignore b/.gitignore index 24e5b0a..1666ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .build +public diff --git a/Package.swift b/Package.swift index ecaf54c..daeb350 100644 --- a/Package.swift +++ b/Package.swift @@ -47,6 +47,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), .product(name: "Logging", package: "swift-log"), + .product(name: "_NIOFileSystem", package: "swift-nio"), .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Noora", package: "Noora") ] diff --git a/Sources/TaskCLI/Command.swift b/Sources/TaskCLI/Command.swift index cbc1edf..20d9324 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, GenerateDocumentationCommand.self] ) } diff --git a/Sources/TaskCLI/DoccFinder.swift b/Sources/TaskCLI/DoccFinder.swift new file mode 100644 index 0000000..d2bfa57 --- /dev/null +++ b/Sources/TaskCLI/DoccFinder.swift @@ -0,0 +1,37 @@ +import _NIOFileSystem + +public struct DoccFinder { + static let fileManager = FileSystem.shared + + public static func getTargetsWithDocumentation(at paths: String...) async throws -> [String] { + try await Self.getTargetsWithDocumentation(at: paths) + } + + static func getTargetsWithDocumentation(at paths: [String]) async throws -> [String] { + var resolved: [String] = [] + + for path in paths { + let itemPath = FilePath(path) + + try await withSubdirectories(at: itemPath) { targetPath in + print("Target path is", targetPath.description) + try await withSubdirectories(at: targetPath) { subdirectory in + guard subdirectory.description.hasSuffix(".docc") else { return } + guard let target = targetPath.lastComponent?.description else { return } + resolved.append(target) + } + } + } + + return resolved + } + + private static func withSubdirectories(at path: FilePath, body: (FilePath) async throws -> Void) async throws { + let directoryHandle = try await fileManager.openDirectory(atPath: path) + for try await childContent in directoryHandle.listContents() { + guard childContent.type == .directory else { continue } + try await body(childContent.path) + } + try await directoryHandle.close() + } +} diff --git a/Sources/TaskCLI/GenerateDocumentation Command.swift b/Sources/TaskCLI/GenerateDocumentation Command.swift new file mode 100644 index 0000000..d9acf63 --- /dev/null +++ b/Sources/TaskCLI/GenerateDocumentation Command.swift @@ -0,0 +1,180 @@ +import ArgumentParser +import AsyncAlgorithms +import Foundation +import Logging +import Noora +import Subprocess + +struct GenerateDocumentationCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "generate-documentation", + abstract: "Generate DocC documentation of all targets 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 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-documentation") + let fileManager = FileManager.default + let projectDirectory = URL(fileURLWithPath: fileManager.currentDirectoryPath) + + let targets = try await Self.targets(for: projectDirectory) + + noora.info("Generating DocC documentation on Linux.") + logger.debug("Current directory", metadata: ["current-directory": "\(projectDirectory.path(percentEncoded: false))", "targets": "\(targets.joined(separator: ", "))"]) + + 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(for: targets) + + 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) + try Self.generateIndexHTML( + templateURL: projectDirectory.appending(path: ".github/workflows/index.tpl.html"), + outputURL: projectDirectory.appending(path: "public/index.html") + ) + + noora.success( + .alert("Documentation generated successfully.", + takeaways: ["Start a local web server with ./public as document root, i.e. with python3 -m http.server to browse the documentation."] + ) + ) + } + + private static func generateIndexHTML(templateURL: URL, outputURL: URL) throws { + var content = try String(contentsOf: templateURL, encoding: .utf8) + + let replacements: [(String, String)] = [ + ("{{project.name}}", "Swift Inotify"), + ("{{project.tagline}}", "🗂️ Monitor filesystem events on Linux using modern Swift concurrency"), + ("{{project.links}}", """ +
  • Inotify: The actual library.
  • \ +
  • TaskCLI: The project build command.
  • + """), + ] + + for (placeholder, value) in replacements { + content = content.replacingOccurrences(of: placeholder, with: value) + } + + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try content.write(to: outputURL, atomically: true, encoding: .utf8) + } + + private static func targets(for projectDirectory: URL) async throws -> [String] { + let sourcesDirectory = projectDirectory.appending(path: "Sources").path + let testsDirectory = projectDirectory.appending(path: "Tests").path + return try await DoccFinder.getTargetsWithDocumentation(at: sourcesDirectory, testsDirectory) + } + + private static func makeRunScript(for targets: [String]) -> String { + targets.map { + "mkdir -p \"./public/\($0.localizedLowercase)\" && " + + "swift package --allow-writing-to-directory \"\($0.localizedLowercase)\" " + + "generate-documentation --disable-indexing --transform-for-static-hosting " + + "--target \"\($0)\" " + + "--hosting-base-path \"\($0.localizedLowercase)\" " + + "--output-path \"./public/\($0.localizedLowercase)\"" + }.joined(separator: " && ") + } + + // 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 result = try await Subprocess.run( + .name("swift"), + arguments: [ + "package", "--package-path", directory.path(percentEncoded: false), + "add-dependency", "--from", Self.doccPluginMinVersion, Self.doccPluginURL + ], + ) { _ in } + + guard result.terminationStatus.isSuccess else { + throw GenerateDocumentationError.dependencyInjectionFailed + } + + logger.info("Injected swift-docc-plugin dependency.") + } +} + +enum GenerateDocumentationError: 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..d3fe160 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-documentation +``` + +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`` +- ``GenerateDocumentationCommand`` ### Configuration - ``GlobalOptions`` + +### Errors + +- ``GenerateDocumentationError``