File: DocumentationCurator.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 (274 lines) | stat: -rw-r--r-- 14,637 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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/*
 This source file is part of the Swift.org open source project

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

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

import Foundation
import Markdown
import SymbolKit

/// Crawls a context and curates nodes if necessary.
struct DocumentationCurator {
    /// The documentation context to crawl.
    private let context: DocumentationContext
    
    /// The current bundle.
    private let bundle: DocumentationBundle
    
    private(set) var problems = [Problem]()
    
    init(in context: DocumentationContext, bundle: DocumentationBundle, initial: Set<ResolvedTopicReference> = []) {
        self.context = context
        self.bundle = bundle
        self.curatedNodes = initial
    }
    
    /// The list of topics that have been curated during the crawl.
    private(set) var curatedNodes: Set<ResolvedTopicReference>
    
    /// Tries to resolve a symbol link in the current module/context.
    func referenceFromSymbolLink(link: SymbolLink, resolved: ResolvedTopicReference) -> ResolvedTopicReference? {
        guard let destination = link.destination else {
            return nil
        }
        
        // Optimization for absolute links.
        if let cached = context.referenceIndex[destination] {
            return cached
        }
        
        // The symbol link may be written with a scheme and bundle identifier.
        let url = ValidatedURL(parsingExact: destination)?.requiring(scheme: ResolvedTopicReference.urlScheme) ?? ValidatedURL(symbolPath: destination)
        if case let .success(resolved) = context.resolve(.unresolved(.init(topicURL: url)), in: resolved, fromSymbolLink: true) {
            return resolved
        }
        return nil
    }
    
    /// Tries to resolve a link in the current module/context.
    mutating func referenceFromLink(link: Link, resolved: ResolvedTopicReference, source: URL?) -> ResolvedTopicReference? {
        // Try a link to a topic
        guard let unresolved = link.destination.flatMap(ValidatedURL.init(parsingAuthoredLink:))?
            .requiring(scheme: ResolvedTopicReference.urlScheme)
            .map(UnresolvedTopicReference.init(topicURL:)) else {
                // Emit a warning regarding the invalid link found in a task group.
                problems.append(Problem(diagnostic: Diagnostic(source: source, severity: .warning, range: link.range, identifier: "org.swift.docc.InvalidDocumentationLink", summary: "The link \((link.destination ?? "").singleQuoted) isn't valid", explanation: "Expected a well-formed URL that uses the \(ResolvedTopicReference.urlScheme.singleQuoted) scheme. You can only curate external links in a 'See Also' section."), possibleSolutions: []))
                return nil
        }
        let maybeResolved = context.resolve(.unresolved(unresolved), in: resolved)
        
        if case let .success(resolved) = maybeResolved {
            // The link resolves to a known topic.
            if let node = context.topicGraph.nodeWithReference(resolved) {
                // Make sure to remove any articles that have been registered in the topic graph
                // from the context's uncurated articles
                if node.kind == .article, context.uncuratedArticles.keys.contains(resolved) {
                    context.uncuratedArticles.removeValue(forKey: resolved)
                }
                return resolved
            }
            // The link resolves to a known content section, emit a warning.
            if resolved.fragment != nil, context.nodeAnchorSections.keys.contains(resolved) {
                problems.append(Problem(diagnostic: Diagnostic(source: source, severity: .warning, range: link.range, identifier: "org.swift.docc.SectionCuration", summary: "The content section link \((link.destination ?? "").singleQuoted) isn't allowed in a Topics link group", explanation: "Content sections cannot participate in the documentation hierarchy."), possibleSolutions: []))
                return resolved
            }
        }
        
        // Check if the link has been externally resolved already.
        if let bundleID = unresolved.topicURL.components.host,
           context.externalDocumentationSources[bundleID] != nil || context.convertServiceFallbackResolver != nil {
            if case .success(let resolvedExternalReference) = context.externallyResolvedLinks[unresolved.topicURL] {
                return resolvedExternalReference
            } else {
                return nil // This link has already failed to resolve.
            }
        }
        
        // Try extracting an article from the cache
        let articleFilename = unresolved.topicURL.components.path.components(separatedBy: "/").last!
        let sourceArticlePath = NodeURLGenerator.Path.article(bundleName: bundle.displayName, articleName: articleFilename).stringValue
        
        let reference = ResolvedTopicReference(
            bundleIdentifier: resolved.bundleIdentifier,
            path: sourceArticlePath,
            sourceLanguages: resolved.sourceLanguages)
        
        guard let currentArticle = self.context.uncuratedArticles[reference],
            let documentationNode = try? DocumentationNode(reference: reference, article: currentArticle.value) else { return nil }
        
        // An article has been found which needs to be extracted from the article cache
        // and curated under the current symbol. To do this we need to re-create the reference
        // to include the current module, update the file location map, and create a new
        // documentation node with the new reference and new semantic article.
        
        // Add reference in the file location map
        context.documentLocationMap[currentArticle.source] = reference
        
        // Add the curated node to the topic graph
        let node = currentArticle.topicGraphNode
        let curatedNode = TopicGraph.Node(reference: reference, kind: node.kind, source: node.source, title: node.title)
        context.topicGraph.addNode(curatedNode)
        
        // Move the article from the article cache to the documentation
        context.linkResolver.localResolver.addArticle(filename: articleFilename, reference: reference, anchorSections: documentationNode.anchorSections)
        
        context.documentationCache[reference] = documentationNode
        for anchor in documentationNode.anchorSections {
            context.nodeAnchorSections[anchor.reference] = anchor
        }
        context.uncuratedArticles.removeValue(forKey: reference)
        
        return reference
    }
    
    private func isReference(_ childReference: ResolvedTopicReference, anAncestorOf nodeReference: ResolvedTopicReference) -> Bool {
        context.topicGraph.reverseEdgesGraph
            .breadthFirstSearch(from: nodeReference)
            .contains(childReference)
    }
    
    /// Crawls the topic graph starting at a given root node, curates articles during.
    /// - Parameters:
    ///   - nodeReference: The root reference to start crawling.
    ///   - prepareForCuration: An optional closure to call just before walking the node's task group links.
    ///   - relateNodes: A closure to call when a parent <-> child relationship is found.
    mutating func crawlChildren(of nodeReference: ResolvedTopicReference, prepareForCuration: (ResolvedTopicReference) -> Void = {_ in}, relateNodes: (ResolvedTopicReference, ResolvedTopicReference) -> Void) throws {
        // Keeping track if all articles have been curated.
        curatedNodes.insert(nodeReference)

        guard let documentationNode = context.documentationCache[nodeReference] else {
            return
        }

        func source() -> URL? {
            return context.documentLocationMap[nodeReference]
        }
        
        let topics: TopicsSection?
        let seeAlso: SeeAlsoSection?
        let automaticallyGeneratedTaskGroups: [AutomaticTaskGroupSection]?
        
        switch documentationNode.semantic {
        case let article as Article:
            topics = article.topics
            seeAlso = article.seeAlso
            automaticallyGeneratedTaskGroups = article.automaticTaskGroups
        case let symbol as Symbol:
            topics = symbol.topics
            seeAlso = symbol.seeAlso
            automaticallyGeneratedTaskGroups = symbol.automaticTaskGroups
        default:
            topics = nil
            seeAlso = nil
            automaticallyGeneratedTaskGroups = nil
        }

        let taskGroups = topics?.taskGroups ?? []
        let authoredSeeAlsoGroups = seeAlso?.taskGroups ?? []
        let addedGroups = automaticallyGeneratedTaskGroups ?? []
        
        // Validate the node groups' links
        (taskGroups + authoredSeeAlsoGroups).forEach({ group in
            problems.append(contentsOf:
                group.problemsForGroupLinks().map({ problem -> Problem in
                    var diagnostic = problem.diagnostic
                    diagnostic.source = context.documentLocationMap[nodeReference]
                    return Problem(diagnostic: diagnostic, possibleSolutions: problem.possibleSolutions)
                })
            )
        })

        // Crawl the automated curation
        try addedGroups.forEach { section in
            try section.references.forEach { childReference in
                // Descend further into curated topics
                try crawlChildren(of: childReference, prepareForCuration: prepareForCuration, relateNodes: relateNodes)
            }
        }
        
        // If the node docs include a topics section, remove the default curation
        // from the symbol graph because we will do custom curation based on the
        // task group links.
        prepareForCuration(nodeReference)
        
        for (groupIndex, group) in taskGroups.enumerated() {
            for (linkIndex, link) in group.links.enumerated() {
                let resolved: ResolvedTopicReference?
                switch link {
                case let link as Link:
                    resolved = referenceFromLink(link: link, resolved: nodeReference, source: source())
                case let symbolLink as SymbolLink:
                    resolved = referenceFromSymbolLink(link: symbolLink, resolved: nodeReference)
                default:
                    // Programmer error, a new conformance was added to ``AnyLink``
                    fatalError("Unexpected link type")
                }
                
                /// Return the link's range or, when unavailable, fall back on any original ranges.
                func range() -> SourceRange? {
                    if let range = link.range {
                        return range
                    }
                    if let topics,
                        topics.originalLinkRangesByGroup.count > groupIndex,
                        topics.originalLinkRangesByGroup[groupIndex].count > linkIndex {
                        return topics.originalLinkRangesByGroup[groupIndex][linkIndex]
                    }
                    return nil
                }
                
                guard let childReference = resolved else {
                    // This problem will be raised when the references are resolved.
                    continue
                }

                guard let childDocumentationNode = context.documentationCache[childReference],
                    (childDocumentationNode.kind == .article || childDocumentationNode.kind.isSymbol || childDocumentationNode.kind == .tutorial || childDocumentationNode.kind == .tutorialArticle) else {
                        continue
                }

                // Allow curating a module node from a manual technology root.
                if childDocumentationNode.kind == .module {
                    func isTechnologyRoot(_ reference: ResolvedTopicReference) -> Bool {
                        guard let node = context.topicGraph.nodeWithReference(reference) else { return false }
                        return node.kind == .module && documentationNode.kind.isSymbol == false
                    }
        
                    let hasTechnologyRoot = isTechnologyRoot(nodeReference) || context.reachableRoots(from: nodeReference).contains(where: isTechnologyRoot)

                    if !hasTechnologyRoot {
                        problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.ModuleCuration", summary: "Linking to \((link.destination ?? "").singleQuoted) from a Topics group in \(nodeReference.absoluteString.singleQuoted) isn't allowed", explanation: "The former is a module, and modules only exist at the root"), possibleSolutions: []))
                        continue
                    }
                }
                
                // Verify we are not creating a graph cyclic relationship.
                guard childReference != nodeReference else {
                    problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference", summary: "A symbol can't link to itself from within its Topics group in \(nodeReference.absoluteString.singleQuoted)"), possibleSolutions: []))
                    continue
                }
                
                guard !isReference(childReference, anAncestorOf: nodeReference) else {
                    problems.append(Problem(diagnostic: Diagnostic(source: source(), severity: .warning, range: range(), identifier: "org.swift.docc.CyclicReference", summary: "Linking to \((link.destination ?? "").singleQuoted) from a Topics group in \(nodeReference.absoluteString.singleQuoted) isn't allowed", explanation: "The former is an ancestor of the latter"), possibleSolutions: []))
                    continue
                }
                
                // Link reference successfully resolved to a topic node
                relateNodes(nodeReference, childReference)
                
                guard !curatedNodes.contains(childReference) else {
                    // Don't crawl the same symbol more than once. 
                    continue
                }
                
                // Descend further into curated topics
                try crawlChildren(of: childReference, prepareForCuration: prepareForCuration, relateNodes: relateNodes)
            }
        }
    }
}