diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 20d11930e..01e4c2fcf 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -17,10 +17,10 @@ import SymbolKit public protocol DocumentationContextDataProvider { /// An object to notify when bundles are added or removed. var delegate: (any DocumentationContextDataProviderDelegate)? { get set } - + /// The documentation bundles that this data provider provides. var bundles: [BundleIdentifier: DocumentationBundle] { get } - + /// Returns the data for the specified `url` in the provided `bundle`. /// /// - Parameters: @@ -34,7 +34,7 @@ public protocol DocumentationContextDataProvider { /// An object that responds to changes in available documentation bundles for a specific provider. @available(*, deprecated, message: "Pass the context its inputs at initialization instead. This deprecated API will be removed after 6.2 is released") public protocol DocumentationContextDataProviderDelegate: AnyObject { - + /// Called when the `dataProvider` has added a new documentation bundle to its list of `bundles`. /// /// - Parameters: @@ -43,7 +43,7 @@ public protocol DocumentationContextDataProviderDelegate: AnyObject { /// /// - Note: This method is called after the `dataProvider` has been added the bundle to its `bundles` property. func dataProvider(_ dataProvider: any DocumentationContextDataProvider, didAddBundle bundle: DocumentationBundle) throws - + /// Called when the `dataProvider` has removed a documentation bundle from its list of `bundles`. /// /// - Parameters: @@ -89,17 +89,17 @@ public class DocumentationContext { public enum ContextError: DescribedError { /// The node couldn't be found in the documentation context. case notFound(URL) - + /// The file wasn't UTF-8 encoded. case utf8StringDecodingFailed(url: URL) - + /// We allow a symbol declaration with no OS (for REST & Plist symbols) /// but if such a declaration is found the symbol can have only one declaration. case unexpectedEmptyPlatformName(String) - + /// The bundle registration operation is cancelled externally. case registrationDisabled - + public var errorDescription: String { switch self { case .notFound(let url): @@ -113,10 +113,10 @@ public class DocumentationContext { } } } - + /// A class that resolves documentation links by orchestrating calls to other link resolver implementations. public var linkResolver: LinkResolver - + private enum _Provider { @available(*, deprecated, message: "Use 'DataProvider' instead. This deprecated API will be removed after 6.2 is released") case legacy(any DocumentationContextDataProvider) @@ -139,7 +139,7 @@ public class DocumentationContext { dataProvider = .legacy(newValue) } } - + func contentsOfURL(_ url: URL, in bundle: DocumentationBundle) throws -> Data { switch dataProvider { case .legacy(let legacyDataProvider): @@ -161,15 +161,15 @@ public class DocumentationContext { } // Having a deprecated setter above requires a computed property. private var _configuration: Configuration - + /// The graph of all the documentation content and their relationships to each other. /// /// > Important: The topic graph has no awareness of source language specific edges. var topicGraph = TopicGraph() - + /// User-provided global options for this documentation conversion. var options: Options? - + /// The set of all manually curated references if `shouldStoreManuallyCuratedReferences` was true at the time of processing and has remained `true` since.. Nil if curation has not been processed yet. public private(set) var manuallyCuratedReferences: Set? @@ -187,12 +187,12 @@ public class DocumentationContext { return node.reference } } - + /// The root module nodes of the Topic Graph. /// /// This property is initialized during the registration of a documentation bundle. public private(set) var rootModules: [ResolvedTopicReference]! - + /// The topic reference of the root module, if it's the only registered module. var soleRootModuleReference: ResolvedTopicReference? { guard rootModules.count > 1 else { @@ -205,15 +205,15 @@ public class DocumentationContext { } return nonVirtualModules.count == 1 ? nonVirtualModules.first : nil } - + typealias LocalCache = ContentCache typealias ExternalCache = ContentCache - + /// Map of document URLs to topic references. var documentLocationMap = BidirectionalMap() /// A storage of already created documentation nodes for the local documentation content. /// - /// The documentation cache is built up incrementally as local content is registered with the documentation context. + /// The documentation cache is built up incrementally as local content is registered with the documentation context. /// /// First, the context adds all symbols, with both their references and symbol IDs for lookup. The ``SymbolGraphRelationshipsBuilder`` looks up documentation /// nodes by their symbol's ID when it builds up in-memory relationships between symbols. Later, the context adds articles and other conceptual content with only their @@ -223,7 +223,7 @@ public class DocumentationContext { var assetManagers = [DocumentationBundle.Identifier: DataAssetManager]() /// A list of non-topic links that can be resolved. var nodeAnchorSections = [ResolvedTopicReference: AnchorSection]() - + /// A storage of externally resolved content. /// /// The external cache is built up in two steps; @@ -237,7 +237,7 @@ public class DocumentationContext { func localOrExternalReference(symbolID: String) -> ResolvedTopicReference? { documentationCache.reference(symbolID: symbolID) ?? externalCache.reference(symbolID: symbolID) } - + /// A list of all the problems that was encountered while registering and processing the documentation bundles in this context. public var problems: [Problem] { return diagnosticEngine.problems @@ -245,12 +245,12 @@ public class DocumentationContext { /// The engine that collects problems encountered while registering and processing the documentation bundles in this context. public var diagnosticEngine: DiagnosticEngine - + /// All the link references that have been resolved from external sources, either successfully or not. /// /// The unsuccessful links are tracked so that the context doesn't attempt to re-resolve the unsuccessful links during rendering which runs concurrently for each page. var externallyResolvedLinks = [ValidatedURL: TopicReferenceResolutionResult]() - + /// A temporary structure to hold a semantic value that hasn't yet had its links resolved. /// /// These temporary values are only expected to exist while the documentation is being built. Once the documentation bundles have been fully registered and the topic graph @@ -258,14 +258,14 @@ public class DocumentationContext { struct SemanticResult { /// The ``Semantic`` value with unresolved links. var value: S - + /// The source of the document that produces the ``value``. var source: URL - + /// The Topic Graph node for this value. var topicGraphNode: TopicGraph.Node } - + /// Temporary storage for articles before they are curated and moved to the documentation cache. /// /// This storage is only used while the documentation context is being built. Once the documentation bundles have been fully registered and the topic graph @@ -273,7 +273,7 @@ public class DocumentationContext { /// /// The key to lookup an article is the reference to the article itself. var uncuratedArticles = [ResolvedTopicReference: SemanticResult
]() - + /// Temporary storage for documentation extension files before they are curated and moved to the documentation cache. /// /// This storage is only used while the documentation context is being built. Once the documentation bundles have been fully registered and the topic graph @@ -302,9 +302,9 @@ public class DocumentationContext { self.diagnosticEngine = diagnosticEngine self._configuration = configuration self.linkResolver = LinkResolver(dataProvider: FileManager.default) - + _legacyDataProvider.delegate = self - + for bundle in dataProvider.bundles.values { try register(bundle) } @@ -344,11 +344,11 @@ public class DocumentationContext { try benchmark(wrap: Benchmark.Duration(id: "bundle-registration")) { // Enable reference caching for this documentation bundle. ResolvedTopicReference.enableReferenceCaching(for: bundle.id) - + try self.register(bundle) } } - + /// Respond to a new `bundle` being removed from the `dataProvider` by unregistering it. /// /// - Parameters: @@ -357,26 +357,26 @@ public class DocumentationContext { @available(*, deprecated, message: "Pass the context its inputs at initialization instead. This deprecated API will be removed after 6.2 is released") public func dataProvider(_ dataProvider: any DocumentationContextDataProvider, didRemoveBundle bundle: DocumentationBundle) throws { linkResolver.localResolver?.unregisterBundle(identifier: bundle.id) - + // Purge the reference cache for this bundle and disable reference caching for // this bundle moving forward. ResolvedTopicReference.purgePool(for: bundle.id) - + unregister(bundle) } - + /// The documentation bundles that are currently registered with the context. @available(*, deprecated, message: "Use 'bundle' instead. This deprecated API will be removed after 6.2 is released") public var registeredBundles: some Collection { _registeredBundles } - + /// Returns the `DocumentationBundle` with the given `identifier` if it's registered with the context, otherwise `nil`. @available(*, deprecated, message: "Use 'bundle' instead. This deprecated API will be removed after 6.2 is released") public func bundle(identifier: String) -> DocumentationBundle? { _bundle(identifier: identifier) } - + // Remove these when removing `registeredBundles` and `bundle(identifier:)`. // These exist so that internal code that need to be compatible with legacy data providers can access the bundles without deprecation warnings. var _registeredBundles: [DocumentationBundle] { @@ -387,7 +387,7 @@ public class DocumentationContext { bundle.map { [$0] } ?? [] } } - + func _bundle(identifier: String) -> DocumentationBundle? { switch dataProvider { case .legacy(let legacyDataProvider): @@ -397,7 +397,7 @@ public class DocumentationContext { return bundle?.id.rawValue == identifier ? bundle : nil } } - + /// Perform semantic analysis on a given `document` at a given `source` location and append any problems found to `problems`. /// /// - Parameters: @@ -412,7 +412,7 @@ public class DocumentationContext { engine.emit(analyzer.problems) return result } - + /// Perform global analysis of compiled Markup /// /// Global analysis differs from semantic analysis in that no transformation is expected to occur. The @@ -442,10 +442,10 @@ public class DocumentationContext { checker.visit(document) diagnosticEngine.emit(checker.problems) } - + /// A cache of plain string module names, keyed by the module node reference. private var moduleNameCache: [ResolvedTopicReference: (displayName: String, symbolName: String)] = [:] - + /// Find the known plain string module name for a given module reference. /// /// - Note: Looking up module names requires that the module names have been pre-resolved. This happens automatically at the end of bundle registration. @@ -463,7 +463,7 @@ public class DocumentationContext { } fatalError("Incorrect use of API: '\(#function)' can only be used with known module references") } - + /// Attempts to resolve the module names of all root modules. /// /// This allows the module names to quickly be looked up using ``moduleName(forModuleReference:)`` @@ -490,15 +490,15 @@ public class DocumentationContext { return (reference: reference, semantic: semantic) }), localBundleID: localBundleID) } - + /// A tuple of a semantic object and its reference in the topic graph. private typealias ReferencedSemanticObject = (reference: ResolvedTopicReference, semantic: Semantic) - + /// Converts a semantic result to a referenced semantic object by removing the generic constraint. private func referencedSemanticObject(from: SemanticResult) -> ReferencedSemanticObject { return (reference: from.topicGraphNode.reference, semantic: from.value) } - + /// Attempts to resolve links external to the given bundle by visiting the given list of semantic objects. /// /// The resolved references are collected in ``externallyResolvedLinks``. @@ -509,7 +509,7 @@ public class DocumentationContext { private func preResolveExternalLinks(semanticObjects: [ReferencedSemanticObject], localBundleID: DocumentationBundle.Identifier) { // If there are no external resolvers added we will not resolve any links. guard !configuration.externalDocumentationConfiguration.sources.isEmpty else { return } - + let collectedExternalLinks = Synchronized([DocumentationBundle.Identifier: Set]()) semanticObjects.concurrentPerform { _, semantic in autoreleasepool { @@ -519,7 +519,7 @@ public class DocumentationContext { // Avoid any synchronization overhead if there are no references to add. guard !externalLinksCollector.collectedExternalReferences.isEmpty else { return } - + // Add the link pairs to `collectedExternalLinks`. collectedExternalLinks.sync { for (bundleID, collectedLinks) in externalLinksCollector.collectedExternalReferences { @@ -528,7 +528,7 @@ public class DocumentationContext { } } } - + for (bundleID, collectedLinks) in collectedExternalLinks.sync({ $0 }) { guard let externalResolver = configuration.externalDocumentationConfiguration.sources[bundleID] else { continue @@ -537,7 +537,7 @@ public class DocumentationContext { let unresolvedURL = externalLink.topicURL let result = externalResolver.resolve(.unresolved(externalLink)) externallyResolvedLinks[unresolvedURL] = result - + if case .success(let resolvedReference) = result { // Add the resolved entity to the documentation cache. if let externallyResolvedNode = externalEntity(with: resolvedReference) { @@ -556,7 +556,7 @@ public class DocumentationContext { } } } - + /// A resolved documentation node along with any relevant problems. private typealias LinkResolveResult = (reference: ResolvedTopicReference, node: DocumentationNode, problems: [Problem]) @@ -568,7 +568,7 @@ public class DocumentationContext { defer { signposter.endInterval("Resolve links", signpostHandle) } - + let references = Array(curatedReferences) let results = Synchronized<[LinkResolveResult]>([]) results.sync({ $0.reserveCapacity(references.count) }) @@ -580,7 +580,7 @@ public class DocumentationContext { else { return false } - + // Check that that documentation comment is inherited from a symbol belonging to another module guard let symbolSemantic = documentationNode.semantic as? Symbol, let originSymbolSemantic = documentationCache[symbolOriginReference]?.semantic as? Symbol @@ -589,14 +589,14 @@ public class DocumentationContext { } return symbolSemantic.moduleReference != originSymbolSemantic.moduleReference } - + let resolveNodeWithReference: (ResolvedTopicReference) -> Void = { [unowned self] reference in guard var documentationNode = try? entity(with: reference), documentationNode.semantic is Article || documentationNode.semantic is Symbol else { return } - + let symbolOriginReference = (documentationNode.semantic as? Symbol)?.origin.flatMap { origin in documentationCache.reference(symbolID: origin.identifier) } @@ -608,16 +608,16 @@ public class DocumentationContext { // Don't resolve any links for this symbol. return } - + var resolver = ReferenceResolver(context: self, bundle: bundle, rootReference: reference, inheritanceParentReference: symbolOriginReference) - + // Update the node with the markup that contains resolved references instead of authored links. - documentationNode.semantic = autoreleasepool { + documentationNode.semantic = autoreleasepool { // We use an autorelease pool to release used memory as soon as possible, since the resolver will copy each semantic value // to rewrite it and replace the authored links with resolved reference strings instead. resolver.visit(documentationNode.semantic) } - + // Also resolve the node's alternate representations. This isn't part of the node's 'semantic' value (resolved above). if let alternateRepresentations = documentationNode.metadata?.alternateRepresentations { for alternateRepresentation in alternateRepresentations { @@ -637,29 +637,29 @@ public class DocumentationContext { problems = resolver.problems } else { // Diagnostics for in-source documentation comments need to be offset based on the start location of the comment in the source file. - + // Get the source location let inSourceDocumentationCommentInfo = documentationNode.inSourceDocumentationChunk - + // Post-process and filter out unwanted diagnostics (for example from inherited documentation comments) problems = resolver.problems.compactMap { problem in guard let source = problem.diagnostic.source else { // Ignore any diagnostic without a source location. These can't be meaningfully presented to the user. return nil } - + if source == inSourceDocumentationCommentInfo?.url, let offset = inSourceDocumentationCommentInfo?.offset { // Diagnostics from an in-source documentation comment need to be offset based on the location of that documentation comment. var modifiedProblem = problem modifiedProblem.offsetWithRange(offset) return modifiedProblem - } - + } + // Diagnostics from documentation extension files have correct source ranges and don't need to be modified. return problem } } - + // Also resolve the node's page images. This isn't part of the node's 'semantic' value (resolved above). let pageImageProblems = documentationNode.metadata?.pageImages.compactMap { pageImage in return resolver.resolve( @@ -668,7 +668,7 @@ public class DocumentationContext { severity: .warning ) } ?? [] - + let result: LinkResolveResult = (reference: reference, node: documentationNode, problems: problems + pageImageProblems) results.sync({ $0.append(result) }) } @@ -685,7 +685,7 @@ public class DocumentationContext { // Record symbol links as symbol "mentions" for automatic cross references // on rendered symbol documentation. if let article = result.node.semantic as? Article, - case .article = DocumentationContentRenderer.roleForArticle(article, nodeKind: result.node.kind) + case .article = DocumentationContentRenderer.roleForArticle(article, nodeKind: result.node.kind) { for markup in article.abstractSection?.content ?? [] { var mentions = SymbolLinkCollector(context: self, article: result.node.reference, baseWeight: 2) @@ -706,10 +706,10 @@ public class DocumentationContext { ) diagnosticEngine.emit(result.problems) } - + mergeFallbackLinkResolutionResults() } - + /// Attempt to resolve links in imported documentation, converting any ``TopicReferences`` from `.unresolved` to `.resolved` where possible. /// /// This function is passed pages that haven't been added to the topic graph yet. Calling this function will load the documentation entity for each of these pages @@ -731,7 +731,7 @@ public class DocumentationContext { defer { signposter.endInterval("Resolve links", signpostHandle) } - + let sourceLanguages = soleRootModuleReference.map { self.sourceLanguages(for: $0) } ?? [.swift] // Tutorial table-of-contents @@ -742,10 +742,10 @@ public class DocumentationContext { var resolver = ReferenceResolver(context: self, bundle: bundle) let tableOfContents = resolver.visit(tableOfContentsResult.value) as! TutorialTableOfContents diagnosticEngine.emit(resolver.problems) - + // Add to document map documentLocationMap[url] = tableOfContentsResult.topicGraphNode.reference - + let tableOfContentsReference = tableOfContentsResult.topicGraphNode.reference.withSourceLanguages(sourceLanguages) let tutorialTableOfContentsNode = DocumentationNode( @@ -758,7 +758,7 @@ public class DocumentationContext { semantic: tableOfContents ) documentationCache[tableOfContentsReference] = tutorialTableOfContentsNode - + // Update the reference in the topic graph with the table-of-contents page's available languages. topicGraph.updateReference( tableOfContentsResult.topicGraphNode.reference, @@ -766,16 +766,16 @@ public class DocumentationContext { ) let anonymousVolumeName = "$volume" - + for volume in tableOfContents.volumes { // Graph node: Volume let volumeReference = tutorialTableOfContentsNode.reference.appendingPath(volume.name ?? anonymousVolumeName) let volumeNode = TopicGraph.Node(reference: volumeReference, kind: .volume, source: .file(url: url), title: volume.name ?? anonymousVolumeName) topicGraph.addNode(volumeNode) - + // Graph edge: Tutorial table-of-contents -> Volume topicGraph.addEdge(from: tableOfContentsResult.topicGraphNode, to: volumeNode) - + for chapter in volume.chapters { // Graph node: Module let baseNodeReference: ResolvedTopicReference @@ -788,10 +788,10 @@ public class DocumentationContext { let chapterReference = baseNodeReference.appendingPath(chapter.name) let chapterNode = TopicGraph.Node(reference: chapterReference, kind: .chapter, source: .file(url: url), title: chapter.name) topicGraph.addNode(chapterNode) - + // Graph edge: Volume -> Chapter topicGraph.addEdge(from: volumeNode, to: chapterNode) - + for tutorialReference in chapter.topicReferences { guard case let .resolved(.success(tutorialReference)) = tutorialReference.topic, let tutorialNode = topicGraph.nodeWithReference(tutorialReference) else { @@ -804,9 +804,9 @@ public class DocumentationContext { } } } - + // Tutorials - + for tutorialResult in tutorials { autoreleasepool { let url = tutorialResult.source @@ -814,12 +814,12 @@ public class DocumentationContext { var resolver = ReferenceResolver(context: self, bundle: bundle) let tutorial = resolver.visit(unresolvedTutorial) as! Tutorial diagnosticEngine.emit(resolver.problems) - + // Add to document map documentLocationMap[url] = tutorialResult.topicGraphNode.reference - + let tutorialReference = tutorialResult.topicGraphNode.reference.withSourceLanguages(sourceLanguages) - + let tutorialNode = DocumentationNode( reference: tutorialReference, kind: .tutorial, @@ -830,7 +830,7 @@ public class DocumentationContext { semantic: tutorial ) documentationCache[tutorialReference] = tutorialNode - + // Update the reference in the topic graph with the tutorial's available languages. topicGraph.updateReference( tutorialResult.topicGraphNode.reference, @@ -838,9 +838,9 @@ public class DocumentationContext { ) } } - + // Tutorial Articles - + for articleResult in tutorialArticles { autoreleasepool { let url = articleResult.source @@ -848,12 +848,12 @@ public class DocumentationContext { var resolver = ReferenceResolver(context: self, bundle: bundle) let article = resolver.visit(unresolvedTutorialArticle) as! TutorialArticle diagnosticEngine.emit(resolver.problems) - + // Add to document map documentLocationMap[url] = articleResult.topicGraphNode.reference let articleReference = articleResult.topicGraphNode.reference.withSourceLanguages(sourceLanguages) - + let articleNode = DocumentationNode( reference: articleReference, kind: .tutorialArticle, @@ -864,7 +864,7 @@ public class DocumentationContext { semantic: article ) documentationCache[articleReference] = articleNode - + // Update the reference in the topic graph with the article's available languages. topicGraph.updateReference( articleResult.topicGraphNode.reference, @@ -872,10 +872,10 @@ public class DocumentationContext { ) } } - + // Articles are resolved in a separate pass } - + private func registerDocuments(from bundle: DocumentationBundle) throws -> ( tutorialTableOfContentsResults: [SemanticResult], tutorials: [SemanticResult], @@ -890,20 +890,20 @@ public class DocumentationContext { var tutorialArticles = [SemanticResult]() var articles = [SemanticResult
]() var documentationExtensions = [SemanticResult
]() - + var references: [ResolvedTopicReference: URL] = [:] let decodeError = Synchronized<(any Error)?>(nil) - + // Load and analyze documents concurrently let analyzedDocuments: [(URL, Semantic)] = bundle.markupURLs.concurrentPerform { url, results in guard decodeError.sync({ $0 == nil }) else { return } - + do { let data = try contentsOfURL(url, in: bundle) let source = String(decoding: data, as: UTF8.self) let document = Document(parsing: source, source: url, options: [.parseBlockDirectives, .parseSymbolLinks]) - + // Check for non-inclusive language in all types of docs if that diagnostic severity is required. if configuration.externalMetadata.diagnosticLevel >= NonInclusiveLanguageChecker.severity { var langChecker = NonInclusiveLanguageChecker(sourceFile: url) @@ -914,23 +914,23 @@ public class DocumentationContext { guard let analyzed = analyze(document, at: url, in: bundle, engine: diagnosticEngine) else { return } - + // Only check non-tutorial documents from markup. if analyzed is Article { check(document, at: url) } - + results.append((url, analyzed)) } catch { decodeError.sync({ $0 = error }) } } - + // Rethrow the decoding error if decoding failed. if let error = decodeError.sync({ $0 }) { throw error } - + // to preserve the order of documents by url let analyzedDocumentsSorted = analyzedDocuments.sorted(by: \.0.absoluteString) @@ -940,11 +940,11 @@ public class DocumentationContext { let path = NodeURLGenerator.pathForSemantic(analyzed, source: url, bundle: bundle) let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguage: .swift) - + // Since documentation extensions' filenames have no impact on the URL of pages, there is no need to enforce unique filenames for them. // At this point we consider all articles with an H1 containing link a "documentation extension." let isDocumentationExtension = (analyzed as? Article)?.title?.child(at: 0) is (any AnyLink) - + if let firstFoundAtURL = references[reference], !isDocumentationExtension { let problem = Problem( diagnostic: Diagnostic( @@ -964,11 +964,11 @@ public class DocumentationContext { diagnosticEngine.emit(problem) continue } - + if !isDocumentationExtension { references[reference] = url } - + /* Add all topic graph nodes up front before resolution starts, because there may be circular linking. @@ -983,27 +983,27 @@ public class DocumentationContext { topicGraph.addNode(topicGraphNode) let result = SemanticResult(value: tutorial, source: url, topicGraphNode: topicGraphNode) tutorials.append(result) - + insertLandmarks(tutorial.landmarks, from: topicGraphNode, source: url) } else if let tutorialArticle = analyzed as? TutorialArticle { let topicGraphNode = TopicGraph.Node(reference: reference, kind: .tutorialArticle, source: .file(url: url), title: tutorialArticle.title ?? "") topicGraph.addNode(topicGraphNode) let result = SemanticResult(value: tutorialArticle, source: url, topicGraphNode: topicGraphNode) tutorialArticles.append(result) - + insertLandmarks(tutorialArticle.landmarks, from: topicGraphNode, source: url) } else if let article = analyzed as? Article { - + // Here we create a topic graph node with the prepared data but we don't add it to the topic graph just yet // because we don't know where in the hierarchy the article belongs, we will add it later when crawling the manual curation via Topics task groups. let topicGraphNode = TopicGraph.Node(reference: reference, kind: .article, source: .file(url: url), title: article.title!.plainText) let result = SemanticResult(value: article, source: url, topicGraphNode: topicGraphNode) - + // Separate articles that look like documentation extension files from other articles, so that the documentation extension files can be matched up with a symbol. // Some links might not resolve in the final documentation hierarchy and we will emit warnings for those later on when we finalize the bundle discovery phase. if isDocumentationExtension { documentationExtensions.append(result) - + // Warn for an incorrect root page metadata directive. if let technologyRoot = result.value.metadata?.technologyRoot { let diagnostic = Diagnostic(source: url, severity: .warning, range: article.metadata?.technologyRoot?.originalMarkup.range, identifier: "org.swift.docc.UnexpectedTechnologyRoot", summary: "Documentation extension files can't become technology roots.") @@ -1035,32 +1035,32 @@ public class DocumentationContext { diagnosticEngine.emit(problem) } } - + return (tutorialTableOfContentsResults, tutorials, tutorialArticles, articles, documentationExtensions) } - + private func insertLandmarks(_ landmarks: some Sequence, from topicGraphNode: TopicGraph.Node, source url: URL) { for landmark in landmarks { guard let range = landmark.range else { continue } - + let landmarkReference = topicGraphNode.reference.withFragment(landmark.title) - + // Graph node: Landmark let landmarkTopicGraphNode = TopicGraph.Node(reference: landmarkReference, kind: .onPageLandmark, source: .range(range, url: url), title: landmark.title) topicGraph.addNode(landmarkTopicGraphNode) - + // Graph edge: Topic -> Landmark topicGraph.addEdge(from: topicGraphNode, to: landmarkTopicGraphNode) - + documentationCache[landmarkReference] = DocumentationNode(reference: landmarkReference, kind: .onPageLandmark, sourceLanguage: .swift, name: .conceptual(title: landmark.title), markup: landmark.markup, semantic: nil) } } - + /// A lookup of resolved references based on the reference's absolute string. private(set) var referenceIndex = [String: ResolvedTopicReference]() - + private func nodeWithInitializedContent( reference: ResolvedTopicReference, match foundDocumentationExtension: DocumentationContext.SemanticResult
?, @@ -1069,7 +1069,7 @@ public class DocumentationContext { guard var updatedNode = documentationCache[reference] else { fatalError("A topic reference that has already been resolved should always exist in the cache.") } - + // Pull a matched article out of the cache and attach content to the symbol updatedNode.initializeSymbolContent( documentationExtension: foundDocumentationExtension?.value, @@ -1093,7 +1093,7 @@ public class DocumentationContext { return updatedNode } - + /// Creates a topic graph node and a documentation node for the given symbol. private func preparedSymbolData(_ symbol: UnifiedSymbolGraph.Symbol, reference: ResolvedTopicReference, module: SymbolGraph.Module, moduleReference: ResolvedTopicReference, fileURL symbolGraphURL: URL?) -> AddSymbolResultWithProblems { let documentation = DocumentationNode(reference: reference, unifiedSymbol: symbol, moduleData: module, moduleReference: moduleReference) @@ -1112,7 +1112,7 @@ public class DocumentationContext { private typealias AddSymbolResult = (reference: ResolvedTopicReference, preciseIdentifier: String, topicGraphNode: TopicGraph.Node, node: DocumentationNode) /// An optional result of converting a symbol into a documentation along with any related problems. private typealias AddSymbolResultWithProblems = (AddSymbolResult, problems: [Problem]) - + /// Concurrently adds a symbol to the graph, index, and cache, or replaces an existing symbol with the same precise identifier /// (for light updates to symbols already in the graph). /// @@ -1139,7 +1139,7 @@ public class DocumentationContext { guard let reference = symbolReferences[symbol.defaultIdentifier] else { fatalError("Symbol with identifier '\(symbol.uniqueIdentifier)' has no reference. A symbol will always have at least one reference.") } - + results.append(preparedSymbolData( symbol, reference: reference, @@ -1157,14 +1157,14 @@ public class DocumentationContext { let symbolData = result.0 topicGraph.addNode(symbolData.topicGraphNode) documentationCache.add(symbolData.node, reference: symbolData.reference, symbolID: symbolData.preciseIdentifier) - + for anchor in result.0.node.anchorSections { nodeAnchorSections[anchor.reference] = anchor } - + diagnosticEngine.emit(result.problems) } - + /// Loads all graph files from a given `bundle` and merges them together while building the symbol relationships and loading any available markdown documentation for those symbols. /// /// - Parameter bundle: The bundle to load symbol graph files from. @@ -1180,21 +1180,21 @@ public class DocumentationContext { defer { signposter.endInterval("Register symbols", signpostHandle) } - + /// We need only unique relationships so we'll collect them in a set. var combinedRelationshipsBySelector = [UnifiedSymbolGraph.Selector: Set]() /// Also track the unique relationships across all languages and platforms var uniqueRelationships = Set() /// Collect symbols from all symbol graphs. var combinedSymbols = [String: UnifiedSymbolGraph.Symbol]() - + var moduleReferences = [String: ResolvedTopicReference]() - + // Build references for all symbols in all of this module's symbol graphs. let symbolReferences = signposter.withIntervalSignpost("Disambiguate references") { linkResolver.localResolver.referencesForSymbols(in: symbolGraphLoader.unifiedGraphs, bundle: bundle, context: self) } - + // Set the index and cache storage capacity to avoid ad-hoc storage resizing. documentationCache.reserveCapacity(symbolReferences.count) documentLocationMap.reserveCapacity(symbolReferences.count) @@ -1203,14 +1203,14 @@ public class DocumentationContext { combinedRelationshipsBySelector.reserveCapacity(symbolReferences.count) uniqueRelationships.reserveCapacity(symbolReferences.count) combinedSymbols.reserveCapacity(symbolReferences.count) - + // Iterate over batches of symbol graphs, each batch describing one module. // Each batch contains one or more symbol graph files. for (moduleName, unifiedSymbolGraph) in symbolGraphLoader.unifiedGraphs { try shouldContinueRegistration() let fileURL = symbolGraphLoader.mainModuleURL(forModule: moduleName) - + let moduleInterfaceLanguages: Set // FIXME: Update with new SymbolKit API once available. // This is a very inefficient way to gather the source languages @@ -1219,32 +1219,32 @@ public class DocumentationContext { let symbolGraphLanguages = Set( unifiedSymbolGraph.symbols.flatMap(\.value.sourceLanguages) ) - + // If the symbol graph has no symbols, we cannot determine what languages is it available for, // so fall back to Swift. moduleInterfaceLanguages = symbolGraphLanguages.isEmpty ? [.swift] : symbolGraphLanguages - + // If it's an existing module, update the interface languages moduleReferences[moduleName] = moduleReferences[moduleName]?.addingSourceLanguages(moduleInterfaceLanguages) - + // Import the symbol graph symbols let moduleReference: ResolvedTopicReference - + // If it's a repeating module, diff & merge matching declarations. if let existingModuleReference = moduleReferences[moduleName] { // This node is known to exist moduleReference = existingModuleReference - + try mergeSymbolDeclarations(from: unifiedSymbolGraph, references: symbolReferences, moduleReference: moduleReference, fileURL: fileURL) } else { guard symbolGraphLoader.hasPrimaryURL(moduleName: moduleName) else { continue } - + // Create a module symbol let moduleIdentifier = SymbolGraph.Symbol.Identifier( precise: moduleName, interfaceLanguage: moduleInterfaceLanguages.first!.id ) - + // Use the default module kind for this bundle if one was provided, // otherwise fall back to 'Framework' let moduleKindDisplayName = bundle.info.defaultModuleKind ?? "Framework" @@ -1258,11 +1258,11 @@ public class DocumentationContext { mixins: [:]) let moduleSymbolReference = SymbolReference(moduleName, interfaceLanguages: moduleInterfaceLanguages, defaultSymbol: moduleSymbol) moduleReference = ResolvedTopicReference(symbolReference: moduleSymbolReference, moduleName: moduleName, bundle: bundle) - + signposter.withIntervalSignpost("Add symbols to topic graph", id: signposter.makeSignpostID()) { addSymbolsToTopicGraph(symbolGraph: unifiedSymbolGraph, url: fileURL, symbolReferences: symbolReferences, moduleReference: moduleReference) } - + // For inherited symbols we remove the source docs (if inheriting docs is disabled) before creating their documentation nodes. for (_, relationships) in unifiedSymbolGraph.relationshipsByLanguage { for relationship in relationships { @@ -1270,7 +1270,7 @@ public class DocumentationContext { if let sourceOrigin = relationship[mixin: SymbolGraph.Relationship.SourceOrigin.self], // Check if it's a memberOf or implementation relationship. (relationship.kind == .memberOf || relationship.kind == .defaultImplementationOf) - { + { SymbolGraphRelationshipsBuilder.addInheritedDefaultImplementation( sourceOrigin: sourceOrigin, inheritedSymbolID: relationship.source, @@ -1299,15 +1299,15 @@ public class DocumentationContext { // Add this module to the dictionary of processed modules to keep track of repeat symbol graphs moduleReferences[moduleName] = moduleReference } - + // Collect symbols and relationships combinedSymbols.merge(unifiedSymbolGraph.symbols, uniquingKeysWith: { $1 }) - + for (selector, relationships) in unifiedSymbolGraph.relationshipsByLanguage { combinedRelationshipsBySelector[selector, default: []].formUnion(relationships) uniqueRelationships.formUnion(relationships) } - + // Keep track of relationships that refer to symbols that are absent from the symbol graph, so that // we can diagnose them. combinedRelationshipsBySelector[ @@ -1316,21 +1316,21 @@ public class DocumentationContext { ].formUnion(unifiedSymbolGraph.orphanRelationships) uniqueRelationships.formUnion(unifiedSymbolGraph.orphanRelationships) } - + try shouldContinueRegistration() - + // Only add the symbol mapping now if the path hierarchy based resolver is the main implementation. // If it is only used for mismatch checking then we must wait until the documentation cache code path has traversed and updated all the colliding nodes. // Otherwise the mappings will save the unmodified references and the hierarchy based resolver won't find the expected parent nodes when resolving links. linkResolver.localResolver.addMappingForSymbols(localCache: documentationCache) - + // Track the symbols that have multiple matching documentation extension files for diagnostics. var symbolsWithMultipleDocumentationExtensionMatches = [ResolvedTopicReference: [SemanticResult
]]() for documentationExtension in documentationExtensions { guard let link = documentationExtension.value.title?.child(at: 0) as? (any AnyLink) else { fatalError("An article shouldn't have ended up in the documentation extension list unless its title was a link. File: \(documentationExtension.source.absoluteString.singleQuoted)") } - + guard let destination = link.destination else { let diagnostic = Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.emptyLinkDestination", summary: """ Documentation extension with an empty link doesn't correspond to any symbol. @@ -1345,7 +1345,7 @@ public class DocumentationContext { diagnosticEngine.emit(Problem(diagnostic: diagnostic)) continue } - + // FIXME: Resolve the link relative to the module https://github.com/swiftlang/swift-docc/issues/516 let reference = TopicReference.unresolved(.init(topicURL: url)) switch resolve(reference, in: bundle.rootReference, fromSymbolLink: true) { @@ -1371,7 +1371,7 @@ public class DocumentationContext { // the process that interacts with the convert service is responsible for maintaining it's own link resolutions implementation to match the behavior of a regular build. // - Diagnosing documentation extension files that don't match any symbols. let reference = documentationExtension.topicGraphNode.reference - + let symbolPath = NodeURLGenerator.Path.documentation(path: url.components.path).stringValue let symbolReference = ResolvedTopicReference( bundleID: reference.bundleID, @@ -1379,7 +1379,7 @@ public class DocumentationContext { fragment: nil, sourceLanguages: reference.sourceLanguages ) - + if let existing = uncuratedDocumentationExtensions[symbolReference] { if symbolsWithMultipleDocumentationExtensionMatches[symbolReference] == nil { symbolsWithMultipleDocumentationExtensionMatches[symbolReference] = [existing] @@ -1390,10 +1390,10 @@ public class DocumentationContext { } continue } - + // Present a diagnostic specific to documentation extension files but get the solutions and notes from the general unresolved link problem. let unresolvedLinkProblem = unresolvedReferenceProblem(source: documentationExtension.source, range: link.range, severity: .warning, uncuratedArticleMatch: nil, errorInfo: errorInfo, fromSymbolLink: link is SymbolLink) - + diagnosticEngine.emit( Problem( diagnostic: Diagnostic(source: documentationExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.SymbolUnmatched", summary: "No symbol matched \(destination.singleQuoted). \(errorInfo.message).", notes: unresolvedLinkProblem.diagnostic.notes), @@ -1422,14 +1422,14 @@ public class DocumentationContext { match: match, bundle: bundle ) - + return (( node: updatedNode, matchedArticleURL: match?.source )) } } - + // Update cache with up-to-date nodes for (updatedNode, matchedArticleURL) in updatedNodes { let reference = updatedNode.reference @@ -1453,10 +1453,10 @@ public class DocumentationContext { // Resolve any external references first preResolveExternalLinks(references: Array(moduleReferences.values) + combinedSymbols.keys.compactMap({ documentationCache.reference(symbolID: $0) }), localBundleID: bundle.id) - + // Look up and add symbols that are _referenced_ in the symbol graph but don't exist in the symbol graph. try resolveExternalSymbols(in: combinedSymbols, relationships: combinedRelationshipsBySelector) - + for (selector, relationships) in combinedRelationshipsBySelector { // Build relationships in the completed graph buildRelationships(relationships, selector: selector, bundle: bundle) @@ -1557,7 +1557,7 @@ public class DocumentationContext { } } } - + /// Identifies all the dictionary keys and records them in the appropriate target dictionaries. private func populateOnPageMemberRelationships( from relationships: Set, @@ -1568,7 +1568,7 @@ public class DocumentationContext { var bodyByTarget = [String: HTTPBody]() var bodyParametersByTarget = [String: [HTTPParameter]]() var responsesByTarget = [String: [HTTPResponse]]() - + for edge in relationships { if edge.kind == .memberOf || edge.kind == .optionalMemberOf { if let source = documentationCache[edge.source], let target = documentationCache[edge.target], @@ -1615,7 +1615,7 @@ public class DocumentationContext { } } } - + // Merge in all the dictionary keys for each target into their section variants. keysByTarget.forEach { targetIdentifier, keys in let target = documentationCache[targetIdentifier] @@ -1628,7 +1628,7 @@ public class DocumentationContext { } } } - + // Merge in all the parameters for each target into their section variants. parametersByTarget.forEach { targetIdentifier, parameters in let target = documentationCache[targetIdentifier] @@ -1641,7 +1641,7 @@ public class DocumentationContext { } } } - + // Merge in the body for each target into their section variants. bodyByTarget.forEach { targetIdentifier, body in let target = documentationCache[targetIdentifier] @@ -1658,7 +1658,7 @@ public class DocumentationContext { } } } - + // Merge in all the responses for each target into their section variants. responsesByTarget.forEach { targetIdentifier, responses in let target = documentationCache[targetIdentifier] @@ -1672,7 +1672,7 @@ public class DocumentationContext { } } } - + /// Look up and add symbols that are _referenced_ in the symbol graph but don't exist in the symbol graph, using an `globalExternalSymbolResolver` (if not `nil`). func resolveExternalSymbols( in symbols: [String: UnifiedSymbolGraph.Symbol], @@ -1682,10 +1682,10 @@ public class DocumentationContext { // Context has no mechanism for resolving external symbol links. No reason to gather any symbols to resolve. return } - + // Gather all the references to symbols that don't exist in the combined symbol graph file and add then by resolving these "external" symbols. var symbolsToResolve = Set() - + // Add all the symbols that are the target of a relationship. These could for example be protocols that are being conformed to, // classes that are being subclassed, or methods that are being overridden. for (_, relationships) in relationships { @@ -1693,7 +1693,7 @@ public class DocumentationContext { symbolsToResolve.insert(edge.target) } } - + // Add all the types that are referenced in a declaration. These could for example be the type of an argument or return value. for symbol in symbols.values { guard let defaultSymbol = symbol.defaultSymbol, let declaration = defaultSymbol[mixin: SymbolGraph.Symbol.DeclarationFragments.self] else { @@ -1706,9 +1706,9 @@ public class DocumentationContext { symbolsToResolve.insert(preciseIdentifier) } } - + // TODO: When the symbol graph includes the precise identifiers for conditional availability, those symbols should also be resolved (rdar://63768609). - + func resolveSymbol(symbolID: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? { if let globalResult = configuration.externalDocumentationConfiguration.globalSymbolResolver?.symbolReferenceAndEntity(withPreciseIdentifier: symbolID) { return globalResult @@ -1720,7 +1720,7 @@ public class DocumentationContext { } return nil } - + // Resolve all the collected symbol identifiers and add them do the topic graph. for symbolID in symbolsToResolve { if let (reference, entity) = resolveSymbol(symbolID: symbolID) { @@ -1730,13 +1730,13 @@ public class DocumentationContext { // The build doesn't necessarily include all documentation dependencies that appear in declarations, conformances, etc. } } - + /// When building multi-platform documentation symbols might have more than one declaration /// depending on variances in their implementation across platforms (e.g. use `NSPoint` vs `CGPoint` parameter in a method). /// This method finds matching symbols between graphs and merges their declarations in case there are differences. func mergeSymbolDeclarations(from otherSymbolGraph: UnifiedSymbolGraph, references: [SymbolGraph.Symbol.Identifier: ResolvedTopicReference], moduleReference: ResolvedTopicReference, fileURL otherSymbolGraphURL: URL?) throws { let mergeError = Synchronized<(any Error)?>(nil) - + let results: [AddSymbolResultWithProblems] = Array(otherSymbolGraph.symbols.values).concurrentPerform { symbol, result in guard let defaultSymbol = symbol.defaultSymbol, let swiftSelector = symbol.defaultSelector, let module = symbol.modules[swiftSelector] else { fatalError(""" @@ -1749,17 +1749,17 @@ public class DocumentationContext { diagnosticEngine.emit(Problem(diagnostic: Diagnostic(source: nil, severity: .error, range: nil, identifier: "org.swift.docc.SymbolDeclarationNotFound", summary: "Symbol with identifier '\(symbol.uniqueIdentifier)' has no declaration"), possibleSolutions: [])) return } - + guard let existingNode = documentationCache[symbol.uniqueIdentifier], existingNode.semantic is Symbol else { // New symbols that didn't exist in the previous graphs should be added. guard let reference = references[symbol.defaultIdentifier] else { fatalError("Symbol with identifier '\(symbol.uniqueIdentifier)' has no reference. A symbol will always have at least one reference.") } - + result.append(preparedSymbolData(symbol, reference: reference, module: module, moduleReference: moduleReference, fileURL: otherSymbolGraphURL)) return } - + do { // It's safe to force unwrap since we validated the data above. // We update the node in place so avoid copying the data around. @@ -1771,7 +1771,7 @@ public class DocumentationContext { }) } } - + // If there was an invalid input error re-throw it. if let error = mergeError.sync({ $0 }) { throw error @@ -1780,19 +1780,19 @@ public class DocumentationContext { // Add any new symbols to the documentation cache. results.forEach { addPreparedSymbolToContext($0) } } - + private static let supportedImageExtensions: Set = ["png", "jpg", "jpeg", "svg", "gif"] private static let supportedVideoExtensions: Set = ["mov", "mp4"] // TODO: Move this functionality to ``DocumentationBundleFileTypes`` (rdar://68156425). - + /// A type of asset. public enum AssetType: CustomStringConvertible { /// An image asset. case image /// A video asset. case video - + public var description: String { switch self { case .image: @@ -1816,12 +1816,12 @@ public class DocumentationContext { case .video: return supportedVideoExtensions.contains(fileExtension) } } - + private func registerMiscResources(from bundle: DocumentationBundle) throws { let miscResources = Set(bundle.miscResourceURLs) try assetManagers[bundle.id, default: DataAssetManager()].register(data: miscResources) } - + private func registeredAssets(withExtensions extensions: Set? = nil, inContexts contexts: [DataAsset.Context] = DataAsset.Context.allCases, forBundleID bundleID: DocumentationBundle.Identifier) -> [DataAsset] { guard let resources = assetManagers[bundleID]?.storage.values else { return [] @@ -1846,12 +1846,12 @@ public class DocumentationContext { public func registeredImageAssets(for bundleID: DocumentationBundle.Identifier) -> [DataAsset] { registeredAssets(withExtensions: DocumentationContext.supportedImageExtensions, forBundleID: bundleID) } - + @available(*, deprecated, renamed: "registeredImageAssets(for:)", message: "registeredImageAssets(for:)' instead. This deprecated API will be removed after 6.2 is released") public func registeredImageAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] { registeredImageAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) } - + /// Returns a list of all the video assets that registered for a given `bundleIdentifier`. /// /// - Parameter bundleID: The identifier of the bundle to return video assets for. @@ -1859,7 +1859,7 @@ public class DocumentationContext { public func registeredVideoAssets(for bundleID: DocumentationBundle.Identifier) -> [DataAsset] { registeredAssets(withExtensions: DocumentationContext.supportedVideoExtensions, forBundleID: bundleID) } - + @available(*, deprecated, renamed: "registeredVideoAssets(for:)", message: "registeredImageAssets(for:)' instead. This deprecated API will be removed after 6.2 is released") public func registeredVideoAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] { registeredVideoAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) @@ -1872,7 +1872,7 @@ public class DocumentationContext { public func registeredDownloadsAssets(for bundleID: DocumentationBundle.Identifier) -> [DataAsset] { registeredAssets(inContexts: [DataAsset.Context.download], forBundleID: bundleID) } - + @available(*, deprecated, renamed: "registeredDownloadsAssets(for:)", message: "registeredDownloadsAssets(for:)' instead. This deprecated API will be removed after 6.2 is released") public func registeredDownloadsAssets(forBundleID bundleIdentifier: BundleIdentifier) -> [DataAsset] { registeredDownloadsAssets(for: DocumentationBundle.Identifier(rawValue: bundleIdentifier)) @@ -1890,34 +1890,34 @@ public class DocumentationContext { } } } - + private func registerRootPages(from articles: Articles, in bundle: DocumentationBundle) { // Create a root leaf node for all root page articles for article in articles { // Create the documentation data guard let (documentation, title) = DocumentationContext.documentationNodeAndTitle(for: article, kind: .collection, in: bundle) else { continue } let reference = documentation.reference - + // Create the documentation node documentLocationMap[article.source] = reference let topicGraphKind = DocumentationNode.Kind.module let graphNode = TopicGraph.Node(reference: reference, kind: topicGraphKind, source: .file(url: article.source), title: title) topicGraph.addNode(graphNode) documentationCache[reference] = documentation - + linkResolver.localResolver.addRootArticle(article, anchorSections: documentation.anchorSections) for anchor in documentation.anchorSections { nodeAnchorSections[anchor.reference] = anchor } - + // Remove the article from the context uncuratedArticles.removeValue(forKey: article.topicGraphNode.reference) } } - + /// When `true` bundle registration will be cancelled asap. private var isRegistrationEnabled = Synchronized(true) - + /// Enables or disables bundle registration. /// /// When given `false` the context will try to cancel as quick as possible @@ -1926,7 +1926,7 @@ public class DocumentationContext { public func setRegistrationEnabled(_ value: Bool) { isRegistrationEnabled.sync({ $0 = value }) } - + /// Adds articles that are not root pages to the documentation cache. /// /// This method adds all of the `articles` to the documentation cache and inserts a node representing @@ -1955,13 +1955,13 @@ public class DocumentationContext { return article } let reference = documentation.reference - + documentationCache[reference] = documentation - + documentLocationMap[article.source] = reference let graphNode = TopicGraph.Node(reference: reference, kind: .article, source: .file(url: article.source), title: title) topicGraph.addNode(graphNode) - + linkResolver.localResolver.addArticle(article, anchorSections: documentation.anchorSections) for anchor in documentation.anchorSections { nodeAnchorSections[anchor.reference] = anchor @@ -1973,7 +1973,7 @@ public class DocumentationContext { return article } } - + /// Registers a synthesized root page for a catalog with only non-root articles. /// /// If the catalog only has one article or has an article with the same name as the catalog itself, that article is turned into the root page instead of creating a new article. @@ -1983,7 +1983,7 @@ public class DocumentationContext { /// - bundle: The bundle containing the articles. private func synthesizeArticleOnlyRootPage(articles: inout [DocumentationContext.SemanticResult
], bundle: DocumentationBundle) { let title = bundle.displayName - + // An inner helper function to register a new root node from an article func registerAsNewRootNode(_ articleResult: SemanticResult
) { uncuratedArticles.removeValue(forKey: articleResult.topicGraphNode.reference) @@ -2013,11 +2013,11 @@ public class DocumentationContext { redirects: articleResult.value.redirects, options: articleResult.value.options ) - + let graphNode = TopicGraph.Node(reference: reference, kind: .module, source: articleResult.topicGraphNode.source, title: title) registerRootPages(from: [.init(value: article, source: articleResult.source, topicGraphNode: graphNode)], in: bundle) } - + if articles.count == 1 { // This catalog only has one article, so we make that the root. registerAsNewRootNode(articles.removeFirst()) @@ -2028,12 +2028,12 @@ public class DocumentationContext { // There's no particular article to make into the root. Instead, create a new minimal root page. let path = NodeURLGenerator.Path.documentation(path: title).stringValue let sourceLanguage = DocumentationContext.defaultLanguage(in: []) - + let reference = ResolvedTopicReference(bundleID: bundle.id, path: path, sourceLanguages: [sourceLanguage]) - + let graphNode = TopicGraph.Node(reference: reference, kind: .module, source: .external, title: title) topicGraph.addNode(graphNode) - + // Build up the "full" markup for an empty technology root article let metadataDirectiveMarkup = BlockDirective(name: "Metadata", children: [ BlockDirective(name: "TechnologyRoot", children: []) @@ -2056,7 +2056,7 @@ public class DocumentationContext { documentationCache[reference] = documentationNode } } - + /// Creates a documentation node and title for the given article semantic result. /// /// - Parameters: @@ -2073,9 +2073,9 @@ public class DocumentationContext { guard let articleMarkup = article.value.markup else { return nil } - + let path = NodeURLGenerator.pathForSemantic(article.value, source: article.source, bundle: bundle) - + // Use the languages specified by the `@SupportedLanguage` directives if present. let availableSourceLanguages = article.value .metadata @@ -2084,15 +2084,15 @@ public class DocumentationContext { metadata.supportedLanguages .map(\.language) ) - + return languages.isEmpty ? nil : languages } ?? availableSourceLanguages - + // If available source languages are provided and it contains Swift, use Swift as the default language of // the article. let defaultSourceLanguage = defaultLanguage(in: availableSourceLanguages) - + let reference = ResolvedTopicReference( bundleID: bundle.id, path: path, @@ -2101,9 +2101,9 @@ public class DocumentationContext { // (github.com/swiftlang/swift-docc/issues/240). ?? [.swift] ) - + let title = article.topicGraphNode.title - + let documentationNode = DocumentationNode( reference: reference, kind: kind, @@ -2113,10 +2113,10 @@ public class DocumentationContext { markup: articleMarkup, semantic: article.value ) - + return (documentationNode, title) } - + /// Curates articles under the root module. /// /// This method creates a new task group under the root page containing references to all of the articles @@ -2135,21 +2135,21 @@ public class DocumentationContext { guard !articlesToAutoCurate.isEmpty else { return [] } - + for article in articlesToAutoCurate { topicGraph.addEdge(from: rootNode, to: article.topicGraphNode) uncuratedArticles.removeValue(forKey: article.topicGraphNode.reference) } - + let articleReferences = articlesToAutoCurate.map(\.topicGraphNode.reference) let automaticTaskGroup = AutomaticTaskGroupSection( title: "Articles", references: articleReferences, renderPositionPreference: .top ) - + let node = try entity(with: rootNode.reference) - + // If the node we're automatically curating the article under is a symbol, automatically curate the article // for each language it's available in. if let symbol = node.semantic as? Symbol { @@ -2159,10 +2159,10 @@ public class DocumentationContext { } else if var taskGroupProviding = node.semantic as? (any AutomaticTaskGroupsProviding) { taskGroupProviding.automaticTaskGroups = [automaticTaskGroup] } - + return articleReferences } - + /** Register a documentation bundle with this context. */ @@ -2200,24 +2200,24 @@ public class DocumentationContext { // Note: Each bundle is registered and processed separately. // Documents and symbols may both reference each other so the bundle is registered in 4 steps - + // In the bundle discovery phase all tasks run in parallel as they don't depend on each other. let discoveryGroup = DispatchGroup() let discoveryQueue = DispatchQueue(label: "org.swift.docc.Discovery", qos: .unspecified, attributes: .concurrent, autoreleaseFrequency: .workItem) - + let discoveryError = Synchronized<(any Error)?>(nil) // Load all bundle symbol graphs into the loader. var symbolGraphLoader: SymbolGraphLoader! var hierarchyBasedResolver: PathHierarchyBasedLinkResolver! - + discoveryGroup.async(queue: discoveryQueue) { [unowned self] in symbolGraphLoader = SymbolGraphLoader( bundle: bundle, dataLoader: { try self.contentsOfURL($0, in: $1) }, symbolGraphTransformer: configuration.convertServiceConfiguration.symbolGraphTransformer ) - + do { try signposter.withIntervalSignpost("Load symbols", id: signposter.makeSignpostID()) { try symbolGraphLoader.loadAll() @@ -2250,7 +2250,7 @@ public class DocumentationContext { }) } } - + // Second, all the documents and symbols are added. // // Note: Documents and symbols may look up resources at this point but shouldn't lookup other documents or @@ -2263,7 +2263,7 @@ public class DocumentationContext { articles: [SemanticResult
], documentationExtensions: [SemanticResult
] )! - + discoveryGroup.async(queue: discoveryQueue) { [unowned self] in do { result = try signposter.withIntervalSignpost("Load documents", id: signposter.makeSignpostID()) { @@ -2276,7 +2276,7 @@ public class DocumentationContext { }) } } - + discoveryGroup.async(queue: discoveryQueue) { [unowned self] in do { try signposter.withIntervalSignpost("Load external resolvers", id: signposter.makeSignpostID()) { @@ -2289,7 +2289,7 @@ public class DocumentationContext { }) } } - + discoveryGroup.wait() try shouldContinueRegistration() @@ -2298,15 +2298,15 @@ public class DocumentationContext { if let encounteredError = discoveryError.sync({ $0 }) { throw encounteredError } - + // All discovery went well, process the inputs. let (tutorialTableOfContentsResults, tutorials, tutorialArticles, allArticles, documentationExtensions) = result var (otherArticles, rootPageArticles) = splitArticles(allArticles) - + let globalOptions = (allArticles + documentationExtensions).compactMap { article in return article.value.options[.global] } - + if globalOptions.count > 1 { let extraGlobalOptionsProblems = globalOptions.map { extraOptionsDirective -> Problem in let diagnostic = Diagnostic( @@ -2320,26 +2320,26 @@ public class DocumentationContext { directive with the \(extraOptionsDirective.scope.rawValue.singleQuoted) scope. """ ) - + guard let range = extraOptionsDirective.originalMarkup.range else { return Problem(diagnostic: diagnostic) } - + let solution = Solution( summary: "Remove extraneous \(extraOptionsDirective.scope) \(Options.directiveName.singleQuoted) directive", replacements: [ Replacement(range: range, replacement: "") ] ) - + return Problem(diagnostic: diagnostic, possibleSolutions: [solution]) } - + diagnosticEngine.emit(extraGlobalOptionsProblems) } else { options = globalOptions.first } - + self.linkResolver.localResolver = hierarchyBasedResolver hierarchyBasedResolver.addMappingForRoots(bundle: bundle) for tutorial in tutorials { @@ -2351,21 +2351,21 @@ public class DocumentationContext { for tutorialTableOfContents in tutorialTableOfContentsResults { hierarchyBasedResolver.addTutorialTableOfContents(tutorialTableOfContents) } - + registerRootPages(from: rootPageArticles, in: bundle) try registerSymbols(from: bundle, symbolGraphLoader: symbolGraphLoader, documentationExtensions: documentationExtensions) // We don't need to keep the loader in memory after we've registered all symbols. symbolGraphLoader = nil - + try shouldContinueRegistration() - + if topicGraph.nodes.isEmpty, !otherArticles.isEmpty, !configuration.convertServiceConfiguration.allowsRegisteringArticlesWithoutTechnologyRoot { synthesizeArticleOnlyRootPage(articles: &otherArticles, bundle: bundle) } - + // Keep track of the root modules registered from symbol graph files, we'll need them to automatically // curate articles. rootModules = topicGraph.nodes.values.compactMap { node in @@ -2374,28 +2374,28 @@ public class DocumentationContext { } return node.reference } - + // Articles that will be automatically curated can be resolved but they need to be pre registered before resolving links. let rootNodeForAutomaticCuration = soleRootModuleReference.flatMap(topicGraph.nodeWithReference(_:)) if configuration.convertServiceConfiguration.allowsRegisteringArticlesWithoutTechnologyRoot || rootNodeForAutomaticCuration != nil { otherArticles = registerArticles(otherArticles, in: bundle) try shouldContinueRegistration() } - + // Third, any processing that relies on resolving other content is done, mainly resolving links. preResolveExternalLinks(semanticObjects: tutorialTableOfContentsResults.map(referencedSemanticObject) + tutorials.map(referencedSemanticObject) + tutorialArticles.map(referencedSemanticObject), localBundleID: bundle.id) - + resolveLinks( tutorialTableOfContents: tutorialTableOfContentsResults, tutorials: tutorials, tutorialArticles: tutorialArticles, bundle: bundle ) - + // After the resolving links in tutorial content all the local references are known and can be added to the referenceIndex for fast lookup. referenceIndex.reserveCapacity(knownIdentifiers.count + nodeAnchorSections.count) for reference in knownIdentifiers { @@ -2404,29 +2404,29 @@ public class DocumentationContext { for reference in nodeAnchorSections.keys { referenceIndex[reference.absoluteString] = reference } - + try shouldContinueRegistration() var allCuratedReferences = try crawlSymbolCuration(in: linkResolver.localResolver.topLevelSymbols(), bundle: bundle) - + // Store the list of manually curated references if doc coverage is on. if configuration.experimentalCoverageConfiguration.shouldStoreManuallyCuratedReferences { manuallyCuratedReferences = allCuratedReferences } - + try shouldContinueRegistration() // Fourth, automatically curate all symbols that haven't been curated manually let automaticallyCurated = signposter.withIntervalSignpost("Auto-curate symbols ", id: signposter.makeSignpostID()) { autoCurateSymbolsInTopicGraph() } - + // Crawl the rest of the symbols that haven't been crawled so far in hierarchy pre-order. allCuratedReferences = try crawlSymbolCuration(in: automaticallyCurated.map(\.symbol), bundle: bundle, initial: allCuratedReferences) // Remove curation paths that have been created automatically above // but we've found manual curation for in the second crawl pass. removeUnneededAutomaticCuration(automaticallyCurated) - + // Automatically curate articles that haven't been manually curated // Article curation is only done automatically if there is only one root module if let rootNode = rootNodeForAutomaticCuration { @@ -2442,9 +2442,9 @@ public class DocumentationContext { // Emit warnings for any remaining uncurated files. emitWarningsForUncuratedTopics() - + linkResolver.localResolver.addAnchorForSymbols(localCache: documentationCache) - + // Fifth, resolve links in nodes that are added solely via curation preResolveExternalLinks(references: Array(allCuratedReferences), localBundleID: bundle.id) resolveLinks(curatedReferences: allCuratedReferences, bundle: bundle) @@ -2455,25 +2455,25 @@ public class DocumentationContext { // the single page. To ensure that links are resolved, explicitly visit all pages. resolveLinks(curatedReferences: Set(knownPages), bundle: bundle) } - + // We should use a read-only context during render time (rdar://65130130). // Sixth - fetch external entities and merge them in the context for case .success(let reference) in externallyResolvedLinks.values { referenceIndex[reference.absoluteString] = reference } - + // Seventh, the complete topic graph—with all nodes and all edges added—is analyzed. signposter.withIntervalSignpost("Analyze topic graph", id: signposter.makeSignpostID()) { topicGraphGlobalAnalysis() } - + preResolveModuleNames() } - + /// Given a list of topics that have been automatically curated, checks if a topic has been additionally manually curated /// and if so removes the automatic curation. - /// + /// /// During the first crawl pass we skip over all automatically curated nodes (as they are not in the topic graph yet. /// After adding all symbols automatically to their parents and running a second crawl pass we discover any manual /// curations that we could not crawl in the first pass. @@ -2533,9 +2533,9 @@ public class DocumentationContext { )) } } - + typealias AutoCuratedSymbolRecord = (symbol: ResolvedTopicReference, parent: ResolvedTopicReference, counterpartParent: ResolvedTopicReference?) - + /// Curate all remaining uncurated symbols under their natural parent from the symbol graph. /// /// This will include all symbols that were not manually curated by the documentation author. @@ -2547,18 +2547,18 @@ public class DocumentationContext { // Check that the node isn't already manually curated topicGraphNode.shouldAutoCurateInCanonicalLocation else { return } - + // Check that the symbol doesn't already have parent's that aren't either language representation's hierarchical parent. // This for example happens for default implementation and symbols that are requirements of protocol conformances. guard parents(of: reference).allSatisfy({ $0 == parentReference || $0 == counterpartParentReference }) else { return } - + guard let topicGraphParentNode = topicGraph.nodeWithReference(parentReference) else { preconditionFailure("Node with reference \(parentReference.absoluteString) exist in link resolver but not in topic graph.") } topicGraph.addEdge(from: topicGraphParentNode, to: topicGraphNode) - + if let counterpartParentReference { guard let topicGraphCounterpartParentNode = topicGraph.nodeWithReference(counterpartParentReference) else { preconditionFailure("Node with reference \(counterpartParentReference.absoluteString) exist in link resolver but not in topic graph.") @@ -2575,7 +2575,7 @@ public class DocumentationContext { guard FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled else { return } - + for (overloadGroupID, overloadSymbolIDs) in overloadGroups { guard overloadSymbolIDs.count > 1 else { assertionFailure("Overload group \(overloadGroupID) contained \(overloadSymbolIDs.count) symbols, but should have more than one symbol to be valid.") @@ -2645,10 +2645,10 @@ public class DocumentationContext { } } } - + /// A closure type getting the information about a reference in a context and returns any possible problems with it. public typealias ReferenceCheck = (DocumentationContext, ResolvedTopicReference) -> [Problem] - + /// Adds new checks to be run during the global topic analysis; after a bundle has been fully registered and its topic graph has been fully built. /// /// - Parameter newChecks: The new checks to add. @@ -2656,7 +2656,7 @@ public class DocumentationContext { public func addGlobalChecks(_ newChecks: [ReferenceCheck]) { configuration.topicAnalysisConfiguration.additionalChecks.append(contentsOf: newChecks) } - + /// Crawls the hierarchy of the given list of nodes, adding relationships in the topic graph for all resolvable task group references. /// - Parameters: /// - references: A list of references to crawl. @@ -2669,7 +2669,7 @@ public class DocumentationContext { defer { signposter.endInterval("Curate symbols", signpostHandle) } - + var crawler = DocumentationCurator(in: self, bundle: bundle, initial: initial) for reference in references { @@ -2677,23 +2677,23 @@ public class DocumentationContext { of: reference, relateNodes: { container, descendant in topicGraph.unsafelyAddEdge(source: container, target: descendant) - + guard topicGraph.nodes[descendant]?.shouldAutoCurateInCanonicalLocation == true else { // Descendant is already marked to be removed from automatic curation. return } - + // An inner function called below func stopAutoCuratingDescendant() { topicGraph.nodes[descendant]?.shouldAutoCurateInCanonicalLocation = false } - + guard let (canonicalContainer, counterpartContainer) = linkResolver.localResolver.nearestContainers(ofSymbol: descendant) else { // Any curation of a non-symbol removes it from automatic curation stopAutoCuratingDescendant() return } - + // For symbols we only stop automatic curation if they are curated within their canonical container's sub-hierarchy // or if a top-level symbol is curated under another top-level symbol (more on that below). // @@ -2723,8 +2723,8 @@ public class DocumentationContext { // The reason for this exception is to allow developers to group top-level types under one-another without requiring an API collection. // For example, in DocC one could curate `DiagnosticConsumer`, `DiagnosticFormattingOptions`, and `Diagnostic` under `DiagnosticEngine`, // treating the `DiagnosticEngine` as the top-level topic for all diagnostic related types. - - + + // To determine if `container` exists in the curated symbol's canonical container's sub-hierarchy, // first find its nearest container symbol (in case `container` is a series of API collections). // @@ -2737,18 +2737,18 @@ public class DocumentationContext { // Continue to automatically curate the descendant under its canonical container. return } - + if nearestSymbolContainer == canonicalContainer || nearestSymbolContainer == counterpartContainer { // The descendant is curated in its canonical container (in either language representation) stopAutoCuratingDescendant() return } - + // An inner function called below func isModule(_ reference: ResolvedTopicReference) -> Bool { topicGraph.nodes[reference]?.kind == .module } - + if isModule(canonicalContainer) || counterpartContainer.map(isModule) == true { guard let curationLocationContainers = linkResolver.localResolver.nearestContainers(ofSymbol: nearestSymbolContainer) else { assertionFailure(""" @@ -2757,7 +2757,7 @@ public class DocumentationContext { """) return } - + if canonicalContainer == curationLocationContainers.main || canonicalContainer == curationLocationContainers.counterpart || counterpartContainer == curationLocationContainers.main || @@ -2771,9 +2771,9 @@ public class DocumentationContext { } ) } - + diagnosticEngine.emit(crawler.problems) - + return crawler.curatedNodes } @@ -2782,7 +2782,7 @@ public class DocumentationContext { for (reference, documentationExtensions) in symbolsWithMultipleDocumentationExtensionMatches { let symbolPath = reference.url.pathComponents.dropFirst(2).joined(separator: "/") let firstExtension = documentationExtensions.first! - + guard let link = firstExtension.value.title?.child(at: 0) as? (any AnyLink) else { fatalError("An article shouldn't have ended up in the documentation extension list unless its title was a link. File: \(firstExtension.source.absoluteString.singleQuoted)") } @@ -2793,13 +2793,13 @@ public class DocumentationContext { } return DiagnosticNote(source: documentationExtension.source, range: link.range ?? zeroRange, message: "\(symbolPath.singleQuoted) is also documented here.") } - + diagnosticEngine.emit( Problem(diagnostic: Diagnostic(source: firstExtension.source, severity: .warning, range: link.range, identifier: "org.swift.docc.DuplicateMarkdownTitleSymbolReferences", summary: "Multiple documentation extensions matched \(symbolPath.singleQuoted).", notes: notes), possibleSolutions: []) ) } } - + /// Emits information diagnostics for uncurated articles. private func emitWarningsForUncuratedTopics() { // Check that all articles are curated @@ -2807,7 +2807,7 @@ public class DocumentationContext { diagnosticEngine.emit(Problem(diagnostic: Diagnostic(source: articleResult.source, severity: .information, range: nil, identifier: "org.swift.docc.ArticleUncurated", summary: "You haven't curated \(articleResult.topicGraphNode.reference.description.singleQuoted)"), possibleSolutions: [])) } } - + /** Analysis that runs after all nodes are successfully registered in the context. Useful for checks that need the complete node graph. @@ -2820,7 +2820,7 @@ public class DocumentationContext { } } diagnosticEngine.emit(problems) - + // Run pre-defined global analysis. for node in topicGraph.nodes.values { switch node.kind { @@ -2831,10 +2831,10 @@ public class DocumentationContext { default: break } } - + // Run analysis to determine whether manually configured alternate representations are valid. analyzeAlternateRepresentations() - + // Run global ``TopicGraph`` global analysis. analyzeTopicGraph() } @@ -2846,7 +2846,7 @@ public class DocumentationContext { let referencesToRemove = topicGraph.nodes.keys.filter { $0.bundleID == bundle.id } - + for reference in referencesToRemove { topicGraph.edges[reference]?.removeAll(where: { $0.bundleID == bundle.id }) topicGraph.reverseEdges[reference]?.removeAll(where: { $0.bundleID == bundle.id }) @@ -2871,34 +2871,34 @@ public class DocumentationContext { let asset = assetManager.allData(named: identifier.path) else { throw ContextError.notFound(identifier.url) } - + let resource = asset.data(bestMatching: trait) - + return try contentsOfURL(resource.url, in: bundle) } - + /// Returns true if a resource with the given identifier exists in the registered bundle. public func resourceExists(with identifier: ResourceReference, ofType expectedAssetType: AssetType? = nil) -> Bool { guard let assetManager = assetManagers[identifier.bundleID] else { return false } - + guard let key = assetManager.bestKey(forAssetName: identifier.path) else { return false } - + guard let expectedAssetType, let asset = assetManager.storage[key] else { return true } - + return asset.hasVariant(withAssetType: expectedAssetType) } - + private func externalEntity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity? { return configuration.externalDocumentationConfiguration.sources[reference.bundleID].map({ $0.entity(with: reference) }) ?? configuration.convertServiceConfiguration.fallbackResolver?.entityIfPreviouslyResolved(with: reference) } - + /** Look for a documentation node among the registered bundles and via any external resolvers. @@ -2909,10 +2909,10 @@ public class DocumentationContext { if let cached = documentationCache[reference] { return cached } - + throw ContextError.notFound(reference.url) } - + private func knownEntityValue( reference: ResolvedTopicReference, valueInLocalEntity: (DocumentationNode) -> Result, @@ -2933,7 +2933,7 @@ public class DocumentationContext { fatalError("Unexpected error when retrieving entity: \(error)") } } - + /// Returns the set of languages the entity corresponding to the given reference is available in. /// /// - Precondition: The entity associated with the given reference must be registered in the context. @@ -2944,7 +2944,7 @@ public class DocumentationContext { valueInExternalEntity: \.sourceLanguages ) } - + /// Returns whether the given reference corresponds to a symbol. func isSymbol(reference: ResolvedTopicReference) -> Bool { knownEntityValue( @@ -2955,7 +2955,7 @@ public class DocumentationContext { } // MARK: - Relationship queries - + /// Fetch the child nodes of a documentation node with the given `reference`, optionally filtering to only children of the given `kind`. /// /// > Important: The returned list can't be used to determine source language specific children. @@ -2978,7 +2978,7 @@ public class DocumentationContext { return nil } } - + /// Fetches the parents of the documentation node with the given `reference`. /// /// - Parameter reference: The reference of the node to fetch parents for. @@ -2986,7 +2986,7 @@ public class DocumentationContext { public func parents(of reference: ResolvedTopicReference) -> [ResolvedTopicReference] { return topicGraph.reverseEdges[reference] ?? [] } - + /// Returns the document URL for the given article or tutorial reference. /// /// - Parameter reference: The identifier for the topic whose file URL to locate. @@ -2997,7 +2997,7 @@ public class DocumentationContext { } return nil } - + /// Returns the URL of the documentation extension of the given reference. /// /// - Parameter reference: The reference to the symbol this function should return the documentation extension URL for. @@ -3008,7 +3008,7 @@ public class DocumentationContext { } return documentLocationMap[reference] } - + /// Attempt to locate the reference for a given file. /// /// - Parameter url: The file whose reference to locate. @@ -3019,24 +3019,24 @@ public class DocumentationContext { /** Attempt to retrieve the title for a given `reference`. - + - Parameter reference: The reference for the topic whose title is desired. - Returns: The title of the topic if it could be found, otherwise `nil`. */ public func title(for reference: ResolvedTopicReference) -> String? { return topicGraph.nodes[reference]?.title } - + /// Returns a sequence that traverses the topic graph in breadth first order from a given reference, without visiting the same node more than once. func breadthFirstSearch(from reference: ResolvedTopicReference) -> some Sequence { topicGraph.breadthFirstSearch(from: reference) } - + /** Attempt to resolve a ``TopicReference``. - + > Note: If the reference is already resolved, the original reference is returned. - + - Parameters: - reference: An unresolved (or resolved) reference. - parent: The *resolved* reference that serves as an enclosing search context, especially the parent reference's bundle identifier. @@ -3047,13 +3047,13 @@ public class DocumentationContext { switch reference { case .unresolved(let unresolvedReference): return linkResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: self) - + case .resolved(let resolved): // This reference is already resolved (either as a success or a failure), so don't change anything. return resolved } } - + /// Update the asset with a new value given the assets name and the topic it's referenced in. /// /// - Parameters: @@ -3063,7 +3063,7 @@ public class DocumentationContext { public func updateAsset(named name: String, asset: DataAsset, in parent: ResolvedTopicReference) { assetManagers[parent.bundleID]?.update(name: name, asset: asset) } - + /// Attempt to resolve an asset given its name and the topic it's referenced in. /// /// - Parameters: @@ -3074,7 +3074,7 @@ public class DocumentationContext { public func resolveAsset(named name: String, in parent: ResolvedTopicReference, withType type: AssetType? = nil) -> DataAsset? { resolveAsset(named: name, bundleID: parent.bundleID, withType: type) } - + func resolveAsset(named name: String, bundleID: DocumentationBundle.Identifier, withType expectedType: AssetType?) -> DataAsset? { if let localAsset = assetManagers[bundleID]?.allData(named: name) { if let expectedType { @@ -3082,17 +3082,17 @@ public class DocumentationContext { return nil } } - + return localAsset } - + if let fallbackAssetResolver = configuration.convertServiceConfiguration.fallbackResolver, let externallyResolvedAsset = fallbackAssetResolver.resolve(assetNamed: name) { assetManagers[bundleID, default: DataAssetManager()] .register(dataAsset: externallyResolvedAsset, forName: name) return externallyResolvedAsset } - + // If no fallbackAssetResolver is set, try to treat it as external media link if let externalMediaLink = URL(string: name), externalMediaLink.isAbsoluteWebURL { @@ -3103,9 +3103,9 @@ public class DocumentationContext { } return nil } - + /// Finds the identifier for a given asset name. - /// + /// /// `name` is one of the following formats: /// - "image" - asset name without extension /// - "image.png" - asset name including extension @@ -3142,19 +3142,19 @@ public class DocumentationContext { nil } } - + /// The references of all nodes in the topic graph. public var knownIdentifiers: [ResolvedTopicReference] { return Array(topicGraph.nodes.keys) } - + /// The references of all the pages in the topic graph. public var knownPages: [ResolvedTopicReference] { return topicGraph.nodes.values .filter { !$0.isVirtual && $0.kind.isPage } .map { $0.reference } } - + func dumpGraph() -> String { return topicGraph.nodes.values .filter { parents(of: $0.reference).isEmpty } @@ -3164,7 +3164,7 @@ public class DocumentationContext { } .joined() } - + private static func defaultLanguage(in sourceLanguages: Set?) -> SourceLanguage { sourceLanguages.map { sourceLanguages in if sourceLanguages.contains(.swift) { @@ -3198,8 +3198,127 @@ extension DocumentationContext { return Problem(diagnostic: Diagnostic(source: source, severity: .information, range: nil, identifier: "org.swift.docc.SymbolNotCurated", summary: "You haven't curated \(node.reference.absoluteString.singleQuoted)"), possibleSolutions: [Solution(summary: "Add a link to \(node.reference.absoluteString.singleQuoted) from a Topics group of another documentation node.", replacements: [])]) } diagnosticEngine.emit(problems) + // Check for multiple root page warnings + warnAboutMultipleRootPages() + } + + /// Checks for multiple root pages in the documentation and emits appropriate warnings. + private func warnAboutMultipleRootPages() { + let problems = detectMultipleRootWarnings() + diagnosticEngine.emit(problems) + } + + func detectMultipleRootWarnings() -> [Problem] { + var problems: [Problem] = [] + + //Skip detection if there's only one or no root + guard rootModules.count > 1 else { + return [] + } + + //collects all root pages that aren't organized as a child of another root + let freestandingRoots = rootModules.filter { root in + return parents(of: root).isEmpty + || !parents(of: root).contains { parentRef in + return rootModules.contains(parentRef) + } + } + + //If all roots are properly organized in a hierarchy, no warning needed + if freestandingRoots.count <= 1 { + return [] + } + + //Categorize roots by type + let moduleRoots = freestandingRoots.filter { root in + let node = topicGraph.nodeWithReference(root) + + //Must be a module node + guard node?.kind == .module else { + return false + } + + //And must not have a TechnologyRoot directive + if let entity = try? entity(with: root), + let metadata = entity.metadata, + metadata.technologyRoot != nil + { + return false + } + + return true + } + + let technologyRoots = freestandingRoots.filter { root in + // Check if this has a TechnologyRoot directive + if let entity = try? entity(with: root), + let metadata = entity.metadata, + metadata.technologyRoot != nil + { + return true + } + return false + } + + //different warning scenarios + + //multiple symbol graph modules + if moduleRoots.count > 1 { + let rootList = moduleRoots.map { $0.lastPathComponent.singleQuoted }.joined( + separator: ", ") + let problem = Problem( + diagnostic: Diagnostic( + severity: .warning, + identifier: "org.swift.docc.MultipleSymbolGraphRoots", + summary: "Documentation has multiple symbol graph modules as root pages", + explanation: + "Found multiple root pages from symbol graph files: \(rootList). The primary root page can't be determined." + ), + possibleSolutions: [] + ) + problems.append(problem) + } + + //symbol graph module and manual technology root + if !moduleRoots.isEmpty && !technologyRoots.isEmpty { + let moduleList = moduleRoots.map { $0.lastPathComponent.singleQuoted }.joined( + separator: ", ") + let technologyList = technologyRoots.map { $0.lastPathComponent.singleQuoted }.joined( + separator: ", ") + + let problem = Problem( + diagnostic: Diagnostic( + severity: .warning, + identifier: "org.swift.docc.MixedRootTypes", + summary: + "Documentation has both symbol graph modules and manual technology roots", + explanation: + "Found symbol graph modules (\(moduleList)) and manual technology roots (\(technologyList)). The symbol graph modules are considered primary roots, making any manual @TechnologyRoot pages unexpected extra roots." + ), + possibleSolutions: [] + ) + problems.append(problem) + } + + //multiple manual technology roots + if technologyRoots.count > 1 { + let rootList = technologyRoots.map { $0.lastPathComponent.singleQuoted }.joined( + separator: ", ") + let problem = Problem( + diagnostic: Diagnostic( + severity: .warning, + identifier: "org.swift.docc.MultipleTechnologyRoots", + summary: "Documentation has multiple manual technology roots", + explanation: + "Found multiple pages with @TechnologyRoot directive: \(rootList). The primary root page can't be determined." + ), + possibleSolutions: [] + ) + problems.append(problem) + } + + return problems } - func analyzeAlternateRepresentations() { var problems = [Problem]() @@ -3221,7 +3340,7 @@ extension DocumentationContext { for reference in knownPages { guard let entity = try? self.entity(with: reference), let alternateRepresentations = entity.metadata?.alternateRepresentations else { continue } - + var sourceLanguageToReference: [SourceLanguage: AlternateRepresentation] = [:] for alternateRepresentation in alternateRepresentations { // Check if the entity is not a symbol, as only symbols are allowed to specify custom alternate representations @@ -3244,7 +3363,7 @@ extension DocumentationContext { let alternateRepresentationEntity = try? self.entity(with: alternateRepresentationReference) else { continue } - + // Check if the resolved entity is not a symbol, as only symbols are allowed as custom alternate representations guard alternateRepresentationEntity.symbol != nil else { problems.append(Problem( @@ -3260,7 +3379,7 @@ extension DocumentationContext { )) continue } - + // Check if the documented symbol already has alternate representations from in-source annotations. let duplicateSourceLanguages = alternateRepresentationEntity.availableSourceLanguages.intersection(entity.availableSourceLanguages) if !duplicateSourceLanguages.isEmpty { @@ -3276,7 +3395,7 @@ extension DocumentationContext { possibleSolutions: [Solution(summary: "Replace this alternate language representation with a symbol which isn't available in \(listSourceLanguages(entity.availableSourceLanguages))", replacements: [])] )) } - + let duplicateAlternateLanguages = Set(sourceLanguageToReference.keys).intersection(alternateRepresentationEntity.availableSourceLanguages) if !duplicateAlternateLanguages.isEmpty { let notes: [DiagnosticNote] = duplicateAlternateLanguages.compactMap { duplicateAlternateLanguage in @@ -3285,7 +3404,7 @@ extension DocumentationContext { let source = range.source else { return nil } - + return DiagnosticNote(source: source, range: range, message: "This directive already specifies an alternate \(duplicateAlternateLanguage.name) representation.") } problems.append(Problem( @@ -3301,14 +3420,14 @@ extension DocumentationContext { possibleSolutions: removeAlternateRepresentationSolution(alternateRepresentation) )) } - + // Update mapping from source language to alternate declaration, for diagnostic purposes for alreadySeenLanguage in alternateRepresentationEntity.availableSourceLanguages { sourceLanguageToReference[alreadySeenLanguage] = alternateRepresentation } } } - + diagnosticEngine.emit(problems) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift index aab9b5b85..c81cd2559 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContext+RootPageTests.swift @@ -15,158 +15,412 @@ import SwiftDocCTestUtilities class DocumentationContext_RootPageTests: XCTestCase { func testArticleOnlyCatalogWithExplicitTechnologyRoot() throws { - let (_, context) = try loadBundle(catalog: - Folder(name: "no-sgf-test.docc", content: [ - // Root page for the collection - TextFile(name: "ReleaseNotes.md", utf8Content: """ - # Release Notes - @Metadata { - @TechnologyRoot - } - Learn about recent changes. - ## Topics - ### Release Notes - - - """), - // A curated article - TextFile(name: "ReleaseNotes 1.2.md", utf8Content: """ - # Release Notes for version 1.2 - Learn about changes in version 1.2 - ## See Also - - - """), - InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), - ]) + let (_, context) = try loadBundle( + catalog: + Folder( + name: "no-sgf-test.docc", + content: [ + // Root page for the collection + TextFile( + name: "ReleaseNotes.md", + utf8Content: """ + # Release Notes + @Metadata { + @TechnologyRoot + } + Learn about recent changes. + ## Topics + ### Release Notes + - + """), + // A curated article + TextFile( + name: "ReleaseNotes 1.2.md", + utf8Content: """ + # Release Notes for version 1.2 + Learn about changes in version 1.2 + ## See Also + - + """), + InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), + ]) ) - + // Verify all articles were loaded in the context XCTAssertEqual(context.knownIdentifiers.count, 2) - + // Verify /documentation/ReleaseNotes is a root node XCTAssertEqual(context.rootModules.map({ $0.url.path }), ["/documentation/ReleaseNotes"]) - + // Verify the root was crawled - XCTAssertEqual(context.topicGraph.edges[ResolvedTopicReference(bundleID: "com.test.example", path: "/documentation/ReleaseNotes", sourceLanguage: .swift)]?.map({ $0.url.path }), - ["/documentation/TestBundle/ReleaseNotes-1.2"]) + XCTAssertEqual( + context.topicGraph.edges[ + ResolvedTopicReference( + bundleID: "com.test.example", path: "/documentation/ReleaseNotes", + sourceLanguage: .swift)]?.map({ $0.url.path }), + ["/documentation/TestBundle/ReleaseNotes-1.2"]) } func testWarnsAboutExtensionFileTechnologyRoot() throws { - let (_, context) = try loadBundle(catalog: - Folder(name: "no-sgf-test.docc", content: [ - // Root page for the collection - TextFile(name: "ReleaseNotes.md", utf8Content: """ - # Release Notes - @Metadata { - @TechnologyRoot - } - Learn about recent changes. - ## Topics - ### Release Notes - - - """), - // A documentation extension file - TextFile(name: "MyClass.md", utf8Content: """ - # ``ReleaseNotes/MyClass`` - @Metadata { - @TechnologyRoot - } - """), - InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), - ]) + let (_, context) = try loadBundle( + catalog: + Folder( + name: "no-sgf-test.docc", + content: [ + // Root page for the collection + TextFile( + name: "ReleaseNotes.md", + utf8Content: """ + # Release Notes + @Metadata { + @TechnologyRoot + } + Learn about recent changes. + ## Topics + ### Release Notes + - + """), + // A documentation extension file + TextFile( + name: "MyClass.md", + utf8Content: """ + # ``ReleaseNotes/MyClass`` + @Metadata { + @TechnologyRoot + } + """), + InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), + ]) ) - + // Verify that we emit a warning when trying to make a symbol a root page - let technologyRootProblem = try XCTUnwrap(context.problems.first(where: { $0.diagnostic.identifier == "org.swift.docc.UnexpectedTechnologyRoot" })) - XCTAssertEqual(technologyRootProblem.diagnostic.source, URL(fileURLWithPath: "/no-sgf-test.docc/MyClass.md")) + let technologyRootProblem = try XCTUnwrap( + context.problems.first(where: { + $0.diagnostic.identifier == "org.swift.docc.UnexpectedTechnologyRoot" + })) + XCTAssertEqual( + technologyRootProblem.diagnostic.source, + URL(fileURLWithPath: "/no-sgf-test.docc/MyClass.md")) XCTAssertEqual(technologyRootProblem.diagnostic.range?.lowerBound.line, 3) let solution = try XCTUnwrap(technologyRootProblem.possibleSolutions.first) XCTAssertEqual(solution.replacements.first?.range.lowerBound.line, 3) XCTAssertEqual(solution.replacements.first?.range.upperBound.line, 3) } - + func testSingleArticleWithoutTechnologyRootDirective() throws { - let (_, context) = try loadBundle(catalog: - Folder(name: "Something.docc", content: [ - TextFile(name: "Article.md", utf8Content: """ - # My article - - A regular article without an explicit `@TechnologyRoot` directive. - """) - ]) + let (_, context) = try loadBundle( + catalog: + Folder( + name: "Something.docc", + content: [ + TextFile( + name: "Article.md", + utf8Content: """ + # My article + + A regular article without an explicit `@TechnologyRoot` directive. + """) + ]) ) - - XCTAssertEqual(context.knownPages.map(\.absoluteString), ["doc://Something/documentation/Article"]) - XCTAssertEqual(context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Article"]) - + + XCTAssertEqual( + context.knownPages.map(\.absoluteString), ["doc://Something/documentation/Article"]) + XCTAssertEqual( + context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Article"]) + XCTAssertEqual(context.problems.count, 0) } - + func testMultipleArticlesWithoutTechnologyRootDirective() throws { - let (_, context) = try loadBundle(catalog: - Folder(name: "Something.docc", content: [ - TextFile(name: "First.md", utf8Content: """ - # My first article - - A regular article without an explicit `@TechnologyRoot` directive. - """), - - TextFile(name: "Second.md", utf8Content: """ - # My second article - - Another regular article without an explicit `@TechnologyRoot` directive. - """), - - TextFile(name: "Third.md", utf8Content: """ - # My third article - - Yet another regular article without an explicit `@TechnologyRoot` directive. - """), - ]) + let (_, context) = try loadBundle( + catalog: + Folder( + name: "Something.docc", + content: [ + TextFile( + name: "First.md", + utf8Content: """ + # My first article + + A regular article without an explicit `@TechnologyRoot` directive. + """), + + TextFile( + name: "Second.md", + utf8Content: """ + # My second article + + Another regular article without an explicit `@TechnologyRoot` directive. + """), + + TextFile( + name: "Third.md", + utf8Content: """ + # My third article + + Yet another regular article without an explicit `@TechnologyRoot` directive. + """), + ]) ) - - XCTAssertEqual(context.knownPages.map(\.absoluteString).sorted(), [ - "doc://Something/documentation/Something", // A synthesized root - "doc://Something/documentation/Something/First", - "doc://Something/documentation/Something/Second", - "doc://Something/documentation/Something/Third", - ]) - XCTAssertEqual(context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Something"], "If no single article is a clear root, the root page is synthesized") - + + XCTAssertEqual( + context.knownPages.map(\.absoluteString).sorted(), + [ + "doc://Something/documentation/Something", // A synthesized root + "doc://Something/documentation/Something/First", + "doc://Something/documentation/Something/Second", + "doc://Something/documentation/Something/Third", + ]) + XCTAssertEqual( + context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Something"], + "If no single article is a clear root, the root page is synthesized") + XCTAssertEqual(context.problems.count, 0) } - + func testMultipleArticlesWithoutTechnologyRootDirectiveWithOneMatchingTheCatalogName() throws { - let (_, context) = try loadBundle(catalog: - Folder(name: "Something.docc", content: [ - TextFile(name: "Something.md", utf8Content: """ - # Some article - - A regular article without an explicit `@TechnologyRoot` directive. - - The name of this article file matches the name of the catalog. - """), - - TextFile(name: "Second.md", utf8Content: """ - # My second article - - Another regular article without an explicit `@TechnologyRoot` directive. - """), - - TextFile(name: "Third.md", utf8Content: """ - # My third article - - Yet another regular article without an explicit `@TechnologyRoot` directive. - """), - ]) + let (_, context) = try loadBundle( + catalog: + Folder( + name: "Something.docc", + content: [ + TextFile( + name: "Something.md", + utf8Content: """ + # Some article + + A regular article without an explicit `@TechnologyRoot` directive. + + The name of this article file matches the name of the catalog. + """), + + TextFile( + name: "Second.md", + utf8Content: """ + # My second article + + Another regular article without an explicit `@TechnologyRoot` directive. + """), + + TextFile( + name: "Third.md", + utf8Content: """ + # My third article + + Yet another regular article without an explicit `@TechnologyRoot` directive. + """), + ]) ) - - XCTAssertEqual(context.knownPages.map(\.absoluteString).sorted(), [ - "doc://Something/documentation/Something", // This article became the root - "doc://Something/documentation/Something/Second", - "doc://Something/documentation/Something/Third", - ]) - XCTAssertEqual(context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Something"]) - + + XCTAssertEqual( + context.knownPages.map(\.absoluteString).sorted(), + [ + "doc://Something/documentation/Something", // This article became the root + "doc://Something/documentation/Something/Second", + "doc://Something/documentation/Something/Third", + ]) + XCTAssertEqual( + context.rootModules.map(\.absoluteString), ["doc://Something/documentation/Something"]) + XCTAssertEqual(context.problems.count, 0) } + + //Multiple Root Warnings Tests + + func testMultipleSymbolGraphModulesWarning() throws { + //created a test bundle with two symbol graph files for different modules + let tempURL = try createTemporaryDirectory() + let bundleURL = tempURL.appendingPathComponent("test.docc") + try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true) + + //created two symbol graph files for different modules + let module1GraphURL = bundleURL.appendingPathComponent("Module1.symbols.json") + let module2GraphURL = bundleURL.appendingPathComponent("Module2.symbols.json") + + // Symbol graph content for Module1 + let module1Graph = makeSymbolGraph( + moduleName: "Module1", + symbols: [], + relationships: [] + ) + + //symbol graph content for Module2 + let module2Graph = makeSymbolGraph( + moduleName: "Module2", + symbols: [], + relationships: [] + ) + + //symbol graphs to files + try JSONEncoder().encode(module1Graph).write(to: module1GraphURL) + try JSONEncoder().encode(module2Graph).write(to: module2GraphURL) + + //created the Info.plist file + let infoPlistURL = bundleURL.appendingPathComponent("Info.plist") + let infoPlist = """ + + + + + CFBundleIdentifier + org.swift.docc.example + CFBundleName + Test Bundle + CFBundleVersion + 1.0.0 + + + """ + try infoPlist.write(to: infoPlistURL, atomically: true, encoding: .utf8) + + //bundle + let (_, _, context) = try loadBundle(from: bundleURL) + + //checks for the warning about multiple symbol graph modules + let multipleModuleWarning = context.problems.first { + $0.diagnostic.identifier == "org.swift.docc.MultipleSymbolGraphRoots" + } + XCTAssertNotNil( + multipleModuleWarning, "Should emit warning about multiple symbol graph modules") + XCTAssertEqual( + multipleModuleWarning?.diagnostic.summary, + "Documentation has multiple symbol graph modules as root pages") + XCTAssertTrue(multipleModuleWarning?.diagnostic.explanation?.contains("Module1") ?? false) + XCTAssertTrue(multipleModuleWarning?.diagnostic.explanation?.contains("Module2") ?? false) + } + + func testMixedRootTypesWarning() throws { + //create a test bundle with both a symbol graph module and a manual technology root article + let tempURL = try createTemporaryDirectory() + let bundleURL = tempURL.appendingPathComponent("mixed-roots.docc") + try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true) + + //create a manual technology root article + let articleURL = bundleURL.appendingPathComponent("Article.md") + let articleContent = """ + # My Documentation + @Metadata { + @TechnologyRoot + } + Learn about this technology. + """ + try articleContent.write(to: articleURL, atomically: true, encoding: .utf8) + + //create a symbol graph file + let symbolGraphURL = bundleURL.appendingPathComponent("MyModule.symbols.json") + + //create a simple symbol graph with a class + let symbolGraph = makeSymbolGraph( + moduleName: "MyModule", + symbols: [ + makeSymbol( + id: "swift.class.MyClass", + language: .swift, + kind: .class, + pathComponents: ["MyModule", "MyClass"] + ) + ], + relationships: [] + ) + + try JSONEncoder().encode(symbolGraph).write(to: symbolGraphURL) + + //create Info.plist + let infoPlistURL = bundleURL.appendingPathComponent("Info.plist") + let infoPlist = """ + + + + + CFBundleIdentifier + com.test.myframework + CFBundleName + MyFramework + CFBundleVersion + 1.0.0 + + + """ + try infoPlist.write(to: infoPlistURL, atomically: true, encoding: .utf8) + + //load the bundle + let (_, _, context) = try loadBundle(from: bundleURL) + + // Check for the warning about mixed root types + let mixedRootsWarning = context.problems.first { + $0.diagnostic.identifier == "org.swift.docc.MixedRootTypes" + } + XCTAssertNotNil(mixedRootsWarning, "Should emit warning about mixed root types") + XCTAssertEqual( + mixedRootsWarning?.diagnostic.summary, + "Documentation has both symbol graph modules and manual technology roots") + XCTAssertTrue( + mixedRootsWarning?.diagnostic.explanation?.contains("symbol graph modules") ?? false) + XCTAssertTrue( + mixedRootsWarning?.diagnostic.explanation?.contains("manual technology roots") ?? false) + } + + func testMultipleTechnologyRootsWarning() throws { + //create a test bundle with multiple manual technology roots + let tempURL = try createTemporaryDirectory() + let bundleURL = tempURL.appendingPathComponent("multiple-tech-roots.docc") + try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true) + + //create first manual technology root + let gettingStartedURL = bundleURL.appendingPathComponent("GettingStarted.md") + let gettingStartedContent = """ + # Getting Started + @Metadata { + @TechnologyRoot + } + Learn how to get started. + """ + try gettingStartedContent.write(to: gettingStartedURL, atomically: true, encoding: .utf8) + + //create second manual technology root + let apiReferenceURL = bundleURL.appendingPathComponent("APIReference.md") + let apiReferenceContent = """ + # API Reference + @Metadata { + @TechnologyRoot + } + Reference documentation for the API. + """ + try apiReferenceContent.write(to: apiReferenceURL, atomically: true, encoding: .utf8) + + //create Info.plist + let infoPlistURL = bundleURL.appendingPathComponent("Info.plist") + let infoPlist = """ + + + + + CFBundleIdentifier + com.test.myframework + CFBundleName + MyFramework + CFBundleVersion + 1.0.0 + + + """ + try infoPlist.write(to: infoPlistURL, atomically: true, encoding: .utf8) + + //load the bundle + let (_, _, context) = try loadBundle(from: bundleURL) + + // Check for the warning about multiple technology roots + let multipleTechRootsWarning = context.problems.first { + $0.diagnostic.identifier == "org.swift.docc.MultipleTechnologyRoots" + } + XCTAssertNotNil( + multipleTechRootsWarning, "Should emit warning about multiple technology roots") + XCTAssertEqual( + multipleTechRootsWarning?.diagnostic.summary, + "Documentation has multiple manual technology roots") + XCTAssertTrue( + multipleTechRootsWarning?.diagnostic.explanation?.contains("GettingStarted") ?? false) + XCTAssertTrue( + multipleTechRootsWarning?.diagnostic.explanation?.contains("APIReference") ?? false) + } }