File: LinkResolver.swift

package info (click to toggle)
swiftlang 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 2,519,992 kB
  • sloc: cpp: 9,107,863; ansic: 2,040,022; asm: 1,135,751; python: 296,500; objc: 82,456; f90: 60,502; lisp: 34,951; pascal: 19,946; sh: 18,133; perl: 7,482; ml: 4,937; javascript: 4,117; makefile: 3,840; awk: 3,535; xml: 914; fortran: 619; cs: 573; ruby: 573
file content (255 lines) | stat: -rw-r--r-- 14,475 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
/*
 This source file is part of the Swift.org open source project

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

 See https://swift.org/LICENSE.txt for license information
 See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

import Foundation
import SymbolKit

/// A class that resolves documentation links by orchestrating calls to other link resolver implementations.
public class LinkResolver {
    /// A list of URLs to documentation archives that the local documentation depends on.
    @_spi(ExternalLinks) // This needs to be public SPI so that the ConvertAction can set it.
    public var dependencyArchives: [URL] = []
    
    var fileManager: FileManagerProtocol = FileManager.default
    /// The link resolver to use to resolve links in the local bundle
    var localResolver: PathHierarchyBasedLinkResolver!
    /// A fallback resolver to use when the local resolver fails to resolve a link.
    ///
    /// This exist to preserve some behaviors for the convert service.
    private let fallbackResolver = FallbackResolverBasedLinkResolver()
    /// A map of link resolvers for external, already build archives
    var externalResolvers: [String: ExternalPathHierarchyResolver] = [:]
    
    /// Create link resolvers for all documentation archive dependencies.
    func loadExternalResolvers() throws {
        let resolvers = try dependencyArchives.compactMap {
            try ExternalPathHierarchyResolver(dependencyArchive: $0, fileManager: fileManager)
        }
        for resolver in resolvers {
            for moduleNode in resolver.pathHierarchy.modules {
                self.externalResolvers[moduleNode.name] = resolver
            }
        }
    }
    
    /// The minimal information about an external entity necessary to render links to it on another page.
    @_spi(ExternalLinks) // This isn't stable API yet.
    public struct ExternalEntity {
        /// Creates a new external entity.
        /// - Parameters:
        ///   - topicRenderReference: The render reference for this external topic.
        ///   - renderReferenceDependencies: Any dependencies for the render reference.
        ///   - sourceLanguages: The different source languages for which this page is available.
        @_spi(ExternalLinks)
        public init(topicRenderReference: TopicRenderReference, renderReferenceDependencies: RenderReferenceDependencies, sourceLanguages: Set<SourceLanguage>) {
            self.topicRenderReference = topicRenderReference
            self.renderReferenceDependencies = renderReferenceDependencies
            self.sourceLanguages = sourceLanguages
        }
        
        /// The render reference for this external topic.
        var topicRenderReference: TopicRenderReference
        /// Any dependencies for the render reference.
        ///
        /// For example, if the external content contains links or images, those are included here.
        var renderReferenceDependencies: RenderReferenceDependencies
        /// The different source languages for which this page is available.
        var sourceLanguages: Set<SourceLanguage>
        
        /// Creates a pre-render new topic content value to be added to a render context's reference store.
        func topicContent() -> RenderReferenceStore.TopicContent {
            return .init(
                renderReference: topicRenderReference,
                canonicalPath: nil,
                taskGroups: nil,
                source: nil,
                isDocumentationExtensionContent: false,
                renderReferenceDependencies: renderReferenceDependencies
            )
        }
    }
    
    /// Attempts to resolve an unresolved reference.
    ///
    /// - Parameters:
    ///   - unresolvedReference: The unresolved reference to resolve.
    ///   - parent: The parent reference to resolve the unresolved reference relative to.
    ///   - isCurrentlyResolvingSymbolLink: Whether or not the documentation link is a symbol link.
    ///   - context: The documentation context to resolve the link in.
    /// - Returns: The result of resolving the reference.
    func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> TopicReferenceResolutionResult {
        // Check if this is an external link that has previously been resolved, either successfully or not.
        if let previousExternalResult = contextExternalLinksLock.sync({ context.externallyResolvedLinks[unresolvedReference.topicURL] }) {
            return previousExternalResult
        }
        
        // Check if this is a link to an external documentation source that should have previously been resolved in `DocumentationContext.preResolveExternalLinks(...)`
        if let bundleID = unresolvedReference.bundleIdentifier,
           !context.registeredBundles.contains(where: { $0.identifier == bundleID || urlReadablePath($0.displayName) == bundleID })
        {
            return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("No external resolver registered for \(bundleID.singleQuoted)."))
        }
        
        do {
            return try localResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink)
        } catch let error as PathHierarchy.Error {
            // Check if there's a known external resolver for this module.
            if case .moduleNotFound(_, let remainingPathComponents, _) = error, let resolver = externalResolvers[remainingPathComponents.first!.full] {
                let result = resolver.resolve(unresolvedReference, fromSymbolLink: isCurrentlyResolvingSymbolLink)
                contextExternalLinksLock.sync {
                    context.externallyResolvedLinks[unresolvedReference.topicURL] = result
                    if case .success(let resolved) = result {
                        context.externalCache[resolved] = resolver.entity(resolved)
                    }
                }
                return result
            }
            
            // If the reference didn't resolve in the path hierarchy, see if it can be resolved in the fallback resolver.
            if let resolvedFallbackReference = fallbackResolver.resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context) {
                return .success(resolvedFallbackReference)
            } else {
                return .failure(unresolvedReference, error.makeTopicReferenceResolutionErrorInfo() { localResolver.fullName(of: $0, in: context) })
            }
        } catch {
            fatalError("Only SymbolPathTree.Error errors are raised from the symbol link resolution code above.")
        }
    }
    
    /// The context resolves links concurrently for different pages.
    ///
    /// Since the link resolver may both read and write to the context to cache externally resolved references, it needs to synchronize those accesses to avoid data races.
    ///
    /// > Note:
    /// > We don't generally want to require a lock around these properties because the context will also render pages in parallel and during rendering the external content
    /// > is only read, so requiring synchronization would add unnecessary overhead during rendering.
    private var contextExternalLinksLock = Lock()
}

// MARK: Fallback resolver

extension DocumentationContext {
    /// Merge links to local pages resolved via the fallback resolver with the context.
    func mergeFallbackLinkResolutionResults() {
        linkResolver.mergeFallbackLinkResolutionResults(context: self)
    }
}

private extension LinkResolver {
    func mergeFallbackLinkResolutionResults(context: DocumentationContext) {
        let resolvedFallbackReferences = fallbackResolver.cachedResolvedFallbackResults.sync({ $0 })
        for (linkText, result) in resolvedFallbackReferences {
            // Even though the links resolved via the fallback resolver represent "local" pages they are considered "external" content
            // because their markup or symbol information wasn't passed as catalog or symbol graph input to DocC. 
            context.externallyResolvedLinks[linkText] = result
            if case .success(let reference) = result {
                context.externalCache[reference] = context.convertServiceFallbackResolver?.entityIfPreviouslyResolved(with: reference)
            }
        }
    }
}

/// A fallback resolver that replicates the exact order of resolved topic references that are attempted to resolve via a fallback resolver when the path hierarchy doesn't have a match.
private final class FallbackResolverBasedLinkResolver {
    var cachedResolvedFallbackResults = Synchronized<[ValidatedURL: TopicReferenceResolutionResult]>([:])
    
    func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> ResolvedTopicReference? {
        let result: TopicReferenceResolutionResult? = resolve(unresolvedReference, in: parent, fromSymbolLink: isCurrentlyResolvingSymbolLink, context: context)
        guard case .success(let resolved) = result else { return nil }
        return resolved
    }
    
    private func resolve(_ unresolvedReference: UnresolvedTopicReference, in parent: ResolvedTopicReference, fromSymbolLink isCurrentlyResolvingSymbolLink: Bool, context: DocumentationContext) -> TopicReferenceResolutionResult? {
        // Check if a fallback reference resolver should resolve this
        let referenceBundleIdentifier = unresolvedReference.bundleIdentifier ?? parent.bundleIdentifier
        guard let fallbackResolver = context.convertServiceFallbackResolver,
              let knownBundleIdentifier = context.registeredBundles.first(where: { $0.identifier == referenceBundleIdentifier || urlReadablePath($0.displayName) == referenceBundleIdentifier })?.identifier,
              fallbackResolver.bundleIdentifier == knownBundleIdentifier
        else {
            return nil
        }
        
        if let cached = cachedResolvedFallbackResults.sync({ $0[unresolvedReference.topicURL] }) {
            return cached
        }
        var allCandidateURLs = [URL]()
        
        let alreadyResolved = ResolvedTopicReference(
            bundleIdentifier: referenceBundleIdentifier,
            path: unresolvedReference.path.prependingLeadingSlash,
            fragment: unresolvedReference.topicURL.components.fragment,
            sourceLanguages: parent.sourceLanguages
        )
        allCandidateURLs.append(alreadyResolved.url)
        
        let currentBundle = context.bundle(identifier: knownBundleIdentifier)!
        if !isCurrentlyResolvingSymbolLink {
            // First look up articles path
            allCandidateURLs.append(contentsOf: [
                // First look up articles path
                currentBundle.articlesDocumentationRootReference.url.appendingPathComponent(unresolvedReference.path),
                // Then technology tutorials root path (for individual tutorial pages)
                currentBundle.technologyTutorialsRootReference.url.appendingPathComponent(unresolvedReference.path),
                // Then tutorials root path (for tutorial table of contents pages)
                currentBundle.tutorialsRootReference.url.appendingPathComponent(unresolvedReference.path),
            ])
        }
        // Try resolving in the local context (as child)
        allCandidateURLs.append(parent.appendingPathOfReference(unresolvedReference).url)
        
        // To look for siblings we require at least a module (first)
        // and a symbol (second) path components.
        let parentPath = parent.path.components(separatedBy: "/").dropLast()
        if parentPath.count >= 2 {
            allCandidateURLs.append(parent.url.deletingLastPathComponent().appendingPathComponent(unresolvedReference.path))
        }
        
        // Check that the parent is not an article (ignoring if absolute or relative link)
        // because we cannot resolve in the parent context if it's not a symbol.
        if parent.path.hasPrefix(currentBundle.documentationRootReference.path) && parentPath.count > 2 {
            let rootPath = currentBundle.documentationRootReference.appendingPath(parentPath[2])
            let resolvedInRoot = rootPath.url.appendingPathComponent(unresolvedReference.path)
            
            // Confirm here that we we're not already considering this link. We only need to specifically
            // consider the parent reference when looking for deeper links.
            if resolvedInRoot.path != allCandidateURLs.last?.path {
                allCandidateURLs.append(resolvedInRoot)
            }
        }
        
        allCandidateURLs.append(currentBundle.documentationRootReference.url.appendingPathComponent(unresolvedReference.path))
        
        for candidateURL in allCandidateURLs {
            guard let candidateReference = ValidatedURL(candidateURL).map({ UnresolvedTopicReference(topicURL: $0) }) else {
                continue
            }
            if let cached = cachedResolvedFallbackResults.sync({ $0[candidateReference.topicURL] }) {
                return cached
            }
            let fallbackResult = fallbackResolver.resolve(.unresolved(candidateReference))
            // Regardless of the outcome, cache the result of each candidate so that they're not resolved more than once.
            cachedResolvedFallbackResults.sync({ $0[candidateReference.topicURL] = fallbackResult })
            
            if case .success(let resolvedReference) = fallbackResult {
                // Cache the resolved reference's URL as well in case it's different from the unresolved reference.
                cachedResolvedFallbackResults.sync({
                    // Cache both the original unresolved reference and the resolved reference.
                    $0[unresolvedReference.topicURL] = fallbackResult
                    if let url = ValidatedURL(resolvedReference.url) {
                        $0[url] = fallbackResult
                    }
                })
                return fallbackResult
            }
        }
        // Give up: there is no local or external document for this reference.
        return nil
    }
}