diff --git a/Sources/SwiftDocC/Checker/Checkers/AbstractContainsFormattedTextOnly.swift b/Sources/SwiftDocC/Checker/Checkers/AbstractContainsFormattedTextOnly.swift index 05f30ea57..a2ed042d6 100644 --- a/Sources/SwiftDocC/Checker/Checkers/AbstractContainsFormattedTextOnly.swift +++ b/Sources/SwiftDocC/Checker/Checkers/AbstractContainsFormattedTextOnly.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 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 @@ -14,6 +14,7 @@ public import Markdown /** A document's abstract may only contain formatted text. Images and links are not allowed. */ +@available(*, deprecated, message: "This check is no longer applicable. This deprecated API will be removed after 6.3 is released") public struct AbstractContainsFormattedTextOnly: Checker { public var problems: [Problem] = [Problem]() private var sourceFile: URL? diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index cd7fca83d..654185a77 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -265,7 +265,6 @@ public class DocumentationContext { /// - source: The location of the document. private func check(_ document: Document, at source: URL) { var checker = CompositeChecker([ - AbstractContainsFormattedTextOnly(sourceFile: source).any(), DuplicateTopicsSections(sourceFile: source).any(), InvalidAdditionalTitle(sourceFile: source).any(), MissingAbstract(sourceFile: source).any(), @@ -2739,7 +2738,7 @@ public class DocumentationContext { knownEntityValue( reference: reference, valueInLocalEntity: \.availableSourceLanguages, - valueInExternalEntity: \.sourceLanguages + valueInExternalEntity: \.availableLanguages ) } @@ -2747,9 +2746,9 @@ public class DocumentationContext { func isSymbol(reference: ResolvedTopicReference) -> Bool { knownEntityValue( reference: reference, - valueInLocalEntity: { node in node.kind.isSymbol }, - valueInExternalEntity: { entity in entity.topicRenderReference.kind == .symbol } - ) + valueInLocalEntity: \.kind, + valueInExternalEntity: \.kind + ).isSymbol } // MARK: - Relationship queries diff --git a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+Communication.swift b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+Communication.swift new file mode 100644 index 000000000..b60dce179 --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+Communication.swift @@ -0,0 +1,198 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 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 +*/ + +extension OutOfProcessReferenceResolver { + // MARK: Capabilities + + /// A set of optional capabilities that either DocC or your external link resolver declares that it supports. + /// + /// ## Supported messages + /// + /// If your external link resolver declares none of the optional capabilities, then DocC will only send it the following messages: + /// - ``RequestV2/link(_:)`` + /// - ``RequestV2/symbol(_:)`` + public struct Capabilities: OptionSet, Codable { + public let rawValue: Int + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + rawValue = try container.decode(Int.self) + } + } + + // MARK: Request & Response + + /// Request messages that DocC sends to the external link resolver. + /// + /// ## Topics + /// ### Base requests + /// + /// Your external link resolver always needs to handle the following requests regardless of its declared capabilities: + /// + /// - ``link(_:)`` + /// - ``symbol(_:)`` + public enum RequestV2: Codable { + /// A request to resolve a link + /// + /// Your external resolver + case link(String) + /// A request to resolve a symbol based on its precise identifier. + case symbol(String) + + // This empty-marker case is here because non-frozen enums are only available when Library Evolution is enabled, + // which is not available to Swift Packages without unsafe flags (rdar://78773361). + // This can be removed once that is available and applied to Swift-DocC (rdar://89033233). + @available(*, deprecated, message: """ + This enum is non-frozen and may be expanded in the future; add a `default` case, and do nothing in it, instead of matching this one. + Your external link resolver won't be passed new messages that it hasn't declared the corresponding capability for. + """) + case _nonFrozenEnum_useDefaultCase + + private enum CodingKeys: CodingKey { + case link, symbol // Default requests keys + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .link(let link): try container.encode(link, forKey: .link) + case .symbol(let id): try container.encode(id, forKey: .symbol) + + case ._nonFrozenEnum_useDefaultCase: + fatalError("Never use '_nonFrozenEnum_useDefaultCase' as a real case.") + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self = switch container.allKeys.first { + case .link?: .link( try container.decode(String.self, forKey: .link)) + case .symbol?: .symbol(try container.decode(String.self, forKey: .symbol)) + case nil: throw OutOfProcessReferenceResolver.Error.unknownTypeOfRequest + } + } + } + + /// A response message from the external link resolver. + /// + /// If your external resolver sends a response that's associated with a capability that DocC hasn't declared support for, then DocC will fail to handle the response. + public enum ResponseV2: Codable { + /// The initial identifier and capabilities message. + /// + /// Your external link resolver should send this message, exactly once, after it has launched to signal that its ready to receive requests. + /// + /// The capabilities that your external link resolver declares in this message determines which optional request messages that DocC will send. + /// If your resolver doesn't declare _any_ capabilities it only needs to handle the 3 default requests. See . + case identifierAndCapabilities(DocumentationBundle.Identifier, Capabilities) + /// The error message of the problem that the external link resolver encountered while resolving the requested topic or symbol. + case failure(DiagnosticInformation) + /// A response with the resolved information about the requested topic or symbol. + case resolved(LinkDestinationSummary) + + // This empty-marker case is here because non-frozen enums are only available when Library Evolution is enabled, + // which is not available to Swift Packages without unsafe flags (rdar://78773361). + // This can be removed once that is available and applied to Swift-DocC (rdar://89033233). + @available(*, deprecated, message: """ + This enum is non-frozen and may be expanded in the future; add a `default` case, and do nothing in it, instead of matching this one. + Your external link resolver won't be passed new messages that it hasn't declared the corresponding capability for. + """) + case _nonFrozenEnum_useDefaultCase + + private enum CodingKeys: String, CodingKey { + // Default response keys + case identifier, capabilities + case failure + case resolved + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self = switch container.allKeys.first { + case .identifier?, .capabilities?: + .identifierAndCapabilities( + try container.decode(DocumentationBundle.Identifier.self, forKey: .identifier), + try container.decode(Capabilities.self, forKey: .capabilities) + ) + case .failure?: + .failure(try container.decode(DiagnosticInformation.self, forKey: .failure)) + case .resolved?: + .resolved(try container.decode(LinkDestinationSummary.self, forKey: .resolved)) + case nil: + throw OutOfProcessReferenceResolver.Error.invalidResponseKindFromClient + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .identifierAndCapabilities(let identifier, let capabilities): + try container.encode(identifier, forKey: .identifier) + try container.encode(capabilities, forKey: .capabilities) + + case .failure(errorMessage: let diagnosticInfo): + try container.encode(diagnosticInfo, forKey: .failure) + + case .resolved(let summary): + try container.encode(summary, forKey: .resolved) + + case ._nonFrozenEnum_useDefaultCase: + fatalError("Never use '_nonFrozenEnum_useDefaultCase' for anything.") + } + } + } +} + +extension OutOfProcessReferenceResolver.ResponseV2 { + /// Information about why the external resolver failed to resolve the `link(_:)`, or `symbol(_:)` request. + public struct DiagnosticInformation: Codable { + /// A brief user-facing summary of the issue that caused the external resolver to fail. + public var summary: String + + /// A list of possible suggested solutions that can address the failure. + public var solutions: [Solution]? + + /// Creates a new value with information about why the external resolver failed to resolve the `link(_:)`, or `symbol(_:)` request. + /// - Parameters: + /// - summary: A brief user-facing summary of the issue that caused the external resolver to fail. + /// - solutions: Possible possible suggested solutions that can address the failure. + public init( + summary: String, + solutions: [Solution]? + ) { + self.summary = summary + self.solutions = solutions + } + + /// A possible solution to an external resolver issue. + public struct Solution: Codable { + /// A brief user-facing description of what the solution is. + public var summary: String + /// A full replacement of the link. + public var replacement: String? + + /// Creates a new solution to an external resolver issue + /// - Parameters: + /// - summary: A brief user-facing description of what the solution is. + /// - replacement: A full replacement of the link. + public init(summary: String, replacement: String?) { + self.summary = summary + self.replacement = replacement + } + } + } +} diff --git a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+DeprecatedCommunication.swift b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+DeprecatedCommunication.swift new file mode 100644 index 000000000..e95a3a01f --- /dev/null +++ b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver+DeprecatedCommunication.swift @@ -0,0 +1,346 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 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 +*/ + +public import Foundation +public import SymbolKit + +extension OutOfProcessReferenceResolver { + + // MARK: Request & Response + + /// An outdated version of a request message to send to the external link resolver. + /// + /// This can either be a request to resolve a topic URL or to resolve a symbol based on its precise identifier. + /// + /// @DeprecationSummary { + /// This version of the communication protocol is no longer recommended. Update to ``RequestV2`` and ``ResponseV2`` instead. + /// + /// The new version of the communication protocol both has a mechanism for expanding functionality in the future (through common ``Capabilities`` between DocC and the external resolver) and supports richer responses for both successful and and failed requests. + /// } + @available(*, deprecated, message: "This version of the communication protocol is no longer recommended. Update to `RequestV2` and `ResponseV2` instead.") + public typealias Request = _DeprecatedRequestV1 + + // Note this type isn't formally deprecated to avoid warnings in the ConvertService, which still _implicitly_ require this version of requests and responses. + public enum _DeprecatedRequestV1: Codable, CustomStringConvertible { + /// A request to resolve a topic URL + case topic(URL) + /// A request to resolve a symbol based on its precise identifier. + case symbol(String) + /// A request to resolve an asset. + case asset(AssetReference) + + private enum CodingKeys: CodingKey { + case topic + case symbol + case asset + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .topic(let url): + try container.encode(url, forKey: .topic) + case .symbol(let identifier): + try container.encode(identifier, forKey: .symbol) + case .asset(let assetReference): + try container.encode(assetReference, forKey: .asset) + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch container.allKeys.first { + case .topic?: + self = .topic(try container.decode(URL.self, forKey: .topic)) + case .symbol?: + self = .symbol(try container.decode(String.self, forKey: .symbol)) + case .asset?: + self = .asset(try container.decode(AssetReference.self, forKey: .asset)) + case nil: + throw OutOfProcessReferenceResolver.Error.unknownTypeOfRequest + } + } + + /// A plain text representation of the request message. + public var description: String { + switch self { + case .topic(let url): + return "topic: \(url.absoluteString.singleQuoted)" + case .symbol(let identifier): + return "symbol: \(identifier.singleQuoted)" + case .asset(let asset): + return "asset with name: \(asset.assetName), bundle identifier: \(asset.bundleID)" + } + } + } + + /// An outdated version of a response message from the external link resolver. + /// + /// @DeprecationSummary { + /// This version of the communication protocol is no longer recommended. Update to ``RequestV2`` and ``ResponseV2`` instead. + /// + /// The new version of the communication protocol both has a mechanism for expanding functionality in the future (through common ``Capabilities`` between DocC and the external resolver) and supports richer responses for both successful and and failed requests. + /// } + @available(*, deprecated, message: "This version of the communication protocol is no longer recommended. Update to `RequestV2` and `ResponseV2` instead.") + public typealias Response = _DeprecatedResponseV1 + + @available(*, deprecated, message: "This version of the communication protocol is no longer recommended. Update to `RequestV2` and `ResponseV2` instead.") + public enum _DeprecatedResponseV1: Codable { + /// A bundle identifier response. + /// + /// This message should only be sent once, after the external link resolver has launched. + case bundleIdentifier(String) + /// The error message of the problem that the external link resolver encountered while resolving the requested topic or symbol. + case errorMessage(String) + /// A response with the resolved information about the requested topic or symbol. + case resolvedInformation(ResolvedInformation) + /// A response with information about the resolved asset. + case asset(DataAsset) + + enum CodingKeys: String, CodingKey { + case bundleIdentifier + case errorMessage + case resolvedInformation + case asset + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch container.allKeys.first { + case .bundleIdentifier?: + self = .bundleIdentifier(try container.decode(String.self, forKey: .bundleIdentifier)) + case .errorMessage?: + self = .errorMessage(try container.decode(String.self, forKey: .errorMessage)) + case .resolvedInformation?: + self = .resolvedInformation(try container.decode(ResolvedInformation.self, forKey: .resolvedInformation)) + case .asset?: + self = .asset(try container.decode(DataAsset.self, forKey: .asset)) + case nil: + throw OutOfProcessReferenceResolver.Error.invalidResponseKindFromClient + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .bundleIdentifier(let bundleIdentifier): + try container.encode(bundleIdentifier, forKey: .bundleIdentifier) + case .errorMessage(let errorMessage): + try container.encode(errorMessage, forKey: .errorMessage) + case .resolvedInformation(let resolvedInformation): + try container.encode(resolvedInformation, forKey: .resolvedInformation) + case .asset(let assetReference): + try container.encode(assetReference, forKey: .asset) + } + } + } + + // MARK: Resolved Information + + /// A type used to transfer information about a resolved reference in the outdated and no longer recommended version of the external resolver communication protocol. + @available(*, deprecated, message: "This type is only used in the outdated, and no longer recommended, version of the out-of-process external resolver communication protocol.") + public struct ResolvedInformation: Codable { + /// Information about the resolved kind. + public let kind: DocumentationNode.Kind + /// Information about the resolved URL. + public let url: URL + /// Information about the resolved title. + public let title: String // DocumentationNode.Name + /// Information about the resolved abstract. + public let abstract: String // Markup + /// Information about the resolved language. + public let language: SourceLanguage + /// Information about the languages where the resolved node is available. + public let availableLanguages: Set + /// Information about the platforms and their versions where the resolved node is available, if any. + public let platforms: [PlatformAvailability]? + /// Information about the resolved declaration fragments, if any. + public let declarationFragments: DeclarationFragments? + + // We use the real types here because they're Codable and don't have public member-wise initializers. + + /// Platform availability for a resolved symbol reference. + public typealias PlatformAvailability = AvailabilityRenderItem + + /// The declaration fragments for a resolved symbol reference. + public typealias DeclarationFragments = SymbolGraph.Symbol.DeclarationFragments + + /// The platform names, derived from the platform availability. + public var platformNames: Set? { + return platforms.map { platforms in Set(platforms.compactMap { $0.name }) } + } + + /// Images that are used to represent the summarized element. + public var topicImages: [TopicImage]? + + /// References used in the content of the summarized element. + public var references: [any RenderReference]? + + /// The variants of content (kind, url, title, abstract, language, declaration) for this resolver information. + public var variants: [Variant]? + + /// A value that indicates whether this symbol is under development and likely to change. + var isBeta: Bool { + guard let platforms, !platforms.isEmpty else { + return false + } + + return platforms.allSatisfy { $0.isBeta == true } + } + + /// Creates a new resolved information value with all its values. + /// + /// - Parameters: + /// - kind: The resolved kind. + /// - url: The resolved URL. + /// - title: The resolved title + /// - abstract: The resolved (plain text) abstract. + /// - language: The resolved language. + /// - availableLanguages: The languages where the resolved node is available. + /// - platforms: The platforms and their versions where the resolved node is available, if any. + /// - declarationFragments: The resolved declaration fragments, if any. + /// - topicImages: Images that are used to represent the summarized element. + /// - references: References used in the content of the summarized element. + /// - variants: The variants of content for this resolver information. + public init( + kind: DocumentationNode.Kind, + url: URL, + title: String, + abstract: String, + language: SourceLanguage, + availableLanguages: Set, + platforms: [PlatformAvailability]? = nil, + declarationFragments: DeclarationFragments? = nil, + topicImages: [TopicImage]? = nil, + references: [any RenderReference]? = nil, + variants: [Variant]? = nil + ) { + self.kind = kind + self.url = url + self.title = title + self.abstract = abstract + self.language = language + self.availableLanguages = availableLanguages + self.platforms = platforms + self.declarationFragments = declarationFragments + self.topicImages = topicImages + self.references = references + self.variants = variants + } + + /// A variant of content for the resolved information. + /// + /// - Note: All properties except for ``traits`` are optional. If a property is `nil` it means that the value is the same as the resolved information's value. + public struct Variant: Codable { + /// The traits of the variant. + public let traits: [RenderNode.Variant.Trait] + + /// A wrapper for variant values that can either be specified, meaning the variant has a custom value, or not, meaning the variant has the same value as the resolved information. + /// + /// This alias is used to make the property declarations more explicit while at the same time offering the convenient syntax of optionals. + public typealias VariantValue = Optional + + /// The kind of the variant or `nil` if the kind is the same as the resolved information. + public let kind: VariantValue + /// The url of the variant or `nil` if the url is the same as the resolved information. + public let url: VariantValue + /// The title of the variant or `nil` if the title is the same as the resolved information. + public let title: VariantValue + /// The abstract of the variant or `nil` if the abstract is the same as the resolved information. + public let abstract: VariantValue + /// The language of the variant or `nil` if the language is the same as the resolved information. + public let language: VariantValue + /// The declaration fragments of the variant or `nil` if the declaration is the same as the resolved information. + /// + /// If the resolver information has a declaration but the variant doesn't, this property will be `Optional.some(nil)`. + public let declarationFragments: VariantValue + + /// Creates a new resolved information variant with the values that are different from the resolved information values. + /// + /// - Parameters: + /// - traits: The traits of the variant. + /// - kind: The resolved kind. + /// - url: The resolved URL. + /// - title: The resolved title + /// - abstract: The resolved (plain text) abstract. + /// - language: The resolved language. + /// - declarationFragments: The resolved declaration fragments, if any. + public init( + traits: [RenderNode.Variant.Trait], + kind: VariantValue = nil, + url: VariantValue = nil, + title: VariantValue = nil, + abstract: VariantValue = nil, + language: VariantValue = nil, + declarationFragments: VariantValue = nil + ) { + self.traits = traits + self.kind = kind + self.url = url + self.title = title + self.abstract = abstract + self.language = language + self.declarationFragments = declarationFragments + } + } + } +} + +@available(*, deprecated, message: "This type is only used in the outdates, and no longer recommended, version of the out-of-process external resolver communication protocol.") +extension OutOfProcessReferenceResolver.ResolvedInformation { + enum CodingKeys: CodingKey { + case kind + case url + case title + case abstract + case language + case availableLanguages + case platforms + case declarationFragments + case topicImages + case references + case variants + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + kind = try container.decode(DocumentationNode.Kind.self, forKey: .kind) + url = try container.decode(URL.self, forKey: .url) + title = try container.decode(String.self, forKey: .title) + abstract = try container.decode(String.self, forKey: .abstract) + language = try container.decode(SourceLanguage.self, forKey: .language) + availableLanguages = try container.decode(Set.self, forKey: .availableLanguages) + platforms = try container.decodeIfPresent([OutOfProcessReferenceResolver.ResolvedInformation.PlatformAvailability].self, forKey: .platforms) + declarationFragments = try container.decodeIfPresent(OutOfProcessReferenceResolver.ResolvedInformation.DeclarationFragments.self, forKey: .declarationFragments) + topicImages = try container.decodeIfPresent([TopicImage].self, forKey: .topicImages) + references = try container.decodeIfPresent([CodableRenderReference].self, forKey: .references).map { decodedReferences in + decodedReferences.map(\.reference) + } + variants = try container.decodeIfPresent([OutOfProcessReferenceResolver.ResolvedInformation.Variant].self, forKey: .variants) + + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(self.kind, forKey: .kind) + try container.encode(self.url, forKey: .url) + try container.encode(self.title, forKey: .title) + try container.encode(self.abstract, forKey: .abstract) + try container.encode(self.language, forKey: .language) + try container.encode(self.availableLanguages, forKey: .availableLanguages) + try container.encodeIfPresent(self.platforms, forKey: .platforms) + try container.encodeIfPresent(self.declarationFragments, forKey: .declarationFragments) + try container.encodeIfPresent(self.topicImages, forKey: .topicImages) + try container.encodeIfPresent(references?.map { CodableRenderReference($0) }, forKey: .references) + try container.encodeIfPresent(self.variants, forKey: .variants) + } +} diff --git a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift index 1988f8b07..d062ed082 100644 --- a/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/External Data/OutOfProcessReferenceResolver.swift @@ -9,54 +9,78 @@ */ public import Foundation -import Markdown -public import SymbolKit +private import Markdown /// A reference resolver that launches and interactively communicates with another process or service to resolve links. /// /// If your external reference resolver or an external symbol resolver is implemented in another executable, you can use this object /// to communicate between DocC and the `docc` executable. /// -/// The launched executable is expected to follow the flow outlined below, sending ``OutOfProcessReferenceResolver/Request`` -/// and ``OutOfProcessReferenceResolver/Response`` values back and forth: +/// ## Launching and responding to requests /// -/// │ -/// 1 ▼ -/// ┌──────────────────┐ -/// │ Output bundle ID │ -/// └──────────────────┘ -/// │ -/// 2 ▼ -/// ┌──────────────────┐ -/// │ Wait for input │◀───┐ -/// └──────────────────┘ │ -/// │ │ -/// 3 ▼ │ repeat -/// ┌──────────────────┐ │ -/// │ Output resolved │ │ -/// │ information │────┘ -/// └──────────────────┘ +/// When creating an out-of-process resolver using ``init(processLocation:errorOutputHandler:)`` to communicate with another executable; +/// DocC launches your link resolver executable and declares _its_ own ``Capabilities`` as a raw value passed via the `--capabilities` option. +/// Your link resolver executable is expected to respond with a ``ResponseV2/identifierAndCapabilities(_:_:)`` message that declares: +/// - The documentation bundle identifier that the executable can to resolve links for. +/// - The capabilities that the resolver supports. /// -/// When resolving against a server, the server is expected to be able to handle messages of type "resolve-reference" with a -/// ``OutOfProcessReferenceResolver/Request`` payload and respond with messages of type "resolved-reference-response" -/// with a ``OutOfProcessReferenceResolver/Response`` payload. +/// After this "handshake" your link resolver executable is expected to wait for ``RequestV2`` messages from DocC and respond with exactly one ``ResponseV2`` per message. +/// A visual representation of this flow of execution can be seen in the diagram below: +/// +/// DocC link resolver executable +/// ┌─┐ ╎ +/// │ ├─────────── Launch ──────────▶┴┐ +/// │ │ --capabilities │ │ +/// │ │ │ │ +/// │ ◀───────── Handshake ─────────┤ │ +/// │ │ { "identifier" : ... , │ │ +/// │ │ "capabilities" : ... } │ │ +/// ┏ loop ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +/// ┃ │ │ │ │ ┃ +/// ┃ │ ├────────── Request ──────────▶ │ ┃ +/// ┃ │ │ { "link" : ... } OR │ │ ┃ +/// ┃ │ │ { "symbol" : ... } │ │ ┃ +/// ┃ │ │ │ │ ┃ +/// ┃ │ ◀────────── Response ─────────┤ │ ┃ +/// ┃ │ │ { "resolved" : ... } OR │ │ ┃ +/// ┃ │ │ { "failure" : ... } │ │ ┃ +/// ┃ │ │ │ │ ┃ +/// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +/// │ │ └─┘ +/// │ │ ╎ +/// +/// ## Interacting with a Convert Service +/// +/// When creating an out-of-process resolver using ``init(bundleID:server:convertRequestIdentifier:)`` to communicate with another process using a ``ConvertService``; +/// DocC sends that service `"resolve-reference"` messages with a``OutOfProcessReferenceResolver/Request`` payload and expects a `"resolved-reference-response"` responses with a ``OutOfProcessReferenceResolver/Response`` payload. +/// +/// Because the ``ConvertService`` messages are _implicitly_ tied to these outdated—and no longer recommended—request and response types, the richness of its responses is limited. +/// +/// - Note: when interacting with a ``ConvertService`` your service also needs to handle "asset" requests (``OutOfProcessReferenceResolver/Request/asset(_:)`` and responses that (``OutOfProcessReferenceResolver/Response/asset(_:)``) that link resolver executables don't need to handle. +/// +/// ## Topics +/// +/// - ``RequestV2`` +/// - ``ResponseV2`` /// /// ## See Also -/// - ``ExternalDocumentationSource`` -/// - ``GlobalExternalSymbolResolver`` /// - ``DocumentationContext/externalDocumentationSources`` /// - ``DocumentationContext/globalExternalSymbolResolver`` -/// - ``Request`` -/// - ``Response`` public class OutOfProcessReferenceResolver: ExternalDocumentationSource, GlobalExternalSymbolResolver { - private let externalLinkResolvingClient: any ExternalLinkResolving + private var implementation: any _Implementation /// The bundle identifier for the reference resolver in the other process. - public let bundleID: DocumentationBundle.Identifier + public var bundleID: DocumentationBundle.Identifier { + implementation.bundleID + } + + // This variable is used below for the `ConvertServiceFallbackResolver` conformance. + private var assetCache: [AssetReference: DataAsset] = [:] /// Creates a new reference resolver that interacts with another executable. /// /// Initializing the resolver will also launch the other executable. The other executable will remain running for the lifetime of this object. + /// This and the rest of the communication between DocC and the link resolver executable is described in /// /// - Parameters: /// - processLocation: The location of the other executable. @@ -73,12 +97,12 @@ public class OutOfProcessReferenceResolver: ExternalDocumentationSource, GlobalE let longRunningProcess = try LongRunningProcess(location: processLocation, errorOutputHandler: errorOutputHandler) - guard case let .bundleIdentifier(decodedBundleIdentifier) = try longRunningProcess.sendAndWait(request: nil as Request?) as Response else { + guard let handshake: InitialHandshakeMessage = try? longRunningProcess.readInitialHandshakeMessage() else { throw Error.invalidBundleIdentifierOutputFromExecutable(processLocation) } - self.bundleID = .init(rawValue: decodedBundleIdentifier) - self.externalLinkResolvingClient = longRunningProcess + // This private type and protocol exist to silence deprecation warnings + self.implementation = (_ImplementationProvider() as (any _ImplementationProviding)).makeImplementation(for: handshake, longRunningProcess: longRunningProcess) } /// Creates a new reference resolver that interacts with a documentation service. @@ -90,179 +114,367 @@ public class OutOfProcessReferenceResolver: ExternalDocumentationSource, GlobalE /// - server: The server to send link resolution requests to. /// - convertRequestIdentifier: The identifier that the resolver will use for convert requests that it sends to the server. public init(bundleID: DocumentationBundle.Identifier, server: DocumentationServer, convertRequestIdentifier: String?) throws { - self.bundleID = bundleID - self.externalLinkResolvingClient = LongRunningService( - server: server, convertRequestIdentifier: convertRequestIdentifier) + self.implementation = (_ImplementationProvider() as any _ImplementationProviding).makeImplementation( + for: .init(identifier: bundleID, capabilities: nil /* always use the V1 implementation */), + longRunningProcess: LongRunningService(server: server, convertRequestIdentifier: convertRequestIdentifier) + ) } - // MARK: External Reference Resolver - - public func resolve(_ reference: TopicReference) -> TopicReferenceResolutionResult { - switch reference { - case .resolved(let resolved): - return resolved + fileprivate struct InitialHandshakeMessage: Decodable { + var identifier: DocumentationBundle.Identifier + var capabilities: Capabilities? // The old V1 handshake didn't include this but the V2 requires it. + + init(identifier: DocumentationBundle.Identifier, capabilities: OutOfProcessReferenceResolver.Capabilities?) { + self.identifier = identifier + self.capabilities = capabilities + } + + private enum CodingKeys: CodingKey { + case bundleIdentifier // Legacy V1 handshake + case identifier, capabilities // V2 handshake + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) - case let .unresolved(unresolvedReference): - guard unresolvedReference.bundleID == bundleID else { - fatalError(""" - Attempted to resolve a local reference externally: \(unresolvedReference.description.singleQuoted). - DocC should never pass a reference to an external resolver unless it matches that resolver's bundle identifier. - """) - } - do { - guard let unresolvedTopicURL = unresolvedReference.topicURL.components.url else { - // Return the unresolved reference if the underlying URL is not valid - return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid.")) - } - let resolvedInformation = try resolveInformationForTopicURL(unresolvedTopicURL) - return .success( resolvedReference(for: resolvedInformation) ) - } catch let error { - return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo(error)) + guard container.contains(.identifier) || container.contains(.bundleIdentifier) else { + throw DecodingError.keyNotFound(CodingKeys.identifier, .init(codingPath: decoder.codingPath, debugDescription: """ + Initial handshake message includes neither a '\(CodingKeys.identifier.stringValue)' key nor a '\(CodingKeys.bundleIdentifier.stringValue)' key. + """)) } + + self.identifier = try container.decodeIfPresent(DocumentationBundle.Identifier.self, forKey: .identifier) + ?? container.decode(DocumentationBundle.Identifier.self, forKey: .bundleIdentifier) + + self.capabilities = try container.decodeIfPresent(Capabilities.self, forKey: .capabilities) } } + // MARK: External Reference Resolver + + public func resolve(_ reference: TopicReference) -> TopicReferenceResolutionResult { + implementation.resolve(reference) + } + @_spi(ExternalLinks) // LinkResolver.ExternalEntity isn't stable API yet public func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { - guard let resolvedInformation = referenceCache[reference.url] else { - fatalError("A topic reference that has already been resolved should always exist in the cache.") - } - return makeEntity(with: resolvedInformation, reference: reference.absoluteString) + implementation.entity(with: reference) } @_spi(ExternalLinks) // LinkResolver.ExternalEntity isn't stable API yet public func symbolReferenceAndEntity(withPreciseIdentifier preciseIdentifier: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? { - guard let resolvedInformation = try? resolveInformationForSymbolIdentifier(preciseIdentifier) else { return nil } - - let reference = ResolvedTopicReference( - bundleID: "com.externally.resolved.symbol", - path: "/\(preciseIdentifier)", - sourceLanguages: sourceLanguages(for: resolvedInformation) - ) - let entity = makeEntity(with: resolvedInformation, reference: reference.absoluteString) - return (reference, entity) + implementation.symbolReferenceAndEntity(withPreciseIdentifier: preciseIdentifier) } +} + +// MARK: Implementations + +private protocol _Implementation: ExternalDocumentationSource, GlobalExternalSymbolResolver { + var bundleID: DocumentationBundle.Identifier { get } + var longRunningProcess: any ExternalLinkResolving { get } - private func makeEntity(with resolvedInformation: ResolvedInformation, reference: String) -> LinkResolver.ExternalEntity { - let (kind, role) = DocumentationContentRenderer.renderKindAndRole(resolvedInformation.kind, semantic: nil) - - var renderReference = TopicRenderReference( - identifier: .init(reference), - title: resolvedInformation.title, - // The resolved information only stores the plain text abstract https://github.com/swiftlang/swift-docc/issues/802 - abstract: [.text(resolvedInformation.abstract)], - url: resolvedInformation.url.path, - kind: kind, - role: role, - fragments: resolvedInformation.declarationFragments?.declarationFragments.map { DeclarationRenderSection.Token(fragment: $0, identifier: nil) }, - isBeta: resolvedInformation.isBeta, - isDeprecated: (resolvedInformation.platforms ?? []).contains(where: { $0.deprecated != nil }), - images: resolvedInformation.topicImages ?? [] - ) - for variant in resolvedInformation.variants ?? [] { - if let title = variant.title { - renderReference.titleVariants.variants.append( - .init(traits: variant.traits, patch: [.replace(value: title)]) - ) - } - if let abstract = variant.abstract { - renderReference.abstractVariants.variants.append( - .init(traits: variant.traits, patch: [.replace(value: [.text(abstract)])]) - ) + // + func resolve(unresolvedReference: UnresolvedTopicReference) throws -> TopicReferenceResolutionResult +} + +private extension _Implementation { + // Avoid some common boilerplate between implementations. + func resolve(_ reference: TopicReference) -> TopicReferenceResolutionResult { + switch reference { + case .resolved(let resolved): + return resolved + + case let .unresolved(unresolvedReference): + guard unresolvedReference.bundleID == bundleID else { + fatalError(""" + Attempted to resolve a local reference externally: \(unresolvedReference.description.singleQuoted). + DocC should never pass a reference to an external resolver unless it matches that resolver's bundle identifier. + """) + } + do { + // This is where each implementation differs + return try resolve(unresolvedReference: unresolvedReference) + } catch let error { + return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo(error)) + } + } + } +} + +// This private protocol allows the out-of-process resolver to create ImplementationV1 without deprecation warnings +private protocol _ImplementationProviding { + func makeImplementation(for handshake: OutOfProcessReferenceResolver.InitialHandshakeMessage, longRunningProcess: any ExternalLinkResolving) -> any _Implementation +} + +private extension OutOfProcessReferenceResolver { + // A concrete type with a deprecated implementation that can be cast to `_ImplementationProviding` to avoid deprecation warnings. + struct _ImplementationProvider: _ImplementationProviding { + @available(*, deprecated) // The V1 implementation is built around several now-deprecated types. This deprecation silences those depreciation warnings. + func makeImplementation(for handshake: OutOfProcessReferenceResolver.InitialHandshakeMessage, longRunningProcess: any ExternalLinkResolving) -> any _Implementation { + if let capabilities = handshake.capabilities { + return ImplementationV2(longRunningProcess: longRunningProcess, bundleID: handshake.identifier, executableCapabilities: capabilities) + } else { + return ImplementationV1(longRunningProcess: longRunningProcess, bundleID: handshake.identifier) } - if let declarationFragments = variant.declarationFragments { - renderReference.fragmentsVariants.variants.append( - .init(traits: variant.traits, patch: [.replace(value: declarationFragments?.declarationFragments.map { DeclarationRenderSection.Token(fragment: $0, identifier: nil) })]) - ) + } + } +} + +// MARK: Version 1 (deprecated) + +extension OutOfProcessReferenceResolver { + /// The original—no longer recommended—version of the out-of-process resolver implementation. + /// + /// This implementation uses ``Request`` and ``Response`` which aren't extensible and have restrictions on the details of the response payloads. + @available(*, deprecated) // The V1 implementation is built around several now-deprecated types. This deprecation silences those depreciation warnings. + private final class ImplementationV1: _Implementation { + let bundleID: DocumentationBundle.Identifier + let longRunningProcess: any ExternalLinkResolving + + init(longRunningProcess: any ExternalLinkResolving, bundleID: DocumentationBundle.Identifier) { + self.longRunningProcess = longRunningProcess + self.bundleID = bundleID + } + + // This is fileprivate so that the ConvertService conformance below can access it. + fileprivate private(set) var referenceCache: [URL: ResolvedInformation] = [:] + private var symbolCache: [String: ResolvedInformation] = [:] + + func resolve(unresolvedReference: UnresolvedTopicReference) throws -> TopicReferenceResolutionResult { + guard let unresolvedTopicURL = unresolvedReference.topicURL.components.url else { + // Return the unresolved reference if the underlying URL is not valid + return .failure(unresolvedReference, TopicReferenceResolutionErrorInfo("URL \(unresolvedReference.topicURL.absoluteString.singleQuoted) is not valid.")) } + let resolvedInformation = try resolveInformationForTopicURL(unresolvedTopicURL) + return .success( resolvedReference(for: resolvedInformation) ) } - let dependencies = RenderReferenceDependencies( - topicReferences: [], - linkReferences: (resolvedInformation.references ?? []).compactMap { $0 as? LinkReference }, - imageReferences: (resolvedInformation.references ?? []).compactMap { $0 as? ImageReference } - ) - return LinkResolver.ExternalEntity( - topicRenderReference: renderReference, - renderReferenceDependencies: dependencies, - sourceLanguages: resolvedInformation.availableLanguages, - symbolKind: DocumentationNode.symbolKind(for: resolvedInformation.kind) - ) - } - - // MARK: Implementation - - private var referenceCache: [URL: ResolvedInformation] = [:] - private var symbolCache: [String: ResolvedInformation] = [:] - private var assetCache: [AssetReference: DataAsset] = [:] - - /// Makes a call to the other process to resolve information about a page based on its URL. - func resolveInformationForTopicURL(_ topicURL: URL) throws -> ResolvedInformation { - if let cachedInformation = referenceCache[topicURL] { - return cachedInformation + func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { + guard let resolvedInformation = referenceCache[reference.url] else { + fatalError("A topic reference that has already been resolved should always exist in the cache.") + } + return makeEntity(with: resolvedInformation, reference: reference.absoluteString) } - let response: Response = try externalLinkResolvingClient.sendAndWait(request: Request.topic(topicURL)) + func symbolReferenceAndEntity(withPreciseIdentifier preciseIdentifier: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? { + guard let resolvedInformation = try? resolveInformationForSymbolIdentifier(preciseIdentifier) else { return nil } + + let reference = ResolvedTopicReference( + bundleID: "com.externally.resolved.symbol", + path: "/\(preciseIdentifier)", + sourceLanguages: sourceLanguages(for: resolvedInformation) + ) + let entity = makeEntity(with: resolvedInformation, reference: reference.absoluteString) + return (reference, entity) + } - switch response { - case .bundleIdentifier: - throw Error.executableSentBundleIdentifierAgain + /// Makes a call to the other process to resolve information about a page based on its URL. + private func resolveInformationForTopicURL(_ topicURL: URL) throws -> ResolvedInformation { + if let cachedInformation = referenceCache[topicURL] { + return cachedInformation + } + + let response: Response = try longRunningProcess.sendAndWait(request: Request.topic(topicURL)) - case .errorMessage(let errorMessage): - throw Error.forwardedErrorFromClient(errorMessage: errorMessage) + switch response { + case .bundleIdentifier: + throw Error.executableSentBundleIdentifierAgain + + case .errorMessage(let errorMessage): + throw Error.forwardedErrorFromClient(errorMessage: errorMessage) + + case .resolvedInformation(let resolvedInformation): + // Cache the information for the resolved reference, that's what's will be used when returning the entity later. + let resolvedReference = resolvedReference(for: resolvedInformation) + referenceCache[resolvedReference.url] = resolvedInformation + return resolvedInformation + + default: + throw Error.unexpectedResponse(response: response, requestDescription: "topic URL") + } + } + + /// Makes a call to the other process to resolve information about a symbol based on its precise identifier. + private func resolveInformationForSymbolIdentifier(_ preciseIdentifier: String) throws -> ResolvedInformation { + if let cachedInformation = symbolCache[preciseIdentifier] { + return cachedInformation + } - case .resolvedInformation(let resolvedInformation): - // Cache the information for the resolved reference, that's what's will be used when returning the entity later. - let resolvedReference = resolvedReference(for: resolvedInformation) - referenceCache[resolvedReference.url] = resolvedInformation - return resolvedInformation + let response: Response = try longRunningProcess.sendAndWait(request: Request.symbol(preciseIdentifier)) - default: - throw Error.unexpectedResponse(response: response, requestDescription: "topic URL") + switch response { + case .bundleIdentifier: + throw Error.executableSentBundleIdentifierAgain + + case .errorMessage(let errorMessage): + throw Error.forwardedErrorFromClient(errorMessage: errorMessage) + + case .resolvedInformation(let resolvedInformation): + symbolCache[preciseIdentifier] = resolvedInformation + return resolvedInformation + + default: + throw Error.unexpectedResponse(response: response, requestDescription: "symbol ID") + } + } + + private func resolvedReference(for resolvedInformation: ResolvedInformation) -> ResolvedTopicReference { + return ResolvedTopicReference( + bundleID: bundleID, + path: resolvedInformation.url.path, + fragment: resolvedInformation.url.fragment, + sourceLanguages: sourceLanguages(for: resolvedInformation) + ) + } + + private func sourceLanguages(for resolvedInformation: ResolvedInformation) -> Set { + // It is expected that the available languages contains the main language + return resolvedInformation.availableLanguages.union(CollectionOfOne(resolvedInformation.language)) + } + + private func makeEntity(with resolvedInformation: ResolvedInformation, reference: String) -> LinkResolver.ExternalEntity { + return LinkResolver.ExternalEntity( + kind: resolvedInformation.kind, + language: resolvedInformation.language, + relativePresentationURL: resolvedInformation.url.withoutHostAndPortAndScheme(), + referenceURL: URL(string: reference)!, + title: resolvedInformation.title, + // The resolved information only stores the plain text abstract and can't be changed. Use the version 2 communication protocol to support rich abstracts. + abstract: [.text(resolvedInformation.abstract)], + availableLanguages: resolvedInformation.availableLanguages, + platforms: resolvedInformation.platforms, + taskGroups: nil, + usr: nil, + declarationFragments: resolvedInformation.declarationFragments?.declarationFragments.map { .init(fragment: $0, identifier: nil) }, + redirects: nil, + topicImages: resolvedInformation.topicImages, + references: resolvedInformation.references, + variants: (resolvedInformation.variants ?? []).map { variant in + .init( + traits: variant.traits, + kind: variant.kind, + language: variant.language, + relativePresentationURL: variant.url?.withoutHostAndPortAndScheme(), + title: variant.title, + abstract: variant.abstract.map { [.text($0)] }, + taskGroups: nil, + usr: nil, + declarationFragments: variant.declarationFragments.map { fragments in + fragments?.declarationFragments.map { .init(fragment: $0, identifier: nil) } + } + ) + } + ) } } - - /// Makes a call to the other process to resolve information about a symbol based on its precise identifier. - private func resolveInformationForSymbolIdentifier(_ preciseIdentifier: String) throws -> ResolvedInformation { - if let cachedInformation = symbolCache[preciseIdentifier] { - return cachedInformation +} + +// MARK: Version 2 + +extension OutOfProcessReferenceResolver { + private final class ImplementationV2: _Implementation { + let longRunningProcess: any ExternalLinkResolving + let bundleID: DocumentationBundle.Identifier + let executableCapabilities: Capabilities + + init( + longRunningProcess: any ExternalLinkResolving, + bundleID: DocumentationBundle.Identifier, + executableCapabilities: Capabilities + ) { + self.longRunningProcess = longRunningProcess + self.bundleID = bundleID + self.executableCapabilities = executableCapabilities } - let response: Response = try externalLinkResolvingClient.sendAndWait(request: Request.symbol(preciseIdentifier)) + private var linkCache: [String /* either a USR or an absolute UnresolvedTopicReference */: LinkDestinationSummary] = [:] - switch response { - case .bundleIdentifier: - throw Error.executableSentBundleIdentifierAgain + func resolve(unresolvedReference: UnresolvedTopicReference) throws -> TopicReferenceResolutionResult { + let linkString = unresolvedReference.topicURL.absoluteString + if let cachedSummary = linkCache[linkString] { + return .success( makeReference(for: cachedSummary) ) + } - case .errorMessage(let errorMessage): - throw Error.forwardedErrorFromClient(errorMessage: errorMessage) + let response: ResponseV2 = try longRunningProcess.sendAndWait(request: RequestV2.link(linkString)) - case .resolvedInformation(let resolvedInformation): - symbolCache[preciseIdentifier] = resolvedInformation - return resolvedInformation + switch response { + case .identifierAndCapabilities: + throw Error.executableSentBundleIdentifierAgain + + case .failure(let diagnosticMessage): + let solutions: [Solution] = (diagnosticMessage.solutions ?? []).map { + Solution(summary: $0.summary, replacements: $0.replacement.map { replacement in + [Replacement( + // The replacement ranges are relative to the link itself. + // To replace the entire link, we create a range from 0 to the original length, both offset by -4 (the "doc:" length) + range: SourceLocation(line: 0, column: -4, source: nil) ..< SourceLocation(line: 0, column: linkString.utf8.count - 4, source: nil), + replacement: replacement + )] + } ?? []) + } + return .failure( + unresolvedReference, + TopicReferenceResolutionErrorInfo(diagnosticMessage.summary, solutions: solutions) + ) + + case .resolved(let linkSummary): + // Cache the information for the original authored link + linkCache[linkString] = linkSummary + // Cache the information for the resolved reference. That's what's will be used when returning the entity later. + let reference = makeReference(for: linkSummary) + linkCache[reference.absoluteString] = linkSummary + if let usr = linkSummary.usr { + // If the page is a symbol, cache its information for the USR as well. + linkCache[usr] = linkSummary + } + return .success(reference) + + default: + throw Error.unexpectedResponse(response: response, requestDescription: "topic link") + } + } + + func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { + guard let linkSummary = linkCache[reference.url.standardized.absoluteString] else { + fatalError("A topic reference that has already been resolved should always exist in the cache.") + } + return linkSummary + } + + func symbolReferenceAndEntity(withPreciseIdentifier preciseIdentifier: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? { + if let cachedSummary = linkCache[preciseIdentifier] { + return (makeReference(for: cachedSummary), cachedSummary) + } - default: - throw Error.unexpectedResponse(response: response, requestDescription: "symbol ID") + guard case ResponseV2.resolved(let linkSummary)? = try? longRunningProcess.sendAndWait(request: RequestV2.symbol(preciseIdentifier)) else { + return nil + } + + // Cache the information for the USR + linkCache[preciseIdentifier] = linkSummary + + // Cache the information for the resolved reference. + let reference = makeReference(for: linkSummary) + linkCache[reference.absoluteString] = linkSummary + + return (reference, linkSummary) + } + + private func makeReference(for linkSummary: LinkDestinationSummary) -> ResolvedTopicReference { + ResolvedTopicReference( + bundleID: linkSummary.referenceURL.host.map { .init(rawValue: $0) } ?? "unknown", + path: linkSummary.referenceURL.path, + fragment: linkSummary.referenceURL.fragment, + sourceLanguages: linkSummary.availableLanguages + ) } - } - - private func resolvedReference(for resolvedInformation: ResolvedInformation) -> ResolvedTopicReference { - return ResolvedTopicReference( - bundleID: bundleID, - path: resolvedInformation.url.path, - fragment: resolvedInformation.url.fragment, - sourceLanguages: sourceLanguages(for: resolvedInformation) - ) - } - - private func sourceLanguages(for resolvedInformation: ResolvedInformation) -> Set { - // It is expected that the available languages contains the main language - return resolvedInformation.availableLanguages.union(CollectionOfOne(resolvedInformation.language)) } } +// MARK: Cross process communication + private protocol ExternalLinkResolving { - func sendAndWait(request: Request?) throws -> Response + func sendAndWait(request: Request) throws -> Response } private class LongRunningService: ExternalLinkResolving { @@ -273,7 +485,7 @@ private class LongRunningService: ExternalLinkResolving { server: server, convertRequestIdentifier: convertRequestIdentifier) } - func sendAndWait(request: Request?) throws -> Response { + func sendAndWait(request: Request) throws -> Response { let responseData = try client.sendAndWait(request) return try JSONDecoder().decode(Response.self, from: responseData) } @@ -290,6 +502,7 @@ private class LongRunningProcess: ExternalLinkResolving { init(location: URL, errorOutputHandler: @escaping (String) -> Void) throws { let process = Process() process.executableURL = location + process.arguments = ["--capabilities", "\(OutOfProcessReferenceResolver.Capabilities().rawValue)"] process.standardInput = input process.standardOutput = output @@ -301,7 +514,7 @@ private class LongRunningProcess: ExternalLinkResolving { errorReadSource.setEventHandler { [errorOutput] in let data = errorOutput.fileHandleForReading.availableData let errorMessage = String(data: data, encoding: .utf8) - ?? "<\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .memory)) of non-utf8 data>" + ?? "<\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .memory)) of non-utf8 data>" errorOutputHandler(errorMessage) } @@ -319,16 +532,25 @@ private class LongRunningProcess: ExternalLinkResolving { private let output = Pipe() private let errorOutput = Pipe() private let errorReadSource: any DispatchSourceRead - - func sendAndWait(request: Request?) throws -> Response { - if let request { - guard let requestString = String(data: try JSONEncoder().encode(request), encoding: .utf8)?.appending("\n"), - let requestData = requestString.data(using: .utf8) - else { - throw OutOfProcessReferenceResolver.Error.unableToEncodeRequestToClient(requestDescription: request.description) - } - input.fileHandleForWriting.write(requestData) + + func readInitialHandshakeMessage() throws -> Response { + return try _readResponse() + } + + func sendAndWait(request: Request) throws -> Response { + // Send + guard let requestString = String(data: try JSONEncoder().encode(request), encoding: .utf8)?.appending("\n"), + let requestData = requestString.data(using: .utf8) + else { + throw OutOfProcessReferenceResolver.Error.unableToEncodeRequestToClient(requestDescription: "\(request)") } + input.fileHandleForWriting.write(requestData) + + // Receive + return try _readResponse() + } + + private func _readResponse() throws -> Response { var response = output.fileHandleForReading.availableData guard !response.isEmpty else { throw OutOfProcessReferenceResolver.Error.processDidExit(code: Int(process.terminationStatus)) @@ -341,8 +563,8 @@ private class LongRunningProcess: ExternalLinkResolving { // To avoid blocking forever we check if the response can be decoded after each chunk of data. return try JSONDecoder().decode(Response.self, from: response) } catch { - if case DecodingError.dataCorrupted = error, // If the data wasn't valid JSON, read more data and try to decode it again. - response.count.isMultiple(of: Int(PIPE_BUF)) // To reduce the risk of deadlocking, check that bytes so far is a multiple of the pipe buffer size. + if case DecodingError.dataCorrupted = error, // If the data wasn't valid JSON, read more data and try to decode it again. + response.count.isMultiple(of: Int(PIPE_BUF)) // To reduce the risk of deadlocking, check that bytes so far is a multiple of the pipe buffer size. { let moreResponseData = output.fileHandleForReading.availableData guard !moreResponseData.isEmpty else { @@ -351,7 +573,7 @@ private class LongRunningProcess: ExternalLinkResolving { response += moreResponseData continue } - + // Other errors are re-thrown as wrapped errors. throw OutOfProcessReferenceResolver.Error.unableToDecodeResponseFromClient(response, error) } @@ -364,13 +586,19 @@ private class LongRunningProcess: ExternalLinkResolving { fatalError("Cannot initialize an out of process resolver outside of macOS or Linux platforms.") } - func sendAndWait(request: Request?) throws -> Response { + func readInitialHandshakeMessage() throws -> Response { + fatalError("Cannot call sendAndWait in non macOS/Linux platform.") + } + + func sendAndWait(request: Request) throws -> Response { fatalError("Cannot call sendAndWait in non macOS/Linux platform.") } #endif } +// MARK: Error + extension OutOfProcessReferenceResolver { /// Errors that may occur when communicating with an external reference resolver. enum Error: Swift.Error, DescribedError { @@ -400,7 +628,7 @@ extension OutOfProcessReferenceResolver { /// The request type was not known (neither 'topic' nor 'symbol'). case unknownTypeOfRequest /// Received an unknown type of response to sent request. - case unexpectedResponse(response: Response, requestDescription: String) + case unexpectedResponse(response: Any, requestDescription: String) /// A plain text representation of the error message. var errorDescription: String { @@ -435,360 +663,46 @@ extension OutOfProcessReferenceResolver { } } -extension OutOfProcessReferenceResolver { - - // MARK: Request & Response - - /// A request message to send to the external link resolver. - /// - /// This can either be a request to resolve a topic URL or to resolve a symbol based on its precise identifier. - public enum Request: Codable, CustomStringConvertible { - /// A request to resolve a topic URL - case topic(URL) - /// A request to resolve a symbol based on its precise identifier. - case symbol(String) - /// A request to resolve an asset. - case asset(AssetReference) - - private enum CodingKeys: CodingKey { - case topic - case symbol - case asset - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .topic(let url): - try container.encode(url, forKey: .topic) - case .symbol(let identifier): - try container.encode(identifier, forKey: .symbol) - case .asset(let assetReference): - try container.encode(assetReference, forKey: .asset) - } - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch container.allKeys.first { - case .topic?: - self = .topic(try container.decode(URL.self, forKey: .topic)) - case .symbol?: - self = .symbol(try container.decode(String.self, forKey: .symbol)) - case .asset?: - self = .asset(try container.decode(AssetReference.self, forKey: .asset)) - case nil: - throw OutOfProcessReferenceResolver.Error.unknownTypeOfRequest - } - } - - /// A plain text representation of the request message. - public var description: String { - switch self { - case .topic(let url): - return "topic: \(url.absoluteString.singleQuoted)" - case .symbol(let identifier): - return "symbol: \(identifier.singleQuoted)" - case .asset(let asset): - return "asset with name: \(asset.assetName), bundle identifier: \(asset.bundleID)" - } - } - } - - /// A response message from the external link resolver. - public enum Response: Codable { - /// A bundle identifier response. - /// - /// This message should only be sent once, after the external link resolver has launched. - case bundleIdentifier(String) - /// The error message of the problem that the external link resolver encountered while resolving the requested topic or symbol. - case errorMessage(String) - /// A response with the resolved information about the requested topic or symbol. - case resolvedInformation(ResolvedInformation) - /// A response with information about the resolved asset. - case asset(DataAsset) - - enum CodingKeys: String, CodingKey { - case bundleIdentifier - case errorMessage - case resolvedInformation - case asset - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - switch container.allKeys.first { - case .bundleIdentifier?: - self = .bundleIdentifier(try container.decode(String.self, forKey: .bundleIdentifier)) - case .errorMessage?: - self = .errorMessage(try container.decode(String.self, forKey: .errorMessage)) - case .resolvedInformation?: - self = .resolvedInformation(try container.decode(ResolvedInformation.self, forKey: .resolvedInformation)) - case .asset?: - self = .asset(try container.decode(DataAsset.self, forKey: .asset)) - case nil: - throw OutOfProcessReferenceResolver.Error.invalidResponseKindFromClient - } - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .bundleIdentifier(let bundleIdentifier): - try container.encode(bundleIdentifier, forKey: .bundleIdentifier) - case .errorMessage(let errorMessage): - try container.encode(errorMessage, forKey: .errorMessage) - case .resolvedInformation(let resolvedInformation): - try container.encode(resolvedInformation, forKey: .resolvedInformation) - case .asset(let assetReference): - try container.encode(assetReference, forKey: .asset) - } - } - } - - // MARK: Resolved Information - - /// A type used to transfer information about a resolved reference to DocC from from a reference resolver in another executable. - public struct ResolvedInformation: Codable { - // This type is duplicating the information from LinkDestinationSummary with some minor differences. - // Changes generally need to be made in both places. It would be good to replace this with LinkDestinationSummary. - // FIXME: https://github.com/swiftlang/swift-docc/issues/802 - - /// Information about the resolved kind. - public let kind: DocumentationNode.Kind - /// Information about the resolved URL. - public let url: URL - /// Information about the resolved title. - public let title: String // DocumentationNode.Name - /// Information about the resolved abstract. - public let abstract: String // Markup - /// Information about the resolved language. - public let language: SourceLanguage - /// Information about the languages where the resolved node is available. - public let availableLanguages: Set - /// Information about the platforms and their versions where the resolved node is available, if any. - public let platforms: [PlatformAvailability]? - /// Information about the resolved declaration fragments, if any. - public let declarationFragments: DeclarationFragments? - - // We use the real types here because they're Codable and don't have public member-wise initializers. - - /// Platform availability for a resolved symbol reference. - public typealias PlatformAvailability = AvailabilityRenderItem - - /// The declaration fragments for a resolved symbol reference. - public typealias DeclarationFragments = SymbolGraph.Symbol.DeclarationFragments - - /// The platform names, derived from the platform availability. - public var platformNames: Set? { - return platforms.map { platforms in Set(platforms.compactMap { $0.name }) } - } - - /// Images that are used to represent the summarized element. - public var topicImages: [TopicImage]? - - /// References used in the content of the summarized element. - public var references: [any RenderReference]? - - /// The variants of content (kind, url, title, abstract, language, declaration) for this resolver information. - public var variants: [Variant]? - - /// A value that indicates whether this symbol is under development and likely to change. - var isBeta: Bool { - guard let platforms, !platforms.isEmpty else { - return false - } - - return platforms.allSatisfy { $0.isBeta == true } - } - - /// Creates a new resolved information value with all its values. - /// - /// - Parameters: - /// - kind: The resolved kind. - /// - url: The resolved URL. - /// - title: The resolved title - /// - abstract: The resolved (plain text) abstract. - /// - language: The resolved language. - /// - availableLanguages: The languages where the resolved node is available. - /// - platforms: The platforms and their versions where the resolved node is available, if any. - /// - declarationFragments: The resolved declaration fragments, if any. - /// - topicImages: Images that are used to represent the summarized element. - /// - references: References used in the content of the summarized element. - /// - variants: The variants of content for this resolver information. - public init( - kind: DocumentationNode.Kind, - url: URL, - title: String, - abstract: String, - language: SourceLanguage, - availableLanguages: Set, - platforms: [PlatformAvailability]? = nil, - declarationFragments: DeclarationFragments? = nil, - topicImages: [TopicImage]? = nil, - references: [any RenderReference]? = nil, - variants: [Variant]? = nil - ) { - self.kind = kind - self.url = url - self.title = title - self.abstract = abstract - self.language = language - self.availableLanguages = availableLanguages - self.platforms = platforms - self.declarationFragments = declarationFragments - self.topicImages = topicImages - self.references = references - self.variants = variants - } - - /// A variant of content for the resolved information. - /// - /// - Note: All properties except for ``traits`` are optional. If a property is `nil` it means that the value is the same as the resolved information's value. - public struct Variant: Codable { - /// The traits of the variant. - public let traits: [RenderNode.Variant.Trait] - - /// A wrapper for variant values that can either be specified, meaning the variant has a custom value, or not, meaning the variant has the same value as the resolved information. - /// - /// This alias is used to make the property declarations more explicit while at the same time offering the convenient syntax of optionals. - public typealias VariantValue = Optional - - /// The kind of the variant or `nil` if the kind is the same as the resolved information. - public let kind: VariantValue - /// The url of the variant or `nil` if the url is the same as the resolved information. - public let url: VariantValue - /// The title of the variant or `nil` if the title is the same as the resolved information. - public let title: VariantValue - /// The abstract of the variant or `nil` if the abstract is the same as the resolved information. - public let abstract: VariantValue - /// The language of the variant or `nil` if the language is the same as the resolved information. - public let language: VariantValue - /// The declaration fragments of the variant or `nil` if the declaration is the same as the resolved information. - /// - /// If the resolver information has a declaration but the variant doesn't, this property will be `Optional.some(nil)`. - public let declarationFragments: VariantValue - - /// Creates a new resolved information variant with the values that are different from the resolved information values. - /// - /// - Parameters: - /// - traits: The traits of the variant. - /// - kind: The resolved kind. - /// - url: The resolved URL. - /// - title: The resolved title - /// - abstract: The resolved (plain text) abstract. - /// - language: The resolved language. - /// - declarationFragments: The resolved declaration fragments, if any. - public init( - traits: [RenderNode.Variant.Trait], - kind: VariantValue = nil, - url: VariantValue = nil, - title: VariantValue = nil, - abstract: VariantValue = nil, - language: VariantValue = nil, - declarationFragments: VariantValue = nil - ) { - self.traits = traits - self.kind = kind - self.url = url - self.title = title - self.abstract = abstract - self.language = language - self.declarationFragments = declarationFragments - } - } - } -} - -extension OutOfProcessReferenceResolver.ResolvedInformation { - enum CodingKeys: CodingKey { - case kind - case url - case title - case abstract - case language - case availableLanguages - case platforms - case declarationFragments - case topicImages - case references - case variants - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - kind = try container.decode(DocumentationNode.Kind.self, forKey: .kind) - url = try container.decode(URL.self, forKey: .url) - title = try container.decode(String.self, forKey: .title) - abstract = try container.decode(String.self, forKey: .abstract) - language = try container.decode(SourceLanguage.self, forKey: .language) - availableLanguages = try container.decode(Set.self, forKey: .availableLanguages) - platforms = try container.decodeIfPresent([OutOfProcessReferenceResolver.ResolvedInformation.PlatformAvailability].self, forKey: .platforms) - declarationFragments = try container.decodeIfPresent(OutOfProcessReferenceResolver.ResolvedInformation.DeclarationFragments.self, forKey: .declarationFragments) - topicImages = try container.decodeIfPresent([TopicImage].self, forKey: .topicImages) - references = try container.decodeIfPresent([CodableRenderReference].self, forKey: .references).map { decodedReferences in - decodedReferences.map(\.reference) - } - variants = try container.decodeIfPresent([OutOfProcessReferenceResolver.ResolvedInformation.Variant].self, forKey: .variants) - - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(self.kind, forKey: .kind) - try container.encode(self.url, forKey: .url) - try container.encode(self.title, forKey: .title) - try container.encode(self.abstract, forKey: .abstract) - try container.encode(self.language, forKey: .language) - try container.encode(self.availableLanguages, forKey: .availableLanguages) - try container.encodeIfPresent(self.platforms, forKey: .platforms) - try container.encodeIfPresent(self.declarationFragments, forKey: .declarationFragments) - try container.encodeIfPresent(self.topicImages, forKey: .topicImages) - try container.encodeIfPresent(references?.map { CodableRenderReference($0) }, forKey: .references) - try container.encodeIfPresent(self.variants, forKey: .variants) - } -} +// MARK: Convert Service extension OutOfProcessReferenceResolver: ConvertServiceFallbackResolver { @_spi(ExternalLinks) + @available(*, deprecated, message: "The ConvertService is implicitly reliant on the deprecated `Request` and `Response` types.") public func entityIfPreviouslyResolved(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity? { - guard referenceCache.keys.contains(reference.url) else { return nil } + guard let implementation = implementation as? ImplementationV1 else { + assertionFailure("ConvertServiceFallbackResolver expects V1 requests and responses") + return nil + } + + guard implementation.referenceCache.keys.contains(reference.url) else { return nil } var entity = entity(with: reference) // The entity response doesn't include the assets that it references. // Before returning the entity, make sure that its references assets are included among the image dependencies. - for image in entity.topicRenderReference.images { + var references = entity.references ?? [] + + for image in entity.topicImages ?? [] { if let asset = resolve(assetNamed: image.identifier.identifier) { - entity.renderReferenceDependencies.imageReferences.append(ImageReference(identifier: image.identifier, imageAsset: asset)) + references.append(ImageReference(identifier: image.identifier, imageAsset: asset)) } } + if !references.isEmpty { + entity.references = references + } + return entity } + @available(*, deprecated, message: "The ConvertService is implicitly reliant on the deprecated `Request` and `Response` types.") func resolve(assetNamed assetName: String) -> DataAsset? { - return try? resolveInformationForAsset(named: assetName) - } - - func resolveInformationForAsset(named assetName: String) throws -> DataAsset { let assetReference = AssetReference(assetName: assetName, bundleID: bundleID) if let asset = assetCache[assetReference] { return asset } - let response = try externalLinkResolvingClient.sendAndWait( - request: Request.asset(AssetReference(assetName: assetName, bundleID: bundleID)) - ) as Response - - switch response { - case .asset(let asset): - assetCache[assetReference] = asset - return asset - case .errorMessage(let errorMessage): - throw Error.forwardedErrorFromClient(errorMessage: errorMessage) - default: - throw Error.unexpectedResponse(response: response, requestDescription: "asset") + guard case .asset(let asset)? = try? implementation.longRunningProcess.sendAndWait(request: Request.asset(assetReference)) as Response else { + return nil } + return asset } } diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift index 6e6fdbb3a..4c6fac350 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/ExternalPathHierarchyResolver.swift @@ -87,31 +87,10 @@ final class ExternalPathHierarchyResolver { /// /// - Precondition: The `reference` was previously resolved by this resolver. func entity(_ reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { - guard let resolvedInformation = content[reference] else { + guard let alreadyResolvedSummary = content[reference] else { fatalError("The resolver should only be asked for entities that it resolved.") } - - let topicReferences: [ResolvedTopicReference] = (resolvedInformation.references ?? []).compactMap { - guard let renderReference = $0 as? TopicRenderReference, - let url = URL(string: renderReference.identifier.identifier), - let bundleID = url.host - else { - return nil - } - return ResolvedTopicReference(bundleID: .init(rawValue: bundleID), path: url.path, fragment: url.fragment, sourceLanguage: .swift) - } - let dependencies = RenderReferenceDependencies( - topicReferences: topicReferences, - linkReferences: (resolvedInformation.references ?? []).compactMap { $0 as? LinkReference }, - imageReferences: (resolvedInformation.references ?? []).compactMap { $0 as? ImageReference } - ) - - return .init( - topicRenderReference: resolvedInformation.topicRenderReference(), - renderReferenceDependencies: dependencies, - sourceLanguages: resolvedInformation.availableLanguages, - symbolKind: DocumentationNode.symbolKind(for: resolvedInformation.kind) - ) + return alreadyResolvedSummary } // MARK: Deserialization @@ -182,9 +161,9 @@ private extension Sequence { // MARK: ExternalEntity -private extension LinkDestinationSummary { +extension LinkDestinationSummary { /// A value that indicates whether this symbol is under development and likely to change. - var isBeta: Bool { + private var isBeta: Bool { guard let platforms, !platforms.isEmpty else { return false } @@ -193,7 +172,7 @@ private extension LinkDestinationSummary { } /// Create a topic render render reference for this link summary and its content variants. - func topicRenderReference() -> TopicRenderReference { + func makeTopicRenderReference() -> TopicRenderReference { let (kind, role) = DocumentationContentRenderer.renderKindAndRole(kind, semantic: nil) var titleVariants = VariantCollection(defaultValue: title) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift index 8f0966fc7..8fa9575bd 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -13,69 +13,76 @@ import SymbolKit /// A rendering-friendly representation of a external node. package struct ExternalRenderNode { - /// Underlying external entity backing this external node. - private var externalEntity: LinkResolver.ExternalEntity - + private var entity: LinkResolver.ExternalEntity + private var topicRenderReference: TopicRenderReference + /// The bundle identifier for this external node. private var bundleIdentifier: DocumentationBundle.Identifier + // This type is designed to misrepresent external content as local content to fit in with the navigator. + // This spreads the issue to more code rather than fixing it, which adds technical debt and can be fragile. + // + // At the time of writing this comment, this type and the issues it comes with has spread to 6 files (+ 3 test files). + // Luckily, none of that code is public API so we can modify or even remove it without compatibility restrictions. init(externalEntity: LinkResolver.ExternalEntity, bundleIdentifier: DocumentationBundle.Identifier) { - self.externalEntity = externalEntity + self.entity = externalEntity self.bundleIdentifier = bundleIdentifier + self.topicRenderReference = externalEntity.makeTopicRenderReference() } /// The identifier of the external render node. package var identifier: ResolvedTopicReference { ResolvedTopicReference( bundleID: bundleIdentifier, - path: externalEntity.topicRenderReference.url, - sourceLanguages: externalEntity.sourceLanguages + path: entity.referenceURL.path, + fragment: entity.referenceURL.fragment, + sourceLanguages: entity.availableLanguages ) } /// The kind of this documentation node. var kind: RenderNode.Kind { - externalEntity.topicRenderReference.kind + topicRenderReference.kind } /// The symbol kind of this documentation node. /// /// This value is `nil` if the referenced page is not a symbol. var symbolKind: SymbolGraph.Symbol.KindIdentifier? { - externalEntity.symbolKind + DocumentationNode.symbolKind(for: entity.kind) } /// The additional "role" assigned to the symbol, if any /// /// This value is `nil` if the referenced page is not a symbol. var role: String? { - externalEntity.topicRenderReference.role + topicRenderReference.role } /// The variants of the title. var titleVariants: VariantCollection { - externalEntity.topicRenderReference.titleVariants + topicRenderReference.titleVariants } /// The variants of the abbreviated declaration of the symbol to display in navigation. var navigatorTitleVariants: VariantCollection<[DeclarationRenderSection.Token]?> { - externalEntity.topicRenderReference.navigatorTitleVariants + topicRenderReference.navigatorTitleVariants } /// Author provided images that represent this page. var images: [TopicImage] { - externalEntity.topicRenderReference.images + entity.topicImages ?? [] } /// The identifier of the external reference. var externalIdentifier: RenderReferenceIdentifier { - externalEntity.topicRenderReference.identifier + topicRenderReference.identifier } /// List of variants of the same external node for various languages. var variants: [RenderNode.Variant]? { - externalEntity.sourceLanguages.map { - RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url]) + entity.availableLanguages.map { + RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [topicRenderReference.url]) } } @@ -83,13 +90,16 @@ package struct ExternalRenderNode { /// /// This value is `false` if the referenced page is not a symbol. var isBeta: Bool { - externalEntity.topicRenderReference.isBeta + topicRenderReference.isBeta } } /// A language specific representation of an external render node value for building a navigator index. struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { - var identifier: ResolvedTopicReference + private var _identifier: ResolvedTopicReference + var identifier: ResolvedTopicReference { + _identifier + } var kind: RenderNode.Kind var metadata: ExternalRenderNodeMetadataRepresentation @@ -109,7 +119,7 @@ struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { } let traits = trait.map { [$0] } ?? [] - self.identifier = renderNode.identifier.withSourceLanguages(Set(arrayLiteral: traitLanguage)) + self._identifier = renderNode.identifier.withSourceLanguages([traitLanguage]) self.kind = renderNode.kind self.metadata = ExternalRenderNodeMetadataRepresentation( diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift index 20291f286..27cd2d2a1 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver.swift @@ -9,7 +9,6 @@ */ import Foundation -public import SymbolKit /// A class that resolves documentation links by orchestrating calls to other link resolver implementations. public class LinkResolver { @@ -42,53 +41,7 @@ public class LinkResolver { /// 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. - /// - symbolKind: The kind of symbol that's being referenced. - @_spi(ExternalLinks) - public init( - topicRenderReference: TopicRenderReference, - renderReferenceDependencies: RenderReferenceDependencies, - sourceLanguages: Set, - symbolKind: SymbolGraph.Symbol.KindIdentifier? = nil - ) { - self.topicRenderReference = topicRenderReference - self.renderReferenceDependencies = renderReferenceDependencies - self.sourceLanguages = sourceLanguages - self.symbolKind = symbolKind - } - - /// 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 - /// The kind of symbol that's being referenced. - /// - /// This value is `nil` if the entity does not reference a symbol. - /// - /// For example, the navigator requires specific knowledge about what type of external symbol is being linked to. - var symbolKind: SymbolGraph.Symbol.KindIdentifier? - - /// 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 - ) - } - } + public typealias ExternalEntity = LinkDestinationSummary // Currently we use the same format as DocC outputs for its own pages. That may change depending on what information we need here. /// Attempts to resolve an unresolved reference. /// @@ -268,3 +221,42 @@ private final class FallbackResolverBasedLinkResolver { return nil } } + +extension LinkResolver.ExternalEntity { + /// Creates a pre-render new topic content value to be added to a render context's reference store. + func makeTopicContent() -> RenderReferenceStore.TopicContent { + .init( + renderReference: makeTopicRenderReference(), + canonicalPath: nil, + taskGroups: nil, + source: nil, + isDocumentationExtensionContent: false, + renderReferenceDependencies: makeRenderDependencies() + ) + } + + func makeRenderDependencies() -> RenderReferenceDependencies { + guard let references else { return .init() } + + return .init( + topicReferences: references.compactMap { ($0 as? TopicRenderReference)?.topicReference(languages: availableLanguages) }, + linkReferences: references.compactMap { $0 as? LinkReference }, + imageReferences: references.compactMap { $0 as? ImageReference } + ) + } +} + +private extension TopicRenderReference { + func topicReference(languages: Set) -> ResolvedTopicReference? { + guard let url = URL(string: identifier.identifier), let rawBundleID = url.host else { + return nil + } + return ResolvedTopicReference( + bundleID: .init(rawValue: rawBundleID), + path: url.path, + fragment: url.fragment, + // TopicRenderReference doesn't have language information. Also, the reference's languages _doesn't_ specify the languages of the linked entity. + sourceLanguages: languages + ) + } +} diff --git a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift index 8c6112dbb..0a53b58c3 100644 --- a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift +++ b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift @@ -201,7 +201,8 @@ public struct LinkDestinationSummary: Codable, Equatable { /// Images that are used to represent the summarized element or `nil` if the images are the same as the summarized element. /// /// If the summarized element has an image but the variant doesn't, this property will be `Optional.some(nil)`. - public let topicImages: VariantValue<[TopicImage]?> + @available(*, deprecated, message: "`TopicRenderReference` doesn't support variant specific topic images. This property will be removed after 6.3 is released") + public let topicImages: VariantValue<[TopicImage]?> = nil /// Creates a new summary variant with the values that are different from the main summarized values. /// @@ -215,7 +216,6 @@ public struct LinkDestinationSummary: Codable, Equatable { /// - taskGroups: The taskGroups of the variant or `nil` if the taskGroups is the same as the summarized element. /// - usr: The precise symbol identifier of the variant or `nil` if the precise symbol identifier is the same as the summarized element. /// - declarationFragments: The declaration of the variant or `nil` if the declaration is the same as the summarized element. - /// - topicImages: Images that are used to represent the summarized element or `nil` if the images are the same as the summarized element. public init( traits: [RenderNode.Variant.Trait], kind: VariantValue = nil, @@ -225,8 +225,7 @@ public struct LinkDestinationSummary: Codable, Equatable { abstract: VariantValue = nil, taskGroups: VariantValue<[LinkDestinationSummary.TaskGroup]?> = nil, usr: VariantValue = nil, - declarationFragments: VariantValue = nil, - topicImages: VariantValue<[TopicImage]?> = nil + declarationFragments: VariantValue = nil ) { self.traits = traits self.kind = kind @@ -237,7 +236,32 @@ public struct LinkDestinationSummary: Codable, Equatable { self.taskGroups = taskGroups self.usr = usr self.declarationFragments = declarationFragments - self.topicImages = topicImages + } + + @available(*, deprecated, renamed: "init(traits:kind:language:relativePresentationURL:title:abstract:taskGroups:usr:declarationFragments:)", message: "Use `init(traits:kind:language:relativePresentationURL:title:abstract:taskGroups:usr:declarationFragments:)` instead. `TopicRenderReference` doesn't support variant specific topic images. This property will be removed after 6.3 is released") + public init( + traits: [RenderNode.Variant.Trait], + kind: VariantValue = nil, + language: VariantValue = nil, + relativePresentationURL: VariantValue = nil, + title: VariantValue = nil, + abstract: VariantValue = nil, + taskGroups: VariantValue<[LinkDestinationSummary.TaskGroup]?> = nil, + usr: VariantValue = nil, + declarationFragments: VariantValue = nil, + topicImages: VariantValue<[TopicImage]?> = nil + ) { + self.init( + traits: traits, + kind: kind, + language: language, + relativePresentationURL: relativePresentationURL, + title: title, + abstract: abstract, + taskGroups: taskGroups, + usr: usr, + declarationFragments: declarationFragments + ) } } @@ -469,8 +493,7 @@ extension LinkDestinationSummary { abstract: nilIfEqual(main: abstract, variant: abstractVariant), taskGroups: nilIfEqual(main: taskGroups, variant: taskGroupVariants[variantTraits]), usr: nil, // The symbol variant uses the same USR - declarationFragments: nilIfEqual(main: declaration, variant: declarationVariant), - topicImages: nil // The symbol variant doesn't currently have their own images + declarationFragments: nilIfEqual(main: declaration, variant: declarationVariant) ) } @@ -578,13 +601,28 @@ extension LinkDestinationSummary { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(kind.id, forKey: .kind) + if DocumentationNode.Kind.allKnownValues.contains(kind) { + try container.encode(kind.id, forKey: .kind) + } else { + try container.encode(kind, forKey: .kind) + } try container.encode(relativePresentationURL, forKey: .relativePresentationURL) try container.encode(referenceURL, forKey: .referenceURL) try container.encode(title, forKey: .title) try container.encodeIfPresent(abstract, forKey: .abstract) - try container.encode(language.id, forKey: .language) - try container.encode(availableLanguages.map { $0.id }, forKey: .availableLanguages) + if SourceLanguage.knownLanguages.contains(language) { + try container.encode(language.id, forKey: .language) + } else { + try container.encode(language, forKey: .language) + } + var languagesContainer = container.nestedUnkeyedContainer(forKey: .availableLanguages) + for language in availableLanguages.sorted() { + if SourceLanguage.knownLanguages.contains(language) { + try languagesContainer.encode(language.id) + } else { + try languagesContainer.encode(language) + } + } try container.encodeIfPresent(platforms, forKey: .platforms) try container.encodeIfPresent(taskGroups, forKey: .taskGroups) try container.encodeIfPresent(usr, forKey: .usr) @@ -600,28 +638,47 @@ extension LinkDestinationSummary { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let kindID = try container.decode(String.self, forKey: .kind) - guard let foundKind = DocumentationNode.Kind.allKnownValues.first(where: { $0.id == kindID }) else { - throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown DocumentationNode.Kind identifier: '\(kindID)'.") + // Kind can either be a known identifier or a full structure + do { + let kindID = try container.decode(String.self, forKey: .kind) + guard let foundKind = DocumentationNode.Kind.allKnownValues.first(where: { $0.id == kindID }) else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown DocumentationNode.Kind identifier: '\(kindID)'.") + } + kind = foundKind + } catch { + kind = try container.decode(DocumentationNode.Kind.self, forKey: .kind) } - kind = foundKind relativePresentationURL = try container.decode(URL.self, forKey: .relativePresentationURL) referenceURL = try container.decode(URL.self, forKey: .referenceURL) title = try container.decode(String.self, forKey: .title) abstract = try container.decodeIfPresent(Abstract.self, forKey: .abstract) - let languageID = try container.decode(String.self, forKey: .language) - guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { - throw DecodingError.dataCorruptedError(forKey: .language, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") - } - language = foundLanguage - - let availableLanguageIDs = try container.decode([String].self, forKey: .availableLanguages) - availableLanguages = try Set(availableLanguageIDs.map { languageID in + // Language can either be an identifier of a known language or a full structure + do { + let languageID = try container.decode(String.self, forKey: .language) guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { - throw DecodingError.dataCorruptedError(forKey: .availableLanguages, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + throw DecodingError.dataCorruptedError(forKey: .language, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + } + language = foundLanguage + } catch DecodingError.typeMismatch { + language = try container.decode(SourceLanguage.self, forKey: .language) + } + + // The set of languages can be a mix of identifiers and full structure + var languagesContainer = try container.nestedUnkeyedContainer(forKey: .availableLanguages) + var decodedLanguages = Set() + while !languagesContainer.isAtEnd { + do { + let languageID = try languagesContainer.decode(String.self) + guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { + throw DecodingError.dataCorruptedError(forKey: .availableLanguages, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + } + decodedLanguages.insert( foundLanguage ) + } catch DecodingError.typeMismatch { + decodedLanguages.insert( try languagesContainer.decode(SourceLanguage.self) ) } - return foundLanguage - }) + } + availableLanguages = decodedLanguages + platforms = try container.decodeIfPresent([AvailabilityRenderItem].self, forKey: .platforms) taskGroups = try container.decodeIfPresent([TaskGroup].self, forKey: .taskGroups) usr = try container.decodeIfPresent(String.self, forKey: .usr) @@ -638,7 +695,7 @@ extension LinkDestinationSummary { extension LinkDestinationSummary.Variant { enum CodingKeys: String, CodingKey { - case traits, kind, title, abstract, language, usr, taskGroups, topicImages + case traits, kind, title, abstract, language, usr, taskGroups case relativePresentationURL = "path" case declarationFragments = "fragments" } @@ -646,44 +703,58 @@ extension LinkDestinationSummary.Variant { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(traits, forKey: .traits) - try container.encodeIfPresent(kind?.id, forKey: .kind) + if let kind { + if DocumentationNode.Kind.allKnownValues.contains(kind) { + try container.encode(kind.id, forKey: .kind) + } else { + try container.encode(kind, forKey: .kind) + } + } try container.encodeIfPresent(relativePresentationURL, forKey: .relativePresentationURL) try container.encodeIfPresent(title, forKey: .title) try container.encodeIfPresent(abstract, forKey: .abstract) - try container.encodeIfPresent(language?.id, forKey: .language) + if let language { + if SourceLanguage.knownLanguages.contains(language) { + try container.encode(language.id, forKey: .language) + } else { + try container.encode(language, forKey: .language) + } + } try container.encodeIfPresent(usr, forKey: .usr) try container.encodeIfPresent(declarationFragments, forKey: .declarationFragments) try container.encodeIfPresent(taskGroups, forKey: .taskGroups) - try container.encodeIfPresent(topicImages, forKey: .topicImages) } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - - let traits = try container.decode([RenderNode.Variant.Trait].self, forKey: .traits) - for case .interfaceLanguage(let languageID) in traits { - guard SourceLanguage.knownLanguages.contains(where: { $0.id == languageID }) else { - throw DecodingError.dataCorruptedError(forKey: .traits, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") - } - } - self.traits = traits - - let kindID = try container.decodeIfPresent(String.self, forKey: .kind) - if let kindID { - guard let foundKind = DocumentationNode.Kind.allKnownValues.first(where: { $0.id == kindID }) else { - throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown DocumentationNode.Kind identifier: '\(kindID)'.") + traits = try container.decode([RenderNode.Variant.Trait].self, forKey: .traits) + + if container.contains(.kind) { + // The kind can either be a known identifier or a full structure + do { + let kindID = try container.decode(String.self, forKey: .kind) + guard let foundKind = DocumentationNode.Kind.allKnownValues.first(where: { $0.id == kindID }) else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown DocumentationNode.Kind identifier: '\(kindID)'.") + } + kind = foundKind + } catch { + kind = try container.decode(DocumentationNode.Kind.self, forKey: .kind) } - kind = foundKind } else { kind = nil } - let languageID = try container.decodeIfPresent(String.self, forKey: .language) - if let languageID { - guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { - throw DecodingError.dataCorruptedError(forKey: .language, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + if container.contains(.language) { + // Language can either be an identifier of a known language or a full structure + do { + let languageID = try container.decode(String.self, forKey: .language) + guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { + throw DecodingError.dataCorruptedError(forKey: .language, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + } + language = foundLanguage + } catch DecodingError.typeMismatch { + language = try container.decode(SourceLanguage.self, forKey: .language) } - language = foundLanguage } else { language = nil } @@ -693,7 +764,6 @@ extension LinkDestinationSummary.Variant { usr = try container.decodeIfPresent(String?.self, forKey: .usr) declarationFragments = try container.decodeIfPresent(LinkDestinationSummary.DeclarationFragments?.self, forKey: .declarationFragments) taskGroups = try container.decodeIfPresent([LinkDestinationSummary.TaskGroup]?.self, forKey: .taskGroups) - topicImages = try container.decodeIfPresent([TopicImage]?.self, forKey: .topicImages) } } diff --git a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift index 2ad9f43a8..465a58aa8 100644 --- a/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift +++ b/Sources/SwiftDocC/Model/Rendering/DocumentationContentRenderer.swift @@ -309,11 +309,13 @@ public class DocumentationContentRenderer { // try resolving that way as a fallback after looking up `documentationCache`. titleVariants = .init(defaultVariantValue: topicGraphOnlyNode.title) } else if let external = documentationContext.externalCache[reference] { - dependencies.topicReferences.append(contentsOf: external.renderReferenceDependencies.topicReferences) - dependencies.linkReferences.append(contentsOf: external.renderReferenceDependencies.linkReferences) - dependencies.imageReferences.append(contentsOf: external.renderReferenceDependencies.imageReferences) + let renderDependencies = external.makeRenderDependencies() - return external.topicRenderReference + dependencies.topicReferences.append(contentsOf: renderDependencies.topicReferences) + dependencies.linkReferences.append(contentsOf: renderDependencies.linkReferences) + dependencies.imageReferences.append(contentsOf: renderDependencies.imageReferences) + + return external.makeTopicRenderReference() } else { titleVariants = .init(defaultVariantValue: reference.absoluteString) } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContext.swift b/Sources/SwiftDocC/Model/Rendering/RenderContext.swift index dade49b7a..26a299c10 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContext.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContext.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 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 @@ -90,7 +90,24 @@ public struct RenderContext { // Add all the external content to the topic store for (reference, entity) in documentationContext.externalCache { - topics[reference] = entity.topicContent() + topics[reference] = entity.makeTopicContent() + + // Also include transitive dependencies in the store, so that the external entity can reference them. + for case let dependency as TopicRenderReference in (entity.references ?? []) { + guard let url = URL(string: dependency.identifier.identifier), let rawBundleID = url.host else { + // This dependency doesn't have a valid topic reference, skip adding it to the render context. + continue + } + + let dependencyReference = ResolvedTopicReference( + bundleID: .init(rawValue: rawBundleID), + path: url.path, + fragment: url.fragment, + // TopicRenderReference doesn't have language information. Also, the reference's languages _doesn't_ specify the languages of the linked entity. + sourceLanguages: reference.sourceLanguages + ) + topics[dependencyReference] = .init(renderReference: dependency, canonicalPath: nil, taskGroups: nil, source: nil, isDocumentationExtensionContent: false) + } } self.store = RenderReferenceStore(topics: topics, assets: assets) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 1fd747b15..aad67d6b8 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -1478,7 +1478,7 @@ public struct RenderNodeTranslator: SemanticVisitor { } } else if let entity = context.externalCache[resolved] { collectedTopicReferences.append(resolved) - destinationsMap[destination] = entity.topicRenderReference.title + destinationsMap[destination] = entity.title } else { fatalError("A successfully resolved reference should have either local or external content.") } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/SwiftDocC/StaticAnalysis.md b/Sources/SwiftDocC/SwiftDocC.docc/SwiftDocC/StaticAnalysis.md index 515502994..a2330698d 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/SwiftDocC/StaticAnalysis.md +++ b/Sources/SwiftDocC/SwiftDocC.docc/SwiftDocC/StaticAnalysis.md @@ -12,7 +12,6 @@ Run static analysis checks on markup files. ### Predefined Checks -- ``AbstractContainsFormattedTextOnly`` - ``DuplicateTopicsSections`` - ``InvalidAdditionalTitle`` - ``MissingAbstract`` @@ -20,4 +19,4 @@ Run static analysis checks on markup files. - ``NonOverviewHeadingChecker`` - ``SeeAlsoInTopicsHeadingChecker`` - + diff --git a/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift b/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift index 4e8713828..ee391de38 100644 --- a/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift +++ b/Tests/SwiftDocCTests/Benchmark/ExternalTopicsHashTests.swift @@ -22,17 +22,13 @@ class ExternalTopicsGraphHashTests: XCTestCase { func symbolReferenceAndEntity(withPreciseIdentifier preciseIdentifier: String) -> (ResolvedTopicReference, LinkResolver.ExternalEntity)? { let reference = ResolvedTopicReference(bundleID: "com.test.symbols", path: "/\(preciseIdentifier)", sourceLanguage: SourceLanguage.swift) let entity = LinkResolver.ExternalEntity( - topicRenderReference: TopicRenderReference( - identifier: .init(preciseIdentifier), - title: preciseIdentifier, - abstract: [], - url: "/" + preciseIdentifier, - kind: .symbol, - estimatedTime: nil - ), - renderReferenceDependencies: .init(), - sourceLanguages: [.swift], - symbolKind: .class + kind: .class, + language: .swift, + relativePresentationURL: URL(string: "/\(preciseIdentifier)")!, + referenceURL: reference.url, + title: preciseIdentifier, + availableLanguages: [.swift], + variants: [] ) return (reference, entity) } diff --git a/Tests/SwiftDocCTests/Checker/Checkers/AbstractContainsFormattedTextOnlyTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/AbstractContainsFormattedTextOnlyTests.swift index 6add5371d..958f31c3d 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/AbstractContainsFormattedTextOnlyTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/AbstractContainsFormattedTextOnlyTests.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 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 @@ -12,6 +12,9 @@ import XCTest @testable import SwiftDocC import Markdown +// This tests `AbstractContainsFormattedTextOnly` which are deprecated. +// Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. +@available(*, deprecated) class AbstractContainsFormattedTextOnlyTests: XCTestCase { var checker = AbstractContainsFormattedTextOnly(sourceFile: nil) diff --git a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift index 2550a5234..a46afb677 100644 --- a/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift +++ b/Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift @@ -532,6 +532,9 @@ class ConvertServiceTests: XCTestCase { } } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testConvertPageWithLinkResolvingAndKnownPathComponents() throws { let symbolGraphFile = Bundle.module.url( forResource: "mykit-one-symbol", @@ -827,7 +830,9 @@ class ConvertServiceTests: XCTestCase { ) } } - + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testConvertTutorialWithCode() throws { let tutorialContent = """ @Tutorial(time: 99) { @@ -998,6 +1003,9 @@ class ConvertServiceTests: XCTestCase { } } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testConvertArticleWithImageReferencesAndDetailedGridLinks() throws { let articleData = try XCTUnwrap(""" # First article @@ -1718,6 +1726,9 @@ class ConvertServiceTests: XCTestCase { #endif } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testConvertPageWithLinkResolving() throws { let symbolGraphFile = Bundle.module.url( forResource: "mykit-one-symbol", @@ -2007,6 +2018,9 @@ class ConvertServiceTests: XCTestCase { } } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testConvertTopLevelSymbolWithLinkResolving() throws { let symbolGraphFile = Bundle.module.url( forResource: "one-symbol-top-level", @@ -2114,6 +2128,9 @@ class ConvertServiceTests: XCTestCase { } } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testOrderOfLinkResolutionRequestsForDocLink() throws { let symbolGraphFile = try XCTUnwrap( Bundle.module.url( @@ -2152,6 +2169,9 @@ class ConvertServiceTests: XCTestCase { XCTAssertEqual(expectedLinkResolutionRequests, receivedLinkResolutionRequests) } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testOrderOfLinkResolutionRequestsForDeeplyNestedSymbol() throws { let symbolGraphFile = try XCTUnwrap( Bundle.module.url( @@ -2191,6 +2211,9 @@ class ConvertServiceTests: XCTestCase { XCTAssertEqual(expectedLinkResolutionRequests, receivedLinkResolutionRequests) } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testOrderOfLinkResolutionRequestsForSymbolLink() throws { let symbolGraphFile = try XCTUnwrap( Bundle.module.url( @@ -2226,7 +2249,10 @@ class ConvertServiceTests: XCTestCase { XCTAssertEqual(expectedLinkResolutionRequests, receivedLinkResolutionRequests) } - func linkResolutionRequestsForConvertRequest(_ request: ConvertRequest) throws -> [String] { + // This test helper uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) + private func linkResolutionRequestsForConvertRequest(_ request: ConvertRequest) throws -> [String] { var receivedLinkResolutionRequests = [String]() let mockLinkResolvingService = LinkResolvingService { message in do { @@ -2314,6 +2340,9 @@ class ConvertServiceTests: XCTestCase { } } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testDoesNotResolveLinksUnlessBundleIDMatches() throws { let tempURL = try createTempFolder(content: [ Folder(name: "unit-test.docc", content: [ diff --git a/Tests/SwiftDocCTests/DocumentationService/DocumentationServer+DefaultTests.swift b/Tests/SwiftDocCTests/DocumentationService/DocumentationServer+DefaultTests.swift index 6f4ddc2c6..9d2fcf89b 100644 --- a/Tests/SwiftDocCTests/DocumentationService/DocumentationServer+DefaultTests.swift +++ b/Tests/SwiftDocCTests/DocumentationService/DocumentationServer+DefaultTests.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 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 @@ -56,6 +56,9 @@ class DocumentationServer_DefaultTests: XCTestCase { wait(for: [expectation], timeout: 1.0) } + // This test uses `OutOfProcessReferenceResolver/Request` and `OutOfProcessReferenceResolver.Response` which are deprecated. + // Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. + @available(*, deprecated) func testQueriesLinkResolutionServer() throws { let symbolGraphFile = Bundle.module.url( forResource: "mykit-one-symbol", diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 4ca7dc631..d0602e48c 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -11,6 +11,7 @@ import Foundation import XCTest @_spi(ExternalLinks) @testable import SwiftDocC +import SwiftDocCTestUtilities class ExternalRenderNodeTests: XCTestCase { private func generateExternalResolver() -> TestMultiResultExternalReferenceResolver { @@ -56,7 +57,6 @@ class ExternalRenderNodeTests: XCTestCase { } func testExternalRenderNode() async throws { - let externalResolver = generateExternalResolver() let (_, bundle, context) = try await testBundleAndContext( copying: "MixedLanguageFramework", @@ -79,37 +79,33 @@ class ExternalRenderNodeTests: XCTestCase { try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) } - var externalRenderNodes = [ExternalRenderNode]() - for externalLink in context.externalCache { - externalRenderNodes.append( - ExternalRenderNode(externalEntity: externalLink.value, bundleIdentifier: bundle.id) - ) - } - externalRenderNodes.sort(by: \.titleVariants.defaultValue) + let externalRenderNodes = context.externalCache.valuesByReference.values.map { + ExternalRenderNode(externalEntity: $0, bundleIdentifier: bundle.id) + }.sorted(by: \.titleVariants.defaultValue) XCTAssertEqual(externalRenderNodes.count, 4) - XCTAssertEqual(externalRenderNodes[0].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCArticle") + XCTAssertEqual(externalRenderNodes[0].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/path/to/external/objCArticle") XCTAssertEqual(externalRenderNodes[0].kind, .article) XCTAssertEqual(externalRenderNodes[0].symbolKind, nil) XCTAssertEqual(externalRenderNodes[0].role, "article") XCTAssertEqual(externalRenderNodes[0].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCArticle") XCTAssertTrue(externalRenderNodes[0].isBeta) - XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCSymbol") + XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/path/to/external/objCSymbol") XCTAssertEqual(externalRenderNodes[1].kind, .symbol) XCTAssertEqual(externalRenderNodes[1].symbolKind, .func) XCTAssertEqual(externalRenderNodes[1].role, "symbol") XCTAssertEqual(externalRenderNodes[1].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCSymbol") XCTAssertFalse(externalRenderNodes[1].isBeta) - XCTAssertEqual(externalRenderNodes[2].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftArticle") + XCTAssertEqual(externalRenderNodes[2].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/path/to/external/swiftArticle") XCTAssertEqual(externalRenderNodes[2].kind, .article) XCTAssertEqual(externalRenderNodes[2].symbolKind, nil) XCTAssertEqual(externalRenderNodes[2].role, "article") XCTAssertEqual(externalRenderNodes[2].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftArticle") XCTAssertFalse(externalRenderNodes[2].isBeta) - XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftSymbol") + XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/path/to/external/swiftSymbol") XCTAssertEqual(externalRenderNodes[3].kind, .symbol) XCTAssertEqual(externalRenderNodes[3].symbolKind, .class) XCTAssertEqual(externalRenderNodes[3].role, "symbol") @@ -118,33 +114,34 @@ class ExternalRenderNodeTests: XCTestCase { } func testExternalRenderNodeVariantRepresentation() throws { - let renderReferenceIdentifier = RenderReferenceIdentifier(forExternalLink: "doc://com.test.external/path/to/external/symbol") + let reference = ResolvedTopicReference(bundleID: "com.test.external", path: "/path/to/external/symbol", sourceLanguages: [.swift, .objectiveC]) // Variants for the title let swiftTitle = "Swift Symbol" - let occTitle = "Occ Symbol" - - // Variants for the navigator title - let navigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "symbol", kind: .identifier)] - let occNavigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "occ_symbol", kind: .identifier)] + let objcTitle = "Objective-C Symbol" // Variants for the fragments - let fragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] - let occFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] + let swiftFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] + let objcFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] let externalEntity = LinkResolver.ExternalEntity( - topicRenderReference: .init( - identifier: renderReferenceIdentifier, - titleVariants: .init(defaultValue: swiftTitle, objectiveCValue: occTitle), - abstractVariants: .init(defaultValue: []), - url: "/example/path/to/external/symbol", - kind: .symbol, - fragmentsVariants: .init(defaultValue: fragments, objectiveCValue: occFragments), - navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle) - ), - renderReferenceDependencies: .init(), - sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")], - symbolKind: .func) + kind: .function, + language: .swift, + relativePresentationURL: URL(string: "/example/path/to/external/symbol")!, + referenceURL: reference.url, + title: swiftTitle, + availableLanguages: [.swift, .objectiveC], + usr: "some-unique-symbol-id", + declarationFragments: swiftFragments, + variants: [ + .init( + traits: [.interfaceLanguage(SourceLanguage.objectiveC.id)], + language: .objectiveC, + title: objcTitle, + declarationFragments: objcFragments + ) + ] + ) let externalRenderNode = ExternalRenderNode( externalEntity: externalEntity, bundleIdentifier: "com.test.external" @@ -154,39 +151,52 @@ class ExternalRenderNodeTests: XCTestCase { NavigatorExternalRenderNode(renderNode: externalRenderNode) ) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) - XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) XCTAssertFalse(swiftNavigatorExternalRenderNode.metadata.isBeta) let objcNavigatorExternalRenderNode = try XCTUnwrap( - NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) + NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage(SourceLanguage.objectiveC.id)) ) - XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) - XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, objcTitle) XCTAssertFalse(objcNavigatorExternalRenderNode.metadata.isBeta) } func testNavigatorWithExternalNodes() async throws { - let externalResolver = generateExternalResolver() - let (_, bundle, context) = try await testBundleAndContext( - copying: "MixedLanguageFramework", - externalResolvers: [externalResolver.bundleID: externalResolver] - ) { url in - let mixedLanguageFrameworkExtension = """ - # ``MixedLanguageFramework`` - - This symbol has a Swift and Objective-C variant. + let catalog = Folder(name: "ModuleName.docc", content: [ + Folder(name: "swift", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", language: .swift, kind: .class, pathComponents: ["SomeClass"]) + ])) + ]), + Folder(name: "clang", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["TLASomeClass"]) + ])) + ]), + + InfoPlist(identifier: "some.custom.identifier"), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + Curate a few external language-specific symbols and articles - ## Topics + ## Topics - ### External Reference + ### External Reference - - - - - - - - - """ - try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) - } + - + - + - + - + """), + ]) + + var configuration = DocumentationContext.Configuration() + let externalResolver = generateExternalResolver() + configuration.externalDocumentationConfiguration.sources[externalResolver.bundleID] = externalResolver + let (bundle, context) = try await loadBundle(catalog: catalog, configuration: configuration) + XCTAssert(context.problems.isEmpty, "Encountered unexpected problems: \(context.problems.map(\.diagnostic.summary))") + let renderContext = RenderContext(documentationContext: context, bundle: bundle) let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) let targetURL = try createTemporaryDirectory() @@ -205,53 +215,63 @@ class ExternalRenderNodeTests: XCTestCase { let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) // Verify that there are no uncurated external links at the top level - let swiftTopLevelExternalNodes = renderIndex.interfaceLanguages["swift"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - let occTopLevelExternalNodes = renderIndex.interfaceLanguages["occ"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - XCTAssertEqual(swiftTopLevelExternalNodes.count, 0) - XCTAssertEqual(occTopLevelExternalNodes.count, 0) + XCTAssertEqual(renderIndex.interfaceLanguages[SourceLanguage.swift.id]?.count(where: \.isExternal), 0) + XCTAssertEqual(renderIndex.interfaceLanguages[SourceLanguage.objectiveC.id]?.count(where: \.isExternal), 0) // Verify that the curated external links are part of the index. - let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let swiftExternalNodes = (renderIndex.interfaceLanguages[SourceLanguage.swift.id]?.first?.children?.filter(\.isExternal) ?? []).sorted(by: \.title) + let objcExternalNodes = (renderIndex.interfaceLanguages[SourceLanguage.objectiveC.id]?.first?.children?.filter(\.isExternal) ?? []).sorted(by: \.title) XCTAssertEqual(swiftExternalNodes.count, 2) - XCTAssertEqual(occExternalNodes.count, 2) + XCTAssertEqual(objcExternalNodes.count, 2) XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle", "SwiftSymbol"]) - XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) - XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) - XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) - XCTAssert(swiftExternalNodes.first { $0.title == "SwiftArticle" }?.isBeta == false) - XCTAssert(swiftExternalNodes.first { $0.title == "SwiftSymbol" }?.isBeta == true) - XCTAssert(occExternalNodes.first { $0.title == "ObjCArticle" }?.isBeta == true) - XCTAssert(occExternalNodes.first { $0.title == "ObjCSymbol" }?.isBeta == false) + XCTAssertEqual(objcExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) + XCTAssert(swiftExternalNodes.first?.isBeta == false) + XCTAssert(swiftExternalNodes.last?.isBeta == true) + XCTAssert(objcExternalNodes.first?.isBeta == true) + XCTAssert(objcExternalNodes.last?.isBeta == false) XCTAssertEqual(swiftExternalNodes.map(\.type), ["article", "class"]) - XCTAssertEqual(occExternalNodes.map(\.type), ["article", "func"]) + XCTAssertEqual(objcExternalNodes.map(\.type), ["article", "func"]) } func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() async throws { + let catalog = Folder(name: "ModuleName.docc", content: [ + Folder(name: "swift", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", language: .swift, kind: .class, pathComponents: ["SomeClass"]) + ])) + ]), + Folder(name: "clang", content: [ + JSONFile(name: "ModuleName.symbols.json", content: makeSymbolGraph(moduleName: "ModuleName", symbols: [ + makeSymbol(id: "some-symbol-id", language: .objectiveC, kind: .class, pathComponents: ["TLASomeClass"]) + ])) + ]), + + InfoPlist(identifier: "some.custom.identifier"), + + TextFile(name: "ModuleName.md", utf8Content: """ + # ``ModuleName`` + + Curate and link to a few external language-specific symbols and articles + + It also has an external reference which is not curated in the Topics section: + + + + ## Topics + + ### External Reference + + - + - + """), + ]) + + var configuration = DocumentationContext.Configuration() let externalResolver = generateExternalResolver() + configuration.externalDocumentationConfiguration.sources[externalResolver.bundleID] = externalResolver + let (bundle, context) = try await loadBundle(catalog: catalog, configuration: configuration) + XCTAssert(context.problems.isEmpty, "Encountered unexpected problems: \(context.problems.map(\.diagnostic.summary))") - let (_, bundle, context) = try await testBundleAndContext( - copying: "MixedLanguageFramework", - externalResolvers: [externalResolver.bundleID: externalResolver] - ) { url in - let mixedLanguageFrameworkExtension = """ - # ``MixedLanguageFramework`` - - This symbol has a Swift and Objective-C variant. - - It also has an external reference which is not curated in the Topics section: - - - - ## Topics - - ### External Reference - - - - - - """ - try mixedLanguageFrameworkExtension.write(to: url.appendingPathComponent("/MixedLanguageFramework.md"), atomically: true, encoding: .utf8) - } let renderContext = RenderContext(documentationContext: context, bundle: bundle) let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) let targetURL = try createTemporaryDirectory() @@ -268,55 +288,52 @@ class ExternalRenderNodeTests: XCTestCase { } builder.finalize() let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) - // Verify that there are no uncurated external links at the top level - let swiftTopLevelExternalNodes = renderIndex.interfaceLanguages["swift"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - let occTopLevelExternalNodes = renderIndex.interfaceLanguages["occ"]?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - XCTAssertEqual(swiftTopLevelExternalNodes.count, 0) - XCTAssertEqual(occTopLevelExternalNodes.count, 0) + XCTAssertEqual(renderIndex.interfaceLanguages[SourceLanguage.swift.id]?.count(where: \.isExternal), 0) + XCTAssertEqual(renderIndex.interfaceLanguages[SourceLanguage.objectiveC.id]?.count(where: \.isExternal), 0) // Verify that the curated external links are part of the index. - let swiftExternalNodes = renderIndex.interfaceLanguages["swift"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] - let occExternalNodes = renderIndex.interfaceLanguages["occ"]?.first { $0.path == "/documentation/mixedlanguageframework" }?.children?.filter { $0.path?.contains("/path/to/external") ?? false } ?? [] + let swiftExternalNodes = (renderIndex.interfaceLanguages[SourceLanguage.swift.id]?.first?.children?.filter(\.isExternal) ?? []).sorted(by: \.title) + let objcExternalNodes = (renderIndex.interfaceLanguages[SourceLanguage.objectiveC.id]?.first?.children?.filter(\.isExternal) ?? []).sorted(by: \.title) XCTAssertEqual(swiftExternalNodes.count, 1) - XCTAssertEqual(occExternalNodes.count, 1) + XCTAssertEqual(objcExternalNodes.count, 1) XCTAssertEqual(swiftExternalNodes.map(\.title), ["SwiftArticle"]) - XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCSymbol"]) - XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) - XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) + XCTAssertEqual(objcExternalNodes.map(\.title), ["ObjCSymbol"]) XCTAssertEqual(swiftExternalNodes.map(\.type), ["article"]) - XCTAssertEqual(occExternalNodes.map(\.type), ["func"]) + XCTAssertEqual(objcExternalNodes.map(\.type), ["func"]) } func testExternalRenderNodeVariantRepresentationWhenIsBeta() throws { - let renderReferenceIdentifier = RenderReferenceIdentifier(forExternalLink: "doc://com.test.external/path/to/external/symbol") + let reference = ResolvedTopicReference(bundleID: "com.test.external", path: "/path/to/external/symbol", sourceLanguages: [.swift, .objectiveC]) // Variants for the title let swiftTitle = "Swift Symbol" - let occTitle = "Occ Symbol" - - // Variants for the navigator title - let navigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "symbol", kind: .identifier)] - let occNavigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "occ_symbol", kind: .identifier)] + let objcTitle = "Objective-C Symbol" // Variants for the fragments - let fragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] - let occFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] + let swiftFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] + let objcFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] let externalEntity = LinkResolver.ExternalEntity( - topicRenderReference: .init( - identifier: renderReferenceIdentifier, - titleVariants: .init(defaultValue: swiftTitle, objectiveCValue: occTitle), - abstractVariants: .init(defaultValue: []), - url: "/example/path/to/external/symbol", - kind: .symbol, - fragmentsVariants: .init(defaultValue: fragments, objectiveCValue: occFragments), - navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle), - isBeta: true - ), - renderReferenceDependencies: .init(), - sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")]) + kind: .function, + language: .swift, + relativePresentationURL: URL(string: "/example/path/to/external/symbol")!, + referenceURL: reference.url, + title: swiftTitle, + availableLanguages: [.swift, .objectiveC], + platforms: [.init(name: "Platform name", introduced: "1.2.3", isBeta: true)], + usr: "some-unique-symbol-id", + declarationFragments: swiftFragments, + variants: [ + .init( + traits: [.interfaceLanguage(SourceLanguage.objectiveC.id)], + language: .objectiveC, + title: objcTitle, + declarationFragments: objcFragments + ) + ] + ) let externalRenderNode = ExternalRenderNode( externalEntity: externalEntity, bundleIdentifier: "com.test.external" @@ -326,14 +343,12 @@ class ExternalRenderNodeTests: XCTestCase { NavigatorExternalRenderNode(renderNode: externalRenderNode) ) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) - XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) XCTAssertTrue(swiftNavigatorExternalRenderNode.metadata.isBeta) let objcNavigatorExternalRenderNode = try XCTUnwrap( - NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) + NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage(SourceLanguage.objectiveC.id)) ) - XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) - XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, objcTitle) XCTAssertTrue(objcNavigatorExternalRenderNode.metadata.isBeta) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift index 7af1fa06e..9a62ddf51 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ExternalPathHierarchyResolverTests.swift @@ -950,7 +950,7 @@ class ExternalPathHierarchyResolverTests: XCTestCase { switch result { case .success(let resolved): let entity = externalResolver.entity(resolved) - XCTAssertEqual(entity.topicRenderReference.isBeta, isBeta, file: file, line: line) + XCTAssertEqual(entity.makeTopicRenderReference().isBeta, isBeta, file: file, line: line) case .failure(_, let errorInfo): XCTFail("Unexpectedly failed to resolve \(label) link: \(errorInfo.message) \(errorInfo.solutions.map(\.summary).joined(separator: ", "))", file: file, line: line) } diff --git a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift index 13b47f674..6e2526397 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ExternalReferenceResolverTests.swift @@ -38,22 +38,15 @@ class ExternalReferenceResolverTests: XCTestCase { fatalError("It is a programming mistake to retrieve an entity for a reference that the external resolver didn't resolve.") } - let (kind, role) = DocumentationContentRenderer.renderKindAndRole(resolvedEntityKind, semantic: nil) return LinkResolver.ExternalEntity( - topicRenderReference: TopicRenderReference( - identifier: .init(reference.absoluteString), - title: resolvedEntityTitle, - abstract: [.text("Externally Resolved Markup Content")], - url: "/example" + reference.path + (reference.fragment.map { "#\($0)" } ?? ""), - kind: kind, - role: role, - fragments: resolvedEntityDeclarationFragments?.declarationFragments.map { fragment in - return DeclarationRenderSection.Token(fragment: fragment, identifier: nil) - } - ), - renderReferenceDependencies: RenderReferenceDependencies(), - sourceLanguages: [resolvedEntityLanguage], - symbolKind: nil + kind: resolvedEntityKind, + language: resolvedEntityLanguage, + relativePresentationURL: URL(string: "/example" + reference.path + (reference.fragment.map { "#\($0)" } ?? ""))!, + referenceURL: reference.url, + title: resolvedEntityTitle, + availableLanguages: [resolvedEntityLanguage], + declarationFragments: resolvedEntityDeclarationFragments?.declarationFragments.map { .init(fragment: $0, identifier: nil) }, + variants: [] ) } } @@ -445,7 +438,7 @@ class ExternalReferenceResolverTests: XCTestCase { XCTAssertEqual(firstExternalRenderReference.identifier.identifier, "doc://com.test.external/path/to/external-page-with-topic-image-1") XCTAssertEqual(firstExternalRenderReference.title, "First external page with topic image") - XCTAssertEqual(firstExternalRenderReference.url, "/example/path/to/external-page-with-topic-image-1") + XCTAssertEqual(firstExternalRenderReference.url, "/path/to/external-page-with-topic-image-1") XCTAssertEqual(firstExternalRenderReference.kind, .article) XCTAssertEqual(firstExternalRenderReference.images, [ @@ -457,7 +450,7 @@ class ExternalReferenceResolverTests: XCTestCase { XCTAssertEqual(secondExternalRenderReference.identifier.identifier, "doc://com.test.external/path/to/external-page-with-topic-image-2") XCTAssertEqual(secondExternalRenderReference.title, "Second external page with topic image") - XCTAssertEqual(secondExternalRenderReference.url, "/example/path/to/external-page-with-topic-image-2") + XCTAssertEqual(secondExternalRenderReference.url, "/path/to/external-page-with-topic-image-2") XCTAssertEqual(secondExternalRenderReference.kind, .article) XCTAssertEqual(secondExternalRenderReference.images, [ @@ -631,19 +624,15 @@ class ExternalReferenceResolverTests: XCTestCase { func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { referencesCreatingEntityFor.insert(reference) - // Return an empty node + // Return an "empty" node return .init( - topicRenderReference: TopicRenderReference( - identifier: .init(reference.absoluteString), - title: "Resolved", - abstract: [], - url: reference.absoluteString, - kind: .symbol, - estimatedTime: nil - ), - renderReferenceDependencies: RenderReferenceDependencies(), - sourceLanguages: [.swift], - symbolKind: .property + kind: .instanceProperty, + language: .swift, + relativePresentationURL: reference.url.withoutHostAndPortAndScheme(), + referenceURL: reference.url, + title: "Resolved", + availableLanguages: [.swift], + variants: [] ) } } diff --git a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift index c875b07f6..7ea89aa72 100644 --- a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift +++ b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift @@ -53,7 +53,7 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { let entity = entityInfo(path: path) return .success( - ResolvedTopicReference(bundleID: bundleID, path: entity.referencePath,fragment: entity.fragment,sourceLanguage: entity.language) + ResolvedTopicReference(bundleID: bundleID, path: entity.referencePath, fragment: entity.fragment, sourceLanguage: entity.language) ) } } @@ -84,34 +84,19 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { } private func makeNode(for entityInfo: EntityInfo, reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { - let (kind, role) = DocumentationContentRenderer.renderKindAndRole(entityInfo.kind, semantic: nil) - - let dependencies: RenderReferenceDependencies - if let topicImages = entityInfo.topicImages { - dependencies = .init(imageReferences: topicImages.map { topicImage, altText in - return ImageReference(identifier: topicImage.identifier, altText: altText, imageAsset: assetsToReturn[topicImage.identifier.identifier] ?? .init()) - }) - } else { - dependencies = .init() - } - - return LinkResolver.ExternalEntity( - topicRenderReference: TopicRenderReference( - identifier: .init(reference.absoluteString), - title: entityInfo.title, - abstract: [.text(entityInfo.abstract.format())], - url: "/example" + reference.path, - kind: kind, - role: role, - fragments: entityInfo.declarationFragments?.declarationFragments.map { fragment in - return DeclarationRenderSection.Token(fragment: fragment, identifier: nil) - }, - isBeta: entityInfo.platforms?.allSatisfy({$0.isBeta == true}) ?? false, - images: entityInfo.topicImages?.map(\.0) ?? [] - ), - renderReferenceDependencies: dependencies, - sourceLanguages: [entityInfo.language], - symbolKind: DocumentationNode.symbolKind(for: entityInfo.kind) + LinkResolver.ExternalEntity( + kind: entityInfo.kind, + language: entityInfo.language, + relativePresentationURL: reference.url.withoutHostAndPortAndScheme(), + referenceURL: reference.url, + title: entityInfo.title, + availableLanguages: [entityInfo.language], + platforms: entityInfo.platforms, + topicImages: entityInfo.topicImages?.map(\.0), + references: entityInfo.topicImages?.map { topicImage, altText in + ImageReference(identifier: topicImage.identifier, altText: altText, imageAsset: assetsToReturn[topicImage.identifier.identifier] ?? .init()) + }, + variants: [] ) } } diff --git a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift index 8d1752b75..a51ef8b10 100644 --- a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift +++ b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift @@ -13,12 +13,10 @@ import SymbolKit @testable import SwiftDocC import SwiftDocCTestUtilities -class ExternalLinkableTests: XCTestCase { +class LinkDestinationSummaryTests: XCTestCase { - // Write example documentation bundle with a minimal Tutorials page - let catalogHierarchy = Folder(name: "unit-test.docc", content: [ - Folder(name: "Symbols", content: []), - Folder(name: "Resources", content: [ + func testSummaryOfTutorialPage() async throws { + let catalogHierarchy = Folder(name: "unit-test.docc", content: [ TextFile(name: "TechnologyX.tutorial", utf8Content: """ @Tutorials(name: "TechnologyX") { @Intro(title: "Technology X") { @@ -89,11 +87,9 @@ class ExternalLinkableTests: XCTestCase { } } """), - ]), - InfoPlist(displayName: "TestBundle", identifier: "com.test.example"), - ]) - - func testSummaryOfTutorialPage() async throws { + InfoPlist(displayName: "TestBundle", identifier: "com.test.example") + ]) + let (bundle, context) = try await loadBundle(catalog: catalogHierarchy) let converter = DocumentationNodeConverter(bundle: bundle, context: context) @@ -486,7 +482,6 @@ class ExternalLinkableTests: XCTestCase { XCTAssertEqual(variant.usr, nil) XCTAssertEqual(variant.kind, nil) XCTAssertEqual(variant.taskGroups, nil) - XCTAssertEqual(variant.topicImages, nil) let encoded = try JSONEncoder().encode(summary) let decoded = try JSONDecoder().decode(LinkDestinationSummary.self, from: encoded) @@ -577,7 +572,6 @@ class ExternalLinkableTests: XCTestCase { ) ] ) - XCTAssertEqual(variant.topicImages, nil) let encoded = try JSONEncoder().encode(summary) let decoded = try JSONDecoder().decode(LinkDestinationSummary.self, from: encoded) @@ -585,6 +579,63 @@ class ExternalLinkableTests: XCTestCase { } } + func testDecodingUnknownKindAndLanguage() throws { + let json = """ + { + "kind" : { + "id" : "kind-id", + "name" : "Kind name", + "isSymbol" : false + }, + "language" : { + "id" : "language-id", + "name" : "Language name", + "idAliases" : [ + "language-alias-id" + ], + "linkDisambiguationID" : "language-id" + }, + "availableLanguages" : [ + "swift", + "data", + { + "id" : "language-id", + "idAliases" : [ + "language-alias-id" + ], + "linkDisambiguationID" : "language-id", + "name" : "Language name" + }, + { + "id" : "language-id-2", + "linkDisambiguationID" : "language-id-2", + "name" : "Other language name" + }, + "occ" + ], + "title" : "Something", + "path" : "/documentation/something", + "referenceURL" : "/documentation/something" + } + """ + + let decoded = try JSONDecoder().decode(LinkDestinationSummary.self, from: Data(json.utf8)) + try assertRoundTripCoding(decoded) + + XCTAssertEqual(decoded.kind, DocumentationNode.Kind(name: "Kind name", id: "kind-id", isSymbol: false)) + XCTAssertEqual(decoded.language, SourceLanguage(name: "Language name", id: "language-id", idAliases: ["language-alias-id"])) + XCTAssertEqual(decoded.availableLanguages, [ + // Known languages + .swift, + .objectiveC, + .data, + + // Custom languages + SourceLanguage(name: "Language name", id: "language-id", idAliases: ["language-alias-id"]), + SourceLanguage(name: "Other language name", id: "language-id-2"), + ]) + } + func testDecodingLegacyData() throws { let legacyData = """ { diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift index 355ae1c32..2a16c9db8 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift @@ -1178,18 +1178,13 @@ class SemaToRenderNodeTests: XCTestCase { let reference = ResolvedTopicReference(bundleID: "com.test.external.symbols", path: "/\(preciseIdentifier)", sourceLanguage: .objectiveC) let entity = LinkResolver.ExternalEntity( - topicRenderReference: TopicRenderReference( - identifier: .init(reference.absoluteString), - title: "SymbolName ( \(preciseIdentifier) )", - abstract: [], - url: "/documentation/FrameworkName/path/to/symbol/\(preciseIdentifier)", - kind: .symbol, - role: "ExternalResolvedSymbolRoleHeading", - estimatedTime: nil - ), - renderReferenceDependencies: .init(), - sourceLanguages: [.objectiveC], - symbolKind: .class + kind: .class, + language: .objectiveC, + relativePresentationURL: URL(string: "/documentation/FrameworkName/path/to/symbol/\(preciseIdentifier)")!, + referenceURL: reference.url, + title: "SymbolName ( \(preciseIdentifier) )", + availableLanguages: [.objectiveC], + variants: [] ) return (reference, entity) } @@ -1207,20 +1202,15 @@ class SemaToRenderNodeTests: XCTestCase { } func entity(with reference: ResolvedTopicReference) -> LinkResolver.ExternalEntity { - let (kind, role) = DocumentationContentRenderer.renderKindAndRole(.collection, semantic: nil) - return LinkResolver.ExternalEntity( - topicRenderReference: TopicRenderReference( - identifier: .init(reference.absoluteString), - title: "Title for \(reference.url.path)", - abstract: [.text("Abstract for \(reference.url.path)")], - url: reference.url.path, - kind: kind, - role: role, - estimatedTime: nil - ), - renderReferenceDependencies: .init(), - sourceLanguages: [.swift], - symbolKind: nil + LinkResolver.ExternalEntity( + kind: .collection, + language: .swift, + relativePresentationURL: reference.url.withoutHostAndPortAndScheme(), + referenceURL: reference.url, + title: "Title for \(reference.url.path)", + abstract: [.text("Abstract for \(reference.url.path)")], + availableLanguages: [.swift], + variants: [] ) } } diff --git a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift b/Tests/SwiftDocCTests/OutOfProcessReferenceResolverV1Tests.swift similarity index 90% rename from Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift rename to Tests/SwiftDocCTests/OutOfProcessReferenceResolverV1Tests.swift index 84b59d722..a4640f8b2 100644 --- a/Tests/SwiftDocCUtilitiesTests/OutOfProcessReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/OutOfProcessReferenceResolverV1Tests.swift @@ -12,10 +12,12 @@ import XCTest import Foundation import SymbolKit @_spi(ExternalLinks) @testable import SwiftDocC -@testable import SwiftDocCUtilities import SwiftDocCTestUtilities -class OutOfProcessReferenceResolverTests: XCTestCase { +// This tests the deprecated V1 implementation of `OutOfProcessReferenceResolver`. +// Deprecating the test silences the deprecation warning when running the tests. It doesn't skip the test. +@available(*, deprecated) +class OutOfProcessReferenceResolverV1Tests: XCTestCase { func testInitializationProcess() throws { #if os(macOS) @@ -51,7 +53,7 @@ class OutOfProcessReferenceResolverTests: XCTestCase { #endif } - func assertResolvesTopicLink(makeResolver: (OutOfProcessReferenceResolver.ResolvedInformation) throws -> OutOfProcessReferenceResolver) throws { + private func assertResolvesTopicLink(makeResolver: (OutOfProcessReferenceResolver.ResolvedInformation) throws -> OutOfProcessReferenceResolver) throws { let testMetadata = OutOfProcessReferenceResolver.ResolvedInformation( kind: .function, url: URL(string: "doc://com.test.bundle/something")!, @@ -100,33 +102,34 @@ class OutOfProcessReferenceResolverTests: XCTestCase { // Resolve the symbol let entity = resolver.entity(with: resolvedReference) + let topicRenderReference = entity.makeTopicRenderReference() - XCTAssertEqual(entity.topicRenderReference.url, testMetadata.url.withoutHostAndPortAndScheme().absoluteString) + XCTAssertEqual(topicRenderReference.url, testMetadata.url.withoutHostAndPortAndScheme().absoluteString) - XCTAssertEqual(entity.topicRenderReference.kind.rawValue, "symbol") - XCTAssertEqual(entity.topicRenderReference.role, "symbol") + XCTAssertEqual(topicRenderReference.kind.rawValue, "symbol") + XCTAssertEqual(topicRenderReference.role, "symbol") - XCTAssertEqual(entity.topicRenderReference.title, "Resolved Title") - XCTAssertEqual(entity.topicRenderReference.abstract, [.text("Resolved abstract for this topic.")]) + XCTAssertEqual(topicRenderReference.title, "Resolved Title") + XCTAssertEqual(topicRenderReference.abstract, [.text("Resolved abstract for this topic.")]) - XCTAssertFalse(entity.topicRenderReference.isBeta) + XCTAssertFalse(topicRenderReference.isBeta) - XCTAssertEqual(entity.sourceLanguages.count, 3) + XCTAssertEqual(entity.availableLanguages.count, 3) - let availableSourceLanguages = entity.sourceLanguages.sorted() + let availableSourceLanguages = entity.availableLanguages.sorted() let expectedLanguages = testMetadata.availableLanguages.sorted() XCTAssertEqual(availableSourceLanguages[0], expectedLanguages[0]) XCTAssertEqual(availableSourceLanguages[1], expectedLanguages[1]) XCTAssertEqual(availableSourceLanguages[2], expectedLanguages[2]) - XCTAssertEqual(entity.topicRenderReference.fragments, [.init(text: "declaration fragment", kind: .text, preciseIdentifier: nil)]) + XCTAssertEqual(topicRenderReference.fragments, [.init(text: "declaration fragment", kind: .text, preciseIdentifier: nil)]) let variantTraits = [RenderNode.Variant.Trait.interfaceLanguage("com.test.another-language.id")] - XCTAssertEqual(entity.topicRenderReference.titleVariants.value(for: variantTraits), "Resolved Variant Title") - XCTAssertEqual(entity.topicRenderReference.abstractVariants.value(for: variantTraits), [.text("Resolved variant abstract for this topic.")]) + XCTAssertEqual(topicRenderReference.titleVariants.value(for: variantTraits), "Resolved Variant Title") + XCTAssertEqual(topicRenderReference.abstractVariants.value(for: variantTraits), [.text("Resolved variant abstract for this topic.")]) - let fragmentVariant = try XCTUnwrap(entity.topicRenderReference.fragmentsVariants.variants.first(where: { $0.traits == variantTraits })) + let fragmentVariant = try XCTUnwrap(topicRenderReference.fragmentsVariants.variants.first(where: { $0.traits == variantTraits })) XCTAssertEqual(fragmentVariant.patch.map(\.operation), [.replace]) if case .replace(let variantFragment) = fragmentVariant.patch.first { XCTAssertEqual(variantFragment, [.init(text: "variant declaration fragment", kind: .text, preciseIdentifier: nil)]) @@ -134,7 +137,7 @@ class OutOfProcessReferenceResolverTests: XCTestCase { XCTFail("Unexpected fragments variant patch") } - XCTAssertEqual(entity.symbolKind, .func) + XCTAssertEqual(entity.kind, .function) } func testResolvingTopicLinkProcess() throws { @@ -273,30 +276,31 @@ class OutOfProcessReferenceResolverTests: XCTestCase { // Resolve the symbol let (_, entity) = try XCTUnwrap(resolver.symbolReferenceAndEntity(withPreciseIdentifier: "abc123"), "Unexpectedly failed to resolve symbol") + let topicRenderReference = entity.makeTopicRenderReference() - XCTAssertEqual(entity.topicRenderReference.url, testMetadata.url.absoluteString) + XCTAssertEqual(topicRenderReference.url, testMetadata.url.absoluteString) - XCTAssertEqual(entity.topicRenderReference.kind.rawValue, "symbol") - XCTAssertEqual(entity.topicRenderReference.role, "symbol") + XCTAssertEqual(topicRenderReference.kind.rawValue, "symbol") + XCTAssertEqual(topicRenderReference.role, "symbol") - XCTAssertEqual(entity.topicRenderReference.title, "Resolved Title") + XCTAssertEqual(topicRenderReference.title, "Resolved Title") - XCTAssertEqual(entity.sourceLanguages.count, 3) + XCTAssertEqual(entity.availableLanguages.count, 3) - let availableSourceLanguages = entity.sourceLanguages.sorted() + let availableSourceLanguages = entity.availableLanguages.sorted() let expectedLanguages = testMetadata.availableLanguages.sorted() XCTAssertEqual(availableSourceLanguages[0], expectedLanguages[0]) XCTAssertEqual(availableSourceLanguages[1], expectedLanguages[1]) XCTAssertEqual(availableSourceLanguages[2], expectedLanguages[2]) - XCTAssertEqual(entity.topicRenderReference.fragments, [.init(text: "declaration fragment", kind: .text, preciseIdentifier: nil)]) + XCTAssertEqual(topicRenderReference.fragments, [.init(text: "declaration fragment", kind: .text, preciseIdentifier: nil)]) let variantTraits = [RenderNode.Variant.Trait.interfaceLanguage("com.test.another-language.id")] - XCTAssertEqual(entity.topicRenderReference.titleVariants.value(for: variantTraits), "Resolved Variant Title") - XCTAssertEqual(entity.topicRenderReference.abstractVariants.value(for: variantTraits), [.text("Resolved variant abstract for this topic.")]) + XCTAssertEqual(topicRenderReference.titleVariants.value(for: variantTraits), "Resolved Variant Title") + XCTAssertEqual(topicRenderReference.abstractVariants.value(for: variantTraits), [.text("Resolved variant abstract for this topic.")]) - let fragmentVariant = try XCTUnwrap(entity.topicRenderReference.fragmentsVariants.variants.first(where: { $0.traits == variantTraits })) + let fragmentVariant = try XCTUnwrap(topicRenderReference.fragmentsVariants.variants.first(where: { $0.traits == variantTraits })) XCTAssertEqual(fragmentVariant.patch.map(\.operation), [.replace]) if case .replace(let variantFragment) = fragmentVariant.patch.first { XCTAssertEqual(variantFragment, [.init(text: "variant declaration fragment", kind: .text, preciseIdentifier: nil)]) @@ -304,19 +308,19 @@ class OutOfProcessReferenceResolverTests: XCTestCase { XCTFail("Unexpected fragments variant patch") } - XCTAssertNil(entity.topicRenderReference.conformance) - XCTAssertNil(entity.topicRenderReference.estimatedTime) - XCTAssertNil(entity.topicRenderReference.defaultImplementationCount) - XCTAssertFalse(entity.topicRenderReference.isBeta) - XCTAssertFalse(entity.topicRenderReference.isDeprecated) - XCTAssertNil(entity.topicRenderReference.propertyListKeyNames) - XCTAssertNil(entity.topicRenderReference.tags) - - XCTAssertEqual(entity.topicRenderReference.images.count, 1) - let topicImage = try XCTUnwrap(entity.topicRenderReference.images.first) + XCTAssertNil(topicRenderReference.conformance) + XCTAssertNil(topicRenderReference.estimatedTime) + XCTAssertNil(topicRenderReference.defaultImplementationCount) + XCTAssertFalse(topicRenderReference.isBeta) + XCTAssertFalse(topicRenderReference.isDeprecated) + XCTAssertNil(topicRenderReference.propertyListKeyNames) + XCTAssertNil(topicRenderReference.tags) + + XCTAssertEqual(topicRenderReference.images.count, 1) + let topicImage = try XCTUnwrap(topicRenderReference.images.first) XCTAssertEqual(topicImage.type, .card) - let image = try XCTUnwrap(entity.renderReferenceDependencies.imageReferences.first(where: { $0.identifier == topicImage.identifier })) + let image = try XCTUnwrap(entity.makeRenderDependencies().imageReferences.first(where: { $0.identifier == topicImage.identifier })) XCTAssertEqual(image.identifier, RenderReferenceIdentifier("external-card")) XCTAssertEqual(image.altText, "External card alt text") @@ -678,11 +682,10 @@ class OutOfProcessReferenceResolverTests: XCTestCase { let resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) XCTAssertEqual(resolver.bundleID, "com.test.bundle") - XCTAssertThrowsError(try resolver.resolveInformationForTopicURL(URL(string: "doc://com.test.bundle/something")!)) { - guard case OutOfProcessReferenceResolver.Error.executableSentBundleIdentifierAgain = $0 else { - XCTFail("Encountered an unexpected type of error.") - return - } + if case .failure(_, let errorInfo) = resolver.resolve(.unresolved(UnresolvedTopicReference(topicURL: ValidatedURL(parsingAuthoredLink: "doc://com.test.bundle/something")!))) { + XCTAssertEqual(errorInfo.message, "Executable sent bundle identifier message again, after it was already received.") + } else { + XCTFail("Unexpectedly resolved the link from an identifier and capabilities response") } #endif } @@ -734,13 +737,13 @@ class OutOfProcessReferenceResolverTests: XCTestCase { // Resolve the symbol let topicLinkEntity = resolver.entity(with: resolvedReference) - - XCTAssertEqual(topicLinkEntity.topicRenderReference.isBeta, isBeta, file: file, line: line) + + XCTAssertEqual(topicLinkEntity.makeTopicRenderReference().isBeta, isBeta, file: file, line: line) // Resolve the symbol let (_, symbolEntity) = try XCTUnwrap(resolver.symbolReferenceAndEntity(withPreciseIdentifier: "abc123"), "Unexpectedly failed to resolve symbol") - XCTAssertEqual(symbolEntity.topicRenderReference.isBeta, isBeta, file: file, line: line) + XCTAssertEqual(symbolEntity.makeTopicRenderReference().isBeta, isBeta, file: file, line: line) } diff --git a/Tests/SwiftDocCTests/OutOfProcessReferenceResolverV2Tests.swift b/Tests/SwiftDocCTests/OutOfProcessReferenceResolverV2Tests.swift new file mode 100644 index 000000000..dcd51bf89 --- /dev/null +++ b/Tests/SwiftDocCTests/OutOfProcessReferenceResolverV2Tests.swift @@ -0,0 +1,680 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 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 XCTest +import Foundation +import SymbolKit +@_spi(ExternalLinks) @testable import SwiftDocC +import SwiftDocCTestUtilities + +#if os(macOS) +class OutOfProcessReferenceResolverV2Tests: XCTestCase { + + func testInitializationProcess() throws { + let temporaryFolder = try createTemporaryDirectory() + + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + // When the executable file doesn't exist + XCTAssertFalse(FileManager.default.fileExists(atPath: executableLocation.path)) + XCTAssertThrowsError(try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }), + "There should be a validation error if the executable file doesn't exist") + + // When the file isn't executable + try "".write(to: executableLocation, atomically: true, encoding: .utf8) + XCTAssertFalse(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + XCTAssertThrowsError(try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }), + "There should be a validation error if the file isn't executable") + + // When the file isn't executable + try """ + #!/bin/bash + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + let resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { errorMessage in + XCTFail("No error output is expected for this test executable. Got:\n\(errorMessage)") + }) + XCTAssertEqual(resolver.bundleID, "com.test.bundle") + } + + private func makeTestSummary() -> (summary: LinkDestinationSummary, imageReference: RenderReferenceIdentifier, imageURLs: (light: URL, dark: URL)) { + let linkedReference = RenderReferenceIdentifier("doc://com.test.bundle/something-else") + let linkedImage = RenderReferenceIdentifier("some-image-identifier") + let linkedVariantReference = RenderReferenceIdentifier("doc://com.test.bundle/something-else-2") + + func cardImages(name: String) -> (light: URL, dark: URL) { + ( URL(string: "https://example.com/path/to/\(name)@2x.png")!, + URL(string: "https://example.com/path/to/\(name)~dark@2x.png")! ) + } + + let imageURLs = cardImages(name: "some-image") + + let summary = LinkDestinationSummary( + kind: .structure, + language: .swift, // This is Swift to account for what is considered a symbol's "first" variant value (rdar://86580516), + relativePresentationURL: URL(string: "/path/so/something")!, + referenceURL: URL(string: "doc://com.test.bundle/something")!, + title: "Resolved Title", + abstract: [ + .text("Resolved abstract with "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .strong(inlineContent: [.text("formatted")]), + .text(" and a link: "), + .reference(identifier: linkedReference, isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil) + ], + availableLanguages: [ + .swift, + .init(name: "Language Name 2", id: "com.test.another-language.id"), + .objectiveC, + ], + platforms: [ + .init(name: "firstOS", introduced: "1.2.3", isBeta: false), + .init(name: "secondOS", introduced: "4.5.6", isBeta: false), + ], + usr: "some-unique-symbol-id", + declarationFragments: .init([ + .init(text: "struct", kind: .keyword, preciseIdentifier: nil), + .init(text: " ", kind: .text, preciseIdentifier: nil), + .init(text: "declaration fragment", kind: .identifier, preciseIdentifier: nil), + ]), + topicImages: [ + .init(pageImagePurpose: .card, identifier: linkedImage) + ], + references: [ + TopicRenderReference(identifier: linkedReference, title: "Something Else", abstract: [.text("Some other page")], url: "/path/to/something-else", kind: .symbol), + TopicRenderReference(identifier: linkedVariantReference, title: "Another Page", abstract: [.text("Yet another page")], url: "/path/to/something-else-2", kind: .article), + + ImageReference( + identifier: linkedImage, + altText: "External card alt text", + imageAsset: DataAsset( + variants: [ + DataTraitCollection(userInterfaceStyle: .light, displayScale: .double): imageURLs.light, + DataTraitCollection(userInterfaceStyle: .dark, displayScale: .double): imageURLs.dark, + ], + metadata: [ + imageURLs.light : DataAsset.Metadata(svgID: nil), + imageURLs.dark : DataAsset.Metadata(svgID: nil), + ], + context: .display + ) + ), + ], + variants: [ + .init( + traits: [.interfaceLanguage("com.test.another-language.id")], + kind: .init(name: "Variant Kind Name", id: "com.test.kind2.id", isSymbol: true), + language: .init(name: "Language Name 2", id: "com.test.another-language.id"), + title: "Resolved Variant Title", + abstract: [ + .text("Resolved variant abstract with "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .strong(inlineContent: [.text("formatted")]), + .text(" and a link: "), + .reference(identifier: linkedVariantReference, isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil) + ], + declarationFragments: .init([ + .init(text: "variant declaration fragment", kind: .text, preciseIdentifier: nil) + ]) + ) + ] + ) + + return (summary, linkedImage, imageURLs) + } + + func testResolvingLinkAndSymbol() throws { + enum RequestKind { + case link, symbol + + func perform(resolver: OutOfProcessReferenceResolver, file: StaticString = #filePath, line: UInt = #line) throws -> LinkResolver.ExternalEntity? { + switch self { + case .link: + let unresolved = TopicReference.unresolved(UnresolvedTopicReference(topicURL: ValidatedURL(parsingExact: "doc://com.test.bundle/something")!)) + let reference: ResolvedTopicReference + switch resolver.resolve(unresolved) { + case .success(let resolved): + reference = resolved + case .failure(_, let errorInfo): + XCTFail("Unexpectedly failed to resolve reference with error: \(errorInfo.message)", file: file, line: line) + return nil + } + + // Resolve the symbol + return resolver.entity(with: reference) + + case .symbol: + return try XCTUnwrap(resolver.symbolReferenceAndEntity(withPreciseIdentifier: "")?.1, file: file, line: line) + } + } + } + + for requestKind in [RequestKind.link, .symbol] { + let (testSummary, linkedImage, imageURLs) = makeTestSummary() + + let resolver: OutOfProcessReferenceResolver + do { + let temporaryFolder = try createTemporaryDirectory() + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + + let encodedLinkSummary = try String(data: JSONEncoder().encode(testSummary), encoding: .utf8)! + + try """ + #!/bin/bash + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + echo '{"resolved":\(encodedLinkSummary)}' # Respond with the test link summary (above) + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) + XCTAssertEqual(resolver.bundleID, "com.test.bundle") + } + + let entity = try XCTUnwrap(requestKind.perform(resolver: resolver)) + let topicRenderReference = entity.makeTopicRenderReference() + + XCTAssertEqual(topicRenderReference.url, testSummary.relativePresentationURL.absoluteString) + + XCTAssertEqual(topicRenderReference.kind.rawValue, "symbol") + XCTAssertEqual(topicRenderReference.role, "symbol") + + XCTAssertEqual(topicRenderReference.title, "Resolved Title") + XCTAssertEqual(topicRenderReference.abstract, [ + .text("Resolved abstract with "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .strong(inlineContent: [.text("formatted")]), + .text(" and a link: "), + .reference(identifier: .init("doc://com.test.bundle/something-else"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil) + ]) + + XCTAssertFalse(topicRenderReference.isBeta) + + XCTAssertEqual(entity.availableLanguages.count, 3) + + let availableSourceLanguages = entity.availableLanguages.sorted() + let expectedLanguages = testSummary.availableLanguages.sorted() + + XCTAssertEqual(availableSourceLanguages[0], expectedLanguages[0]) + XCTAssertEqual(availableSourceLanguages[1], expectedLanguages[1]) + XCTAssertEqual(availableSourceLanguages[2], expectedLanguages[2]) + + XCTAssertEqual(topicRenderReference.fragments, [ + .init(text: "struct", kind: .keyword, preciseIdentifier: nil), + .init(text: " ", kind: .text, preciseIdentifier: nil), + .init(text: "declaration fragment", kind: .identifier, preciseIdentifier: nil), + ]) + + let variantTraits = [RenderNode.Variant.Trait.interfaceLanguage("com.test.another-language.id")] + XCTAssertEqual(topicRenderReference.titleVariants.value(for: variantTraits), "Resolved Variant Title") + XCTAssertEqual(topicRenderReference.abstractVariants.value(for: variantTraits), [ + .text("Resolved variant abstract with "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .strong(inlineContent: [.text("formatted")]), + .text(" and a link: "), + .reference(identifier: .init("doc://com.test.bundle/something-else-2"), isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil) + ]) + + let fragmentVariant = try XCTUnwrap(topicRenderReference.fragmentsVariants.variants.first(where: { $0.traits == variantTraits })) + XCTAssertEqual(fragmentVariant.patch.map(\.operation), [.replace]) + if case .replace(let variantFragment) = fragmentVariant.patch.first { + XCTAssertEqual(variantFragment, [.init(text: "variant declaration fragment", kind: .text, preciseIdentifier: nil)]) + } else { + XCTFail("Unexpected fragments variant patch") + } + + XCTAssertNil(topicRenderReference.conformance) + XCTAssertNil(topicRenderReference.estimatedTime) + XCTAssertNil(topicRenderReference.defaultImplementationCount) + XCTAssertFalse(topicRenderReference.isBeta) + XCTAssertFalse(topicRenderReference.isDeprecated) + XCTAssertNil(topicRenderReference.propertyListKeyNames) + XCTAssertNil(topicRenderReference.tags) + + XCTAssertEqual(topicRenderReference.images.count, 1) + let topicImage = try XCTUnwrap(topicRenderReference.images.first) + XCTAssertEqual(topicImage.type, .card) + + let image = try XCTUnwrap(entity.makeRenderDependencies().imageReferences.first(where: { $0.identifier == topicImage.identifier })) + + XCTAssertEqual(image.identifier, linkedImage) + XCTAssertEqual(image.altText, "External card alt text") + + XCTAssertEqual(image.asset, DataAsset( + variants: [ + DataTraitCollection(userInterfaceStyle: .light, displayScale: .double): imageURLs.light, + DataTraitCollection(userInterfaceStyle: .dark, displayScale: .double): imageURLs.dark, + ], + metadata: [ + imageURLs.light: DataAsset.Metadata(svgID: nil), + imageURLs.dark: DataAsset.Metadata(svgID: nil), + ], + context: .display + )) + } + } + + func testForwardsErrorOutputProcess() throws { + let temporaryFolder = try createTemporaryDirectory() + + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + try """ + #!/bin/bash + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this resolver's identifier & capabilities + echo "Some error output" 1>&2 # Write to stderr + read # Wait for docc to send a request + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + let didReadErrorOutputExpectation = expectation(description: "Did read forwarded error output.") + + let resolver = try? OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { + errorMessage in + XCTAssertEqual(errorMessage, "Some error output\n") + didReadErrorOutputExpectation.fulfill() + }) + XCTAssertEqual(resolver?.bundleID, "com.test.bundle") + + wait(for: [didReadErrorOutputExpectation], timeout: 20.0) + } + + func testLinksAndImagesInExternalAbstractAreIncludedInTheRenderedPageReferenecs() async throws { + let externalBundleID: DocumentationBundle.Identifier = "com.example.test" + + let imageRef = RenderReferenceIdentifier("some-external-card-image-identifier") + let linkRef = RenderReferenceIdentifier("doc://\(externalBundleID)/path/to/other-page") + + let imageURL = URL(string: "https://example.com/path/to/some-image.png")! + + let originalLinkedImage = ImageReference( + identifier: imageRef, + imageAsset: DataAsset( + variants: [.init(displayScale: .standard): imageURL], + metadata: [imageURL: .init()], + context: .display + ) + ) + + let originalLinkedTopic = TopicRenderReference( + identifier: linkRef, + title: "Resolved title of link inside abstract", + abstract: [ + .text("This transient content is not displayed anywhere"), + ], + url: "/path/to/other-page", + kind: .article + ) + + let externalSummary = LinkDestinationSummary( + kind: .article, + language: .swift, + relativePresentationURL: URL(string: "/path/to/something")!, + referenceURL: URL(string: "doc://\(externalBundleID)/path/to/something")!, + title: "Resolved title", + abstract: [ + .text("External abstract with an image "), + .image(identifier: imageRef, metadata: nil), + .text(" and link "), + .reference(identifier: linkRef, isActive: true, overridingTitle: nil, overridingTitleInlineContent: nil), + .text("."), + ], + availableLanguages: [.swift], + platforms: nil, + taskGroups: nil, + usr: nil, + declarationFragments: nil, + redirects: nil, + topicImages: nil, + references: [originalLinkedImage, originalLinkedTopic], + variants: [] + ) + + let resolver: OutOfProcessReferenceResolver + do { + let temporaryFolder = try createTemporaryDirectory() + let encodedResponse = try String(decoding: JSONEncoder().encode(OutOfProcessReferenceResolver.ResponseV2.resolved(externalSummary)), as: UTF8.self) + + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + try """ + #!/bin/bash + echo '{"identifier":"\(externalBundleID)","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + echo '\(encodedResponse)' # Respond with the resolved link summary + read # Wait for docc to send another request + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) + } + + let catalog = Folder(name: "unit-test.docc", content: [ + TextFile(name: "Something.md", utf8Content: """ + # My root page + + This page curates an an external page (so that its abstract and transient references are displayed on the page) + + ## Topics + + ### An external link + + - + """) + ]) + let inputDirectory = Folder(name: "path", content: [Folder(name: "to", content: [catalog])]) + + var configuration = DocumentationContext.Configuration() + configuration.externalDocumentationConfiguration.sources = [ + externalBundleID: resolver + ] + let (_, context) = try await loadBundle(catalog: inputDirectory, configuration: configuration) + XCTAssertEqual(context.problems.map(\.diagnostic.summary), [], "Encountered unexpected problems") + + let reference = try XCTUnwrap(context.soleRootModuleReference, "This example catalog only has a root page") + + let converter = DocumentationContextConverter( + bundle: context.bundle, + context: context, + renderContext: RenderContext( + documentationContext: context, + bundle: context.bundle + ) + ) + let renderNode = try XCTUnwrap(converter.renderNode(for: context.entity(with: reference))) + + // Verify that the topic section exist and has the external link + XCTAssertEqual(renderNode.topicSections.flatMap { [$0.title ?? ""] + $0.identifiers }, [ + "An external link", + "doc://\(externalBundleID)/path/to/something", // Resolved links use their canonical references + ]) + + // Verify that the externally resolved page's references are included on the page + XCTAssertEqual(Set(renderNode.references.keys), [ + "doc://com.example.test/path/to/something", // The external page that the root links to + + "some-external-card-image-identifier", // The image in that page's abstract + "doc://com.example.test/path/to/other-page", // The link in that page's abstract + ], "The external page and its two references should be included on this page") + + XCTAssertEqual(renderNode.references[imageRef.identifier] as? ImageReference, originalLinkedImage) + XCTAssertEqual(renderNode.references[linkRef.identifier] as? TopicRenderReference, originalLinkedTopic) + } + + func testExternalLinkFailureResultInDiagnosticWithSolutions() async throws { + let externalBundleID: DocumentationBundle.Identifier = "com.example.test" + + let resolver: OutOfProcessReferenceResolver + do { + let temporaryFolder = try createTemporaryDirectory() + + let diagnosticInfo = OutOfProcessReferenceResolver.ResponseV2.DiagnosticInformation( + summary: "Some external link issue summary", + solutions: [ + .init(summary: "Some external solution", replacement: "some-replacement") + ] + ) + let encodedDiagnostic = try String(decoding: JSONEncoder().encode(diagnosticInfo), as: UTF8.self) + + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + try """ + #!/bin/bash + echo '{"identifier":"\(externalBundleID)","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + echo '{"failure":\(encodedDiagnostic)}' # Respond with an error message + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) + } + + let catalog = Folder(name: "unit-test.docc", content: [ + TextFile(name: "Something.md", utf8Content: """ + # My root page + + This page contains an external link that will fail to resolve: + """) + ]) + let inputDirectory = Folder(name: "path", content: [Folder(name: "to", content: [catalog])]) + + var configuration = DocumentationContext.Configuration() + configuration.externalDocumentationConfiguration.sources = [ + externalBundleID: resolver + ] + let (_, context) = try await loadBundle(catalog: inputDirectory, configuration: configuration) + + XCTAssertEqual(context.problems.map(\.diagnostic.summary), [ + "Some external link issue summary", + ]) + + let problem = try XCTUnwrap(context.problems.sorted(by: \.diagnostic.identifier).first) + + XCTAssertEqual(problem.diagnostic.summary, "Some external link issue summary") + XCTAssertEqual(problem.diagnostic.range?.lowerBound, .init(line: 3, column: 69, source: URL(fileURLWithPath: "/path/to/unit-test.docc/Something.md"))) + XCTAssertEqual(problem.diagnostic.range?.upperBound, .init(line: 3, column: 97, source: URL(fileURLWithPath: "/path/to/unit-test.docc/Something.md"))) + + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssertEqual(solution.summary, "Some external solution") + XCTAssertEqual(solution.replacements.count, 1) + XCTAssertEqual(solution.replacements.first?.range.lowerBound, .init(line: 3, column: 65, source: nil)) + XCTAssertEqual(solution.replacements.first?.range.upperBound, .init(line: 3, column: 97, source: nil)) + + // Verify the warning presentation + let diagnosticOutput = LogHandle.LogStorage() + let fileSystem = try TestFileSystem(folders: [inputDirectory]) + let diagnosticFormatter = DiagnosticConsoleWriter(LogHandle.memory(diagnosticOutput), formattingOptions: [], highlight: true, dataProvider: fileSystem) + diagnosticFormatter.receive(context.diagnosticEngine.problems) + try diagnosticFormatter.flush() + + let warning = "\u{001B}[1;33m" + let highlight = "\u{001B}[1;32m" + let suggestion = "\u{001B}[1;39m" + let clear = "\u{001B}[0;0m" + XCTAssertEqual(diagnosticOutput.text, """ + \(warning)warning: Some external link issue summary\(clear) + --> /path/to/unit-test.docc/Something.md:3:69-3:97 + 1 | # My root page + 2 | + 3 + This page contains an external link that will fail to resolve: + | ╰─\(suggestion)suggestion: Some external solution\(clear) + + """) + + // Verify the suggestion replacement + let source = try XCTUnwrap(problem.diagnostic.source) + let original = String(decoding: try fileSystem.contents(of: source), as: UTF8.self) + + XCTAssertEqual(try solution.applyTo(original), """ + # My root page + + This page contains an external link that will fail to resolve: + """) + } + + func testEncodingAndDecodingRequests() throws { + do { + let request = OutOfProcessReferenceResolver.RequestV2.link("doc://com.example/path/to/something") + + let data = try JSONEncoder().encode(request) + if case .link(let link) = try JSONDecoder().decode(OutOfProcessReferenceResolver.RequestV2.self, from: data) { + XCTAssertEqual(link, "doc://com.example/path/to/something") + } else { + XCTFail("Decoded the wrong type of request") + } + } + + do { + let request = OutOfProcessReferenceResolver.RequestV2.symbol("some-unique-symbol-id") + + let data = try JSONEncoder().encode(request) + if case .symbol(let usr) = try JSONDecoder().decode(OutOfProcessReferenceResolver.RequestV2.self, from: data) { + XCTAssertEqual(usr, "some-unique-symbol-id") + } else { + XCTFail("Decoded the wrong type of request") + } + } + } + + func testEncodingAndDecodingResponses() throws { + // Identifier and capabilities + do { + let request = OutOfProcessReferenceResolver.ResponseV2.identifierAndCapabilities("com.example.test", []) + + let data = try JSONEncoder().encode(request) + if case .identifierAndCapabilities(let identifier, let capabilities) = try JSONDecoder().decode(OutOfProcessReferenceResolver.ResponseV2.self, from: data) { + XCTAssertEqual(identifier.rawValue, "com.example.test") + XCTAssertEqual(capabilities.rawValue, 0) + } else { + XCTFail("Decoded the wrong type of message") + } + } + + // Failures + do { + let originalInfo = OutOfProcessReferenceResolver.ResponseV2.DiagnosticInformation( + summary: "Some summary", + solutions: [ + .init(summary: "Some solution", replacement: "some-replacement") + ] + ) + + let request = OutOfProcessReferenceResolver.ResponseV2.failure(originalInfo) + let data = try JSONEncoder().encode(request) + if case .failure(let info) = try JSONDecoder().decode(OutOfProcessReferenceResolver.ResponseV2.self, from: data) { + XCTAssertEqual(info.summary, originalInfo.summary) + XCTAssertEqual(info.solutions?.count, originalInfo.solutions?.count) + for (solution, originalSolution) in zip(info.solutions ?? [], originalInfo.solutions ?? []) { + XCTAssertEqual(solution.summary, originalSolution.summary) + XCTAssertEqual(solution.replacement, originalSolution.replacement) + } + } else { + XCTFail("Decoded the wrong type of message") + } + } + + // Resolved link information + do { + let originalSummary = makeTestSummary().summary + let message = OutOfProcessReferenceResolver.ResponseV2.resolved(originalSummary) + + let data = try JSONEncoder().encode(message) + if case .resolved(let summary) = try JSONDecoder().decode(OutOfProcessReferenceResolver.ResponseV2.self, from: data) { + XCTAssertEqual(summary, originalSummary) + } else { + XCTFail("Decoded the wrong type of message") + return + } + } + } + + func testErrorWhenReceivingBundleIdentifierTwice() throws { + let temporaryFolder = try createTemporaryDirectory() + + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + try """ + #!/bin/bash + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this identifier & capabilities again + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + let resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) + XCTAssertEqual(resolver.bundleID, "com.test.bundle") + + if case .failure(_, let errorInfo) = resolver.resolve(.unresolved(UnresolvedTopicReference(topicURL: ValidatedURL(parsingAuthoredLink: "doc://com.test.bundle/something")!))) { + XCTAssertEqual(errorInfo.message, "Executable sent bundle identifier message again, after it was already received.") + } else { + XCTFail("Unexpectedly resolved the link from an identifier and capabilities response") + } + } + + func testResolvingSymbolBetaStatusProcess() throws { + func betaStatus(forSymbolWithPlatforms platforms: [LinkDestinationSummary.PlatformAvailability], file: StaticString = #filePath, line: UInt = #line) throws -> Bool { + let summary = LinkDestinationSummary( + kind: .class, + language: .swift, + relativePresentationURL: URL(string: "/documentation/ModuleName/Something")!, + referenceURL: URL(string: "/documentation/ModuleName/Something")!, + title: "Something", + availableLanguages: [.swift, .objectiveC], + platforms: platforms, + variants: [] + ) + + let resolver: OutOfProcessReferenceResolver + do { + let temporaryFolder = try createTemporaryDirectory() + let executableLocation = temporaryFolder.appendingPathComponent("link-resolver-executable") + + let encodedLinkSummary = try String(data: JSONEncoder().encode(summary), encoding: .utf8)! + + try """ + #!/bin/bash + #!/bin/bash + echo '{"identifier":"com.test.bundle","capabilities": 0}' # Write this resolver's identifier & capabilities + read # Wait for docc to send a request + echo '{"resolved":\(encodedLinkSummary)}' # Respond with the test link summary (above) + """.write(to: executableLocation, atomically: true, encoding: .utf8) + + // `0o0700` is `-rwx------` (read, write, & execute only for owner) + try FileManager.default.setAttributes([.posixPermissions: 0o0700], ofItemAtPath: executableLocation.path) + XCTAssert(FileManager.default.isExecutableFile(atPath: executableLocation.path)) + + resolver = try OutOfProcessReferenceResolver(processLocation: executableLocation, errorOutputHandler: { _ in }) + XCTAssertEqual(resolver.bundleID, "com.test.bundle", file: file, line: line) + } + + let (_, symbolEntity) = try XCTUnwrap(resolver.symbolReferenceAndEntity(withPreciseIdentifier: "abc123"), "Unexpectedly failed to resolve symbol") + return symbolEntity.makeTopicRenderReference().isBeta + } + + // All platforms are in beta + XCTAssertEqual(true, try betaStatus(forSymbolWithPlatforms: [ + .init(name: "fooOS", introduced: "1.2.3", isBeta: true), + .init(name: "barOS", introduced: "1.2.3", isBeta: true), + .init(name: "bazOS", introduced: "1.2.3", isBeta: true), + ])) + + // One platform is stable, the other two are in beta + XCTAssertEqual(false, try betaStatus(forSymbolWithPlatforms: [ + .init(name: "fooOS", introduced: "1.2.3", isBeta: false), + .init(name: "barOS", introduced: "1.2.3", isBeta: true), + .init(name: "bazOS", introduced: "1.2.3", isBeta: true), + ])) + + // No platforms explicitly supported + XCTAssertEqual(false, try betaStatus(forSymbolWithPlatforms: [])) + } +} +#endif diff --git a/bin/test-data-external-resolver b/bin/test-data-external-resolver index 23d9f9d13..d38e2db51 100755 --- a/bin/test-data-external-resolver +++ b/bin/test-data-external-resolver @@ -2,7 +2,7 @@ # # This source file is part of the Swift.org open source project # -# Copyright (c) 2021 Apple Inc. and the Swift project authors +# Copyright (c) 2021-2025 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 @@ -18,108 +18,177 @@ # absolute documentation links with the "com.test.bundle" identifier. For example: RESPONSE='{ - "resolvedInformation" : { - "abstract" : "Resolved abstract.", - "availableLanguages" : [ + "resolved" : { + "abstract" : [ { - "id" : "swift", - "name" : "Language Name", - "idAliases" : [], - "linkDisambiguationID": "swift" + "text" : "Resolved ", + "type" : "text" }, { - "id" : "occ", - "name" : "Variant Language Name", - "idAliases" : [ - "objective-c", - "c" + "inlineContent" : [ + { + "text" : "formatted", + "type" : "text" + } ], - "linkDisambiguationID" : "c" + "type" : "strong" + }, + { + "text" : " abstract with ", + "type" : "text" + }, + { + "identifier" : "doc://com.test.bundle/path/to/other-page", + "isActive" : true, + "type" : "reference" + }, + { + "text" : ".", + "type" : "text" } ], - "declarationFragments" : [ + "availableLanguages" : [ + "swift", + "data", + { + "id" : "language-id", + "idAliases" : [ + "language-alias-id" + ], + "linkDisambiguationID" : "language-id", + "name" : "Language name" + }, + { + "id" : "language-id-2", + "linkDisambiguationID" : "language-id-2", + "name" : "Other language name" + }, + "occ" + ], + "fragments" : [ + { + "kind" : "keyword", + "text" : "resolved" + }, { "kind" : "text", - "spelling" : "declaration fragment" + "text" : " " + }, + { + "kind" : "identifier", + "text" : "fragment" } ], "kind" : { - "id" : "com.test.kind.id", + "id" : "kind-id", "isSymbol" : true, - "name" : "Kind Name" + "name" : "Kind name" }, "language" : { - "id" : "swift", - "name" : "Language Name", - "idAliases" : [], - "linkDisambiguationID": "swift" - + "id" : "language-id", + "idAliases" : [ + "language-alias-id" + ], + "linkDisambiguationID" : "language-id", + "name" : "Language name" }, + "path" : "/documentation/something", "platforms" : [ { "beta" : false, - "introducedAt" : "1.0.0", - "name" : "Platform Name" + "introducedAt" : "1.2.3", + "name" : "Platform name" } ], - "topicImages": [ + "referenceURL" : "doc://com.test.bundle/documentation/something", + "references" : [ { - "type": "card", - "identifier": "some-external-card-image-identifier" - } - ], - "references": [ + "abstract" : [ + { + "text" : "The abstract of another page that is linked to", + "type" : "text" + } + ], + "identifier" : "doc://com.test.bundle/path/to/other-page", + "kind" : "article", + "title" : "Linked from abstract", + "type" : "topic", + "url" : "/path/to/other-page" + }, { - "type": "image", - "identifier": "some-external-card-image-identifier", - "variants": [ + "alt" : "Resolved image alt text", + "identifier" : "some-external-card-image-identifier", + "type" : "image", + "variants" : [ { - "url": "http:\/\/example.com\/some-image-1x.jpg", - "traits": [ + "traits" : [ "1x" - ] - }, - { - "url": "http:\/\/example.com\/some-image-1x-dark.jpg", - "traits": [ - "1x", "dark" - ] + ], + "url" : "http://example.com/some-image.jpg" }, { - "url": "http:\/\/example.com\/some-image-2x.jpg", - "traits": [ - "2x" - ] + "traits" : [ + "2x", + "dark" + ], + "url" : "http://example.com/some-image@2x~dark.jpg" } ] } ], - "title" : "Resolved Title", - "url" : "doc:\/\/com.test.bundle\/resolved/path\/", + "title" : "Resolved title", + "topicImages" : [ + { + "identifier" : "some-external-card-image-identifier", + "type" : "card" + } + ], + "usr" : "resolved-unique-symbol-id", "variants" : [ { - "abstract" : "Resolved variant abstract for this topic.", - "declarationFragments" : [ + "abstract" : [ + { + "text" : "Resolved abstract", + "type" : "text" + }, + { + "code" : "variant", + "type" : "codeVoice" + }, + { + "text" : "Resolved abstract", + "type" : "text" + } + ], + "fragments" : [ + { + "kind" : "keyword", + "text" : "resolved" + }, + { + "kind" : "text", + "text" : " " + }, + { + "kind" : "identifier", + "text" : "variant" + }, { "kind" : "text", - "spelling" : "variant declaration fragment" + "text" : ": " + }, + { + "kind" : "typeIdentifier", + "text" : "fragment" } ], "kind" : { - "id" : "com.test.other-kind.id", + "id" : "variant-kind-id", "isSymbol" : true, - "name" : "Variant Kind Name" - }, - "language" : { - "id" : "occ", - "name" : "Variant Language Name", - "idAliases" : [ - "objective-c", - "c" - ], - "linkDisambiguationID" : "c" + "name" : "Variant kind name" }, - "title" : "Resolved Variant Title", + "language" : "occ", + "title" : "Resolved variant title", "traits" : [ { "interfaceLanguage" : "occ" @@ -130,8 +199,11 @@ RESPONSE='{ } }' -# Write this resolver's bundle identifier -echo '{"bundleIdentifier":"com.test.bundle"}' +# Write this resolver's identifier and capabilities +echo '{ + "identifier": "com.test.bundle", + "capabilities": 0 +}' # Forever, wait for DocC to send a request and respond the resolved information while true