Skip to content

Commit 37df0ee

Browse files
authored
Add simplified implementation of documentation input discovery (#1038)
* Add utility to file manager protocol to separate files and directories * Update test file system to discover inputs using new input provider * Generate inputs from symbol graphs when there is no catalog * Use package access for new API that might change in a follow up PR
1 parent 3963a21 commit 37df0ee

File tree

8 files changed

+550
-71
lines changed

8 files changed

+550
-71
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -45,12 +45,12 @@ public enum DocumentationBundleFileTypes {
4545
return url.lastPathComponent.hasSuffix(symbolGraphFileExtension)
4646
}
4747

48-
private static let documentationBundleFileExtension = "docc"
49-
/// Checks if a folder is a documentation bundle.
48+
private static let documentationCatalogFileExtension = "docc"
49+
/// Checks if a folder is a documentation catalog.
5050
/// - Parameter url: The folder to check.
51-
/// - Returns: Whether or not the folder at `url` is a documentation bundle.
52-
public static func isDocumentationBundle(_ url: URL) -> Bool {
53-
return url.pathExtension.lowercased() == documentationBundleFileExtension
51+
/// - Returns: Whether or not the folder at `url` is a documentation catalog.
52+
public static func isDocumentationCatalog(_ url: URL) -> Bool {
53+
url.pathExtension.lowercased() == documentationCatalogFileExtension
5454
}
5555

5656
private static let infoPlistFileName = "Info.plist"
@@ -85,3 +85,10 @@ public enum DocumentationBundleFileTypes {
8585
return url.lastPathComponent == themeSettingsFileName
8686
}
8787
}
88+
89+
extension DocumentationBundleFileTypes {
90+
@available(*, deprecated, renamed: "isDocumentationCatalog(_:)", message: "Use 'isDocumentationCatalog(_:)' instead. This deprecated API will be removed after 6.1 is released")
91+
public static func isDocumentationBundle(_ url: URL) -> Bool {
92+
isDocumentationCatalog(url)
93+
}
94+
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import SymbolKit
13+
14+
extension DocumentationContext {
15+
16+
/// A type that provides inputs for a unit of documentation.
17+
package struct InputsProvider {
18+
/// The file manager that the provider uses to read file and directory contents from the file system.
19+
private var fileManager: FileManagerProtocol
20+
21+
/// Creates a new documentation inputs provider.
22+
/// - Parameter fileManager: The file manager that the provider uses to read file and directory contents from the file system.
23+
package init(fileManager: FileManagerProtocol) {
24+
self.fileManager = fileManager
25+
}
26+
27+
/// Creates a new documentation inputs provider.
28+
package init() {
29+
self.init(fileManager: FileManager.default)
30+
}
31+
}
32+
}
33+
34+
// MARK: Catalog discovery
35+
36+
extension DocumentationContext.InputsProvider {
37+
38+
private typealias FileTypes = DocumentationBundleFileTypes
39+
40+
/// A discovered documentation catalog.
41+
package struct CatalogURL {
42+
let url: URL
43+
}
44+
45+
struct MultipleCatalogsError: DescribedError {
46+
let startingPoint: URL
47+
let catalogs: [URL]
48+
49+
var errorDescription: String {
50+
"""
51+
Found multiple documentation catalogs in \(startingPoint.standardizedFileURL.path):
52+
\(catalogs.map { ($0.relative(to: startingPoint) ?? $0).standardizedFileURL.path }.sorted().map { " - \($0)" }.joined(separator: "\n"))
53+
"""
54+
}
55+
}
56+
57+
/// Traverses the file system from the given starting point to find a documentation catalog.
58+
/// - Parameters:
59+
/// - startingPoint: The top of the directory hierarchy that the provider traverses to find a documentation catalog.
60+
/// - allowArbitraryCatalogDirectories: Whether to treat the starting point as a documentation catalog if the provider doesn't find an actual catalog on the file system.
61+
/// - Returns: The found documentation catalog.
62+
/// - Throws: If the directory hierarchy contains more than one documentation catalog.
63+
package func findCatalog(
64+
startingPoint: URL,
65+
allowArbitraryCatalogDirectories: Bool = false
66+
) throws -> CatalogURL? {
67+
var foundCatalogs: [URL] = []
68+
69+
var urlsToCheck = [startingPoint]
70+
while !urlsToCheck.isEmpty {
71+
let url = urlsToCheck.removeFirst()
72+
73+
guard !FileTypes.isDocumentationCatalog(url) else {
74+
// Don't look for catalogs inside of other catalogs.
75+
foundCatalogs.append(url)
76+
continue
77+
}
78+
79+
urlsToCheck.append(contentsOf: try fileManager.contentsOfDirectory(at: url, options: .skipsHiddenFiles).directories)
80+
}
81+
82+
guard foundCatalogs.count <= 1 else {
83+
throw MultipleCatalogsError(startingPoint: startingPoint, catalogs: foundCatalogs)
84+
}
85+
86+
let catalogURL = foundCatalogs.first
87+
// If the provider didn't find a catalog, check if the root should be treated as a catalog
88+
?? (allowArbitraryCatalogDirectories ? startingPoint : nil)
89+
90+
return catalogURL.map(CatalogURL.init)
91+
}
92+
}
93+
94+
// MARK: Inputs creation
95+
96+
extension DocumentationContext {
97+
package typealias Inputs = DocumentationBundle
98+
}
99+
100+
extension DocumentationContext.InputsProvider {
101+
102+
package typealias Options = BundleDiscoveryOptions
103+
104+
/// Creates a collection of documentation inputs from the content of the given documentation catalog.
105+
///
106+
/// - Parameters:
107+
/// - catalogURL: The location of a discovered documentation catalog.
108+
/// - options: Options to configure how the provider creates the documentation inputs.
109+
/// - Returns: Inputs that categorize the files of the given catalog.
110+
package func makeInputs(contentOf catalogURL: CatalogURL, options: Options) throws -> DocumentationContext.Inputs {
111+
let url = catalogURL.url
112+
let shallowContent = try fileManager.contentsOfDirectory(at: url, options: [.skipsHiddenFiles]).files
113+
let infoPlistData = try shallowContent
114+
.first(where: FileTypes.isInfoPlistFile)
115+
.map { try fileManager.contents(of: $0) }
116+
117+
let info = try DocumentationContext.Inputs.Info(
118+
from: infoPlistData,
119+
bundleDiscoveryOptions: options,
120+
derivedDisplayName: url.deletingPathExtension().lastPathComponent
121+
)
122+
123+
let foundContents = try findContents(in: url)
124+
return DocumentationContext.Inputs(
125+
info: info,
126+
symbolGraphURLs: foundContents.symbolGraphs + options.additionalSymbolGraphFiles,
127+
markupURLs: foundContents.markup,
128+
miscResourceURLs: foundContents.resources,
129+
customHeader: shallowContent.first(where: FileTypes.isCustomHeader),
130+
customFooter: shallowContent.first(where: FileTypes.isCustomFooter),
131+
themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile)
132+
)
133+
}
134+
135+
/// Finds all the markup files, resource files, and symbol graph files in the given directory.
136+
private func findContents(in startURL: URL) throws -> (markup: [URL], resources: [URL], symbolGraphs: [URL]) {
137+
// Find all the files
138+
var foundMarkup: [URL] = []
139+
var foundResources: [URL] = []
140+
var foundSymbolGraphs: [URL] = []
141+
142+
var urlsToCheck = [startURL]
143+
while !urlsToCheck.isEmpty {
144+
let url = urlsToCheck.removeFirst()
145+
146+
var (files, directories) = try fileManager.contentsOfDirectory(at: url, options: .skipsHiddenFiles)
147+
148+
urlsToCheck.append(contentsOf: directories)
149+
150+
// Group the found files by type
151+
let markupPartitionIndex = files.partition(by: FileTypes.isMarkupFile)
152+
var nonMarkupFiles = files[..<markupPartitionIndex]
153+
let symbolGraphPartitionIndex = nonMarkupFiles.partition(by: FileTypes.isSymbolGraphFile)
154+
155+
foundMarkup.append(contentsOf: files[markupPartitionIndex...] )
156+
foundResources.append(contentsOf: nonMarkupFiles[..<symbolGraphPartitionIndex] )
157+
foundSymbolGraphs.append(contentsOf: nonMarkupFiles[symbolGraphPartitionIndex...] )
158+
}
159+
160+
return (markup: foundMarkup, resources: foundResources, symbolGraphs: foundSymbolGraphs)
161+
}
162+
}
163+
164+
// MARK: Create without catalog
165+
166+
extension DocumentationContext.InputsProvider {
167+
/// Creates a collection of documentation inputs from the symbol graph files and other command line options.
168+
///
169+
/// - Parameter options: Options to configure how the provider creates the documentation inputs.
170+
/// - Returns: Inputs that categorize the files of the given catalog.
171+
package func makeInputsFromSymbolGraphs(options: Options) throws -> DocumentationContext.Inputs? {
172+
guard !options.additionalSymbolGraphFiles.isEmpty else {
173+
return nil
174+
}
175+
176+
// Find all the unique module names from the symbol graph files and generate a top level module page for each of them.
177+
var moduleNames = Set<String>()
178+
for url in options.additionalSymbolGraphFiles {
179+
let data = try fileManager.contents(of: url)
180+
let container = try JSONDecoder().decode(SymbolGraphModuleContainer.self, from: data)
181+
moduleNames.insert(container.module.name)
182+
}
183+
let derivedDisplayName = moduleNames.count == 1 ? moduleNames.first : nil
184+
185+
let info = try DocumentationContext.Inputs.Info(bundleDiscoveryOptions: options, derivedDisplayName: derivedDisplayName)
186+
187+
var topLevelPages: [URL] = []
188+
if moduleNames.count == 1, let moduleName = moduleNames.first, moduleName != info.displayName {
189+
let tempURL = fileManager.uniqueTemporaryDirectory()
190+
try? fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil)
191+
192+
let url = tempURL.appendingPathComponent("\(moduleName).md")
193+
topLevelPages.append(url)
194+
try fileManager.createFile(
195+
at: url,
196+
contents: Data("""
197+
# ``\(moduleName)``
198+
199+
@Metadata {
200+
@DisplayName("\(info.displayName)")
201+
}
202+
""".utf8),
203+
options: .atomic
204+
)
205+
}
206+
207+
return DocumentationBundle(
208+
info: info,
209+
symbolGraphURLs: options.additionalSymbolGraphFiles,
210+
markupURLs: topLevelPages,
211+
miscResourceURLs: []
212+
)
213+
}
214+
}
215+
216+
/// A wrapper type that decodes only the module in the symbol graph.
217+
private struct SymbolGraphModuleContainer: Decodable {
218+
/// The decoded symbol graph module.
219+
let module: SymbolGraph.Module
220+
221+
typealias CodingKeys = SymbolGraph.CodingKeys
222+
223+
init(from decoder: Decoder) throws {
224+
let container = try decoder.container(keyedBy: CodingKeys.self)
225+
226+
self.module = try container.decode(SymbolGraph.Module.self, forKey: .module)
227+
}
228+
}
229+
230+
// MARK: Discover and create
231+
232+
extension DocumentationContext.InputsProvider {
233+
/// Traverses the file system from the given starting point to find a documentation catalog and creates a collection of documentation inputs from that catalog.
234+
///
235+
/// If the provider can't find a catalog, it will try to create documentation inputs from the option's symbol graph files.
236+
///
237+
/// - Parameters:
238+
/// - startingPoint: The top of the directory hierarchy that the provider traverses to find a documentation catalog.
239+
/// - allowArbitraryCatalogDirectories: Whether to treat the starting point as a documentation catalog if the provider doesn't find an actual catalog on the file system.
240+
/// - options: Options to configure how the provider creates the documentation inputs.
241+
/// - Returns: The documentation inputs for the found documentation catalog, or `nil` if the directory hierarchy doesn't contain a catalog.
242+
/// - Throws: If the directory hierarchy contains more than one documentation catalog.
243+
package func inputs(
244+
startingPoint: URL,
245+
allowArbitraryCatalogDirectories: Bool = false,
246+
options: Options
247+
) throws -> DocumentationContext.Inputs? {
248+
if let catalogURL = try findCatalog(startingPoint: startingPoint, allowArbitraryCatalogDirectories: allowArbitraryCatalogDirectories) {
249+
try makeInputs(contentOf: catalogURL, options: options)
250+
} else {
251+
try makeInputsFromSymbolGraphs(options: options)
252+
}
253+
}
254+
}

Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ extension LocalFileSystemDataProvider {
4040
preconditionFailure("Expected directory object at path '\(root.url.absoluteString)'.")
4141
}
4242

