diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift index 392d5c3ea9..c7c1d3a84a 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-2024 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information @@ -45,12 +45,12 @@ public enum DocumentationBundleFileTypes { return url.lastPathComponent.hasSuffix(symbolGraphFileExtension) } - private static let documentationBundleFileExtension = "docc" - /// Checks if a folder is a documentation bundle. + private static let documentationCatalogFileExtension = "docc" + /// Checks if a folder is a documentation catalog. /// - Parameter url: The folder to check. - /// - Returns: Whether or not the folder at `url` is a documentation bundle. - public static func isDocumentationBundle(_ url: URL) -> Bool { - return url.pathExtension.lowercased() == documentationBundleFileExtension + /// - Returns: Whether or not the folder at `url` is a documentation catalog. + public static func isDocumentationCatalog(_ url: URL) -> Bool { + url.pathExtension.lowercased() == documentationCatalogFileExtension } private static let infoPlistFileName = "Info.plist" @@ -85,3 +85,10 @@ public enum DocumentationBundleFileTypes { return url.lastPathComponent == themeSettingsFileName } } + +extension DocumentationBundleFileTypes { + @available(*, deprecated, renamed: "isDocumentationCatalog(_:)", message: "Use 'isDocumentationCatalog(_:)' instead. This deprecated API will be removed after 6.1 is released") + public static func isDocumentationBundle(_ url: URL) -> Bool { + isDocumentationCatalog(url) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift b/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift new file mode 100644 index 0000000000..c0bc78c89e --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/Input Discovery/DocumentationInputsProvider.swift @@ -0,0 +1,254 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import SymbolKit + +extension DocumentationContext { + + /// A type that provides inputs for a unit of documentation. + package struct InputsProvider { + /// The file manager that the provider uses to read file and directory contents from the file system. + private var fileManager: FileManagerProtocol + + /// Creates a new documentation inputs provider. + /// - Parameter fileManager: The file manager that the provider uses to read file and directory contents from the file system. + package init(fileManager: FileManagerProtocol) { + self.fileManager = fileManager + } + + /// Creates a new documentation inputs provider. + package init() { + self.init(fileManager: FileManager.default) + } + } +} + +// MARK: Catalog discovery + +extension DocumentationContext.InputsProvider { + + private typealias FileTypes = DocumentationBundleFileTypes + + /// A discovered documentation catalog. + package struct CatalogURL { + let url: URL + } + + struct MultipleCatalogsError: DescribedError { + let startingPoint: URL + let catalogs: [URL] + + var errorDescription: String { + """ + Found multiple documentation catalogs in \(startingPoint.standardizedFileURL.path): + \(catalogs.map { ($0.relative(to: startingPoint) ?? $0).standardizedFileURL.path }.sorted().map { " - \($0)" }.joined(separator: "\n")) + """ + } + } + + /// Traverses the file system from the given starting point to find a documentation catalog. + /// - Parameters: + /// - startingPoint: The top of the directory hierarchy that the provider traverses to find a documentation catalog. + /// - allowArbitraryCatalogDirectories: Whether to treat the starting point as a documentation catalog if the provider doesn't find an actual catalog on the file system. + /// - Returns: The found documentation catalog. + /// - Throws: If the directory hierarchy contains more than one documentation catalog. + package func findCatalog( + startingPoint: URL, + allowArbitraryCatalogDirectories: Bool = false + ) throws -> CatalogURL? { + var foundCatalogs: [URL] = [] + + var urlsToCheck = [startingPoint] + while !urlsToCheck.isEmpty { + let url = urlsToCheck.removeFirst() + + guard !FileTypes.isDocumentationCatalog(url) else { + // Don't look for catalogs inside of other catalogs. + foundCatalogs.append(url) + continue + } + + urlsToCheck.append(contentsOf: try fileManager.contentsOfDirectory(at: url, options: .skipsHiddenFiles).directories) + } + + guard foundCatalogs.count <= 1 else { + throw MultipleCatalogsError(startingPoint: startingPoint, catalogs: foundCatalogs) + } + + let catalogURL = foundCatalogs.first + // If the provider didn't find a catalog, check if the root should be treated as a catalog + ?? (allowArbitraryCatalogDirectories ? startingPoint : nil) + + return catalogURL.map(CatalogURL.init) + } +} + +// MARK: Inputs creation + +extension DocumentationContext { + package typealias Inputs = DocumentationBundle +} + +extension DocumentationContext.InputsProvider { + + package typealias Options = BundleDiscoveryOptions + + /// Creates a collection of documentation inputs from the content of the given documentation catalog. + /// + /// - Parameters: + /// - catalogURL: The location of a discovered documentation catalog. + /// - options: Options to configure how the provider creates the documentation inputs. + /// - Returns: Inputs that categorize the files of the given catalog. + package func makeInputs(contentOf catalogURL: CatalogURL, options: Options) throws -> DocumentationContext.Inputs { + let url = catalogURL.url + let shallowContent = try fileManager.contentsOfDirectory(at: url, options: [.skipsHiddenFiles]).files + let infoPlistData = try shallowContent + .first(where: FileTypes.isInfoPlistFile) + .map { try fileManager.contents(of: $0) } + + let info = try DocumentationContext.Inputs.Info( + from: infoPlistData, + bundleDiscoveryOptions: options, + derivedDisplayName: url.deletingPathExtension().lastPathComponent + ) + + let foundContents = try findContents(in: url) + return DocumentationContext.Inputs( + info: info, + symbolGraphURLs: foundContents.symbolGraphs + options.additionalSymbolGraphFiles, + markupURLs: foundContents.markup, + miscResourceURLs: foundContents.resources, + customHeader: shallowContent.first(where: FileTypes.isCustomHeader), + customFooter: shallowContent.first(where: FileTypes.isCustomFooter), + themeSettings: shallowContent.first(where: FileTypes.isThemeSettingsFile) + ) + } + + /// Finds all the markup files, resource files, and symbol graph files in the given directory. + private func findContents(in startURL: URL) throws -> (markup: [URL], resources: [URL], symbolGraphs: [URL]) { + // Find all the files + var foundMarkup: [URL] = [] + var foundResources: [URL] = [] + var foundSymbolGraphs: [URL] = [] + + var urlsToCheck = [startURL] + while !urlsToCheck.isEmpty { + let url = urlsToCheck.removeFirst() + + var (files, directories) = try fileManager.contentsOfDirectory(at: url, options: .skipsHiddenFiles) + + urlsToCheck.append(contentsOf: directories) + + // Group the found files by type + let markupPartitionIndex = files.partition(by: FileTypes.isMarkupFile) + var nonMarkupFiles = files[.. DocumentationContext.Inputs? { + guard !options.additionalSymbolGraphFiles.isEmpty else { + return nil + } + + // Find all the unique module names from the symbol graph files and generate a top level module page for each of them. + var moduleNames = Set() + for url in options.additionalSymbolGraphFiles { + let data = try fileManager.contents(of: url) + let container = try JSONDecoder().decode(SymbolGraphModuleContainer.self, from: data) + moduleNames.insert(container.module.name) + } + let derivedDisplayName = moduleNames.count == 1 ? moduleNames.first : nil + + let info = try DocumentationContext.Inputs.Info(bundleDiscoveryOptions: options, derivedDisplayName: derivedDisplayName) + + var topLevelPages: [URL] = [] + if moduleNames.count == 1, let moduleName = moduleNames.first, moduleName != info.displayName { + let tempURL = fileManager.uniqueTemporaryDirectory() + try? fileManager.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil) + + let url = tempURL.appendingPathComponent("\(moduleName).md") + topLevelPages.append(url) + try fileManager.createFile( + at: url, + contents: Data(""" + # ``\(moduleName)`` + + @Metadata { + @DisplayName("\(info.displayName)") + } + """.utf8), + options: .atomic + ) + } + + return DocumentationBundle( + info: info, + symbolGraphURLs: options.additionalSymbolGraphFiles, + markupURLs: topLevelPages, + miscResourceURLs: [] + ) + } +} + +/// A wrapper type that decodes only the module in the symbol graph. +private struct SymbolGraphModuleContainer: Decodable { + /// The decoded symbol graph module. + let module: SymbolGraph.Module + + typealias CodingKeys = SymbolGraph.CodingKeys + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.module = try container.decode(SymbolGraph.Module.self, forKey: .module) + } +} + +// MARK: Discover and create + +extension DocumentationContext.InputsProvider { + /// Traverses the file system from the given starting point to find a documentation catalog and creates a collection of documentation inputs from that catalog. + /// + /// If the provider can't find a catalog, it will try to create documentation inputs from the option's symbol graph files. + /// + /// - Parameters: + /// - startingPoint: The top of the directory hierarchy that the provider traverses to find a documentation catalog. + /// - allowArbitraryCatalogDirectories: Whether to treat the starting point as a documentation catalog if the provider doesn't find an actual catalog on the file system. + /// - options: Options to configure how the provider creates the documentation inputs. + /// - Returns: The documentation inputs for the found documentation catalog, or `nil` if the directory hierarchy doesn't contain a catalog. + /// - Throws: If the directory hierarchy contains more than one documentation catalog. + package func inputs( + startingPoint: URL, + allowArbitraryCatalogDirectories: Bool = false, + options: Options + ) throws -> DocumentationContext.Inputs? { + if let catalogURL = try findCatalog(startingPoint: startingPoint, allowArbitraryCatalogDirectories: allowArbitraryCatalogDirectories) { + try makeInputs(contentOf: catalogURL, options: options) + } else { + try makeInputsFromSymbolGraphs(options: options) + } + } +} diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift b/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift index 49b1f579ba..8694506fc8 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/LocalFileSystemDataProvider+BundleDiscovery.swift @@ -40,7 +40,7 @@ extension LocalFileSystemDataProvider { preconditionFailure("Expected directory object at path '\(root.url.absoluteString)'.") } - if DocumentationBundleFileTypes.isDocumentationBundle(rootDirectory.url) { + if DocumentationBundleFileTypes.isDocumentationCatalog(rootDirectory.url) { bundles.append(try createBundle(rootDirectory, rootDirectory.children, options: options)) } else { // Recursively descend when the current root directory isn't a documentation bundle. @@ -125,7 +125,7 @@ extension LocalFileSystemDataProvider { /// - recursive: If `true`, this function will recursively check the files of all directories in the array. If `false`, it will ignore all directories. /// - Returns: A list of all the non-markup files. private func findNonMarkupFiles(_ bundleChildren: [FSNode], recursive: Bool) -> [FSNode.File] { - return bundleChildren.files(recursive: recursive) { !DocumentationBundleFileTypes.isMarkupFile($0.url) } + bundleChildren.files(recursive: recursive) { !DocumentationBundleFileTypes.isMarkupFile($0.url) && !DocumentationBundleFileTypes.isSymbolGraphFile($0.url) } } private func findCustomHeader(_ bundleChildren: [FSNode]) -> FSNode.File? { diff --git a/Sources/SwiftDocC/Utility/FileManagerProtocol.swift b/Sources/SwiftDocC/Utility/FileManagerProtocol.swift index 1ffcc5f511..527f150bb5 100644 --- a/Sources/SwiftDocC/Utility/FileManagerProtocol.swift +++ b/Sources/SwiftDocC/Utility/FileManagerProtocol.swift @@ -87,6 +87,14 @@ package protocol FileManagerProtocol { /// - options: Options for writing the data. Provide `nil` to use the default /// writing options of the file manager. func createFile(at location: URL, contents: Data, options writingOptions: NSData.WritingOptions?) throws + + /// Performs a shallow search of the specified directory and returns the file and directory URLs for the contained items. + /// + /// - Parameters: + /// - url: The URL for the directory whose contents to enumerate. + /// - mark: Options for the enumeration. Because this method performs only shallow enumerations, the only supported option is `skipsHiddenFiles`. + /// - Returns: The URLs of each file and directory that's contained in `url`. + func contentsOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL]) } extension FileManagerProtocol { @@ -123,4 +131,17 @@ extension FileManager: FileManagerProtocol { package func uniqueTemporaryDirectory() -> URL { temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true) } + + // 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. + package func contentsOfDirectory(at url: URL, options mask: DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL]) { + var allContents = try contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey], options: .skipsHiddenFiles) + + let partitionIndex = try allContents.partition { + try $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true + } + return ( + files: Array( allContents[.. [DocumentationBundle] { - // Ignore the bundle discovery options, these test bundles are already built. - return _bundles + try DocumentationContext.InputsProvider(fileManager: self) + .inputs(startingPoint: URL(fileURLWithPath: currentDirectoryPath), options: options) + .map { [$0] } ?? [] } /// Thread safe access to the file system. @@ -71,48 +71,8 @@ package class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataPro files["/"] = Self.folderFixtureData files["/tmp"] = Self.folderFixtureData - // Import given folders - try updateDocumentationBundles(withFolders: folders) - } - - func updateDocumentationBundles(withFolders folders: [Folder]) throws { - _bundles.removeAll() - for folder in folders { - let files = try addFolder(folder) - - func asCatalog(_ file: File) -> Folder? { - if let folder = file as? Folder, URL(fileURLWithPath: folder.name).pathExtension == "docc" { - return folder - } - return nil - } - - if let catalog = asCatalog(folder) ?? folder.recursiveContent.mapFirst(where: asCatalog(_:)) { - let files = files.filter({ $0.hasPrefix(catalog.absoluteURL.path) }).compactMap({ URL(fileURLWithPath: $0) }) - - let markupFiles = files.filter({ DocumentationBundleFileTypes.isMarkupFile($0) }) - let miscFiles = files.filter({ !DocumentationBundleFileTypes.isMarkupFile($0) }) - let graphs = files.filter({ DocumentationBundleFileTypes.isSymbolGraphFile($0) }) - let customHeader = files.first(where: { DocumentationBundleFileTypes.isCustomHeader($0) }) - let customFooter = files.first(where: { DocumentationBundleFileTypes.isCustomFooter($0) }) - - let info = try DocumentationBundle.Info( - from: try catalog.recursiveContent.mapFirst(where: { $0 as? InfoPlist })?.data(), - bundleDiscoveryOptions: nil, - derivedDisplayName: URL(fileURLWithPath: catalog.name).deletingPathExtension().lastPathComponent - ) - - let bundle = DocumentationBundle( - info: info, - symbolGraphURLs: graphs, - markupURLs: markupFiles, - miscResourceURLs: miscFiles, - customHeader: customHeader, - customFooter: customFooter - ) - _bundles.append(bundle) - } + try addFolder(folder) } } @@ -185,20 +145,20 @@ package class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataPro return files.keys.contains(path) } - package func copyItem(at srcURL: URL, to dstURL: URL) throws { + package func copyItem(at source: URL, to destination: URL) throws { guard !disableWriting else { return } filesLock.lock() defer { filesLock.unlock() } - try ensureParentDirectoryExists(for: dstURL) + try ensureParentDirectoryExists(for: destination) - let srcPath = srcURL.path - let dstPath = dstURL.path + let sourcePath = source.path + let destinationPath = destination.path - files[dstPath] = files[srcPath] - for (path, data) in files where path.hasPrefix(srcPath) { - files[path.replacingOccurrences(of: srcPath, with: dstPath)] = data + files[destinationPath] = files[sourcePath] + for (path, data) in files where path.hasPrefix(sourcePath) { + files[path.replacingOccurrences(of: sourcePath, with: destinationPath)] = data } } @@ -309,7 +269,6 @@ package class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataPro } package func contentsOfDirectory(at url: URL, includingPropertiesForKeys keys: [URLResourceKey]?, options mask: FileManager.DirectoryEnumerationOptions) throws -> [URL] { - if let keys { XCTAssertTrue(keys.isEmpty, "includingPropertiesForKeys is not implemented in contentsOfDirectory in TestFileSystem") } @@ -318,13 +277,25 @@ package class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataPro XCTFail("The given directory enumeration option(s) \(mask.rawValue) have not been implemented in the test file system: \(mask)") } - let skipHiddenFiles = mask == .skipsHiddenFiles - let contents = try contentsOfDirectory(atPath: url.path) - let output: [URL] = contents.filter({ skipHiddenFiles ? !$0.hasPrefix(".") : true}).map { - url.appendingPathComponent($0) + let skipHiddenFiles = mask.contains(.skipsHiddenFiles) + var contents = try contentsOfDirectory(atPath: url.path) + if skipHiddenFiles { + contents.removeAll(where: { $0.hasPrefix(".") }) } + + return contents.map { url.appendingPathComponent($0)} + } - return output + package func contentsOfDirectory(at url: URL, options mask: FileManager.DirectoryEnumerationOptions) throws -> (files: [URL], directories: [URL]) { + var allContents = try contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: mask) + + let partitionIndex = allContents.partition { + self.files[$0.path] == Self.folderFixtureData + } + return ( + files: Array( allContents[.. URL { @@ -389,7 +360,3 @@ package class TestFileSystem: FileManagerProtocol, DocumentationWorkspaceDataPro } } -private extension File { - /// A URL of the file node if it was located in the root of the file system. - var absoluteURL: URL { return URL(string: "/\(name)")! } -} diff --git a/Tests/SwiftDocCTests/Infrastructure/Input Discovery/DocumentationInputsProviderTests.swift b/Tests/SwiftDocCTests/Infrastructure/Input Discovery/DocumentationInputsProviderTests.swift new file mode 100644 index 0000000000..7bbaffb5cf --- /dev/null +++ b/Tests/SwiftDocCTests/Infrastructure/Input Discovery/DocumentationInputsProviderTests.swift @@ -0,0 +1,230 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2024 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import SwiftDocCTestUtilities +@testable import SwiftDocC + +class DocumentationInputsProviderTests: XCTestCase { + + func testDiscoversSameFilesAsPreviousImplementation() throws { + let folderHierarchy = Folder(name: "one", content: [ + Folder(name: "two", content: [ + // Start search here. + TextFile(name: "AAA.md", utf8Content: ""), + + Folder(name: "three", content: [ + TextFile(name: "BBB.md", utf8Content: ""), + + // This is the catalog that both file system should find + Folder(name: "Found.docc", content: [ + // This top-level Info.plist will be read for bundle information + InfoPlist(displayName: "CustomDisplayName"), + + // These top-level files will be treated as a custom footer and a custom theme + TextFile(name: "footer.html", utf8Content: ""), + TextFile(name: "theme-settings.json", utf8Content: ""), + + // Top-level content will be found + TextFile(name: "CCC.md", utf8Content: ""), + JSONFile(name: "SomethingTopLevel.symbols.json", content: makeSymbolGraph(moduleName: "Something")), + DataFile(name: "first.png", data: Data()), + + Folder(name: "Inner", content: [ + // Nested content will also be found + TextFile(name: "DDD.md", utf8Content: ""), + JSONFile(name: "SomethingNested.symbols.json", content: makeSymbolGraph(moduleName: "Something")), + DataFile(name: "second.png", data: Data()), + + // A catalog within a catalog is just another directory + Folder(name: "Nested.docc", content: [ + TextFile(name: "EEE.md", utf8Content: ""), + ]), + + // A nested Info.plist is considered a miscellaneous resource. + InfoPlist(displayName: "CustomDisplayName"), + + // A nested file will be treated as a miscellaneous resource. + TextFile(name: "header.html", utf8Content: ""), + ]), + ]), + + Folder(name: "four", content: [ + TextFile(name: "FFF.md", utf8Content: ""), + ]) + ]), + ]), + // This catalog is outside the provider's search scope + Folder(name: "OutsideSearchScope.docc", content: []), + ]) + + let tempDirectory = try createTempFolder(content: [folderHierarchy]) + let realProvider = DocumentationContext.InputsProvider(fileManager: FileManager.default) + + let testFileSystem = try TestFileSystem(folders: [folderHierarchy]) + let testProvider = DocumentationContext.InputsProvider(fileManager: testFileSystem) + + let options = BundleDiscoveryOptions(fallbackIdentifier: "com.example.test", additionalSymbolGraphFiles: [ + tempDirectory.appendingPathComponent("/path/to/SomethingAdditional.symbols.json") + ]) + + let foundPrevImplBundle = try XCTUnwrap(LocalFileSystemDataProvider(rootURL: tempDirectory.appendingPathComponent("/one/two")).bundles(options: options).first) + let foundRealBundle = try XCTUnwrap(realProvider.inputs(startingPoint: tempDirectory.appendingPathComponent("/one/two"), options: options)) + + let foundTestBundle = try XCTUnwrap(testProvider.inputs(startingPoint: URL(fileURLWithPath: "/one/two"), options: .init( + infoPlistFallbacks: options.infoPlistFallbacks, + // The test file system has a default base URL and needs different URLs for the symbol graph files + additionalSymbolGraphFiles: [ + URL(fileURLWithPath: "/path/to/SomethingAdditional.symbols.json") + ]) + )) + + for (bundle, relativeBase) in [ + (foundPrevImplBundle, tempDirectory.appendingPathComponent("/one/two/three")), + (foundRealBundle, tempDirectory.appendingPathComponent("/one/two/three")), + (foundTestBundle, URL(fileURLWithPath: "/one/two/three")), + ] { + func relativePathString(_ url: URL) -> String { + url.relative(to: relativeBase)!.path + } + + XCTAssertEqual(bundle.displayName, "CustomDisplayName") + XCTAssertEqual(bundle.identifier, "com.example.test") + XCTAssertEqual(bundle.markupURLs.map(relativePathString).sorted(), [ + "Found.docc/CCC.md", + "Found.docc/Inner/DDD.md", + "Found.docc/Inner/Nested.docc/EEE.md", + ]) + XCTAssertEqual(bundle.miscResourceURLs.map(relativePathString).sorted(), [ + "Found.docc/Info.plist", + "Found.docc/Inner/Info.plist", + "Found.docc/Inner/header.html", + "Found.docc/Inner/second.png", + "Found.docc/first.png", + "Found.docc/footer.html", + "Found.docc/theme-settings.json", + ]) + XCTAssertEqual(bundle.symbolGraphURLs.map(relativePathString).sorted(), [ + "../../../path/to/SomethingAdditional.symbols.json", + "Found.docc/Inner/SomethingNested.symbols.json", + "Found.docc/SomethingTopLevel.symbols.json", + ]) + XCTAssertEqual(bundle.customFooter.map(relativePathString), "Found.docc/footer.html") + XCTAssertEqual(bundle.customHeader.map(relativePathString), nil) + XCTAssertEqual(bundle.themeSettings.map(relativePathString), "Found.docc/theme-settings.json") + } + } + + func testDefaultsToStartingPointWhenAllowingArbitraryDirectories() throws { + let fileSystem = try TestFileSystem(folders: [ + Folder(name: "one", content: [ + Folder(name: "two", content: [ + // Start search here. + Folder(name: "three", content: [ + Folder(name: "four", content: []), + ]), + ]), + // This catalog is outside the provider's search scope + Folder(name: "OutsideScope.docc", content: []), + ]) + ]) + + let provider = DocumentationContext.InputsProvider(fileManager: fileSystem) + let startingPoint = URL(fileURLWithPath: "/one/two") + + // Allow arbitrary directories as a fallback + do { + let foundBundle = try provider.inputs( + startingPoint: startingPoint, + allowArbitraryCatalogDirectories: true, + options: .init() + ) + XCTAssertEqual(foundBundle?.displayName, "two") + XCTAssertEqual(foundBundle?.identifier, "two") + } + + // Without arbitrary directories as a fallback + do { + XCTAssertNil(try provider.inputs( + startingPoint: startingPoint, + allowArbitraryCatalogDirectories: false, + options: .init() + )) + } + } + + func testRaisesErrorWhenFindingMultipleCatalogs() throws { + let fileSystem = try TestFileSystem(folders: [ + Folder(name: "one", content: [ + Folder(name: "two", content: [ + // Start search here. + Folder(name: "three", content: [ + Folder(name: "four.docc", content: []), + ]), + Folder(name: "five.docc", content: []), + ]), + ]) + ]) + + + let provider = DocumentationContext.InputsProvider(fileManager: fileSystem) + + XCTAssertThrowsError( + try provider.inputs( + startingPoint: URL(fileURLWithPath: "/one/two"), + options: .init() + ) + ) { error in + XCTAssertEqual(error.localizedDescription, """ + Found multiple documentation catalogs in /one/two: + - five.docc + - three/four.docc + """ + ) + } + } + + func testGeneratesInputsFromSymbolGraphWhenThereIsNoCatalog() throws { + let fileSystem = try TestFileSystem(folders: [ + Folder(name: "one", content: [ + Folder(name: "two", content: [ + // Start search here. + Folder(name: "three", content: [ + Folder(name: "four", content: []), + ]), + ]), + // This catalog is outside the provider's search scope + Folder(name: "OutsideScope.docc", content: []), + + ]), + + Folder(name: "path", content: [ + Folder(name: "to", content: [ + // The path to this symbol graph file is passed via the options + JSONFile(name: "Something.symbols.json", content: makeSymbolGraph(moduleName: "Something")), + ]) + ]) + ]) + + let provider = DocumentationContext.InputsProvider(fileManager: fileSystem) + let startingPoint = URL(fileURLWithPath: "/one/two") + + let foundBundle = try provider.inputs( + startingPoint: startingPoint, + options: .init(additionalSymbolGraphFiles: [ + URL(fileURLWithPath: "/path/to/Something.symbols.json")]) + ) + XCTAssertEqual(foundBundle?.displayName, "Something") + XCTAssertEqual(foundBundle?.identifier, "Something") + XCTAssertEqual(foundBundle?.symbolGraphURLs.map(\.path), [ + "/path/to/Something.symbols.json", + ]) + } +} diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift index 30f1c62e1d..a183249220 100644 --- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift @@ -1797,7 +1797,7 @@ class ConvertActionTests: XCTestCase { _ = try action.perform(logHandle: .none) - XCTAssertEqual(ResolvedTopicReference._numberOfCachedReferences(bundleID: #function), 8) + XCTAssertEqual(ResolvedTopicReference._numberOfCachedReferences(bundleID: #function), 13) } func testIgnoresAnalyzerHintsByDefault() throws { diff --git a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift index 51a866f3e8..57966ac569 100644 --- a/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/Utility/TestFileSystemTests.swift @@ -9,6 +9,7 @@ */ import XCTest +import SwiftDocC @testable import SwiftDocCTestUtilities class TestFileSystemTests: XCTestCase { @@ -17,7 +18,6 @@ class TestFileSystemTests: XCTestCase { let fs = try TestFileSystem(folders: []) XCTAssertEqual(fs.currentDirectoryPath, "/") XCTAssertFalse(fs.identifier.isEmpty) - XCTAssertTrue(try fs.bundles().isEmpty) var isDirectory = ObjCBool(false) XCTAssertTrue(fs.fileExists(atPath: "/", isDirectory: &isDirectory)) XCTAssertEqual(fs.files.keys.sorted(), ["/", "/tmp"], "The root (/) should be the only existing path.")