|  | 
|  | 1 | +/* | 
|  | 2 | + This source file is part of the Swift.org open source project | 
|  | 3 | + | 
|  | 4 | + Copyright (c) 2024 Apple Inc. and the Swift project authors | 
|  | 5 | + Licensed under Apache License v2.0 with Runtime Library Exception | 
|  | 6 | + | 
|  | 7 | + See https://swift.org/LICENSE.txt for license information | 
|  | 8 | + See https://swift.org/CONTRIBUTORS.txt for Swift project authors | 
|  | 9 | +*/ | 
|  | 10 | + | 
|  | 11 | +import Foundation | 
|  | 12 | +import SymbolKit | 
|  | 13 | + | 
|  | 14 | +/// A type that writes the auto-generated curation into documentation extension files. | 
|  | 15 | +public struct GeneratedCurationWriter { | 
|  | 16 | +    let context: DocumentationContext | 
|  | 17 | +    let catalogURL: URL? | 
|  | 18 | +    let outputURL: URL | 
|  | 19 | +    let linkResolver: PathHierarchyBasedLinkResolver | 
|  | 20 | +     | 
|  | 21 | +    public init( | 
|  | 22 | +        context: DocumentationContext, | 
|  | 23 | +        catalogURL: URL?, | 
|  | 24 | +        outputURL: URL | 
|  | 25 | +    ) { | 
|  | 26 | +        self.context = context | 
|  | 27 | +         | 
|  | 28 | +        self.catalogURL = catalogURL | 
|  | 29 | +        self.outputURL = outputURL | 
|  | 30 | +         | 
|  | 31 | +        self.linkResolver = context.linkResolver.localResolver | 
|  | 32 | +    } | 
|  | 33 | +     | 
|  | 34 | +    /// Generates the markdown representation of the auto-generated curation for a given symbol reference. | 
|  | 35 | +    /// | 
|  | 36 | +    /// - Parameters: | 
|  | 37 | +    ///   - reference: The symbol reference to generate curation text for. | 
|  | 38 | +    /// - Returns: The auto-generated curation text, or `nil` if this reference has no auto-generated curation. | 
|  | 39 | +    func defaultCurationText(for reference: ResolvedTopicReference) -> String? { | 
|  | 40 | +        guard let node = context.documentationCache[reference], | 
|  | 41 | +              let symbol = node.semantic as? Symbol, | 
|  | 42 | +              let automaticTopics = try? AutomaticCuration.topics(for: node, withTraits: [], context: context), | 
|  | 43 | +              !automaticTopics.isEmpty | 
|  | 44 | +        else { | 
|  | 45 | +            return nil | 
|  | 46 | +        } | 
|  | 47 | +         | 
|  | 48 | +        let relativeLinks = linkResolver.disambiguatedRelativeLinksForDescendants(of: reference) | 
|  | 49 | +         | 
|  | 50 | +        // Top-level curation has a few special behaviors regarding symbols with different representations in multiple languages. | 
|  | 51 | +        let isForTopLevelCuration = symbol.kind.identifier == .module | 
|  | 52 | +         | 
|  | 53 | +        var text = "" | 
|  | 54 | +        for taskGroup in automaticTopics { | 
|  | 55 | +            if isForTopLevelCuration, let firstReference = taskGroup.references.first, context.documentationCache[firstReference]?.symbol?.kind.identifier == .typeProperty { | 
|  | 56 | +                // Skip type properties in top-level curation. It's not clear what's the right place for these symbols are since they exist in | 
|  | 57 | +                // different places in different source languages (which documentation extensions don't yet have a way of representing). | 
|  | 58 | +                continue | 
|  | 59 | +            } | 
|  | 60 | +             | 
|  | 61 | +            let links: [(link: String, comment: String?)] = taskGroup.references.compactMap { (curatedReference: ResolvedTopicReference) -> (String, String?)? in | 
|  | 62 | +                guard let linkInfo = relativeLinks[curatedReference] else { return nil } | 
|  | 63 | +                // If this link contains disambiguation, include a comment with the full symbol declaration to make it easier to know which symbol the link refers to. | 
|  | 64 | +                var commentText: String? | 
|  | 65 | +                if linkInfo.hasDisambiguation { | 
|  | 66 | +                    commentText = context.documentationCache[curatedReference]?.symbol?.declarationFragments?.map(\.spelling) | 
|  | 67 | +                        // Replace sequences of whitespace and newlines with a single space | 
|  | 68 | +                        .joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ") | 
|  | 69 | +                } | 
|  | 70 | +                 | 
|  | 71 | +                return ("\n- ``\(linkInfo.link)``", commentText.map { " <!-- \($0) -->" }) | 
|  | 72 | +            } | 
|  | 73 | +             | 
|  | 74 | +            guard !links.isEmpty else { continue } | 
|  | 75 | +             | 
|  | 76 | +            text.append("\n\n### \(taskGroup.title ?? "<!-- This auto-generated topic has no title -->")\n") | 
|  | 77 | +             | 
|  | 78 | +            // Calculate the longest link to nicely align all the comments | 
|  | 79 | +            let longestLink = links.map(\.link.count).max()! // `links` are non-empty so it's safe to force-unwrap `.max()` here | 
|  | 80 | +            for (link, comment) in links { | 
|  | 81 | +                if let comment = comment { | 
|  | 82 | +                    text.append(link.padding(toLength: longestLink, withPad: " ", startingAt: 0)) | 
|  | 83 | +                    text.append(comment) | 
|  | 84 | +                } else { | 
|  | 85 | +                    text.append(link) | 
|  | 86 | +                } | 
|  | 87 | +            } | 
|  | 88 | +        } | 
|  | 89 | +         | 
|  | 90 | +        guard !text.isEmpty else { return nil } | 
|  | 91 | +         | 
|  | 92 | +        var prefix = "<!-- The content below this line is auto-generated and is redundant. You should either incorporate it into your content above this line or delete it. -->" | 
|  | 93 | +         | 
|  | 94 | +        // Add "## Topics" to the curation text unless the symbol already had some manual curation. | 
|  | 95 | +        let hasAnyManualCuration = symbol.topics?.taskGroups.isEmpty == false | 
|  | 96 | +        if !hasAnyManualCuration { | 
|  | 97 | +            prefix.append("\n\n## Topics") | 
|  | 98 | +        } | 
|  | 99 | +        return "\(prefix)\(text)\n" | 
|  | 100 | +    } | 
|  | 101 | +     | 
|  | 102 | +    enum Error: DescribedError { | 
|  | 103 | +        case symbolLinkNotFound(TopicReferenceResolutionErrorInfo) | 
|  | 104 | +         | 
|  | 105 | +        var errorDescription: String { | 
|  | 106 | +            switch self { | 
|  | 107 | +            case .symbolLinkNotFound(let errorInfo): | 
|  | 108 | +                var errorMessage = "'--from-symbol <symbol-link>' not found: \(errorInfo.message)" | 
|  | 109 | +                for solution in errorInfo.solutions { | 
|  | 110 | +                    errorMessage.append("\n\(solution.summary.replacingOccurrences(of: "\n", with: ""))") | 
|  | 111 | +                } | 
|  | 112 | +                return errorMessage | 
|  | 113 | +            } | 
|  | 114 | +        } | 
|  | 115 | +    } | 
|  | 116 | +     | 
|  | 117 | +    /// Generates documentation extension content with a markdown representation of DocC's auto-generated curation. | 
|  | 118 | +    /// - Parameters: | 
|  | 119 | +    ///   - symbolLink: A link to the symbol whose sub hierarchy the curation writer will descend. | 
|  | 120 | +    ///   - depthLimit: The depth limit of how far the curation writer will descend from its starting point symbol. | 
|  | 121 | +    /// - Returns: A collection of file URLs and their markdown content. | 
|  | 122 | +    public func generateDefaultCurationContents(fromSymbol symbolLink: String? = nil, depthLimit: Int? = nil) throws -> [URL: String] { | 
|  | 123 | +        // Used in documentation extension page titles to reference symbols that don't already have a documentation extension file. | 
|  | 124 | +        let allAbsoluteLinks = linkResolver.pathHierarchy.disambiguatedAbsoluteLinks() | 
|  | 125 | +         | 
|  | 126 | +        guard var curationCrawlRoot = linkResolver.modules().first else { | 
|  | 127 | +            return [:] | 
|  | 128 | +        } | 
|  | 129 | +         | 
|  | 130 | +        if let symbolLink = symbolLink { | 
|  | 131 | +            switch context.linkResolver.resolve(UnresolvedTopicReference(topicURL: .init(symbolPath: symbolLink)), in: curationCrawlRoot, fromSymbolLink: true, context: context) { | 
|  | 132 | +            case .success(let foundSymbol): | 
|  | 133 | +                curationCrawlRoot = foundSymbol | 
|  | 134 | +            case .failure(_, let errorInfo): | 
|  | 135 | +                throw Error.symbolLinkNotFound(errorInfo) | 
|  | 136 | +            } | 
|  | 137 | +        } | 
|  | 138 | +         | 
|  | 139 | +        var contentsToWrite = [URL: String]() | 
|  | 140 | +        for (usr, reference) in context.symbolIndex { | 
|  | 141 | +            // Filter out symbols that aren't in the specified sub hierarchy. | 
|  | 142 | +            if symbolLink != nil || depthLimit != nil { | 
|  | 143 | +                guard reference == curationCrawlRoot || context.pathsTo(reference).contains(where: { path in path.suffix(depthLimit ?? .max).contains(curationCrawlRoot)}) else { | 
|  | 144 | +                    continue | 
|  | 145 | +                } | 
|  | 146 | +            } | 
|  | 147 | +             | 
|  | 148 | +            guard let absoluteLink = allAbsoluteLinks[usr], let curationText = defaultCurationText(for: reference) else { continue } | 
|  | 149 | +            if let catalogURL = catalogURL, let existingURL = context.documentationExtensionURL(for: reference) { | 
|  | 150 | +                let updatedFileURL: URL | 
|  | 151 | +                if catalogURL == outputURL { | 
|  | 152 | +                    updatedFileURL = existingURL | 
|  | 153 | +                } else { | 
|  | 154 | +                    var url = outputURL | 
|  | 155 | +                    let relativeComponents = existingURL.standardizedFileURL.pathComponents.dropFirst(catalogURL.standardizedFileURL.pathComponents.count) | 
|  | 156 | +                    for component in relativeComponents.dropLast() { | 
|  | 157 | +                        url.appendPathComponent(component, isDirectory: true) | 
|  | 158 | +                    } | 
|  | 159 | +                    url.appendPathComponent(relativeComponents.last!, isDirectory: false) | 
|  | 160 | +                    updatedFileURL = url | 
|  | 161 | +                } | 
|  | 162 | +                // Append to the end of the file. See if we can avoid reading the existing contents on disk. | 
|  | 163 | +                var contents = try String(contentsOf: existingURL) | 
|  | 164 | +                contents.append("\n") | 
|  | 165 | +                contents.append(curationText) | 
|  | 166 | +                contentsToWrite[updatedFileURL] = contents | 
|  | 167 | +            } else { | 
|  | 168 | +                let relativeReferencePath = reference.url.pathComponents.dropFirst(2).joined(separator: "/") | 
|  | 169 | +                let fileName = urlReadablePath("/" + relativeReferencePath) | 
|  | 170 | +                let newFileURL = NodeURLGenerator.fileSafeURL(outputURL.appendingPathComponent("\(fileName).md")) | 
|  | 171 | +                 | 
|  | 172 | +                let contents = """ | 
|  | 173 | +                # ``\(absoluteLink)`` | 
|  | 174 | +                 | 
|  | 175 | +                \(curationText) | 
|  | 176 | +                """ | 
|  | 177 | +                contentsToWrite[newFileURL] = contents | 
|  | 178 | +            } | 
|  | 179 | +        } | 
|  | 180 | +         | 
|  | 181 | +        return contentsToWrite | 
|  | 182 | +    } | 
|  | 183 | +} | 
0 commit comments