|
| 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