|
| 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 | +} |
0 commit comments