Skip to content

Commit 63f47d3

Browse files
authored
Add initial experimental support for combined documentation for multiple targets (#84)
* Add a minimal build graph for documentation tasks rdar://116698361 * Build documentation for targets in reverse dependency order rdar://116698361 * Fix unrelated warning about a deprecated (renamed) DocC flag * Combine nested conditionals into one if-statement * Decode the supported features for a given DocC executable * List all the generated documentation archvies * Add flag to enable combined documentation support This flag allows the targets to link to each other and creates an additional combined archive. rdar://116698361 * Warn if the DocC executable doesn't support combined documentation * Update integration tests to more explicitly check for archive paths in console output * Update check-source to include 2024 as a supported year * Address code review feedback: - Check for errors after queue has run the build operations - Avoid repeat-visiting targets when constructing the build graph - Update internal-only documentation comment * Add a type to encapsulate performing work for each build graph item * Remove extra blank line before license comment which cause a false-positive source validation error
1 parent 5761ba9 commit 63f47d3

17 files changed

+747
-67
lines changed

IntegrationTests/Tests/Utility/XCTestCase+swiftPackage.swift

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -144,14 +144,20 @@ struct SwiftInvocationResult {
144144

145145
var referencedDocCArchives: [URL] {
146146
return standardOutput
147-
.components(separatedBy: .whitespacesAndNewlines)
148-
.map { component in
149-
return component.trimmingCharacters(in: CharacterSet(charactersIn: "'."))
147+
.components(separatedBy: .newlines)
148+
.filter { line in
149+
line.hasPrefix("Generated DocC archive at")
150150
}
151-
.filter { component in
152-
return component.hasSuffix(".doccarchive")
151+
.flatMap { line in
152+
line.components(separatedBy: .whitespaces)
153+
.map { component in
154+
return component.trimmingCharacters(in: CharacterSet(charactersIn: "'."))
155+
}
156+
.filter { component in
157+
return component.hasSuffix(".doccarchive")
158+
}
159+
.compactMap(URL.init(fileURLWithPath:))
153160
}
154-
.compactMap(URL.init(fileURLWithPath:))
155161
}
156162

157163
var pluginOutputsDirectory: URL {

Plugins/Swift-DocC Convert/SwiftDocCConvert.swift

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// This source file is part of the Swift.org open source project
22
//
3-
// Copyright (c) 2022 Apple Inc. and the Swift project authors
3+
// Copyright (c) 2022-2024 Apple Inc. and the Swift project authors
44
// Licensed under Apache License v2.0 with Runtime Library Exception
55
//
66
// See https://swift.org/LICENSE.txt for license information
@@ -30,6 +30,19 @@ import PackagePlugin
3030
}
3131

3232
let verbose = argumentExtractor.extractFlag(named: "verbose") > 0
33+
let isCombinedDocumentationEnabled = argumentExtractor.extractFlag(named: PluginFlag.enableCombinedDocumentationSupportFlagName) > 0
34+
35+
if isCombinedDocumentationEnabled {
36+
let doccFeatures = try? DocCFeatures(doccExecutable: doccExecutableURL)
37+
guard doccFeatures?.contains(.linkDependencies) == true else {
38+
// The developer uses the combined documentation plugin flag with a DocC version that doesn't support combined documentation.
39+
Diagnostics.error("""
40+
Unsupported use of '--\(PluginFlag.enableCombinedDocumentationSupportFlagName)'. \
41+
DocC version at '\(doccExecutableURL.path)' doesn't support combined documentation.
42+
""")
43+
return
44+
}
45+
}
3346

3447
// Parse the given command-line arguments
3548
let parsedArguments = ParsedArguments(argumentExtractor.remainingArguments)
@@ -56,14 +69,9 @@ import PackagePlugin
5669
let snippetExtractor: SnippetExtractor? = nil
5770
#endif
5871

59-
60-
// Iterate over the Swift source module targets we were given.
61-
for (index, target) in swiftSourceModuleTargets.enumerated() {
62-
if index != 0 {
63-
// Emit a line break if this is not the first target being built.
64-
print()
65-
}
66-
72+
// An inner function that defines the work to build documentation for a given target.
73+
func performBuildTask(_ task: DocumentationBuildGraph<SwiftSourceModuleTarget>.Task) throws -> URL? {
74+
let target = task.target
6775
print("Generating documentation for '\(target.name)'...")
6876

6977
let symbolGraphs = try packageManager.doccSymbolGraphs(
@@ -74,27 +82,24 @@ import PackagePlugin
7482
customSymbolGraphOptions: parsedArguments.symbolGraphArguments
7583
)
7684

77-
if try FileManager.default.contentsOfDirectory(atPath: symbolGraphs.targetSymbolGraphsDirectory.path).isEmpty {
78-
// This target did not produce any symbol graphs. Let's check if it has a
79-
// DocC catalog.
85+
if target.doccCatalogPath == nil,
86+
try FileManager.default.contentsOfDirectory(atPath: symbolGraphs.targetSymbolGraphsDirectory.path).isEmpty
87+
{
88+
// This target did not produce any symbol graphs and has no DocC catalog.
89+
let message = """
90+
'\(target.name)' does not contain any documentable symbols or a \
91+
DocC catalog and will not produce documentation
92+
"""
8093

81-
guard target.doccCatalogPath != nil else {
82-
let message = """
83-
'\(target.name)' does not contain any documentable symbols or a \
84-
DocC catalog and will not produce documentation
85-
"""
86-
87-
if swiftSourceModuleTargets.count > 1 {
88-
// We're building multiple targets, just throw a warning for this
89-
// one target that does not produce documentation.
90-
Diagnostics.warning(message)
91-
continue
92-
} else {
93-
// This is the only target being built so throw an error
94-
Diagnostics.error(message)
95-
return
96-
}
94+
if swiftSourceModuleTargets.count > 1 {
95+
// We're building multiple targets, just emit a warning for this
96+
// one target that does not produce documentation.
97+
Diagnostics.warning(message)
98+
} else {
99+
// This is the only target being built so emit an error
100+
Diagnostics.error(message)
97101
}
102+
return nil
98103
}
99104

100105
// Construct the output path for the generated DocC archive
@@ -108,14 +113,22 @@ import PackagePlugin
108113
// arguments to pass to `docc`. ParsedArguments will merge the flags provided
109114
// by the user with default fallback values for required flags that were not
110115
// provided.
111-
let doccArguments = parsedArguments.doccArguments(
116+
var doccArguments = parsedArguments.doccArguments(
112117
action: .convert,
113118
targetKind: target.kind == .executable ? .executable : .library,
114119
doccCatalogPath: target.doccCatalogPath,
115120
targetName: target.name,
116121
symbolGraphDirectoryPath: symbolGraphs.unifiedSymbolGraphsDirectory.path,
117122
outputPath: doccArchiveOutputPath
118123
)
124+
if isCombinedDocumentationEnabled {
125+
doccArguments.append(CommandLineOption.enableExternalLinkSupport.defaultName)
126+
127+
for taskDependency in task.dependencies {
128+
let dependencyArchivePath = taskDependency.target.doccArchiveOutputPath(in: context)
129+
doccArguments.append(contentsOf: [CommandLineOption.externalLinkDependency.defaultName, dependencyArchivePath])
130+
}
131+
}
119132

120133
if verbose {
121134
let arguments = doccArguments.joined(separator: " ")
@@ -138,15 +151,57 @@ import PackagePlugin
138151
let describedOutputPath = doccArguments.outputPath ?? "unknown location"
139152
print("Generated DocC archive at '\(describedOutputPath)'")
140153
} else {
141-
Diagnostics.error("""
142-
'docc convert' invocation failed with a nonzero exit code: '\(process.terminationStatus)'
143-
"""
144-
)
154+
Diagnostics.error("'docc convert' invocation failed with a nonzero exit code: '\(process.terminationStatus)'")
145155
}
156+
157+
return URL(fileURLWithPath: doccArchiveOutputPath)
146158
}
147159

148-
if swiftSourceModuleTargets.count > 1 {
149-
print("\nMultiple DocC archives generated at '\(context.pluginWorkDirectory.string)'")
160+
let buildGraphRunner = DocumentationBuildGraphRunner(buildGraph: .init(targets: swiftSourceModuleTargets))
161+
var documentationArchives = try buildGraphRunner.perform(performBuildTask)
162+
.compactMap { $0 }
163+
164+
if documentationArchives.count > 1 {
165+
documentationArchives = documentationArchives.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
166+
167+
if isCombinedDocumentationEnabled {
168+
// Merge the archives into a combined archive
169+
let combinedArchiveName = "Combined \(context.package.displayName) Documentation.doccarchive"
170+
let combinedArchiveOutput = URL(fileURLWithPath: context.pluginWorkDirectory.appending(combinedArchiveName).string)
171+
172+
var mergeCommandArguments = ["merge"]
173+
mergeCommandArguments.append(contentsOf: documentationArchives.map(\.standardizedFileURL.path))
174+
mergeCommandArguments.append(contentsOf: ["--output-path", combinedArchiveOutput.path])
175+
176+
// Remove the combined archive if it already exists
177+
try? FileManager.default.removeItem(at: combinedArchiveOutput)
178+
179+
// Create a new combined archive
180+
let process = try Process.run(doccExecutableURL, arguments: mergeCommandArguments)
181+
process.waitUntilExit()
182+
183+
// Display the combined archive before the other generated archives
184+
documentationArchives.insert(combinedArchiveOutput, at: 0)
185+
}
186+
187+
print("""
188+
Generated \(documentationArchives.count) DocC archives in '\(context.pluginWorkDirectory.string)':
189+
\(documentationArchives.map(\.lastPathComponent).joined(separator: "\n "))
190+
""")
191+
}
192+
}
193+
}
194+
195+
// We add the conformance here so that 'DocumentationBuildGraphTarget' doesn't need to know about 'SwiftSourceModuleTarget' or import 'PackagePlugin'.
196+
extension SwiftSourceModuleTarget: DocumentationBuildGraphTarget {
197+
var dependencyIDs: [String] {
198+
// List all the target dependencies in a flat list.
199+
dependencies.flatMap {
200+
switch $0 {
201+
case .target(let target): return [target.id]
202+
case .product(let product): return product.targets.map { $0.id }
203+
@unknown default: return []
204+
}
150205
}
151206
}
152207
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
4+
// Licensed under Apache License v2.0 with Runtime Library Exception
5+
//
6+
// See https://swift.org/LICENSE.txt for license information
7+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
8+
9+
import Foundation
10+
11+
/// A target that can have a documentation task in the build graph
12+
protocol DocumentationBuildGraphTarget {
13+
typealias ID = String
14+
/// The unique identifier of this target
15+
var id: ID { get }
16+
/// The unique identifiers of this target's direct dependencies (non-transitive).
17+
var dependencyIDs: [ID] { get }
18+
}
19+
20+
/// A build graph of documentation tasks.
21+
struct DocumentationBuildGraph<Target: DocumentationBuildGraphTarget> {
22+
fileprivate typealias ID = Target.ID
23+
/// All the documentation tasks
24+
let tasks: [Task]
25+
26+
/// Creates a new documentation build graph for a series of targets with dependencies.
27+
init(targets: some Sequence<Target>) {
28+
// Create tasks
29+
let taskLookup: [ID: Task] = targets.reduce(into: [:]) { acc, target in
30+
acc[target.id] = Task(target: target)
31+
}
32+
// Add dependency information to each task
33+
for task in taskLookup.values {
34+
task.dependencies = task.target.dependencyIDs.compactMap { taskLookup[$0] }
35+
}
36+
37+
tasks = Array(taskLookup.values)
38+
}
39+
40+
/// Creates a list of dependent operations to perform the given work for each task in the build graph.
41+
///
42+
/// You can add these operations to an `OperationQueue` to perform them in dependency order
43+
/// (dependencies before dependents). The queue can run these operations concurrently.
44+
///
45+
/// - Parameter work: The work to perform for each task in the build graph.
46+
/// - Returns: A list of dependent operations that performs `work` for each documentation task task.
47+
func makeOperations(performing work: @escaping (Task) -> Void) -> [Operation] {
48+
var builder = OperationBuilder(work: work)
49+
for task in tasks {
50+
builder.buildOperationHierarchy(for: task)
51+
}
52+
53+
return Array(builder.operationsByID.values)
54+
}
55+
}
56+
57+
extension DocumentationBuildGraph {
58+
/// A documentation task in the build graph
59+
final class Task {
60+
/// The target to build documentation for
61+
let target: Target
62+
/// The unique identifier of the task
63+
fileprivate var id: ID { target.id }
64+
/// The other documentation tasks that this task depends on.
65+
fileprivate(set) var dependencies: [Task]
66+
67+
init(target: Target) {
68+
self.target = target
69+
self.dependencies = []
70+
}
71+
}
72+
}
73+
74+
extension DocumentationBuildGraph {
75+
/// A type that builds a hierarchy of dependent operations
76+
private struct OperationBuilder {
77+
/// The work that each operation should perform
78+
let work: (Task) -> Void
79+
/// A lookup of operations by their ID
80+
private(set) var operationsByID: [ID: Operation] = [:]
81+
82+
/// Adds new dependent operations to the builder.
83+
///
84+
/// You can access the created dependent operations using `operationsByID.values`.
85+
mutating func buildOperationHierarchy(for task: Task) {
86+
let operation = makeOperation(for: task)
87+
for dependency in task.dependencies {
88+
let hasAlreadyVisitedTask = operationsByID[dependency.id] != nil
89+
90+
let dependentOperation = makeOperation(for: dependency)
91+
operation.addDependency(dependentOperation)
92+
93+
if !hasAlreadyVisitedTask {
94+
buildOperationHierarchy(for: dependency)
95+
}
96+
}
97+
}
98+
99+
/// Returns the existing operation for the given task or creates a new operation if the builder didn't already have an operation for this task.
100+
private mutating func makeOperation(for task: Task) -> Operation {
101+
if let existing = operationsByID[task.id] {
102+
return existing
103+
}
104+
// Copy the closure and the target into a block operation object
105+
let new = BlockOperation { [work, task] in
106+
work(task)
107+
}
108+
operationsByID[task.id] = new
109+
return new
110+
}
111+
}
112+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
4+
// Licensed under Apache License v2.0 with Runtime Library Exception
5+
//
6+
// See https://swift.org/LICENSE.txt for license information
7+
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
8+
9+
10+
import Foundation
11+
12+
/// A type that runs tasks for each target in a build graph in dependency order.
13+
struct DocumentationBuildGraphRunner<Target: DocumentationBuildGraphTarget> {
14+
15+
let buildGraph: DocumentationBuildGraph<Target>
16+
17+
typealias Work<Result> = (DocumentationBuildGraph<Target>.Task) throws -> Result
18+
19+
func perform<Result>(_ work: @escaping Work<Result>) throws -> [Result] {
20+
// Create a serial queue to perform each documentation build task
21+
let queue = OperationQueue()
22+
queue.maxConcurrentOperationCount = 1
23+
24+
// Operations can't raise errors. Instead we catch the error from 'performBuildTask(_:)'
25+
// and cancel the remaining tasks.
26+
let resultLock = NSLock()
27+
var caughtError: Error?
28+
var results: [Result] = []
29+
30+
let operations = buildGraph.makeOperations { [work] task in
31+
do {
32+
let result = try work(task)
33+
resultLock.withLock {
34+
results.append(result)
35+
}
36+
} catch {
37+
resultLock.withLock {
38+
caughtError = error
39+
queue.cancelAllOperations()
40+
}
41+
}
42+
}
43+
44+
// Run all the documentation build tasks in dependency order (dependencies before dependents).
45+
queue.addOperations(operations, waitUntilFinished: true)
46+
47+
// If any of the build tasks raised an error. Re-throw that error.
48+
if let caughtError {
49+
throw caughtError
50+
}
51+
52+
return results
53+
}
54+
}

Sources/SwiftDocCPluginUtilities/CommandLineOptions/CommandLineOption.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,15 @@ extension CommandLineOption {
5959
static let fallbackDefaultModuleKind = CommandLineOption(
6060
defaultName: "--fallback-default-module-kind"
6161
)
62+
63+
/// A DocC flag that enables support for linking to other DocC archives and enables
64+
/// other documentation builds to link to the generated DocC archive.
65+
static let enableExternalLinkSupport = CommandLineOption(
66+
defaultName: "--enable-experimental-external-link-support"
67+
)
68+
69+
/// A DocC flag that specifies a dependency DocC archive that the current build can link to.
70+
static let externalLinkDependency = CommandLineOption(
71+
defaultName: "--dependency"
72+
)
6273
}

0 commit comments

Comments
 (0)