43-
if DocumentationBundleFileTypes.isDocumentationBundle(rootDirectory.url) {
43+
if DocumentationBundleFileTypes.isDocumentationCatalog(rootDirectory.url) {
4444
bundles.append(try createBundle(rootDirectory, rootDirectory.children, options: options))
4545
} else {
4646
// Recursively descend when the current root directory isn't a documentation bundle.
@@ -125,7 +125,7 @@ extension LocalFileSystemDataProvider {
125125
/// - recursive: If `true`, this function will recursively check the files of all directories in the array. If `false`, it will ignore all directories.
126126
/// - Returns: A list of all the non-markup files.
127127
private func findNonMarkupFiles(_ bundleChildren: [FSNode], recursive: Bool) -> [FSNode.File] {
128-
return bundleChildren.files(recursive: recursive) { !DocumentationBundleFileTypes.isMarkupFile($0.url) }
128+
bundleChildren.files(recursive: recursive) { !DocumentationBundleFileTypes.isMarkupFile($0.url) && !DocumentationBundleFileTypes.isSymbolGraphFile($0.url) }
129129
}
130130

131131
private func findCustomHeader(_ bundleChildren: [FSNode]) -> FSNode.File? {

Sources/SwiftDocC/Utility/FileManagerProtocol.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ package protocol FileManagerProtocol {
8787
/// - options: Options for writing the data. Provide `nil` to use the default
8888
/// writing options of the file manager.
8989
func createFile(at location: URL, contents: Data, options writingOptions: NSData.WritingOptions?) throws
90+
91+
/// Performs a shallow search of the specified directory and returns the file and directory URLs for the contained items.
92+
///
93+
/// - Parameters:
94+
/// - url: The URL for the directory whose contents to enumerate.
95+
/// - mark: Options for the enumeration. Because this method performs only shallow enumerations, the only supported option is `skipsHiddenFiles`.
96+
/// - Returns: The URLs of each file and directory that's contained in `url`.
97+
func contentsOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL])
9098
}
9199

92100
extension FileManagerProtocol {
@@ -123,4 +131,17 @@ extension FileManager: FileManagerProtocol {
123131
package func uniqueTemporaryDirectory() -> URL {
124132
temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true)
125133
}
134+
135+
// This method doesn't exist on `FileManger`. We define it on the protocol to enable the FileManager to provide an implementation that avoids repeated reads to discover which contained items are files and which are directories.
136+
package func contentsOfDirectory(at url: URL, options mask: DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL]) {
137+
var allContents = try contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles)
138+
139+
let partitionIndex = try allContents.partition {
140+
try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true
141+
}
142+
return (
143+
files: Array( allContents[..<partitionIndex] ),
144+
directories: Array( allContents[partitionIndex...] )
145+
)
146+
}
126147
}

0 commit comments

Comments
 (0)